From 0923d862a171d4d491dfc8f9df63822128d60f8a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:57:49 -0800 Subject: [PATCH] =?UTF-8?q?PSD=20import:=20File=20=E2=80=BA=20Open=20conve?= =?UTF-8?q?rts=20a=20PSD=20file's=20layers=20to=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a .psd or .psb used to give one merged image. document.open now reads the layer records and builds the document from them. The PSD import options page (https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) says the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a clipping mask into one layer and discards hidden layers that require merging, and that Import Hidden Layers brings hidden layers in. document.open does the same, with hiddenLayers: true for the hidden layers; layers: false keeps the merged image. VectorCraft's own rules: - Each layer becomes a layer holding its pixels as an image, with its name, visibility, opacity and blend mode. A group becomes a layer of sublayers; a Pass Through group is not isolated. - Clipped layers show where the layer below has pixels. That layer's fill opacity fades only its own pixels. Layers clipped to an adjustment layer, or to a fill that stores no pixels and isn't drawn, show over the whole canvas. - A layer mask becomes the layer's opacity mask, its default color and invert flag applied; a disabled mask is kept, switched off. - Fill opacity multiplies the layer's opacity. - Layers and groups in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, whether or not they hold transparent pixels; Color Dodge, Color Burn and Difference keep their mode. A merged Dissolve layer's pixels are opaque or transparent, in a pattern of each layer's own. - A solid color fill layer that stores no pixels and has no vector mask comes in as its color (RGB, CMYK, Grayscale, Lab or HSB). - A layer name stored only as a Pascal string, without its Unicode name, is read as Mac Roman. - Adjustment layers, fill layers that aren't drawn, layer effects that are switched on, knockouts and vector masks are not applied; the warnings count them. - A merge whose canvases need more memory than allowed leaves the layer alone, in the Normal mode or unclipped, with a warning. This changes what opening a layered PSD does. Batch Trace Folder passes layers: false, so a PSD is still traced as its merged image. A file whose layers can't be read, or whose layers and merges need more memory than allowed, opens as the merged image with a warning. Lengths, counts, nesting, decoded pixels and the canvases merges make are bounded. Zipped 32-bit data with prediction is now read, in layers and in the merged image. import_fuzz gains a case that mutates the PSD and PSB samples and opens them with their layers. The PSD reader's new error messages have rows in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md, README. --- README.md | 2 +- .../src/cmd/addons/document/batch_trace.rs | 3 +- .../engine/src/cmd/addons/document/tests.rs | 28 + crates/engine/src/cmd/fileio.rs | 3 + crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/formats.rs | 3 +- crates/engine/src/cmd/fileio/load.rs | 10 + crates/engine/src/cmd/fileio/pdfimport.rs | 28 +- crates/engine/src/cmd/fileio/psdlayers.rs | 1529 +++++++++++++++++ crates/engine/src/cmd/fileio/psdread.rs | 209 ++- .../engine/src/cmd/fileio/tests_psdlayers.rs | 1017 +++++++++++ crates/engine/src/cmd/fileio/tests_psdread.rs | 25 + crates/engine/src/cmd/place/text.rs | 2 +- crates/engine/tests/import_fuzz.rs | 21 + crates/ui-egui/src/i18n/bg.tsv | 8 +- crates/ui-egui/src/i18n/bs.tsv | 8 +- crates/ui-egui/src/i18n/ca.tsv | 8 +- crates/ui-egui/src/i18n/cs.tsv | 8 +- crates/ui-egui/src/i18n/da.tsv | 8 +- crates/ui-egui/src/i18n/de.tsv | 10 +- crates/ui-egui/src/i18n/el.tsv | 8 +- crates/ui-egui/src/i18n/es.tsv | 8 +- crates/ui-egui/src/i18n/fi.tsv | 8 +- crates/ui-egui/src/i18n/fr.tsv | 8 +- crates/ui-egui/src/i18n/hi.tsv | 8 +- crates/ui-egui/src/i18n/hu.tsv | 8 +- crates/ui-egui/src/i18n/id.tsv | 8 +- crates/ui-egui/src/i18n/it.tsv | 8 +- crates/ui-egui/src/i18n/ja.tsv | 8 +- crates/ui-egui/src/i18n/ka.tsv | 8 +- crates/ui-egui/src/i18n/ko.tsv | 8 +- crates/ui-egui/src/i18n/nl.tsv | 8 +- crates/ui-egui/src/i18n/pl.tsv | 8 +- crates/ui-egui/src/i18n/pt-br.tsv | 8 +- crates/ui-egui/src/i18n/ru.tsv | 8 +- crates/ui-egui/src/i18n/sv.tsv | 8 +- crates/ui-egui/src/i18n/tr.tsv | 8 +- crates/ui-egui/src/i18n/uk.tsv | 8 +- crates/ui-egui/src/i18n/vi.tsv | 8 +- crates/ui-egui/src/i18n/zh-hans.tsv | 8 +- crates/ui-egui/src/i18n/zh-hant.tsv | 8 +- docs/file-format-parity.md | 4 +- docs/mcp.md | 61 +- docs/target-app-parity.md | 1 + 44 files changed, 3048 insertions(+), 118 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/psdlayers.rs create mode 100644 crates/engine/src/cmd/fileio/tests_psdlayers.rs diff --git a/README.md b/README.md index dc7a1cfa5..7e49943a3 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu (92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out, PSD/PSB opened with their layers and placed as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master diff --git a/crates/engine/src/cmd/addons/document/batch_trace.rs b/crates/engine/src/cmd/addons/document/batch_trace.rs index 61e59fc54..084bd5ca2 100644 --- a/crates/engine/src/cmd/addons/document/batch_trace.rs +++ b/crates/engine/src/cmd/addons/document/batch_trace.rs @@ -113,7 +113,8 @@ pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result { for path in &files { let opened_before = s.documents().len(); let one = |s: &mut Session| -> Result { - s.execute("document.open", &json!({ "path": path }))?; + // A PSD or PSB is traced as its merged image, not as the first of its layers. + s.execute("document.open", &json!({ "path": path, "layers": false }))?; s.execute("select.all", &json!({}))?; s.execute(trace, &json!({ "preset": preset }))?; let stem = fileio::file_stem(path); diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index b0fbb4142..43e86caf5 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -333,6 +333,34 @@ fn batch_trace_folder_traces_and_saves_each_image() { let _ = std::fs::remove_dir_all(&dir); } +/// A layered PSD is traced as its merged image: a white layer under a black square gives the +/// square's shape, not a trace of the white layer alone. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_trace_folder_traces_a_layered_psd_as_its_merged_image() { + const C: &str = "addon.document.batchTraceFolder"; + let dir = folder("batch-trace-psd"); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 24, "height": 24})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 24, "height": 24})).unwrap(); + s.execute("layer.new", &json!({"name": "Square"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 6, "y": 6, "width": 12, "height": 12})).unwrap(); + let psd = s.execute("document.export", &json!({"format": "psd"})).unwrap(); + std::fs::write(src.join("two.psd"), vectorcraft_format::base64_decode(psd["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + let r = + s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "preset": "Black and White Logo", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 1, "{r}"); + let svg = std::fs::read_to_string(out.join("two.svg")).unwrap(); + assert!(svg.contains("fill=\"#000000\""), "the black square is traced: {svg}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Batch Trace Folder rejects optional values with the wrong type before opening images. #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4..eaa417f4e 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -50,6 +50,7 @@ mod pdfimport; pub mod pngtext; pub mod ppi; pub mod progress; +mod psdlayers; pub mod psdread; mod pxrread; mod save; @@ -218,6 +219,8 @@ mod tests_pdfx; #[cfg(test)] mod tests_psd; #[cfg(test)] +mod tests_psdlayers; +#[cfg(test)] mod tests_psdread; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 81e8168d0..13df0d5eb 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 23aeb83bb..78ca6149a 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -493,7 +493,8 @@ pub const FORMATS: &[Format] = &[ // Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a // vector app for a raster editor's documents. reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false), - // Photoshop's large document format: read like a PSD (its merged image), never written. + // The large document form of PSD: read like a PSD (its layers, or its merged image), never + // written. reader("psb", "PSB", &["psb"], "image/x-psb", true), // PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't. reader("ps", "PostScript", &["ps"], "application/postscript", false), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58fff3cad..5f8b70187 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -492,6 +492,16 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result preview_only = i.preview_only; (i.doc, i.warnings, false) } + // A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with + // `layers: false`, its merged image opens. Layers that can't be read leave the merged image. + "psd" | "psb" if opts.layers => { + let cms = vectorcraft_color::cms::active(); + match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) { + Ok(Some((doc, warnings))) => (doc, warnings, false), + Ok(None) => (raster_doc(&title, bytes)?, vec![], false), + Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false), + } + } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 2a67cf4a4..2a0dede17 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -28,8 +28,10 @@ pub struct LoadOptions { /// What a PDF's text becomes. pub text_as: TextAs, /// A PDF's optional content groups become layers (else one layer per page, without the art - /// that is off). + /// that is off), and a PSD or PSB file's layers become layers (else its merged image opens). pub layers: bool, + /// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out). + pub hidden_layers: bool, /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). pub dxf: vectorcraft_cad::ImportOptions, /// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its @@ -57,6 +59,7 @@ impl Default for LoadOptions { grays: Grays::default(), text_as: TextAs::default(), layers: true, + hidden_layers: false, dxf: Default::default(), editing_data: true, trust_editing_data: false, @@ -69,7 +72,8 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`), + /// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false), + /// `editingData` (true | false | `always`), /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF /// import options). pub fn from_params(cmd: &str, p: &Value) -> Result { @@ -95,6 +99,10 @@ impl LoadOptions { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, }; + let hidden_layers = match p.get("hiddenLayers").filter(|v| !v.is_null()) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?, + }; let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) { None => (true, false), Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), @@ -108,7 +116,21 @@ impl LoadOptions { .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names, native: true }) + Ok(Self { + pages, + crop, + password, + color_mode, + grays, + text_as, + layers, + hidden_layers, + dxf, + editing_data, + trust_editing_data, + legacy_names, + native: true, + }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/psdlayers.rs b/crates/engine/src/cmd/fileio/psdlayers.rs new file mode 100644 index 000000000..c3c45029a --- /dev/null +++ b/crates/engine/src/cmd/fileio/psdlayers.rs @@ -0,0 +1,1529 @@ +//! File › Open of a PSD or PSB file with its layers as objects. The import option that converts +//! layers to objects +//! () +//! keeps as much of the layer structure as it can, merges the layers of a clipping mask into one +//! layer, and discards hidden layers that need merging; the rest of what this module does is +//! VectorCraft's own. Read from the layer records of the public PSD file format specification +//! (). +//! +//! Each layer becomes a layer of the document, with the layer's name, visibility, opacity and blend +//! mode, holding its pixels as an image at their place on the canvas. A layer group becomes a layer +//! whose sublayers are the group's layers (a pass-through group lets them blend with what is below +//! it; any other group is isolated). A layer mask becomes the layer's opacity mask: a gray image +//! whose outside is hidden or shown as the mask's default color says, kept and switched off when +//! the mask is disabled. A layer's opacity is multiplied by its fill opacity, except in a layer +//! that others are clipped to, whose fill opacity fades only its own pixels. A solid color fill +//! layer that stores no pixels and has no vector mask comes in as an image of its color over the +//! canvas (an RGB, CMYK, Grayscale, Lab or HSB color); type, shape and other fill layers come in as +//! the pixels the file stores for them. +//! +//! Layers clipped to the layer below them are merged into it as one image (named after them all, +//! joined with " + "), shown where the layer below has pixels. Layers clipped to an adjustment +//! layer, or to a fill layer drawn over the whole canvas, show over the whole canvas. A layer in a +//! blend mode VectorCraft doesn't have (Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear +//! Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color, Lighter Color) is merged with the +//! layers below it in its group. A Dissolve layer's partly transparent pixels are merged as opaque +//! or transparent, opaque at the share of its pixels their transparency gives, in a fixed pattern +//! that differs from layer to layer. When a merge's canvases would need more memory than allowed, +//! the layer comes in alone: in the Normal mode, or unclipped. +//! Hidden layers are left out unless `hidden` asks for them. A hidden layer that would need a merge +//! (one in a mode VectorCraft lacks, one that layers are clipped to, one clipped to another, or +//! one below a merged layer) is always left out, with the layers clipped to it. Adjustment layers, +//! fill layers that store no pixels and whose fill isn't read or whose shape is a vector mask, +//! layer effects that are switched on, knockouts and vector masks aren't applied; the warnings +//! count them and the hidden layers left out. +//! +//! The file is untrusted: every count, length and size is checked before anything is allocated, +//! groups nest at most [`MAX_DEPTH`] deep (layers deeper than a document may nest merge into the +//! group around them, with a warning), each layer's pixels, like the canvases one step of a +//! merge holds together, must fit the memory allowed, and all the pixels decoded and the canvases +//! the merges make must fit [`TOTAL_DECODED`] times that. + +use std::cell::Cell; +use std::sync::Arc; + +use image::RgbaImage; +use vectorcraft_color::BlendMode; +use vectorcraft_color::blend::{blend_rgb, composite}; +use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; +use vectorcraft_doc::{Document, ImageObject, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask}; +use vectorcraft_geom::Affine; + +use super::load::{RasterImage, as_png}; +use super::psdread::{Header, Reader, byte, header, layer_channel, layer_info, long_key, row_bytes, sample, to_rgb}; +use crate::cmd::place::text::mac_roman; + +/// Most layer groups inside one another. +pub(super) const MAX_DEPTH: usize = 64; +/// The pixels all layers and masks decode to, merges' second readings included, and the canvases +/// the merges make may take this many times the memory allowed for one image. +const TOTAL_DECODED: u64 = 8; +/// Most channels one layer has (colors, transparency and masks), as many as a document may. +const MAX_CHANNELS: usize = 56; +/// The fewest bytes a layer record takes. +const MIN_RECORD: usize = 34; +const DAMAGED: &str = "the PSD file's layer records are damaged"; + +/// The keys of the tagged blocks that make a layer an adjustment layer. +const ADJUSTMENTS: [&[u8; 4]; 18] = [ + b"levl", b"curv", b"brit", b"blnc", b"hue ", b"hue2", b"selc", b"mixr", b"grdm", b"phfl", b"expA", b"vibA", b"thrs", b"nvrt", b"post", b"clrL", + b"blwh", b"CgEd", +]; + +/// A layer's blend mode. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Blend { + Mode(BlendMode), + /// A group's Pass Through. + Pass, + /// A mode VectorCraft doesn't have: the layer is merged with the layers below it. + Other(Other), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Other { + Dissolve, + LinearBurn, + LinearDodge, + VividLight, + LinearLight, + PinLight, + HardMix, + Subtract, + Divide, + DarkerColor, + LighterColor, +} + +impl Other { + fn label(self) -> &'static str { + match self { + Other::Dissolve => "Dissolve", + Other::LinearBurn => "Linear Burn", + Other::LinearDodge => "Linear Dodge", + Other::VividLight => "Vivid Light", + Other::LinearLight => "Linear Light", + Other::PinLight => "Pin Light", + Other::HardMix => "Hard Mix", + Other::Subtract => "Subtract", + Other::Divide => "Divide", + Other::DarkerColor => "Darker Color", + Other::LighterColor => "Lighter Color", + } + } +} + +/// The blend mode a record's key names (an unknown key: Normal). +fn blend(key: &[u8; 4]) -> Blend { + use BlendMode as M; + let mode = match key { + b"pass" => return Blend::Pass, + b"diss" => return Blend::Other(Other::Dissolve), + b"lbrn" => return Blend::Other(Other::LinearBurn), + b"lddg" => return Blend::Other(Other::LinearDodge), + b"vLit" => return Blend::Other(Other::VividLight), + b"lLit" => return Blend::Other(Other::LinearLight), + b"pLit" => return Blend::Other(Other::PinLight), + b"hMix" => return Blend::Other(Other::HardMix), + b"fsub" => return Blend::Other(Other::Subtract), + b"fdiv" => return Blend::Other(Other::Divide), + b"dkCl" => return Blend::Other(Other::DarkerColor), + b"lgCl" => return Blend::Other(Other::LighterColor), + b"dark" => M::Darken, + b"mul " => M::Multiply, + b"idiv" => M::ColorBurn, + b"lite" => M::Lighten, + b"scrn" => M::Screen, + b"div " => M::ColorDodge, + b"over" => M::Overlay, + b"sLit" => M::SoftLight, + b"hLit" => M::HardLight, + b"diff" => M::Difference, + b"smud" => M::Exclusion, + b"hue " => M::Hue, + b"sat " => M::Saturation, + b"colr" => M::Color, + b"lum " => M::Luminosity, + _ => M::Normal, + }; + Blend::Mode(mode) +} + +/// A rectangle of the canvas, in pixels: top, left, bottom, right. +#[derive(Clone, Copy, Debug, Default)] +struct Bounds { + top: i32, + left: i32, + bottom: i32, + right: i32, +} + +impl Bounds { + fn read(r: &mut Reader<'_>) -> Result { + let mut v = || r.u32().map(|v| v as i32); + Ok(Self { top: v()?, left: v()?, bottom: v()?, right: v()? }) + } + /// Width and height (0 when empty or inverted). + fn size(self) -> (usize, usize) { + let side = |a: i32, b: i32| usize::try_from(i64::from(b) - i64::from(a)).unwrap_or(0); + (side(self.left, self.right), side(self.top, self.bottom)) + } +} + +/// What a layer holds besides plain pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Content { + Pixels, + /// A type layer: its pixels come in. + Type, + /// An adjustment layer: not applied. + Adjustment, + /// A solid color fill layer: its stored pixels, or its color over the whole canvas when it + /// stores none (nothing when the color can't be read, or when its shape is a vector mask). + Solid(Option), + /// A gradient or pattern fill layer: its stored pixels, if any. + Fill, +} + +/// A solid color fill layer's color. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Paint { + /// sRGB, 0..1. + Rgb([f32; 3]), + /// Ink amounts, 0..1. + Cmyk([f32; 4]), +} + +/// A layer mask's place and its color outside it. +#[derive(Clone, Copy, Debug)] +struct MaskInfo { + bounds: Bounds, + default: u8, + disabled: bool, + /// The mask's values, and its default color, are read inverted. + inverted: bool, +} + +/// One layer record: a layer, or a group's opening or closing divider. +struct Record<'a> { + name: String, + bounds: Bounds, + /// Channel id (−1 transparency, −2 the layer mask, 0.. the colors) and its data, compression + /// method first. + channels: Vec<(i16, &'a [u8])>, + blend: Blend, + /// The layer's opacity, 0..1. + opacity: f32, + /// Its fill opacity, 0..1. + fill: f32, + clipped: bool, + hidden: bool, + mask: Option, + /// The section divider type: 0 a layer, 1 or 2 a group (open or closed in the panel), 3 the + /// end of a group (the record below its layers). + divider: u32, + content: Content, + effects: bool, + vector_mask: bool, + knockout: bool, +} + +/// The layer records of `info` (the layer count, the records, then their channels' data). +fn records(info: &[u8], psb: bool) -> Result>, String> { + let mut r = Reader::new(info); + let count = usize::from((r.u16()? as i16).unsigned_abs()); + if count.saturating_mul(MIN_RECORD) > info.len() { + return Err(DAMAGED.into()); + } + let mut heads = Vec::with_capacity(count); + for _ in 0..count { + heads.push(record(&mut r, psb)?); + } + let mut out = Vec::with_capacity(count); + for (mut rec, lengths) in heads { + rec.channels = lengths.into_iter().map(|(id, n)| r.take(n).map(|d| (id, d))).collect::>()?; + out.push(rec); + } + Ok(out) +} + +/// One record, with the lengths of its channels' data (which follows every record). +fn record<'a>(r: &mut Reader<'a>, psb: bool) -> Result<(Record<'a>, Vec<(i16, usize)>), String> { + let bounds = Bounds::read(r)?; + let n = usize::from(r.u16()?); + if n > MAX_CHANNELS { + return Err(DAMAGED.into()); + } + let mut lengths = Vec::with_capacity(n); + for _ in 0..n { + let id = r.u16()? as i16; + lengths.push((id, r.length(psb)?)); + } + if &r.array::<4>()? != b"8BIM" { + return Err(DAMAGED.into()); + } + let blend = blend(&r.array()?); + let opacity = f32::from(r.u8()?) / 255.0; + let clipped = r.u8()? != 0; + // Bit 1: hidden. + let hidden = r.u8()? & 2 != 0; + r.u8()?; + let extra = r.section(false)?; + let mut rec = Record { + name: String::new(), + bounds, + channels: vec![], + blend, + opacity, + fill: 1.0, + clipped, + hidden, + mask: None, + divider: 0, + content: Content::Pixels, + effects: false, + vector_mask: false, + knockout: false, + }; + rec.read_extra(extra, psb)?; + Ok((rec, lengths)) +} + +impl Record<'_> { + /// The layer mask, blending ranges, name and tagged blocks after the record's fixed part. A + /// damaged mask, blending ranges or name is an error; a damaged tagged block keeps what was + /// read before it. + fn read_extra(&mut self, extra: &[u8], psb: bool) -> Result<(), String> { + let damaged = |_| DAMAGED.to_string(); + let mut e = Reader::new(extra); + let mask = e.section(false).map_err(damaged)?; + self.mask = match mask_info(mask) { + Some(info) => Some(info), + None if mask.is_empty() => None, + None => return Err(DAMAGED.into()), + }; + // The blending ranges. + e.section(false).map_err(damaged)?; + // A Pascal string padded to a multiple of 4 bytes, length byte included, in the system's + // encoding (its Unicode name follows in `luni`), read as Mac Roman as psd-tools (MIT) reads + // it: , `psd/layer_and_mask.py`. + let n = usize::from(e.u8().map_err(damaged)?); + let name = e.take(n).map_err(damaged)?; + self.name = name.iter().map(|&b| mac_roman(b)).collect(); + e.take((4 - (n + 1) % 4) % 4).map_err(damaged)?; + // Effects in a descriptor (`lfx2`, `lmfx`) say whether they are switched on; the older + // `lrFX` block is counted whenever it is there. + let (mut effects, mut old_effects) = (None, false); + while e.rest().len() >= 12 { + let (Ok(signature), Ok(key)) = (e.array::<4>(), e.array::<4>()) else { break }; + if !matches!(&signature, b"8BIM" | b"8B64") { + break; + } + let Ok(data) = e.section(psb && long_key(&key)) else { break }; + match &key { + b"luni" => { + if let Some(name) = unicode(data) { + self.name = name; + } + } + b"lsct" | b"lsdk" => { + if let Some(t) = data.get(..4) { + self.divider = u32::from_be_bytes([t[0], t[1], t[2], t[3]]); + } + if data.get(4..8) == Some(b"8BIM") + && let Some(Ok(key)) = data.get(8..12).map(<[u8; 4]>::try_from) + { + self.blend = blend(&key); + } + } + b"iOpa" => self.fill = data.first().map_or(1.0, |&v| f32::from(v) / 255.0), + b"TySh" | b"tySh" => self.content = Content::Type, + b"SoCo" => self.content = Content::Solid(solid_color(data)), + b"GdFl" | b"PtFl" => self.content = Content::Fill, + b"lfx2" | b"lmfx" => effects = Some(effects == Some(true) || effects_on(data)), + b"lrFX" => old_effects = true, + b"vmsk" | b"vsms" => self.vector_mask = true, + // Shallow or deep knockout. + b"knko" => self.knockout = data.first().is_some_and(|&k| k != 0), + k if ADJUSTMENTS.contains(&k) => self.content = Content::Adjustment, + _ => {} + } + // Some writers pad blocks further: skip to the next signature. + for _ in 0..3 { + if e.rest().is_empty() || e.rest().starts_with(b"8BIM") || e.rest().starts_with(b"8B64") { + break; + } + let _ = e.take(1); + } + } + self.effects = effects.unwrap_or(old_effects); + Ok(()) + } + + /// Does the record store no pixels (bounds of no area, or no color channel)? + fn stores_nothing(&self) -> bool { + let (w, h) = self.bounds.size(); + w == 0 || h == 0 || !self.channels.iter().any(|(id, _)| *id >= 0) + } + + /// Is the layer a fill drawn from its color over the whole canvas (a solid color fill that + /// stores no pixels and has no vector mask)? + fn color_over_canvas(&self) -> bool { + matches!(self.content, Content::Solid(Some(_))) && self.stores_nothing() && !self.vector_mask + } + + /// Does the layer cover the whole canvas without drawing (an adjustment layer, or a fill that + /// stores no pixels, has no vector mask and isn't drawn from its color)? Layers clipped to it + /// show over the whole canvas. + fn covers_without_drawing(&self) -> bool { + match self.content { + Content::Adjustment => true, + Content::Solid(None) | Content::Fill => self.stores_nothing() && !self.vector_mask, + _ => false, + } + } + + /// Is the layer a fill that stores no pixels and whose shape is a vector mask, so that it + /// isn't drawn? + fn undrawn_shape(&self) -> bool { + matches!(self.content, Content::Solid(_) | Content::Fill) && self.stores_nothing() && self.vector_mask + } +} + +/// Does an effects descriptor (`lfx2`, `lmfx`) have its master switch on and an effect switched on? +/// Read as psd-tools (MIT) reads them, a missing switch being off: `masterFXSwitch` and each +/// effect's `enab` (, `api/effects.py`). +fn effects_on(data: &[u8]) -> bool { + // A key, then its type `bool`, then one byte. + let values = |key: &'static [u8]| data.windows(key.len() + 1).filter(move |w| w.starts_with(key)).filter_map(|w| w.last().map(|&v| v != 0)); + values(b"masterFXSwitchbool").any(|on| on) && values(b"enabbool").any(|on| on) +} + +/// The layer mask data's place, default color, disabled flag (bit 1) and invert flag (bit 2). +fn mask_info(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let bounds = Bounds::read(&mut r).ok()?; + let default = r.u8().ok()?; + let flags = r.u8().ok()?; + Some(MaskInfo { bounds, default, disabled: flags & 2 != 0, inverted: flags & 4 != 0 }) +} + +/// A `luni` block's name: a count of UTF-16 units, then the units. +fn unicode(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let n = usize::try_from(r.u32().ok()?).ok()?; + let units: Vec = r.take(n.checked_mul(2)?).ok()?.as_chunks::<2>().0.iter().map(|u| u16::from_be_bytes(*u)).collect(); + Some(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) +} + +/// The color of a solid color fill layer's descriptor, from the values after its color keys: red, +/// green and blue (0..255, or 0..1 in the `redFloat` keys), cyan, magenta, yellow and black (0..100 +/// percent ink), gray (0..100 percent black), Lab (L 0..100, a and b) or hue (degrees), saturation +/// and brightness (0..100). `None` for a color in another model. +fn solid_color(data: &[u8]) -> Option { + // A key, then `doub` and a double, or `UntF`, a unit and a double. + let value = |key: &[u8]| -> Option { + let at = data.windows(key.len()).position(|w| w == key)? + key.len(); + let at = match data.get(at..at + 4)? { + b"doub" => at + 4, + b"UntF" => at + 8, + _ => return None, + }; + let v = f64::from_be_bytes(data.get(at..at + 8)?.try_into().ok()?); + v.is_finite().then_some(v) + }; + let unit = |v: f64| v.clamp(0.0, 1.0) as f32; + let rgb = |keys: [&[u8]; 3], scale: f64| -> Option<[f32; 3]> { + Some([unit(value(keys[0])? / scale), unit(value(keys[1])? / scale), unit(value(keys[2])? / scale)]) + }; + if let Some(c) = rgb([b"Rd ", b"Grn ", b"Bl "], 255.0).or_else(|| rgb([b"redFloat", b"greenFloat", b"blueFloat"], 1.0)) { + return Some(Paint::Rgb(c)); + } + if let (Some(c), Some(m), Some(y), Some(k)) = (value(b"Cyn "), value(b"Mgnt"), value(b"Ylw "), value(b"Blck")) { + return Some(Paint::Cmyk([c, m, y, k].map(|v| unit(v / 100.0)))); + } + if let Some(g) = value(b"Gry ") { + return Some(Paint::Rgb([unit(1.0 - g / 100.0); 3])); + } + if let (Some(l), Some(a), Some(b)) = (value(b"Lmnc"), value(b"A "), value(b"B ")) { + let [r, g, b] = lab_to_srgb(Lab { l: l as f32, a: a as f32, b: b as f32 }); + return Some(Paint::Rgb([r, g, b].map(|v| v.clamp(0.0, 1.0)))); + } + let (h, s, b) = (value(b"H ")?, value(b"Strt")?, value(b"Brgh")?); + Some(Paint::Rgb(hsb(h, unit(s / 100.0), unit(b / 100.0)))) +} + +/// Hue `h` (degrees), saturation `s` and brightness `b` (0..1) as RGB. +fn hsb(h: f64, s: f32, b: f32) -> [f32; 3] { + let h = (h.rem_euclid(360.0) / 60.0) as f32; + let f = |n: f32| { + let k = (n + h) % 6.0; + b - b * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} + +/// The layer tree: records in the order the file stores them, bottom first. +enum Item { + Layer(usize), + /// A group's record and its layers. + Group(usize, Vec), +} + +fn tree(records: &[Record<'_>]) -> Result, String> { + let mut stack: Vec> = vec![vec![]]; + for (i, rec) in records.iter().enumerate() { + match rec.divider { + 3 => { + if stack.len() > MAX_DEPTH { + return Err("the PSD file's layer groups are nested too deeply".into()); + } + stack.push(vec![]); + } + 1 | 2 => { + // A group without its end divider holds nothing. + let children = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { vec![] }; + if let Some(top) = stack.last_mut() { + top.push(Item::Group(i, children)); + } + } + _ => { + if let Some(top) = stack.last_mut() { + top.push(Item::Layer(i)); + } + } + } + } + // Groups the file never closes: their layers join the group around them. + while stack.len() > 1 { + let inner = stack.pop().unwrap_or_default(); + if let Some(top) = stack.last_mut() { + top.extend(inner); + } + } + Ok(stack.pop().unwrap_or_default()) +} + +/// Straight RGBA, 0..1, at `left`, `top` on the canvas. +#[derive(Clone, Default)] +struct Canvas { + left: i64, + top: i64, + width: usize, + height: usize, + px: Vec<[f32; 4]>, +} + +impl Canvas { + fn from_image(left: i64, top: i64, img: &RgbaImage) -> Self { + let px = img.pixels().map(|p| p.0.map(|v| f32::from(v) / 255.0)).collect(); + Self { left, top, width: img.width() as usize, height: img.height() as usize, px } + } + fn get(&self, x: i64, y: i64) -> Option<[f32; 4]> { + let (x, y) = (usize::try_from(x - self.left).ok()?, usize::try_from(y - self.top).ok()?); + if x >= self.width || y >= self.height { + return None; + } + self.px.get(y * self.width + x).copied() + } + /// The canvas as an image, cropped to its painted pixels; `None` when nothing is painted. + fn to_image(&self) -> Option<(i64, i64, RgbaImage)> { + let painted = |x: usize, y: usize| self.px.get(y * self.width + x).is_some_and(|p| p[3] > 0.0); + let rows: Vec = (0..self.height).filter(|&y| (0..self.width).any(|x| painted(x, y))).collect(); + let (&y0, &y1) = (rows.first()?, rows.last()?); + let cols: Vec = (0..self.width).filter(|&x| (y0..=y1).any(|y| painted(x, y))).collect(); + let (&x0, &x1) = (cols.first()?, cols.last()?); + let img = RgbaImage::from_fn((x1 - x0 + 1) as u32, (y1 - y0 + 1) as u32, |x, y| { + let p = self.px.get((y0 + y as usize) * self.width + x0 + x as usize).copied().unwrap_or_default(); + image::Rgba(p.map(byte)) + }); + Some((self.left + x0 as i64, self.top + y0 as i64, img)) + } +} + +/// The blend function of a mode VectorCraft doesn't have, on straight RGB. +fn blend_other(mode: Other, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + let each = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)).clamp(0.0, 1.0)); + // Darker and Lighter Color compare the total of the channel values. + let total = |c: [f32; 3]| c[0] + c[1] + c[2]; + let burn = |b: f32, s: f32| blend_rgb(BlendMode::ColorBurn, [b; 3], [s; 3])[0]; + let dodge = |b: f32, s: f32| blend_rgb(BlendMode::ColorDodge, [b; 3], [s; 3])[0]; + match mode { + // Dissolve's pixels are opaque or transparent (`coverage`); the opaque ones replace. + Other::Dissolve => s, + Other::LinearBurn => each(|b, s| b + s - 1.0), + Other::LinearDodge => each(|b, s| b + s), + Other::VividLight => [0, 1, 2].map(|i| { + let (b, s) = (b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)); + if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) } + }), + Other::LinearLight => each(|b, s| b + 2.0 * s - 1.0), + Other::PinLight => each(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), + Other::HardMix => each(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), + Other::Subtract => each(|b, s| b - s), + Other::Divide => each(|b, s| if s <= 0.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { b / s }), + Other::DarkerColor => { + if total(s) < total(b) { + s + } else { + b + } + } + Other::LighterColor => { + if total(s) > total(b) { + s + } else { + b + } + } + } +} + +/// Source-over compositing of straight `s` onto straight `b` in `mode`, as +/// [`vectorcraft_color::blend::composite`] does for the modes it has. +fn over(mode: Blend, b: [f32; 4], s: [f32; 4]) -> [f32; 4] { + let other = match mode { + Blend::Mode(m) => return composite(m, b, s), + Blend::Pass => return composite(BlendMode::Normal, b, s), + Blend::Other(o) => o, + }; + let (ab, a) = (b[3].clamp(0.0, 1.0), s[3].clamp(0.0, 1.0)); + let ao = a + ab * (1.0 - a); + if ao <= 0.0 { + return [0.0; 4]; + } + let mixed = blend_other(other, [b[0], b[1], b[2]], [s[0], s[1], s[2]]); + let c = |i: usize| ((1.0 - ab) * s[i] + ab * mixed[i]) * a + (1.0 - a) * ab * b[i]; + [c(0) / ao, c(1) / ao, c(2) / ao, ao] +} + +/// What one entry of a layer list becomes: a layer of the document. +struct Unit<'i> { + name: String, + visible: bool, + opacity: f32, + blend: Blend, + /// The record whose layer mask the layer takes. + mask_of: Option, + /// The record the layer comes from (each record has its own Dissolve pattern). + record: usize, + body: Body<'i>, + /// While layers are clipped to it: how its merged canvas is shaped. + clip: Option, +} + +impl Unit<'_> { + /// Is it what a merge of layers in modes VectorCraft lacks made: one image, visible, at full + /// opacity in the Normal mode, without a mask? + fn is_merge(&self) -> bool { + matches!(self.body, Body::Merged(_)) + && self.clip.is_none() + && self.mask_of.is_none() + && self.visible + && self.opacity >= 1.0 + && self.blend == Blend::Mode(BlendMode::Normal) + } +} + +/// The shape of a layer merged with the layers clipped to it. +enum Clip { + /// The layer's own transparency at each pixel of its canvas; the canvas holds its pixels at its + /// fill opacity, the clipped layers composited over them. + Shape(Vec), + /// The whole canvas of the file (a layer that covers it without drawing): the canvas holds the + /// clipped layers alone. + Whole, +} + +enum Body<'i> { + /// A layer record's pixels. + Layer(usize), + /// A group's layers. + Group(&'i [Item]), + /// Layers merged into one image. + Merged(Canvas), +} + +/// A unit's body drawn alone. +enum Drawn { + /// It draws nothing. + Nothing, + Canvas(Canvas), + /// Its canvas would need more memory than allowed. + TooLarge, +} + +/// What the warnings say. +#[derive(Default)] +struct Notes { + types: usize, + adjustments: usize, + fills: usize, + effects: usize, + vector_masks: usize, + knockouts: usize, + /// Hidden layers left out, and layers clipped to them. + hidden: usize, + merged: Vec<&'static str>, + unmerged: usize, + /// Clipped layers left unclipped: the merge needs more memory than allowed. + unclipped: usize, + /// Layers clipped to a fill that isn't drawn. + clipped_out: usize, +} + +/// The counts a merge that can't be made gives back. +#[derive(Clone, Copy)] +struct Counts { + skipped: usize, + merged: usize, + unmerged: usize, + unclipped: usize, + clipped_out: usize, +} + +/// `n` things: "1 layer was", "2 layers were". +fn count(n: usize, one: &str, many: &str) -> String { + if n == 1 { format!("1 {one} was") } else { format!("{n} {many} were") } +} + +impl Notes { + fn warnings(self) -> Vec { + let mut out = vec![]; + if self.types > 0 { + let n = if self.types == 1 { "1 type layer".to_string() } else { format!("{} type layers", self.types) }; + out.push(format!("{n} came in as pixels: the text isn't editable")); + } + if self.adjustments > 0 { + out.push(format!("{} not applied", count(self.adjustments, "adjustment layer", "adjustment layers"))); + } + if self.fills > 0 { + out.push(format!( + "{} not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)", + count(self.fills, "fill layer", "fill layers") + )); + } + let layers = |n: usize| if n == 1 { "1 layer".to_string() } else { format!("{n} layers") }; + if self.effects > 0 { + out.push(format!("the layer effects of {} weren't drawn", layers(self.effects))); + } + if self.vector_masks > 0 { + out.push(format!("the vector masks of {} weren't applied", layers(self.vector_masks))); + } + if self.knockouts > 0 { + out.push(format!("the knockout of {} wasn't applied", layers(self.knockouts))); + } + if self.hidden > 0 { + out.push(format!("{} left out (hidden layers and the layers clipped to them)", count(self.hidden, "layer", "layers"))); + } + if self.clipped_out == 1 { + out.push("1 layer clipped to a fill layer that wasn't drawn was left out".into()); + } else if self.clipped_out > 1 { + out.push(format!("{} layers clipped to fill layers that weren't drawn were left out", self.clipped_out)); + } + if !self.merged.is_empty() { + out.push(format!("layers in blend modes VectorCraft doesn't have ({}) were merged with the layers below them", self.merged.join(", "))); + } + if self.unmerged > 0 { + out.push(format!( + "{} in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + layers(self.unmerged) + )); + } + if self.unclipped > 0 { + out.push(format!("{} clipped to the layer below came in unclipped: the merge needs more memory than allowed", layers(self.unclipped))); + } + out + } + + fn counts(&self, skipped: usize) -> Counts { + Counts { skipped, merged: self.merged.len(), unmerged: self.unmerged, unclipped: self.unclipped, clipped_out: self.clipped_out } + } +} + +struct Builder<'a, F> { + h: &'a Header<'a>, + records: &'a [Record<'a>], + /// Hidden layers come in, hidden. + hidden: bool, + max_alloc: u64, + /// Bytes held by the copies of what is below the pass-through groups being merged. + held: u64, + cmyk: F, + doc: Document, + /// Canvas pixels to points. + to_doc: Affine, + ppi: Option<(f64, f64)>, + layers: usize, + notes: Notes, + /// The records [`Notes`] counted. + noted: Vec, + /// The records counted as hidden layers left out. + skipped: Vec, + /// Those records, in the order they were counted. + skip_log: Vec, + /// Bytes of layer and mask pixels decoded, and of canvases the merges made, so far. + spent: Cell, +} + +impl [f32; 3]> Builder<'_, F> { + fn record(&self, i: usize) -> Result<&Record<'_>, String> { + self.records.get(i).ok_or_else(|| DAMAGED.to_string()) + } + + /// Refuse to allocate `bytes` past the memory allowed. + fn fits(&self, bytes: u64) -> Result<(), String> { + if bytes > self.max_alloc { Err("a layer of the PSD file is too large to read".into()) } else { Ok(()) } + } + + /// Count `bytes` of decoded pixels, or of a canvas a merge makes, against the whole import's + /// allowance, [`TOTAL_DECODED`] times the memory allowed for one image. + fn spend(&self, bytes: u64) -> Result<(), String> { + let total = self.spent.get().saturating_add(bytes); + self.spent.set(total); + if total > self.max_alloc.saturating_mul(TOTAL_DECODED) { + return Err("the PSD file's layers take more memory to read than allowed".into()); + } + Ok(()) + } + + /// Record `i`'s pixels as straight RGBA, at their place on the canvas; `None` when it has none. + fn image(&self, i: usize) -> Result, String> { + let rec = self.record(i)?; + let h = self.h; + let max_side = if h.psb { 300_000 } else { 30_000 }; + let (w, rows) = match rec.content { + Content::Adjustment => return Ok(None), + // A fill layer that stores no pixels (a shape layer stores its shape's; one that + // doesn't, with only its vector mask, isn't drawn). + Content::Solid(Some(paint)) if rec.color_over_canvas() => { + let (w, rows) = (h.width as usize, h.height as usize); + self.fits(4 * w as u64 * rows as u64)?; + self.spend(4 * w as u64 * rows as u64)?; + let rgb = match paint { + Paint::Rgb(rgb) => rgb, + Paint::Cmyk(ink) => (self.cmyk)(ink), + }; + let px = image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), 255]); + return Ok(Some((0, 0, RgbaImage::from_pixel(h.width, h.height, px)))); + } + Content::Pixels | Content::Type | Content::Solid(_) | Content::Fill => rec.bounds.size(), + }; + if rec.stores_nothing() { + return Ok(None); + } + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let colors = h.mode.colors(); + let row = row_bytes(w, h.depth); + self.fits(((colors as u64 + 1) * row as u64).saturating_add(4 * w as u64).saturating_mul(rows as u64))?; + self.spend(4 * w as u64 * rows as u64)?; + let plane = |id: i16| -> Result>, String> { + match rec.channels.iter().find(|(c, _)| *c == id) { + Some((_, data)) => layer_channel(data, rows, row, h.psb, h.depth).map(Some), + None => Ok(None), + } + }; + let planes = (0..colors as i16).map(plane).collect::, _>>()?; + let alpha = plane(-1)?; + let linear = h.depth == 32; + let img = RgbaImage::from_fn(w as u32, rows as u32, |x, y| { + let (x, y) = (x as usize, y as usize); + let at = |p: &Option>, linear: bool, missing: f32| { + p.as_ref().and_then(|p| p.get(y * row..(y + 1) * row)).map_or(missing, |r| sample(r, x, h.depth, linear)) + }; + let rgb = to_rgb(h, |c| planes.get(c).map_or(0.0, |p| at(p, linear, 0.0)), &self.cmyk); + image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), byte(at(&alpha, false, 1.0))]) + }); + Ok(Some((i64::from(rec.bounds.left), i64::from(rec.bounds.top), img))) + } + + /// Record `i`'s layer mask as gray values at their place, with the value outside them; `None` + /// when it has none, or it is disabled and not `even_disabled`. + fn mask(&self, i: usize, even_disabled: bool) -> Result, String> { + let rec = self.record(i)?; + let Some(m) = rec.mask.filter(|m| even_disabled || !m.disabled) else { return Ok(None) }; + let Some((_, data)) = rec.channels.iter().find(|(c, _)| *c == -2) else { return Ok(None) }; + let (w, rows) = m.bounds.size(); + let max_side = if self.h.psb { 300_000 } else { 30_000 }; + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let row = row_bytes(w, self.h.depth); + self.fits((row as u64 + w as u64).saturating_mul(rows as u64))?; + self.spend(w as u64 * rows as u64)?; + let plane = layer_channel(data, rows, row, self.h.psb, self.h.depth)?; + let read = |v: u8| if m.inverted { 255 - v } else { v }; + let img = image::GrayImage::from_fn(w as u32, rows as u32, |x, y| { + let r = plane.get(y as usize * row..(y as usize + 1) * row).unwrap_or_default(); + image::Luma([read(byte(sample(r, x as usize, self.h.depth, false)))]) + }); + Ok(Some((i64::from(m.bounds.left), i64::from(m.bounds.top), img, read(m.default)))) + } + + /// Multiply `c`'s transparency by record `i`'s layer mask. + fn apply_mask(&self, c: &mut Canvas, i: usize) -> Result<(), String> { + let Some((left, top, m, default)) = self.mask(i, false)? else { return Ok(()) }; + let (w, h) = (i64::from(m.width()), i64::from(m.height())); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % c.width.max(1)) as i64, c.top + (k / c.width.max(1)) as i64); + let inside = (left..left + w).contains(&x) && (top..top + h).contains(&y); + let v = if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { default }; + p[3] *= f32::from(v) / 255.0; + } + Ok(()) + } + + /// What `items` (a layer list, bottom first) become, before the modes VectorCraft lacks are + /// merged: layers clipped to the one below are merged into it, and hidden ones are left out + /// unless `keep_hidden`. Hidden layers a clip would merge are always left out. + fn units<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + // The layer the next clipped layers clip to: its index in `out`, or `None` when it was left + // out (its clipped layers go with it). + let mut base: Option> = None; + // The next clipped layers come in unclipped: a merge with their base didn't fit. + let mut unclipped = false; + for item in items { + let (i, body) = match item { + Item::Layer(i) => (*i, Body::Layer(*i)), + Item::Group(i, children) => (*i, Body::Group(children)), + }; + let rec = self.record(i)?; + let (name, hidden, clipped, blend) = (rec.name.clone(), rec.hidden, rec.clipped, rec.blend); + let opacity = rec.opacity * rec.fill; + if clipped && let Some(b) = base { + match b { + // Clipped to a layer left out, or hidden itself: left out. + None => self.skip(i), + Some(_) if hidden => self.skip(i), + // A hidden base (with `keep_hidden`) would need the merge: it is left out with + // the layers clipped to it. + Some(b) if out.get(b).is_some_and(|u| !u.visible) => { + if b < out.len() { + let u = out.remove(b); + self.skip(u.record); + } + self.skip(i); + base = Some(None); + } + Some(b) => { + // Counted even when it draws nothing (an adjustment layer). + self.note(i)?; + if !unclipped && self.clip(&mut out, b, &body, i, &name, blend, opacity)? { + continue; + } + // The merge doesn't fit: this layer and the next ones clipped to the same + // base come in as layers of their own. + unclipped = true; + self.notes.unclipped += 1; + out.push(Unit { name, visible: true, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + } + } + continue; + } + unclipped = false; + if hidden && !keep_hidden { + self.skip(i); + base = Some(None); + continue; + } + self.note(i)?; + out.push(Unit { name, visible: !hidden, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + base = Some(Some(out.len() - 1)); + } + for u in &mut out { + self.finish_clip(u); + } + Ok(out) + } + + /// Merge the layer of record `i` (`body`, named `name`, drawn alone with its layer mask, at + /// `opacity` in `blend`) into unit `b` of `out`, which it is clipped to. The base's own layer + /// mask stays its opacity mask, which hides the clipped layers with it. `false`, with `out` as + /// it was, when the canvases would need more memory than allowed. + #[allow(clippy::too_many_arguments)] + fn clip(&mut self, out: &mut [Unit<'_>], b: usize, body: &Body<'_>, i: usize, name: &str, blend: Blend, opacity: f32) -> Result { + let Some(u) = out.get(b) else { return Ok(true) }; + let (whole, undrawn, fill, base_opacity) = { + let r = self.record(u.record)?; + let layer = matches!(u.body, Body::Layer(_)); + (layer && r.covers_without_drawing(), layer && r.undrawn_shape(), r.fill, r.opacity) + }; + // A fill whose shape is a vector mask isn't drawn, and nothing clipped to it is either. + if undrawn && u.clip.is_none() { + self.notes.clipped_out += 1; + return Ok(true); + } + let layer = match self.draw_body(body, Some(i))? { + Drawn::Nothing => return Ok(true), + Drawn::TooLarge => return Ok(false), + Drawn::Canvas(c) => c, + }; + let room = self.room(); + let layer_bytes = 16 * layer.px.len() as u64; + let Some(u) = out.get_mut(b) else { return Ok(true) }; + if u.clip.is_none() { + let (canvas, clip) = if whole { + (Canvas::default(), Clip::Whole) + } else { + match self.draw_body(&u.body, None)? { + Drawn::TooLarge => return Ok(false), + // A base that draws nothing shows nothing of its clipped layers either. + Drawn::Nothing => (Canvas::default(), Clip::Shape(vec![])), + Drawn::Canvas(mut c) => { + // Its transparency, 4 bytes a pixel, is kept beside the canvas. + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + self.spend(4 * c.px.len() as u64)?; + let shape = c.px.iter().map(|p| p[3]).collect(); + for p in &mut c.px { + p[3] = fill; + } + (c, Clip::Shape(shape)) + } + } + }; + u.body = Body::Merged(canvas); + u.clip = Some(clip); + u.opacity = base_opacity; + } + let Body::Merged(c) = &mut u.body else { return Ok(true) }; + match &u.clip { + Some(Clip::Shape(_)) => { + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + clip_onto(c, &layer, blend, opacity, i); + } + _ => { + let mut acc = (!c.px.is_empty()).then(|| std::mem::take(c)); + let laid = blend_onto(&mut acc, &layer, blend, opacity, i, room, &|n| self.spend(n)); + *c = acc.unwrap_or_default(); + if !laid? { + return Ok(false); + } + } + } + u.name.push_str(" + "); + u.name.push_str(name); + Ok(true) + } + + /// Unit `u`'s merged canvas, once every layer clipped to it is in: transparent outside its + /// shape. + fn finish_clip(&self, u: &mut Unit<'_>) { + let Some(clip) = u.clip.take() else { return }; + let Body::Merged(c) = &mut u.body else { return }; + match clip { + Clip::Shape(shape) => c.px.iter_mut().zip(shape).for_each(|(p, a)| p[3] *= a), + Clip::Whole => { + let (w, h, width) = (i64::from(self.h.width), i64::from(self.h.height), c.width.max(1)); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % width) as i64, c.top + (k / width) as i64); + if !(0..w).contains(&x) || !(0..h).contains(&y) { + p[3] = 0.0; + } + } + } + } + } + + /// Count what record `i` holds that doesn't come in. + fn note(&mut self, i: usize) -> Result<(), String> { + // Once, though a merge may draw it again. + match self.noted.get_mut(i) { + Some(true) => return Ok(()), + Some(n) => *n = true, + None => {} + } + let (content, empty, effects, vector_mask, knockout) = { + let rec = self.record(i)?; + (rec.content, rec.stores_nothing(), rec.effects, rec.vector_mask, rec.knockout) + }; + match content { + Content::Type => self.notes.types += 1, + Content::Adjustment => self.notes.adjustments += 1, + // A fill drawn from neither its pixels nor its color. + Content::Fill | Content::Solid(None) if empty => self.notes.fills += 1, + Content::Solid(Some(_)) if empty && vector_mask => self.notes.fills += 1, + _ => {} + } + self.notes.effects += usize::from(effects); + self.notes.vector_masks += usize::from(vector_mask); + self.notes.knockouts += usize::from(knockout); + Ok(()) + } + + /// Count record `i` as a hidden layer left out (once). + fn skip(&mut self, i: usize) { + if let Some(s) = self.skipped.get_mut(i) + && !*s + { + *s = true; + self.notes.hidden += 1; + self.skip_log.push(i); + } + } + + /// `items`' units with the layers in blend modes VectorCraft lacks merged with those below. + fn merged<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + for mut u in self.units(items, keep_hidden)? { + let Blend::Other(other) = u.blend else { + out.push(u); + continue; + }; + // A hidden layer that would need the merge is left out. + if !u.visible { + self.skip(u.record); + continue; + } + // A merge that can't be made counts nothing its groups would have counted. + let saved = self.notes.counts(self.skip_log.len()); + let made = match out.as_mut_slice() { + // Onto the previous merge alone: composited onto its canvas, in place. + [prev] if prev.is_merge() => { + let mut acc = match &mut prev.body { + Body::Merged(c) => Some(std::mem::take(c)), + _ => None, + }; + let made = self.composite(&mut acc, &u); + if let Body::Merged(c) = &mut prev.body { + *c = acc.unwrap_or_default(); + } + if made? { + prev.name.push_str(" + "); + prev.name.push_str(&u.name); + true + } else { + false + } + } + _ => match self.merge(&out, &u)? { + Some(canvas) => { + let mut name = String::new(); + for (k, b) in out.iter_mut().filter(|b| b.visible).enumerate() { + if k == 0 { + name = std::mem::take(&mut b.name); + } else { + name.push_str(" + "); + name.push_str(&b.name); + } + } + if out.iter().any(|b| b.visible) { + name.push_str(" + "); + } + name.push_str(&u.name); + // Hidden layers below are left out with the rest. + for b in out.iter().filter(|b| !b.visible).map(|b| b.record).collect::>() { + self.skip(b); + } + out.clear(); + out.push(Unit { + name, + visible: true, + opacity: 1.0, + blend: Blend::Mode(BlendMode::Normal), + mask_of: None, + record: u.record, + body: Body::Merged(canvas), + clip: None, + }); + true + } + None => false, + }, + }; + if made { + if !self.notes.merged.contains(&other.label()) { + self.notes.merged.push(other.label()); + } + } else { + self.restore(saved); + self.notes.unmerged += 1; + u.blend = Blend::Mode(BlendMode::Normal); + out.push(u); + } + } + Ok(out) + } + + /// The counts as they were before a merge that couldn't be made. + fn restore(&mut self, c: Counts) { + for i in self.skip_log.split_off(c.skipped) { + if let Some(s) = self.skipped.get_mut(i) { + *s = false; + self.notes.hidden = self.notes.hidden.saturating_sub(1); + } + } + self.notes.merged.truncate(c.merged); + self.notes.unmerged = c.unmerged; + self.notes.unclipped = c.unclipped; + self.notes.clipped_out = c.clipped_out; + } + + /// The visible units `below` with `top` composited over them; `None` when the canvas would need + /// more memory than allowed. + fn merge(&mut self, below: &[Unit<'_>], top: &Unit<'_>) -> Result, String> { + let mut acc: Option = None; + for u in below.iter().filter(|u| u.visible).chain([top]) { + if !self.composite(&mut acc, u)? { + return Ok(None); + } + } + Ok(Some(acc.unwrap_or_default())) + } + + /// The bytes a merge step may still take: the memory allowed, less the copies of what is below + /// the pass-through groups being merged. + fn room(&self) -> u64 { + self.max_alloc.saturating_sub(self.held) + } + + /// Unit `u` composited onto `acc`. A pass-through group lays its layers onto `acc` one by one, + /// so that they blend with what is below it; at less than full opacity or with a layer mask, the + /// result is then faded back toward `acc` as it was by the opacity and the mask. Anything else + /// is drawn alone first. `false` when the canvas would need more memory than allowed. + fn composite(&mut self, acc: &mut Option, u: &Unit<'_>) -> Result { + if let (Body::Group(items), Blend::Pass) = (&u.body, u.blend) { + let mask = match u.mask_of { + Some(i) if self.has_mask(Some(i))? => self.mask(i, false)?, + _ => None, + }; + if u.opacity >= 1.0 && mask.is_none() { + return self.lay(acc, items); + } + let bytes = acc.as_ref().map_or(0, |c| 16 * c.px.len() as u64); + if bytes > self.room() { + return Ok(false); + } + self.spend(bytes)?; + let below = acc.clone(); + self.held = self.held.saturating_add(bytes); + let laid = self.lay(acc, items); + self.held = self.held.saturating_sub(bytes); + if !laid? { + return Ok(false); + } + if let Some(after) = acc.as_mut() { + fade(after, below.as_ref(), u.opacity, mask.as_ref()); + } + return Ok(true); + } + let c = match self.draw_body(&u.body, u.mask_of)? { + Drawn::Nothing => return Ok(true), + Drawn::Canvas(c) => c, + Drawn::TooLarge => return Ok(false), + }; + blend_onto(acc, &c, u.blend, u.opacity, u.record, self.room(), &|n| self.spend(n)) + } + + /// `items`' layers composited onto `acc` one by one; `false` when a canvas would need more + /// memory than allowed. + fn lay(&mut self, acc: &mut Option, items: &[Item]) -> Result { + for c in self.merged(items, false)? { + if !self.composite(acc, &c)? { + return Ok(false); + } + } + Ok(true) + } + + /// Does record `mask_of` have a layer mask that is switched on? + fn has_mask(&self, mask_of: Option) -> Result { + let Some(i) = mask_of else { return Ok(false) }; + let rec = self.record(i)?; + Ok(rec.mask.is_some_and(|m| !m.disabled) && rec.channels.iter().any(|(c, _)| *c == -2)) + } + + /// A unit's body drawn alone, with the layer mask of record `mask_of`, before its own opacity + /// and blend mode. Its canvas counts against the import's allowance. + fn draw_body(&mut self, body: &Body<'_>, mask_of: Option) -> Result { + let mut c = match body { + Body::Merged(c) => { + let bytes = 16 * c.px.len() as u64; + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + c.clone() + } + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => { + let bytes = 16 * u64::from(img.width()) * u64::from(img.height()); + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + Canvas::from_image(left, top, &img) + } + None => return Ok(Drawn::Nothing), + }, + Body::Group(items) => { + let mut acc: Option = None; + if !self.lay(&mut acc, items)? { + return Ok(Drawn::TooLarge); + } + let Some(acc) = acc else { return Ok(Drawn::Nothing) }; + acc + } + }; + if let Some(i) = mask_of { + self.apply_mask(&mut c, i)?; + } + Ok(Drawn::Canvas(c)) + } + + /// A new layer of the document named `name`, colored as the next new layer would be. + fn layer(&mut self, name: &str) -> Node { + let n = Node::layer(self.doc.alloc_id(), name, LayerColor::Preset((self.layers % LAYER_COLORS.len()) as u8)); + self.layers += 1; + n + } + + /// `img` at `left`, `top` on the canvas as an image of the document named `name`. + fn image_node(&mut self, name: &str, left: i64, top: i64, img: RgbaImage) -> Result { + let RasterImage { key, blob, width, height, .. } = as_png(img, None, self.ppi).map_err(|e| e.to_string())?; + let xf = self.to_doc * Affine::translate((left as f64, top as f64)); + let mut n = Node::new( + self.doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), + ); + n.name = Some(name.to_string()); + self.doc.images.insert(key, blob); + Ok(n) + } + + /// Layers of the document for `items`, bottom first. + fn list(&mut self, items: &[Item]) -> Result>, String> { + let mut out = vec![]; + for u in self.merged(items, self.hidden)? { + if let Some(n) = self.node(u)? { + out.push(Arc::new(n)); + } + } + Ok(out) + } + + /// The layer of the document unit `u` becomes; `None` for a layer that draws nothing (an + /// adjustment layer, or one without pixels). + fn node(&mut self, u: Unit<'_>) -> Result, String> { + let children = match &u.body { + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Merged(c) => match c.to_image() { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Group(items) => self.list(items)?, + }; + let mut layer = self.layer(&u.name); + layer.visible = u.visible; + layer.opacity = u.opacity.clamp(0.0, 1.0); + (layer.blend, layer.isolate) = match u.blend { + Blend::Mode(m) => (m, matches!(u.body, Body::Group(_))), + Blend::Pass | Blend::Other(_) => (BlendMode::Normal, false), + }; + if let Some(c) = layer.children_mut() { + *c = children; + } + if let Some(i) = u.mask_of { + layer.extras_mut().mask = self.opacity_mask(i)?.map(Box::new); + } + Ok(Some(layer)) + } + + /// Record `i`'s layer mask as an opacity mask: a gray image, outside which the layer is hidden + /// or shown as the mask's default color says (a mask of no pixels hides all of it, or none). + fn opacity_mask(&mut self, i: usize) -> Result, String> { + let disabled = self.record(i)?.mask.is_some_and(|m| m.disabled); + let Some((left, top, gray, default)) = self.mask(i, true)? else { return Ok(None) }; + let (art, clip) = if gray.width() == 0 || gray.height() == 0 { + if default == 255 { + return Ok(None); + } + let black = RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 255])); + (self.image_node("Layer Mask", left, top, black)?, true) + } else { + self.fits(5 * u64::from(gray.width()) * u64::from(gray.height()))?; + let rgba = RgbaImage::from_fn(gray.width(), gray.height(), |x, y| { + let v = gray.get_pixel(x, y).0[0]; + image::Rgba([v, v, v, 255]) + }); + (self.image_node("Layer Mask", left, top, rgba)?, default == 0) + }; + let mut mask = OpacityMask::new(art, clip); + mask.disabled = disabled; + Ok(Some(mask)) + } +} + +/// The coverage of a pixel at `x`, `y` of the canvas with `alpha`, 0..1, in `blend`: in Dissolve +/// fully opaque or fully transparent, opaque at a share of the pixels equal to `alpha`, chosen by a +/// fixed hash of the pixel's place and of `record`, so that each layer has its own pattern; `alpha` +/// in every other mode. +fn coverage(blend: Blend, alpha: f32, x: i64, y: i64, record: usize) -> f32 { + if !matches!(blend, Blend::Other(Other::Dissolve)) { + return alpha; + } + let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ (record as u64).wrapping_mul(0x1656_67B1_9E37_79F9); + h ^= h >> 31; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 29; + // The top 24 bits, 0..1. + let r = (h >> 40) as f32 / (1u64 << 24) as f32; + if r < alpha { 1.0 } else { 0.0 } +} + +/// `layer` (of record `record`) composited onto `base`, the canvas of the layer it is clipped to, +/// in `blend` at `opacity`, where the two overlap. +fn clip_onto(base: &mut Canvas, layer: &Canvas, blend: Blend, opacity: f32, record: usize) { + let (x0, x1) = (base.left.max(layer.left), (base.left + base.width as i64).min(layer.left + layer.width as i64)); + let (y0, y1) = (base.top.max(layer.top), (base.top + base.height as i64).min(layer.top + layer.height as i64)); + for y in y0..y1 { + for x in x0..x1 { + let Some(mut s) = layer.get(x, y) else { continue }; + let k = (y - base.top) as usize * base.width + (x - base.left) as usize; + let Some(b) = base.px.get_mut(k) else { continue }; + s[3] = coverage(blend, s[3] * opacity, x, y, record); + *b = over(blend, *b, s); + } + } +} + +/// `after` faded back toward `before` (transparent where it has no pixels) by `opacity` times the +/// value of `mask` (its place, its gray values and the value outside it) at each pixel, in +/// premultiplied color. +fn fade(after: &mut Canvas, before: Option<&Canvas>, opacity: f32, mask: Option<&(i64, i64, image::GrayImage, u8)>) { + let width = after.width.max(1); + for (k, p) in after.px.iter_mut().enumerate() { + let (x, y) = (after.left + (k % width) as i64, after.top + (k / width) as i64); + let m = mask.map_or(255, |(left, top, m, default)| { + let inside = (*left..*left + i64::from(m.width())).contains(&x) && (*top..*top + i64::from(m.height())).contains(&y); + if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { *default } + }); + let t = opacity * f32::from(m) / 255.0; + let b = before.and_then(|c| c.get(x, y)).unwrap_or([0.0; 4]); + let alpha = (1.0 - t) * b[3] + t * p[3]; + if alpha <= 0.0 { + *p = [0.0; 4]; + continue; + } + let mix = |i: usize| ((1.0 - t) * b[3] * b[i] + t * p[3] * p[i]) / alpha; + *p = [mix(0), mix(1), mix(2), alpha]; + } +} + +/// `src` (of record `record`) at `opacity` composited onto `acc` (nothing when `None`) in `blend`, +/// on a canvas covering both (`acc` itself when it covers `src`; a larger one is counted with +/// `spend`). `false`, with `acc` as it was, when the canvases held, `acc` and `src` and a larger +/// canvas when one is needed, would take more than `max_alloc` bytes. +fn blend_onto( + acc: &mut Option, + src: &Canvas, + blend: Blend, + opacity: f32, + record: usize, + max_alloc: u64, + spend: &dyn Fn(u64) -> Result<(), String>, +) -> Result { + let (mut left, mut top, mut right, mut bottom) = (src.left, src.top, src.left + src.width as i64, src.top + src.height as i64); + if let Some(a) = acc.as_ref() { + left = left.min(a.left); + top = top.min(a.top); + right = right.max(a.left + a.width as i64); + bottom = bottom.max(a.top + a.height as i64); + } + let (Ok(width), Ok(height)) = (usize::try_from(right - left), usize::try_from(bottom - top)) else { return Ok(false) }; + let held = (src.px.len() as u64).saturating_add(acc.as_ref().map_or(0, |a| a.px.len() as u64)); + let covers = acc.as_ref().is_some_and(|a| (a.left, a.top, a.width, a.height) == (left, top, width, height)); + if covers { + if held.saturating_mul(16) > max_alloc { + return Ok(false); + } + } else { + let bytes = (width as u64).saturating_mul(height as u64).saturating_mul(16); + if bytes.saturating_add(held.saturating_mul(16)) > max_alloc { + return Ok(false); + } + spend(bytes)?; + let mut out = Canvas { left, top, width, height, px: vec![[0.0; 4]; width * height] }; + if let Some(a) = acc.take() { + for (y, row) in a.px.chunks(a.width.max(1)).enumerate() { + let at = (a.top - top) as usize + y; + let start = at * width + (a.left - left) as usize; + if let Some(dst) = out.px.get_mut(start..start + row.len()) { + dst.copy_from_slice(row); + } + } + } + *acc = Some(out); + } + let Some(out) = acc.as_mut() else { return Ok(false) }; + // Only the source's rows and columns change. + let (sw, x0, y0) = (src.width.max(1), (src.left - left) as usize, (src.top - top) as usize); + for (k, s) in src.px.iter().enumerate() { + let Some(p) = out.px.get_mut((y0 + k / sw) * width + x0 + k % sw) else { continue }; + let a = coverage(blend, s[3] * opacity, src.left + (k % sw) as i64, src.top + (k / sw) as i64, record); + *p = over(blend, *p, [s[0], s[1], s[2], a]); + } + Ok(true) +} + +/// PSD or PSB file `bytes` with its layers as layers of the document (see the module +/// documentation), with warnings for what doesn't come in. `None` when the file has no layers (its +/// merged image is all of it). CMYK converts through `cmyk` (ink amounts 0..1 → sRGB). +pub(super) fn import( + bytes: &[u8], + hidden: bool, + max_alloc: u64, + cmyk: impl Fn([f32; 4]) -> [f32; 3], +) -> Result)>, String> { + let mut r = Reader::new(bytes); + let h = header(&mut r)?; + // Layer info that holds only the count (which says whether the merged image has transparency) + // has no layers either. + let Some(info) = layer_info(h.layers, h.psb).filter(|info| info.len() > 2) else { return Ok(None) }; + let records = records(info, h.psb)?; + if records.is_empty() { + return Ok(None); + } + let items = tree(&records)?; + let ppi = super::ppi::resolution(bytes).filter(|(x, y)| x.is_finite() && y.is_finite() && *x > 0.0 && *y > 0.0); + let (sx, sy) = crate::cmd::place::pt_per_px(ppi); + let doc = Document::new(f64::from(h.width) * sx, f64::from(h.height) * sy); + let mut b = Builder { + h: &h, + records: &records, + hidden, + max_alloc, + held: 0, + cmyk, + doc, + to_doc: Affine::scale_non_uniform(sx, sy), + ppi, + layers: 0, + notes: Notes::default(), + noted: vec![false; records.len()], + skipped: vec![false; records.len()], + skip_log: vec![], + spent: Cell::new(0), + }; + let layers = b.list(&items)?; + let Builder { mut doc, notes, .. } = b; + if !layers.is_empty() { + doc.layers = layers; + } + // Groups nest up to `MAX_DEPTH`, deeper than a document may: what is too deep merges into + // the group around it, with a warning. + let mut warnings = notes.warnings(); + doc.limit_depth_noting(&mut warnings); + Ok(Some((doc, warnings))) +} diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 84ac8203a..93e7b61f1 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -1,7 +1,8 @@ //! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and //! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers, //! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification" -//! (). Layers aren't read. +//! (). File › Open reads the +//! layers with [`super::psdlayers`], which shares this reader. //! //! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its //! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw, @@ -16,7 +17,7 @@ use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; /// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Mode { +pub(super) enum Mode { Bitmap, Gray, Indexed, @@ -27,7 +28,7 @@ enum Mode { impl Mode { /// The channels holding colour (an alpha channel may follow them). - fn colors(self) -> usize { + pub(super) fn colors(self) -> usize { match self { Mode::Bitmap | Mode::Gray | Mode::Indexed => 1, Mode::Rgb | Mode::Lab => 3, @@ -46,68 +47,68 @@ pub fn is_psb(bytes: &[u8]) -> bool { is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2]) } -const TRUNCATED: &str = "the PSD file is cut short"; +pub(super) const TRUNCATED: &str = "the PSD file is cut short"; const DAMAGED: &str = "the PSD file's header is damaged"; /// Big-endian reads through `b`, each checked against its end. -struct Reader<'a> { +pub(super) struct Reader<'a> { b: &'a [u8], at: usize, } impl<'a> Reader<'a> { - fn new(b: &'a [u8]) -> Self { + pub(super) fn new(b: &'a [u8]) -> Self { Self { b, at: 0 } } - fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + pub(super) fn take(&mut self, n: usize) -> Result<&'a [u8], String> { let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?; self.at += n; Ok(s) } - fn array(&mut self) -> Result<[u8; N], String> { + pub(super) fn array(&mut self) -> Result<[u8; N], String> { self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string()) } - fn u8(&mut self) -> Result { + pub(super) fn u8(&mut self) -> Result { self.array::<1>().map(|[v]| v) } - fn u16(&mut self) -> Result { + pub(super) fn u16(&mut self) -> Result { self.array().map(u16::from_be_bytes) } - fn u32(&mut self) -> Result { + pub(super) fn u32(&mut self) -> Result { self.array().map(u32::from_be_bytes) } /// A section length: 4 bytes in a PSD, 8 in a PSB. - fn length(&mut self, psb: bool) -> Result { + pub(super) fn length(&mut self, psb: bool) -> Result { let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) }; usize::try_from(n).map_err(|_| TRUNCATED.to_string()) } /// A section: its length, then that many bytes. - fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { + pub(super) fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { let n = self.length(psb)?; self.take(n) } - fn rest(&self) -> &'a [u8] { + pub(super) fn rest(&self) -> &'a [u8] { self.b.get(self.at..).unwrap_or_default() } } /// The fixed-size header and the sections before the image data. -struct Header<'a> { - psb: bool, +pub(super) struct Header<'a> { + pub(super) psb: bool, channels: usize, - width: u32, - height: u32, - depth: u16, - mode: Mode, + pub(super) width: u32, + pub(super) height: u32, + pub(super) depth: u16, + pub(super) mode: Mode, /// Colour mode data: an indexed image's palette (256 reds, then greens, then blues). palette: &'a [u8], /// The image resources. resources: &'a [u8], /// The layer and mask information. - layers: &'a [u8], + pub(super) layers: &'a [u8], } -fn header<'a>(r: &mut Reader<'a>) -> Result, String> { +pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result, String> { if !is_psd(r.b) { return Err("not a PSD file".into()); } @@ -183,41 +184,45 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { /// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is /// empty) in their `Lr16` / `Lr32` block. fn merged_alpha(layers: &[u8], psb: bool) -> bool { - let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0); + layer_info(layers, psb).is_some_and(|info| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0)) +} + +/// Do tagged blocks with this key give an 8-byte length in a PSB? +pub(super) fn long_key(key: &[u8; 4]) -> bool { + matches!(key, b"Lr16" | b"Lr32" | b"Layr" | b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD") +} + +/// The layer info: the layer count, the layer records and their channels. It is the first part of +/// the layer and mask information, or for 16- and 32-bit documents (whose first part is empty) the +/// `Lr16` / `Lr32` block after it. `None` when the file has neither. +pub(super) fn layer_info(layers: &[u8], psb: bool) -> Option<&[u8]> { let mut r = Reader::new(layers); - let Ok(info) = r.section(psb) else { return false }; + let info = r.section(psb).ok()?; if !info.is_empty() { - return count_negative(info); + return Some(info); } // The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the // layer blocks of a PSB), data padded to 4 bytes. - if r.section(false).is_err() { - return false; - } + r.section(false).ok()?; while r.rest().len() >= 12 { - let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false }; - let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); - let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD"); - let Ok(data) = r.section(psb && long) else { - return false; - }; - if layer_block { - return count_negative(data); - } - if r.take((4 - data.len() % 4) % 4).is_err() { - return false; + r.take(4).ok()?; + let key = r.array::<4>().ok()?; + let data = r.section(psb && long_key(&key)).ok()?; + if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + return Some(data); } + r.take((4 - data.len() % 4) % 4).ok()?; } - false + None } /// The most bytes one channel row may take (a PSB at 32 bits). -fn row_bytes(width: usize, depth: u16) -> usize { +pub(super) fn row_bytes(width: usize, depth: u16) -> usize { if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) } } /// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is). -fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { +pub(super) fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { let mut at = 0; while let Some((&n, rest)) = src.split_first() { src = rest; @@ -269,32 +274,77 @@ fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Res } Ok(out) } - 2 | 3 => { - let mut out = Vec::new(); - flate2::read::ZlibDecoder::new(r.rest()) - .take(wanted as u64) - .read_to_end(&mut out) - .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; - if out.len() < wanted { - return Err(TRUNCATED.into()); - } - if method == 3 { - // Each row stores the difference from the sample before it. - match h.depth { - 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), - 16 => out.chunks_exact_mut(row).for_each(|r| { - let mut prev = 0u16; - for s in r.as_chunks_mut::<2>().0 { - prev = prev.wrapping_add(u16::from_be_bytes(*s)); - *s = prev.to_be_bytes(); - } - }), - _ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()), + 2 | 3 => inflate(r.rest(), wanted, method == 3, h.depth, row), + m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + } +} + +/// `wanted` bytes of zipped image data, rows of `row` bytes; with `predicted`, each row stores the +/// difference from the sample before it. In 32-bit data a row stores the first bytes of its samples, +/// then their second bytes, and so on, and the differences run byte by byte along the whole row (as +/// psd-tools, MIT, reads it: , `compression/__init__.py`). +fn inflate(src: &[u8], wanted: usize, predicted: bool, depth: u16, row: usize) -> Result, String> { + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(src) + .take(wanted as u64) + .read_to_end(&mut out) + .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; + if out.len() < wanted { + return Err(TRUNCATED.into()); + } + if predicted && row > 0 { + match depth { + 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), + 16 => out.chunks_exact_mut(row).for_each(|r| { + let mut prev = 0u16; + for s in r.as_chunks_mut::<2>().0 { + prev = prev.wrapping_add(u16::from_be_bytes(*s)); + *s = prev.to_be_bytes(); } + }), + 32 => { + let w = row / 4; + let mut planes = vec![0u8; row]; + for r in out.chunks_exact_mut(row) { + let mut prev = 0u8; + for (dst, &b) in planes.iter_mut().zip(r.iter()) { + prev = prev.wrapping_add(b); + *dst = prev; + } + for (x, s) in r.as_chunks_mut::<4>().0.iter_mut().enumerate() { + for (k, v) in s.iter_mut().enumerate() { + *v = planes.get(k * w + x).copied().unwrap_or(0); + } + } + } + } + // 1-bit. + _ => return Err("zipped 1-bit PSD image data with prediction can't be read".into()), + } + } + Ok(out) +} + +/// One channel of a layer: its compression method, then `rows` rows of `row` bytes (PackBits rows +/// after their byte counts, 4 bytes each in a PSB). +pub(super) fn layer_channel(data: &[u8], rows: usize, row: usize, psb: bool, depth: u16) -> Result, String> { + let mut r = Reader::new(data); + let method = r.u16()?; + let wanted = rows.checked_mul(row).ok_or(TRUNCATED)?; + match method { + 0 => r.take(wanted).map(<[u8]>::to_vec), + 1 => { + let size = if psb { 4 } else { 2 }; + let counts = r.take(rows.checked_mul(size).ok_or(TRUNCATED)?)?; + let mut out = vec![0; wanted]; + for (dst, n) in out.chunks_exact_mut(row.max(1)).zip(counts.chunks_exact(size)) { + let n = n.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)); + unpack_bits(r.take(n)?, dst); } Ok(out) } - m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + 2 | 3 => inflate(r.rest(), wanted, method == 3, depth, row), + m => Err(format!("the PSD file's layer data uses an unknown compression ({m})")), } } @@ -304,7 +354,7 @@ fn srgb_encode(v: f32) -> f32 { } /// Sample `x` of a channel row, 0..1. -fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { +pub(super) fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { match depth { 1 => { // Bitmap: a set bit is black. @@ -321,10 +371,25 @@ fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { } } -fn byte(v: f32) -> u8 { +pub(super) fn byte(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } +/// The sRGB color of a pixel whose color channels `v` gives (0..1, as stored), in `h`'s mode. +pub(super) fn to_rgb(h: &Header<'_>, v: impl Fn(usize) -> f32, cmyk: &impl Fn([f32; 4]) -> [f32; 3]) -> [f32; 3] { + match h.mode { + Mode::Bitmap | Mode::Gray => [v(0); 3], + Mode::Indexed => { + let i = usize::from(byte(v(0))); + [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) + } + Mode::Rgb => [v(0), v(1), v(2)], + // Inks are stored as 1 − amount. + Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), + Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), + } +} + /// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink /// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than /// `max_alloc` bytes. @@ -348,17 +413,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3]) let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect(); for (x, px) in line.enumerate() { let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors)); - let rgb = match h.mode { - Mode::Bitmap | Mode::Gray => [v(0); 3], - Mode::Indexed => { - let i = usize::from(byte(v(0))); - [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) - } - Mode::Rgb => [v(0), v(1), v(2)], - // Inks are stored as 1 − amount. - Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), - Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), - }; + let rgb = to_rgb(&h, v, &cmyk); let a = if alpha { v(colors) } else { 1.0 }; // The merged image is stored on white: take the white back out. let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a }; diff --git a/crates/engine/src/cmd/fileio/tests_psdlayers.rs b/crates/engine/src/cmd/fileio/tests_psdlayers.rs new file mode 100644 index 000000000..31f670d7f --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psdlayers.rs @@ -0,0 +1,1017 @@ +//! Opening PSD and PSB files with their layers as layers (`document.open`'s `layers` and +//! `hiddenLayers`): names, visibility, opacity, blend modes, groups, layer masks, clipped layers +//! and blend modes VectorCraft lacks merged, 16-bit and PSB files, VectorCraft's own PSD export +//! read back, and damaged files read without a panic. + +use serde_json::{Value, json}; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +/// One layer record of a synthetic file. +#[derive(Clone)] +struct L { + name: &'static str, + /// Top, left, bottom, right. + rect: [i32; 4], + /// Red, green, blue and alpha, the same for every pixel. + rgba: [u8; 4], + blend: &'static [u8; 4], + opacity: u8, + clipped: bool, + hidden: bool, + divider: Option, + /// Rect, default color, flags and the mask's pixels. + mask: Option<([i32; 4], u8, u8, Vec)>, + /// Extra tagged blocks. + blocks: Vec<([u8; 4], Vec)>, + /// Zipped channels: with prediction when `true`. + zip: Option, + /// The record lists no channels, whatever its rect. + bare: bool, + /// The Pascal name's bytes, with no Unicode name block. + legacy: Option<&'static [u8]>, +} + +fn layer(name: &'static str, rect: [i32; 4], rgba: [u8; 4]) -> L { + L { + name, + rect, + rgba, + blend: b"norm", + opacity: 255, + clipped: false, + hidden: false, + divider: None, + mask: None, + blocks: vec![], + zip: None, + bare: false, + legacy: None, + } +} + +fn group_end() -> L { + L { divider: Some(3), ..layer("", [0; 4], [0; 4]) } +} + +fn group(name: &'static str, blend: &'static [u8; 4]) -> L { + L { divider: Some(1), blend, ..layer(name, [0; 4], [0; 4]) } +} + +/// A tagged block, its length padded to even. +fn block(key: &[u8; 4], data: &[u8], psb: bool) -> Vec { + let mut data = data.to_vec(); + data.resize(data.len().next_multiple_of(2), 0); + let mut b = b"8BIM".to_vec(); + b.extend(key); + if psb && matches!(key, b"Lr16" | b"Lr32") { + b.extend((data.len() as u64).to_be_bytes()); + } else { + b.extend((data.len() as u32).to_be_bytes()); + } + b.extend(data); + b +} + +/// One row of a zipped channel with prediction: each sample stored as the difference from the one +/// before it, and in 32 bits the row's first bytes, then its second bytes and so on, each byte the +/// difference from the byte before it. +fn predict(row: &[u8], depth: u16) -> Vec { + let bytewise = |b: &[u8]| -> Vec { b.iter().scan(0u8, |prev, &v| Some(v.wrapping_sub(std::mem::replace(prev, v)))).collect() }; + match depth { + 16 => { + let mut prev = 0u16; + row.chunks(2) + .flat_map(|s| { + let v = u16::from_be_bytes([s[0], s[1]]); + v.wrapping_sub(std::mem::replace(&mut prev, v)).to_be_bytes() + }) + .collect() + } + 32 => bytewise(&(0..4).flat_map(|k| row.chunks(4).map(move |s| s[k])).collect::>()), + _ => bytewise(row), + } +} + +/// A channel of `n` samples of `v` (in 32 bits, `v` / 255 as a float) at `depth`, raw, zipped +/// (with prediction when `zip` is `Some(true)`) or PackBits rows of `width` when `rle`. +fn channel(v: u8, n: usize, width: usize, depth: u16, rle: bool, psb: bool, zip: Option) -> Vec { + let sample = if depth == 32 { (f32::from(v) / 255.0).to_be_bytes().to_vec() } else { vec![v; usize::from(depth / 8)] }; + let row: Vec = std::iter::repeat_n(sample.as_slice(), width).flatten().copied().collect(); + if let Some(predicted) = zip { + let rows = n.checked_div(width).unwrap_or(0); + let one = if predicted { predict(&row, depth) } else { row }; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + for _ in 0..rows { + std::io::Write::write_all(&mut z, &one).unwrap(); + } + let mut d = vec![0, if predicted { 3 } else { 2 }]; + d.extend(z.finish().unwrap()); + return d; + } + if !rle || n == 0 { + let mut d = vec![0, 0]; + d.extend(std::iter::repeat_n(sample.as_slice(), n).flatten()); + return d; + } + // Each row as literal runs of at most 128 bytes. + let mut packed = vec![]; + for run in row.chunks(128) { + packed.push((run.len() - 1) as u8); + packed.extend(run); + } + let rows = n / width; + let mut d = vec![0, 1]; + for _ in 0..rows { + if psb { + d.extend((packed.len() as u32).to_be_bytes()); + } else { + d.extend((packed.len() as u16).to_be_bytes()); + } + } + for _ in 0..rows { + d.extend(&packed); + } + d +} + +/// The layer info: the count (negative: the merged image has transparency), the records, then +/// their channels. +fn layer_info(layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let mut records: Vec = vec![]; + let mut data: Vec = vec![]; + for l in layers { + let [t, lf, b, r] = l.rect; + let (w, h) = ((r - lf).max(0) as usize, (b - t).max(0) as usize); + let mut channels: Vec<(i16, Vec)> = [(-1, l.rgba[3]), (0, l.rgba[0]), (1, l.rgba[1]), (2, l.rgba[2])] + .iter() + .filter(|_| !l.bare) + .map(|&(id, v)| (id, channel(v, w * h, w, depth, rle, psb, l.zip))) + .collect(); + let mut mask = vec![]; + if let Some(([mt, ml, mb, mr], default, flags, pixels)) = &l.mask { + let mut d = vec![0, 0]; + for &p in pixels { + d.extend(std::iter::repeat_n(p, usize::from(depth / 8))); + } + channels.push((-2, d)); + for v in [mt, ml, mb, mr] { + mask.extend(v.to_be_bytes()); + } + mask.extend([*default, *flags, 0, 0]); + } + for v in l.rect { + records.extend(v.to_be_bytes()); + } + records.extend((channels.len() as u16).to_be_bytes()); + for (id, d) in &channels { + records.extend(id.to_be_bytes()); + if psb { + records.extend((d.len() as u64).to_be_bytes()); + } else { + records.extend((d.len() as u32).to_be_bytes()); + } + data.extend(d); + } + records.extend(b"8BIM"); + records.extend(l.blend); + records.extend([l.opacity, u8::from(l.clipped), 0x08 | if l.hidden { 2 } else { 0 }, 0]); + let mut extra = (mask.len() as u32).to_be_bytes().to_vec(); + extra.extend(mask); + extra.extend(0u32.to_be_bytes()); + let ascii = l.legacy.unwrap_or(l.name.as_bytes()); + extra.push(ascii.len() as u8); + extra.extend(ascii); + // The Pascal string padded to a multiple of 4 (what comes before it is too). + extra.resize(extra.len().next_multiple_of(4), 0); + if l.legacy.is_none() { + let units: Vec = l.name.encode_utf16().collect(); + let mut luni = (units.len() as u32).to_be_bytes().to_vec(); + luni.extend(units.iter().flat_map(|u| u.to_be_bytes())); + extra.extend(block(b"luni", &luni, psb)); + } + if let Some(kind) = l.divider { + extra.extend(block(b"lsct", &[&kind.to_be_bytes()[..], b"8BIM", l.blend].concat(), psb)); + } + for (key, d) in &l.blocks { + extra.extend(block(key, d, psb)); + } + records.extend((extra.len() as u32).to_be_bytes()); + records.extend(extra); + } + let mut info = (-(layers.len() as i16)).to_be_bytes().to_vec(); + info.extend(records); + info.extend(data); + info.resize(info.len().next_multiple_of(2), 0); + info +} + +/// An RGB document of `w` × `h` pixels at 72 ppi holding `layers` (bottom first). 16- and 32-bit +/// documents keep their layers in an `Lr16` or `Lr32` block. +fn file(w: u32, h: u32, layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let info = layer_info(layers, depth, rle, psb); + let length = |n: usize, out: &mut Vec| { + if psb { + out.extend((n as u64).to_be_bytes()); + } else { + out.extend((n as u32).to_be_bytes()); + } + }; + let mut section = vec![]; + if depth == 8 { + length(info.len(), &mut section); + section.extend(info); + section.extend(0u32.to_be_bytes()); + } else { + length(0, &mut section); + section.extend(0u32.to_be_bytes()); + section.extend(block(if depth == 32 { b"Lr32" } else { b"Lr16" }, &info, psb)); + } + let mut f = b"8BPS".to_vec(); + f.extend(if psb { 2u16 } else { 1 }.to_be_bytes()); + f.extend([0; 6]); + f.extend(4u16.to_be_bytes()); + f.extend(h.to_be_bytes()); + f.extend(w.to_be_bytes()); + f.extend(depth.to_be_bytes()); + f.extend(3u16.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + length(section.len(), &mut f); + f.extend(section); + // The merged image: raw, mid gray, opaque. + f.extend([0, 0]); + let n = (w * h) as usize * usize::from(depth / 8); + f.extend(std::iter::repeat_n(128, n * 3)); + f.extend(std::iter::repeat_n(255, n)); + f +} + +/// The warning for one hidden layer left out. +const HIDDEN_1: &str = "1 layer was left out (hidden layers and the layers clipped to them)"; + +fn open(f: &[u8], p: Value) -> (Session, Value) { + let mut s = Session::new(); + let r = s.execute("document.open", &merge(json!({"name": "layers.psd", "dataBase64": vectorcraft_format::base64_encode(f)}), p)).unwrap(); + (s, r) +} + +fn name(n: &Node) -> &str { + n.name.as_deref().unwrap_or("") +} + +/// The layer's one image, named as the layer: its place (points) and its pixels. +fn image_of(s: &Session, l: &Node) -> ((f64, f64), image::RgbaImage) { + let Some([c]) = l.children().map(Vec::as_slice) else { panic!("{} holds {:?}", name(l), l.children().map(Vec::len)) }; + assert_eq!(name(c), name(l)); + pixels_of(s, c) +} + +/// An image's place (points) and its pixels. +fn pixels_of(s: &Session, c: &Node) -> ((f64, f64), image::RgbaImage) { + let NodeKind::Image(img) = &c.kind else { panic!("{} isn't an image", name(c)) }; + let blob = &s.doc().unwrap().doc.images[&img.key]; + let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + assert_eq!((px.width(), px.height()), (img.width, img.height)); + let o = img.xf * kurbo::Point::ZERO; + ((o.x, o.y), px) +} + +/// Bottom, a pass-through group of a multiply layer at 50 % and a hidden layer, a screen group, and +/// a layer at 40 % fill opacity. +fn stack() -> Vec { + let mut fill = layer("Faded", [1, 1, 3, 3], [0, 0, 255, 255]); + fill.blocks.push((*b"iOpa", vec![102])); + vec![ + layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), + group_end(), + L { blend: b"mul ", opacity: 128, ..layer("Inner A", [0, 0, 4, 4], [0, 255, 0, 255]) }, + L { hidden: true, ..layer("Inner B", [4, 4, 8, 8], [0, 0, 255, 255]) }, + group("Folder", b"pass"), + group_end(), + layer("Lit", [2, 2, 6, 6], [10, 20, 30, 128]), + group("Screens", b"scrn"), + fill, + ] +} + +#[test] +fn layers_open_as_layers_with_their_names_visibility_opacity_and_blend_modes() { + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({})); + assert_eq!((&r["format"], &r["warnings"]), (&json!("psd"), &json!([HIDDEN_1])), "{r}"); + let d = &s.doc().unwrap().doc; + assert!((d.artboards[0].rect.width() - 8.0).abs() < 1e-9 && (d.artboards[0].rect.height() - 8.0).abs() < 1e-9); + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "bottom first"); + // The bottom layer's image covers the canvas in its color. + let ((x, y), px) = image_of(&s, &d.layers[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(3, 3).0), ((0.0, 0.0), (8, 8), [255, 0, 0, 255])); + // A pass-through group: a layer of sublayers that blend with what is below; its hidden layer + // is left out. + let folder = &d.layers[1]; + assert!(matches!(folder.kind, NodeKind::Layer { .. }) && !folder.isolate && folder.blend == BlendMode::Normal); + let inner: Vec<&str> = folder.children().unwrap().iter().map(|c| name(c)).collect(); + assert_eq!(inner, ["Inner A"]); + let a = &folder.children().unwrap()[0]; + assert!(a.blend == BlendMode::Multiply && (a.opacity - 128.0 / 255.0).abs() < 1e-6 && a.visible); + // Another group mode: isolated, in its mode; a layer keeps its transparency and place. + let screens = &d.layers[2]; + assert!(screens.isolate && screens.blend == BlendMode::Screen); + let ((x, y), px) = image_of(&s, &screens.children().unwrap()[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((2.0, 2.0), (4, 4), [10, 20, 30, 128])); + // Fill opacity multiplies the opacity. + assert!((d.layers[3].opacity - 102.0 / 255.0).abs() < 1e-6, "{}", d.layers[3].opacity); + // Hidden layers come in hidden when asked for. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"hiddenLayers": true})); + assert_eq!(r["warnings"], json!([]), "{r}"); + let folder = &s.doc().unwrap().doc.layers[1]; + let inner: Vec<(&str, bool)> = folder.children().unwrap().iter().map(|c| (name(c), c.visible)).collect(); + assert_eq!(inner, [("Inner A", true), ("Inner B", false)]); + // `layers: false` opens the merged image, as before. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"layers": false})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.layers.len(), d.layers[0].children().map(Vec::len)), (1, Some(1)), "{r}"); + assert_eq!(pixels_of(&s, &d.layers[0].children().unwrap()[0]).1.get_pixel(0, 0).0, [128, 128, 128, 255]); + let bad = Session::new().execute("document.open", &json!({"name": "a.psd", "dataBase64": "AAAA", "hiddenLayers": "yes"})); + assert!(bad.unwrap_err().to_string().contains("hiddenLayers must be true or false")); +} + +#[test] +fn sixteen_bit_packbits_and_psb_layers_read() { + for (depth, rle, psb) in [(16, false, false), (8, true, false), (8, true, true), (16, true, true)] { + let (s, r) = open(&file(8, 8, &stack(), depth, rle, psb), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "{depth} {rle} {psb}: {r}"); + let px = image_of(&s, &d.layers[2].children().unwrap()[0]).1; + assert_eq!(px.get_pixel(1, 1).0, [10, 20, 30, 128], "{depth} {rle} {psb}"); + } +} + +/// Zipped channels, with and without prediction, at 8, 16 and 32 bits (32-bit colors are linear, +/// so the test uses 0 and 1, which sRGB's curve keeps). +#[test] +fn zipped_and_32_bit_layers_read() { + for depth in [8, 16, 32] { + for zip in [None, Some(false), Some(true)] { + if depth != 32 && zip.is_none() { + continue; + } + let layers = [L { zip, ..layer("Back", [0, 0, 4, 4], [0, 0, 255, 255]) }, L { zip, ..layer("Front", [1, 1, 3, 4], [255, 0, 255, 128]) }]; + let (s, r) = open(&file(4, 4, &layers, depth, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Back", "Front"], "{depth} {zip:?}: {r}"); + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(2, 1).0), ((1.0, 1.0), (3, 2), [255, 0, 255, 128]), "{depth} {zip:?}"); + } + } +} + +/// A layer without a Unicode name takes the name its Pascal string holds, read as Mac Roman. +#[test] +fn names_without_a_unicode_name_read_as_mac_roman() { + let l = L { legacy: Some(b"Caf\x8E \xD2Old\xD3"), ..layer("", [0, 0, 1, 1], [0, 0, 0, 255]) }; + let (s, r) = open(&file(1, 1, &[l], 8, false, false), json!({})); + assert_eq!(name(&s.doc().unwrap().doc.layers[0]), "Café “Old”", "{r}"); +} + +#[test] +fn layer_masks_become_opacity_masks() { + let mut masked = layer("Masked", [0, 0, 8, 8], [255, 255, 255, 255]); + masked.mask = Some(([2, 2, 4, 4], 0, 0, vec![255, 128, 0, 255])); + let mut shown = layer("Shown outside", [0, 0, 8, 8], [0, 0, 0, 255]); + shown.mask = Some(([0, 0, 1, 2], 255, 0, vec![0, 0])); + let mut off = layer("Off", [0, 0, 8, 8], [0, 0, 0, 255]); + off.mask = Some(([0, 0, 1, 1], 0, 2, vec![0])); + // Flag bit 2: the mask's values and its default color are read inverted. + let mut inverted = layer("Inverted", [0, 0, 8, 8], [0, 0, 0, 255]); + inverted.mask = Some(([0, 0, 1, 2], 0, 4, vec![255, 64])); + let (s, _) = open(&file(8, 8, &[masked, shown, off, inverted], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let mask = |i: usize| d.layers[i].extras().mask.as_deref().expect("a mask"); + let m = mask(0); + assert!(m.clip && !m.disabled, "outside a mask with default 0 is hidden"); + let ((x, y), px) = pixels_of(&s, &m.art); + assert_eq!(((x, y), px.dimensions()), ((2.0, 2.0), (2, 2))); + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0, px.get_pixel(0, 1).0], [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255]]); + assert!(!mask(1).clip, "outside a mask with default 255 shows"); + assert!(mask(2).disabled, "a disabled mask is kept, switched off"); + let m = mask(3); + assert!(!m.clip, "an inverted default of 0 shows outside the mask"); + let px = pixels_of(&s, &m.art).1; + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0], [[0, 0, 0, 255], [191, 191, 191, 255]]); +} + +#[test] +fn clipped_layers_merge_into_the_layer_below() { + let base = L { opacity: 200, ..layer("Base", [4, 4, 8, 8], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 8, 6], [0, 0, 255, 255]) }; + let hidden = L { clipped: true, hidden: true, ..layer("Gone", [0, 0, 8, 8], [0, 255, 0, 255]) }; + let (s, r) = + open(&file(8, 8, &[layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), base, clipped, hidden], 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Base + Clipped"], "{r}"); + assert_eq!(r["warnings"], json!([HIDDEN_1]), "a hidden clipped layer is left out: {r}"); + // The base keeps its place, transparency and opacity; the clipped layer shows only on it. + let merged = &d.layers[1]; + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions()), ((4.0, 4.0), (4, 4))); + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(3, 3).0), ([0, 0, 255, 255], [255, 255, 0, 255])); + // A base's layer mask stays the merged layer's opacity mask, over the unmasked pixels. + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 255, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[masked, clipped], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Masked + Clipped", "{r}"); + assert!(merged.extras().mask.as_deref().is_some_and(|m| m.clip && !m.disabled), "{r}"); + let px = image_of(&s, merged).1; + assert_eq!((px.dimensions(), px.get_pixel(3, 3).0), ((4, 4), [0, 0, 255, 255])); + // A hidden base would need the merge: even with hidden layers asked for, it is left out with + // the layers clipped to it. + let hidden_base = L { hidden: true, ..layer("Hidden base", [0, 0, 4, 4], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let layers = [layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), hidden_base, clipped, layer("Over", [0, 0, 1, 1], [9, 9, 9, 255])]; + for hidden in [false, true] { + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({"hiddenLayers": hidden})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Over"], "{hidden}: {r}"); + assert_eq!(r["warnings"], json!(["2 layers were left out (hidden layers and the layers clipped to them)"]), "{hidden}: {r}"); + } +} + +/// A masked base merged with its clipped layers, then with a layer in a mode VectorCraft lacks, +/// still hides what its mask hides. +#[test] +fn a_merged_base_keeps_its_mask_in_a_later_merge() { + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 0, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let dodge = L { blend: b"lddg", ..layer("Dodge", [3, 3, 4, 4], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 4, 4], [0, 255, 0, 255]), masked, clipped, dodge]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(name(&d.layers[0]), "Bottom + Masked + Clipped + Dodge", "{r}"); + let px = image_of(&s, &d.layers[0]).1; + // Inside the mask the clipped layer shows; outside it, the layer below. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(2, 2).0), ([0, 0, 255, 255], [0, 255, 0, 255])); +} + +#[test] +fn layers_in_modes_vectorcraft_lacks_merge_with_the_layers_below() { + let dodge = L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let hidden = L { hidden: true, ..layer("Hidden", [0, 0, 8, 8], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden, dodge, layer("Top", [6, 6, 8, 8], [0, 0, 0, 255])]; + let (s, r) = open(&file(8, 8, &layers, 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dodge", "Top"], "hidden layers a merge takes in are left out: {r}"); + let (_, px) = image_of(&s, &d.layers[0]); + // Linear Dodge adds: 100 + 100, 50 + 100, 0 + 100. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(5, 5).0), ([200, 150, 100, 255], [100, 50, 0, 255])); + assert!(r["warnings"].to_string().contains("Linear Dodge"), "{r}"); + // Darker Color keeps the color whose channels add up to less: (0, 0, 200) over (100, 50, 0) + // has the larger total but the lower luminance, so the base stays. + let darker = L { blend: b"dkCl", ..layer("Darker", [0, 0, 2, 2], [0, 0, 200, 255]) }; + let (s, _) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), darker], 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + assert_eq!(px.get_pixel(0, 0).0, [100, 50, 0, 255]); + // A hidden layer in such a mode would need the merge: left out, hidden layers asked for or not. + let hidden_dodge = L { hidden: true, blend: b"lddg", ..layer("Hidden dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let (s, r) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden_dodge], 8, false, false), json!({"hiddenLayers": true})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!((names, &r["warnings"]), (vec!["Bottom"], &json!([HIDDEN_1])), "{r}"); +} + +/// A merge whose canvases would need more memory than allowed leaves the layer in the Normal mode, +/// with a warning. +#[test] +fn a_merge_past_the_memory_allowed_leaves_the_layer_normal() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [layer("Bottom", [0, 0, 2, 2], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [2, 2, 4, 4], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + // Each 2 × 2 layer fits 128 bytes (16 a pixel), and so does the first canvas of the merge with + // the layer beside it; the 4 × 4 canvas of both doesn't. + let (doc, warnings) = psdlayers::import(&f, false, 128, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // With room for it, the merge is made. + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Dodge"]); + // A merge onto a canvas that already covers the layer holds both: two 4 × 4 canvases take 512 + // bytes, past 300, although the canvas of the two 1 × 1 layers below fits (288 bytes). + let layers = [ + layer("Bottom", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Corner", [3, 3, 4, 4], [0, 50, 100, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 4, 4], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom", "Corner", "Dodge"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Corner + Dodge"]); + // A group below whose layers each fit, but whose canvas (4 × 4 with both 1 × 1 layers held, 288 + // bytes) doesn't fit 100: the layers stay, and the layer comes in Normal. + let layers = [ + group_end(), + layer("Left", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Right", [3, 3, 4, 4], [0, 50, 100, 255]), + group("Pair", b"norm"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 100, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Pair", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(doc.layers[0].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Left", "Right"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Pair + Dodge"]); + // A layer below whose pixels fit (128 bytes) but whose canvas (16 bytes a pixel, 256) doesn't + // fit 200: the same. + let layers = [layer("Big", [0, 0, 4, 4], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 200, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Big", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // A clipped layer whose merge doesn't fit (the base's canvas and its transparency, 20 bytes a + // pixel, beside the clipped layer's canvas, 16: 576 bytes, past 300) comes in unclipped. + let layers = [layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]), L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base", "Clipped"]); + assert_eq!(warnings, ["1 layer clipped to the layer below came in unclipped: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base + Clipped"]); +} + +/// A merge that can't be made counts nothing that the merges inside it counted: here the merge +/// in a group at half opacity fails inside the outer merge's attempt (the copy of what is below the +/// group takes the room) and succeeds when the group comes in. +#[test] +fn a_merge_that_cant_be_made_counts_only_itself() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [ + layer("Under", [0, 0, 2, 2], [100, 50, 0, 255]), + group_end(), + layer("Inner", [0, 0, 2, 2], [10, 10, 10, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [20, 20, 20, 255]) }, + L { opacity: 128, ..group("Pass", b"pass") }, + L { blend: b"diss", ..layer("Top", [0, 0, 2, 2], [0, 0, 0, 255]) }, + ]; + let (doc, warnings) = psdlayers::import(&file(2, 2, &layers, 8, false, false), false, 150, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Under", BlendMode::Normal), ("Pass", BlendMode::Normal), ("Top", BlendMode::Normal)]); + assert_eq!(doc.layers[1].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Inner + Dodge"]); + assert_eq!( + warnings, + [ + "layers in blend modes VectorCraft doesn't have (Linear Dodge) were merged with the layers below them", + "1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + ] + ); +} + +/// Layers in modes VectorCraft lacks, one after another, merge onto the canvas the first merge made +/// without copying it: 300 of them over a 64 × 64 canvas (64 KiB) open within an allowance that a +/// copy for each would pass. Merges that need a copy of that canvas each, past the allowance, open +/// the merged image instead. +#[test] +fn merges_onto_a_merge_reuse_its_canvas() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let dodge = |rect: [i32; 4]| L { blend: b"lddg", ..layer("d", rect, [1, 1, 1, 255]) }; + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + layers.extend((0..300).map(|_| dodge([1, 1, 2, 2]))); + let (doc, warnings) = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!((doc.layers.len(), warnings.len()), (1, 1), "{warnings:?}"); + assert!(name(&doc.layers[0]).starts_with("Bottom + d + d") && name(&doc.layers[0]).matches(" + d").count() == 301); + // A Normal layer between each pair: every merge copies the 64 KiB canvas, and 150 of them pass + // eight times the allowance of 1 MiB. + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + for _ in 0..150 { + layers.extend([layer("n", [1, 1, 2, 2], [1, 1, 1, 255]), dodge([2, 2, 3, 3])]); + } + let e = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A Dissolve layer at half opacity merges as pixels of its own color or of the color below, +/// about half of each, and none in between. +#[test] +fn dissolve_layers_merge_as_opaque_or_transparent_pixels() { + let dissolve = L { blend: b"diss", opacity: 128, ..layer("Dissolve", [0, 0, 16, 16], [255, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 16, 16], [255, 255, 255, 255]), dissolve]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dissolve"], "{r}"); + let (_, px) = image_of(&s, &d.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + let white = px.pixels().filter(|p| p.0 == [255, 255, 255, 255]).count(); + assert_eq!(red + white, 256, "every pixel is the layer's color or the color below"); + assert!((96..=160).contains(&red), "{red} of 256 pixels are the layer's"); + // A second Dissolve layer at half opacity over the first has its own pattern: about a quarter + // of the pixels still show the first. + let blue = L { blend: b"diss", opacity: 128, ..layer("Blue", [0, 0, 16, 16], [0, 0, 255, 255]) }; + let layers = [layers[0].clone(), layers[1].clone(), blue]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + assert!((32..=96).contains(&red), "{red} of 256 pixels show the lower layer: {r}"); +} + +#[test] +fn what_doesnt_come_in_is_said() { + let mut text = layer("Words", [0, 0, 2, 2], [0, 0, 0, 255]); + text.blocks.push((*b"TySh", vec![0; 4])); + let mut adjust = layer("Levels", [0, 0, 0, 0], [0; 4]); + adjust.blocks.push((*b"levl", vec![0; 4])); + let mut fx = layer("Shadowed", [0, 0, 2, 2], [0, 0, 0, 255]); + fx.blocks.push((*b"lfx2", effects(true, true))); + fx.blocks.push((*b"knko", vec![1, 0, 0, 0])); + // Effects with the master switch off, or none switched on, aren't counted. + let mut fx_off = layer("Switched off", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_off.blocks.push((*b"lfx2", effects(false, true))); + let mut fx_none = layer("None on", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_none.blocks.push((*b"lfx2", effects(true, false))); + // An adjustment layer clipped to the layer below draws nothing, and is still counted. + let mut clipped_adjust = L { clipped: true, ..layer("Curves", [0, 0, 0, 0], [0; 4]) }; + clipped_adjust.blocks.push((*b"curv", vec![0; 4])); + let mut solid = layer("Fill", [0, 0, 0, 0], [0; 4]); + let mut soco = vec![0; 8]; + for (key, v) in [(b"Rd doub", 255.0f64), (b"Grn doub", 0.0), (b"Bl doub", 51.0)] { + soco.extend(key); + soco.extend(v.to_be_bytes()); + } + solid.blocks.push((*b"SoCo", soco.clone())); + // A shape layer: a solid color fill with a vector mask and the shape's pixels stored. + let mut shape = layer("Shape", [1, 1, 3, 2], [255, 0, 51, 255]); + shape.blocks.push((*b"SoCo", soco.clone())); + shape.blocks.push((*b"vmsk", vec![0; 8])); + // One with its vector mask only, no pixels: not drawn, rather than its color over the canvas, + // and the layer clipped to it isn't either. + let mut outline = layer("Outline", [0, 0, 0, 0], [0; 4]); + outline.blocks.push((*b"SoCo", soco)); + outline.blocks.push((*b"vsms", vec![0; 8])); + let on_outline = L { clipped: true, ..layer("On outline", [0, 0, 2, 2], [1, 1, 1, 255]) }; + let layers = [solid, text, clipped_adjust, adjust, fx, fx_off, fx_none, shape, outline, on_outline]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Fill", "Words", "Shadowed", "Switched off", "None on", "Shape"], "an adjustment layer draws nothing"); + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(3, 3).0, [255, 0, 51, 255], "a solid color fill covers the canvas"); + let ((x, y), px) = image_of(&s, &d.layers[5]); + assert_eq!(((x, y), px.dimensions()), ((1.0, 1.0), (1, 2)), "a shape layer comes in as the pixels it stores"); + let w = r["warnings"].to_string(); + for said in [ + "1 type layer came in as pixels", + "2 adjustment layers were not applied", + "1 fill layer was not drawn", + "the layer effects of 1 layer weren't drawn", + "the vector masks of 2 layers", + "the knockout of 1 layer wasn't applied", + "1 layer clipped to a fill layer that wasn't drawn was left out", + ] { + assert!(w.contains(said), "{said}: {w}"); + } +} + +/// An effects descriptor (`lfx2`) with its master switch and one effect's `enab` set: each key after +/// its length (0 for a 4-character key), then `bool` and a byte. +fn effects(master: bool, enabled: bool) -> Vec { + let mut d = vec![0; 8]; + d.extend(14u32.to_be_bytes()); + d.extend(b"masterFXSwitchbool"); + d.push(u8::from(master)); + d.extend(0u32.to_be_bytes()); + d.extend(b"enabbool"); + d.push(u8::from(enabled)); + d +} + +/// A layer's fill opacity fades only its own pixels when layers are clipped to it: they still show +/// in its shape, and the merged layer keeps the layer's opacity. +#[test] +fn a_clipping_base_fill_opacity_fades_only_its_own_pixels() { + for (fill, size, corner) in [(0u8, (4, 2), None), (128, (4, 4), Some([255, 0, 0, 128]))] { + let mut base = L { opacity: 200, ..layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]) }; + base.blocks.push((*b"iOpa", vec![fill])); + let photo = L { clipped: true, ..layer("Photo", [0, 0, 2, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[base, photo], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Base + Photo", "{r}"); + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6, "{fill}: {}", merged.opacity); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((0.0, 0.0), size, [0, 0, 255, 255]), "{fill}"); + if let Some(c) = corner { + assert!(px.get_pixel(3, 3).0.iter().zip(c).all(|(&a, b)| a.abs_diff(b) <= 1), "{fill}: {:?}", px.get_pixel(3, 3)); + } + } +} + +/// Layers clipped to an adjustment layer show over the whole canvas of the file, through the +/// adjustment layer's mask. +#[test] +fn layers_clipped_to_an_adjustment_layer_show_over_the_canvas() { + let mut curves = layer("Curves", [0, 0, 0, 0], [0; 4]); + curves.blocks.push((*b"curv", vec![0; 4])); + curves.mask = Some(([0, 0, 1, 1], 255, 0, vec![0])); + let photo = L { clipped: true, ..layer("Photo", [-1, -1, 2, 5], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[layer("Under", [0, 0, 4, 4], [255, 0, 0, 255]), curves, photo], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Curves + Photo"], "{r}"); + // Cut to the canvas: 4 × 2 at its corner. + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(1, 1).0), ((0.0, 0.0), (4, 2), [0, 0, 255, 255])); + assert!(d.layers[1].extras().mask.is_some(), "the adjustment layer's mask stays"); + assert!(r["warnings"].to_string().contains("1 adjustment layer was not applied"), "{r}"); +} + +/// Each blend mode VectorCraft lacks merges with the layer below by its formula, from the public +/// descriptions: +/// (Vivid Light, Pin Light, Hard Mix, Darker and Lighter Color) and +/// (Linear Burn, Linear Dodge, Linear Light, Subtract, +/// Divide). Color Dodge, Color Burn and Difference keep their mode. +#[test] +fn every_mode_vectorcraft_lacks_merges_by_its_formula() { + let b = [200u8, 100, 30]; + let s = [60u8, 180, 128]; + let burn = |b: f64, c: f64| if c <= 0.0 { if b >= 1.0 { 1.0 } else { 0.0 } } else { 1.0 - ((1.0 - b) / c).min(1.0) }; + let dodge = |b: f64, c: f64| if c >= 1.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { (b / (1.0 - c)).min(1.0) }; + let each = |f: &dyn Fn(f64, f64) -> f64| -> [u8; 3] { + [0, 1, 2].map(|i| (f(f64::from(b[i]) / 255.0, f64::from(s[i]) / 255.0).clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let total = |c: [u8; 3]| c.iter().map(|&v| u32::from(v)).sum::(); + let cases: [(&[u8; 4], [u8; 3]); 10] = [ + (b"lbrn", each(&|b, s| b + s - 1.0)), + (b"lddg", each(&|b, s| b + s)), + (b"vLit", each(&|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) })), + (b"lLit", each(&|b, s| b + 2.0 * s - 1.0)), + (b"pLit", each(&|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) })), + (b"hMix", each(&|b, s| if (b + s) * 255.0 >= 254.999 { 1.0 } else { 0.0 })), + (b"fsub", each(&|b, s| b - s)), + (b"fdiv", each(&|b, s| if s <= 0.0 { 1.0 } else { b / s })), + (b"dkCl", if total(s) < total(b) { s } else { b }), + (b"lgCl", if total(s) > total(b) { s } else { b }), + ]; + for (key, want) in cases { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Top"], "{key:?}: {r}"); + let got = image_of(&st, &d.layers[0]).1.get_pixel(0, 0).0; + assert!( + got[..3].iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= 1) && got[3] == 255, + "{}: {got:?}, want {want:?}", + String::from_utf8_lossy(key) + ); + } + for (key, mode) in [(b"div ", BlendMode::ColorDodge), (b"idiv", BlendMode::ColorBurn), (b"diff", BlendMode::Difference)] { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + let got: Vec<(&str, BlendMode)> = d.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Top", mode)], "{r}"); + } +} + +/// A descriptor's numbers: each key (a 4-character key after a length of 0, a longer one after its +/// length), then `doub` and the value, or for `H `, `UntF`, `#Ang` and the value. +fn descriptor(values: &[(&[u8], f64)]) -> Vec { + let mut d = vec![0; 8]; + for (key, v) in values { + d.extend(if key.len() == 4 { 0u32 } else { key.len() as u32 }.to_be_bytes()); + d.extend(*key); + d.extend(if *key == b"H " { &b"UntF#Ang"[..] } else { b"doub" }); + d.extend(v.to_be_bytes()); + } + d +} + +/// Solid color fill layers that store no pixels come in as their color in every color model a +/// descriptor uses; one whose color can't be read, and a gradient fill that stores no pixels, are +/// counted; a gradient fill that stores pixels comes in as them. +#[test] +fn solid_color_fills_read_every_color_model() { + let fill = |name: &'static str, values: &[(&[u8], f64)]| { + let mut l = layer(name, [0, 0, 0, 0], [0; 4]); + l.blocks.push((*b"SoCo", descriptor(values))); + l + }; + let mut gradient = layer("Gradient", [0, 0, 0, 0], [0; 4]); + gradient.blocks.push((*b"GdFl", vec![0; 8])); + let mut gradient_shape = layer("Gradient shape", [1, 1, 2, 2], [7, 8, 9, 255]); + gradient_shape.blocks.push((*b"GdFl", vec![0; 8])); + // Bounds, but no channels: it stores no pixels either. + let mut pattern = L { bare: true, ..layer("Pattern", [0, 0, 4, 4], [0; 4]) }; + pattern.blocks.push((*b"PtFl", vec![0; 8])); + let layers = [ + fill("Float", &[(b"redFloat", 0.0), (b"greenFloat", 0.0), (b"blueFloat", 1.0)]), + fill("Cmyk", &[(b"Cyn ", 0.0), (b"Mgnt", 100.0), (b"Ylw ", 100.0), (b"Blck", 0.0)]), + fill("Gray", &[(b"Gry ", 25.0)]), + fill("Lab", &[(b"Lmnc", 100.0), (b"A ", 0.0), (b"B ", 0.0)]), + fill("Hsb", &[(b"H ", 120.0), (b"Strt", 100.0), (b"Brgh", 100.0)]), + fill("Unread", &[(b"Bk ", 1.0)]), + gradient, + gradient_shape, + pattern, + ]; + // A naive CMYK conversion, so that the colors don't depend on the color settings. + let naive = |c: [f32; 4]| [0, 1, 2].map(|i| (1.0 - c[i]) * (1.0 - c[3])); + let (doc, warnings) = psdlayers::import(&file(4, 4, &layers, 8, false, false), false, 1 << 20, naive).unwrap().unwrap(); + let names: Vec<&str> = doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Float", "Cmyk", "Gray", "Lab", "Hsb", "Gradient shape"]); + let mut s = Session::new(); + s.add_document(doc, None); + let d = &s.doc().unwrap().doc; + let colors: Vec<[u8; 4]> = d.layers[..5].iter().map(|l| image_of(&s, l).1.get_pixel(2, 2).0).collect(); + assert_eq!(colors[..3], [[0, 0, 255, 255], [255, 0, 0, 255], [191, 191, 191, 255]]); + assert!(colors[3].iter().all(|&v| v >= 253), "Lab 100, 0, 0 is white: {:?}", colors[3]); + assert_eq!(colors[4], [0, 255, 0, 255]); + assert_eq!(image_of(&s, &d.layers[5]).1.get_pixel(0, 0).0, [7, 8, 9, 255]); + assert_eq!(warnings, ["3 fill layers were not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)"]); +} + +/// VectorCraft's own PSD export opens with the layers it wrote: groups as layers of sublayers, +/// hidden ones hidden, with their opacity and blend mode. +#[test] +fn psd_exports_open_with_their_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap(); + s.execute("layer.new", &json!({"name": "Blue"})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 40})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "Multiply"})).unwrap(); + s.execute("layer.new", &json!({"name": "Off"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let off = s.doc().unwrap().doc.layers.last().unwrap().id; + s.execute("layer.setProps", &json!({"id": off.0, "visible": false})).unwrap(); + for (options, top) in [ + (json!({"hiddenLayers": true}), vec![("Layer 1", true), ("Blue", true), ("Off", false)]), + (json!({"maxEditability": true}), vec![("Layer 1", true), ("Blue", true)]), + ] { + let r = s.execute("document.export", &merge(json!({"format": "psd"}), options.clone())).unwrap(); + let f = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let (back, r) = open(&f, json!({"hiddenLayers": true})); + let d = &back.doc().unwrap().doc; + let got: Vec<(&str, bool)> = d.layers.iter().map(|l| (name(l), l.visible)).collect(); + assert_eq!(got, top, "{options}: {r}"); + if options["maxEditability"] == true { + // Each layer a group: its objects become sublayers, the rectangle's opacity and blend mode + // on its own. + let blue = d.layers[1].children().unwrap(); + assert_eq!(blue.len(), 1, "{options}"); + assert!(blue[0].blend == BlendMode::Multiply && (blue[0].opacity - 0.5).abs() < 0.01, "{options}"); + } + } +} + +#[test] +fn files_without_layers_or_with_damaged_layers_open_as_their_merged_image() { + // No layer records: the merged image, without a warning. + let flat = { + let mut f = file(2, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 8, false, false); + // The layer and mask section emptied: 4 length bytes of 0, after the header and two + // empty sections. + let at = 26 + 8; + let len = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize; + f.splice(at..at + 4 + len, 0u32.to_be_bytes()); + f + }; + let (_, r) = open(&flat, json!({})); + assert_eq!(r["warnings"], json!([]), "{r}"); + // Layer info of just the count (negative: the merged image has transparency) has no layers. + let count_only = { + let mut f = flat.clone(); + let at = 26 + 8; + f.splice(at..at + 4, [&10u32.to_be_bytes()[..], &2u32.to_be_bytes(), &(-1i16).to_be_bytes(), &[0, 0, 0, 0]].concat()); + f + }; + let (s, r) = open(&count_only, json!({})); + assert_eq!((&r["warnings"], s.doc().unwrap().doc.layers[0].children().map(Vec::len)), (&json!([]), Some(1)), "{r}"); + // A channel cut short: the merged image, with a warning. + let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap(); + cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes()); + let (s, r) = open(&cut, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1)); + // Groups nested past the limit. + let mut deep: Vec = (0..=psdlayers::MAX_DEPTH).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + let (_, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert!(r["warnings"][0].as_str().unwrap().contains("nested too deeply"), "{r}"); + // Groups within the limit but deeper than a document may nest: the deepest merge into the + // group around them, with the warning. + let mut deep: Vec = (0..40).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + deep.extend((0..40).map(|_| group("g", b"norm"))); + let (s, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert_eq!(r["warnings"], json!([vectorcraft_doc::depth::merged_note()]), "{r}"); + fn depth(n: &Node) -> usize { + 1 + n.children().map_or(0, |c| c.iter().map(|c| depth(c)).max().unwrap_or(0)) + } + assert!(s.doc().unwrap().doc.layers.iter().map(|l| depth(l)).max().unwrap() <= vectorcraft_doc::MAX_DEPTH); +} + +/// A record whose layer mask or name is cut short opens the merged image with a warning, rather +/// than a layer without its mask or name. +#[test] +fn records_with_a_damaged_mask_or_name_open_as_the_merged_image() { + let good = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + // The record's extra data: an empty mask section, empty blending ranges, then the name. + let at = good.windows(10).position(|w| w == [0, 0, 0, 0, 0, 0, 0, 0, 1, b'x']).unwrap(); + // A mask section of 4 bytes, too short for a mask's place, default color and flags. + let mut short_mask = good.clone(); + short_mask[at + 3] = 4; + // A name longer than the record's extra data. + let mut long_name = good.clone(); + long_name[at + 8] = 0xff; + for f in [short_mask, long_name] { + let (s, r) = open(&f, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1), "{r}"); + } +} + +#[test] +fn damaged_layers_never_panic() { + let mut masked = layer("Masked", [0, 0, 3, 3], [1, 2, 3, 255]); + masked.mask = Some(([1, 1, 2, 2], 0, 0, vec![200])); + let layers = [ + layer("A", [0, 0, 3, 3], [255, 0, 0, 255]), + group_end(), + masked, + L { clipped: true, ..layer("C", [0, 0, 3, 3], [0, 0, 255, 255]) }, + L { blend: b"lbrn", ..layer("D", [0, 0, 2, 2], [9, 9, 9, 255]) }, + group("G", b"pass"), + ]; + for (rle, psb) in [(false, false), (true, true)] { + let good = file(3, 3, &layers, 8, rle, psb); + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + assert!(psdlayers::import(&good, true, 1 << 30, cms).unwrap().is_some()); + for i in 0..good.len() { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = psdlayers::import(&f, true, 1 << 30, cms); + } + } + for n in 0..good.len() { + let _ = psdlayers::import(&good[..n], true, 1 << 30, cms); + } + // A layer past the memory allowed is refused, not read. + assert!(psdlayers::import(&good, true, 64, cms).unwrap_err().contains("too large")); + } + // Many layers that each fit the memory allowed for one image, but not all together (8 bytes a + // pixel to read an 8 × 8 layer; 4 a pixel decoded, against 8 times the allowance). + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let many: Vec = (0..20).map(|_| layer("x", [0, 0, 8, 8], [1, 2, 3, 255])).collect(); + assert!(psdlayers::import(&file(8, 8, &many[..16], 8, false, false), false, 512, cms).unwrap().is_some()); + let e = psdlayers::import(&file(8, 8, &many, 8, false, false), false, 512, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A layer in a mode VectorCraft lacks above a pass-through group merges with the group's layers +/// blending onto what is below the group, as they do without the merge. +#[test] +fn merges_keep_pass_through_groups_blending_with_what_is_below() { + let layers = [ + layer("Bottom", [0, 0, 2, 2], [100, 200, 50, 255]), + group_end(), + L { blend: b"mul ", ..layer("Multiply", [0, 0, 2, 2], [128, 128, 128, 255]) }, + group("Pass", b"pass"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [0, 0, 0, 255]) }, + ]; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Pass + Dodge"], "{r}"); + // 100, 200 and 50 times 128/255. + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0, [50, 100, 25, 255]); + // At half opacity, or with a layer mask, the group's layers still blend with what is below + // it, and the result fades back toward it: halfway from (100, 200, 50) to (50, 100, 25). + let mut masked = group("Pass", b"pass"); + masked.mask = Some(([0, 0, 2, 2], 0, 0, vec![128, 128, 128, 128])); + for top in [L { opacity: 128, ..group("Pass", b"pass") }, masked] { + let mut layers = layers.clone(); + layers[3] = top; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Pass + Dodge"], "{r}"); + let px = image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0; + assert!(px.iter().zip([75, 150, 38, 255]).all(|(&a, b)| a.abs_diff(b) <= 1), "{px:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index d411496b0..08a46f0e1 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -141,6 +141,31 @@ fn packbits_zip_and_psb_files_read() { zip.extend(z.finish().unwrap()); let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip)); assert_eq!(px.iter().map(|p| p[0]).collect::>(), [16, 32, 48]); + // Zipped with prediction, 32 bits: the row's first bytes, then its second bytes and so on, + // each byte stored as the difference from the byte before it. + let samples = [0.0f32, 1.0, 0.0].map(f32::to_be_bytes); + let planes: Vec = (0..4).flat_map(|k| samples.iter().map(move |s| s[k])).collect(); + let deltas: Vec = planes.iter().scan(0u8, |prev, &b| Some(b.wrapping_sub(std::mem::replace(prev, b)))).collect(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &deltas).unwrap(); + let mut zip = vec![0, 3]; + zip.extend(z.finish().unwrap()); + let px = pixels(&psd(1, 1, (3, 1), 32, 1, &[], &[], &[], &zip)); + assert_eq!(px.iter().map(|p| p[0]).collect::>(), [0, 255, 0]); + // The same layout as another implementation writes it: psd-tools 1.26.1 (MIT, + // ), `compression.compress(data, + // Compression.ZIP_WITH_PREDICTION, 3, 2, 32)` of the samples 0, 1, 0.25 and 0.5, 0.125, 1, + // reads as the same samples stored raw. + let theirs = [ + 0x78, 0x9c, 0x63, 0xb0, 0xff, 0x7f, 0xa8, 0x81, 0xa1, 0x81, 0x01, 0x04, 0xec, 0xff, 0x33, 0x1e, 0x64, 0x68, 0x80, 0xb0, 0x01, 0x59, 0xd1, + 0x06, 0x01, + ]; + let mut zip = vec![0, 3]; + zip.extend(theirs); + let samples: Vec = [0.0f32, 1.0, 0.25, 0.5, 0.125, 1.0].iter().flat_map(|v| v.to_be_bytes()).collect(); + let stored = pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &raw(&[&samples]))); + assert_eq!(pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &zip)), stored); + assert_eq!(stored.iter().map(|p| p[0]).collect::>()[..2], [0, 255]); } #[test] diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index ab5562b5f..25f5f36db 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -108,7 +108,7 @@ pub(in crate::cmd) fn windows_1252(b: u8) -> char { } /// Byte `b` of Mac Roman. -pub(super) fn mac_roman(b: u8) -> char { +pub(in crate::cmd) fn mac_roman(b: u8) -> char { const HIGH: [char; 128] = [ 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f729ed394..586f177ae 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1807,6 +1807,27 @@ proptest! { }); prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } + + /// Mutated copies of the PSD and PSB samples never crash opening them with their layers, hidden + /// ones too: the PSD's layer records, channel lengths and data, and the PSB's layer count. + /// (Masks, clipping and groups are covered by `tests_psdlayers::damaged_layers_never_panic`.) + #[test] + fn mutated_layered_psds_open_without_panics(which in 6usize..8, cut in 0usize..1_000_000, edits in prop::collection::vec((0usize..1_000_000, any::()), 0..12)) { + let (name, mut bytes) = raster_samples().swap_remove(which); + let len = bytes.len(); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at % len) { + *x = b; + } + } + bytes.truncate((cut % (len + 1)).max(8)); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "hiddenLayers": true}); + let _ = no_panic(s.execute("document.open", &p)); + }); + prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); + } } /// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4 diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c..9209c0e57 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4775,7 +4775,13 @@ axis Both И двете @msg the PSD file has an unknown colour mode ({m}) PSD файлът има непознат цветови режим ({m}) @msg the PSD file's image data uses an unknown compression ({m}) образните данни на PSD файла използват непознато компресиране ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD изображението е твърде голямо за четене ({_1} × {_2} пиксела) -@msg zipped 32-bit PSD image data with prediction can't be read архивирани (zipped) 32-битови PSD образни данни с предсказване не могат да бъдат прочетени +@msg zipped 1-bit PSD image data with prediction can't be read архивирани (zipped) 1-битови PSD образни данни с предсказване не могат да бъдат прочетени +@msg the PSD file's layer data uses an unknown compression ({m}) данните на слоевете на PSD файла използват непознато компресиране ({m}) +@msg the PSD file's layer records are damaged записите на слоевете на PSD файла са повредени +@msg the PSD file's layer groups are nested too deeply групите слоеве на PSD файла са вложени твърде дълбоко +@msg a layer of the PSD file is too large to read слой на PSD файла е твърде голям за четене +@msg the PSD file's layers take more memory to read than allowed слоевете на PSD файла изискват повече памет за четене от позволеното +@msg the layers can't be read ({e}): the file opened as its merged image слоевете не могат да бъдат прочетени ({e}): файлът е отворен като обединеното си изображение @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битови PSD файлове в този цветови режим не могат да бъдат прочетени @msg the PSD file is cut short PSD файлът е непълен @msg the PSD file's header is damaged заглавната част (header) на PSD файла е повредена diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95cea..6b538ee67 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5143,7 +5143,13 @@ imageTrace Outline Obris @msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati @msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) -@msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati +@msg zipped 1-bit PSD image data with prediction can't be read zipovani 1-bitni PSD podaci slike s predviđanjem ne mogu se pročitati +@msg the PSD file's layer data uses an unknown compression ({m}) podaci slojeva PSD datoteke koriste nepoznatu kompresiju ({m}) +@msg the PSD file's layer records are damaged zapisi slojeva PSD datoteke su oštećeni +@msg the PSD file's layer groups are nested too deeply grupe slojeva PSD datoteke su previše duboko ugniježđene +@msg a layer of the PSD file is too large to read sloj PSD datoteke je prevelik za čitanje +@msg the PSD file's layers take more memory to read than allowed slojevi PSD datoteke zahtijevaju više memorije za čitanje nego što je dozvoljeno +@msg the layers can't be read ({e}): the file opened as its merged image slojevi se ne mogu pročitati ({e}): datoteka je otvorena kao spojena slika @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati @msg the PSD file is cut short PSD datoteka je skraćena @msg the PSD file's header is damaged zaglavlje PSD datoteke je oštećeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e0..aff3376fe 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6001,7 +6001,13 @@ digits Malayalam Malaiàlam @msg x and y must be provided together cal proporcionar x i y alhora @msg x and y must both be numbers x i y han de ser tots dos nombres @msg Your unsaved changes are kept in this browser: reload the page to get them back. Els canvis no desats es conserven en aquest navegador: torna a carregar la pàgina per recuperar-los. -@msg zipped 32-bit PSD image data with prediction can't be read les dades d’imatge PSD de 32 bits comprimides amb predicció no es poden llegir +@msg zipped 1-bit PSD image data with prediction can't be read les dades d’imatge PSD d’1 bit comprimides amb predicció no es poden llegir +@msg the PSD file's layer data uses an unknown compression ({m}) les dades de capa del fitxer PSD utilitzen una compressió desconeguda ({m}) +@msg the PSD file's layer records are damaged els registres de capes del fitxer PSD estan malmesos +@msg the PSD file's layer groups are nested too deeply els grups de capes del fitxer PSD estan imbricats massa profundament +@msg a layer of the PSD file is too large to read una capa del fitxer PSD és massa gran per llegir-la +@msg the PSD file's layers take more memory to read than allowed les capes del fitxer PSD necessiten més memòria de la permesa per llegir-les +@msg the layers can't be read ({e}): the file opened as its merged image no es poden llegir les capes ({e}): el fitxer s’ha obert com a imatge combinada @msg zone must be a string zone ha de ser una cadena @msg zone must be tab, center, left, right, top or bottom zone ha de ser tab, center, left, right, top o bottom @msg {_1} failed: {e} {_1} ha fallat: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fa..e9dfef5ac 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5241,7 +5241,13 @@ imageTrace Outline Obrys @msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst @msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) -@msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst +@msg zipped 1-bit PSD image data with prediction can't be read zazipovaná 1bitová obrazová data PSD s predikcí nelze přečíst +@msg the PSD file's layer data uses an unknown compression ({m}) data vrstev souboru PSD používají neznámou kompresi ({m}) +@msg the PSD file's layer records are damaged záznamy vrstev souboru PSD jsou poškozené +@msg the PSD file's layer groups are nested too deeply skupiny vrstev souboru PSD jsou vnořené příliš hluboko +@msg a layer of the PSD file is too large to read vrstva souboru PSD je příliš velká na přečtení +@msg the PSD file's layers take more memory to read than allowed vrstvy souboru PSD potřebují ke čtení víc paměti, než je povoleno +@msg the layers can't be read ({e}): the file opened as its merged image vrstvy nelze přečíst ({e}): soubor se otevřel jako sloučený obraz @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst @msg the PSD file is cut short soubor PSD je useknutý @msg the PSD file's header is damaged hlavička souboru PSD je poškozená diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6b..324c90c41 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4822,7 +4822,13 @@ imageTrace Outline Kontur @msg the PSD file has an unknown colour mode ({m}) PSD-filen har en ukendt farvetilstand ({m}) @msg the PSD file's image data uses an unknown compression ({m}) PSD-filens billeddata bruger en ukendt komprimering ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-billedet er for stort til at læse ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read zippet 32-bit PSD-billeddata med prædiktion kan ikke læses +@msg zipped 1-bit PSD image data with prediction can't be read zippet 1-bit PSD-billeddata med prædiktion kan ikke læses +@msg the PSD file's layer data uses an unknown compression ({m}) PSD-filens lagdata bruger en ukendt komprimering ({m}) +@msg the PSD file's layer records are damaged PSD-filens lagposter er beskadigede +@msg the PSD file's layer groups are nested too deeply PSD-filens laggrupper er indlejret for dybt +@msg a layer of the PSD file is too large to read et lag i PSD-filen er for stort til at læse +@msg the PSD file's layers take more memory to read than allowed PSD-filens lag kræver mere hukommelse at læse end tilladt +@msg the layers can't be read ({e}): the file opened as its merged image lagene kan ikke læses ({e}): filen blev åbnet som sit samlede billede @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bit PSD-filer i denne farvetilstand kan ikke læses @msg the PSD file is cut short PSD-filen er afkortet @msg the PSD file's header is damaged PSD-filens header er beskadiget diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81..1f7cccb4d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4964,7 +4964,7 @@ theme Appearance Erscheinungsbild The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: -# PSD files (opened and placed as their merged image). +# PSD files. @msg not a PSD file keine PSD-Datei @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus @msg the PSD file has an unknown colour mode ({m}) die PSD-Datei hat einen unbekannten Farbmodus ({m}) @@ -4984,7 +4984,13 @@ theme Appearance Erscheinungsbild @msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann @msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) -@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden +@msg zipped 1-bit PSD image data with prediction can't be read ZIP-komprimierte 1-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden +@msg the PSD file's layer data uses an unknown compression ({m}) die Ebenendaten der PSD-Datei verwenden eine unbekannte Komprimierung ({m}) +@msg the PSD file's layer records are damaged die Ebenendatensätze der PSD-Datei sind beschädigt +@msg the PSD file's layer groups are nested too deeply die Ebenengruppen der PSD-Datei sind zu tief verschachtelt +@msg a layer of the PSD file is too large to read eine Ebene der PSD-Datei ist zu groß zum Lesen +@msg the PSD file's layers take more memory to read than allowed die Ebenen der PSD-Datei brauchen zum Lesen mehr Speicher als erlaubt +@msg the layers can't be read ({e}): the file opened as its merged image die Ebenen können nicht gelesen werden ({e}): die Datei wurde als ihr zusammengefügtes Bild geöffnet @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden @msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig @msg the JPEG XL container is too large to read der JPEG-XL-Container ist zu groß zum Lesen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be..7a3ef8508 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4920,7 +4920,13 @@ digits Malayalam Μαλαγιάλαμ @msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί @msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) -@msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται +@msg zipped 1-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 1 bit με πρόβλεψη δεν διαβάζονται +@msg the PSD file's layer data uses an unknown compression ({m}) τα δεδομένα επιπέδων του αρχείου PSD χρησιμοποιούν άγνωστη συμπίεση ({m}) +@msg the PSD file's layer records are damaged οι εγγραφές επιπέδων του αρχείου PSD είναι κατεστραμμένες +@msg the PSD file's layer groups are nested too deeply οι ομάδες επιπέδων του αρχείου PSD είναι ένθετες σε υπερβολικό βάθος +@msg a layer of the PSD file is too large to read ένα επίπεδο του αρχείου PSD είναι πολύ μεγάλο για ανάγνωση +@msg the PSD file's layers take more memory to read than allowed τα επίπεδα του αρχείου PSD χρειάζονται περισσότερη μνήμη για ανάγνωση από όση επιτρέπεται +@msg the layers can't be read ({e}): the file opened as its merged image τα επίπεδα δεν μπορούν να διαβαστούν ({e}): το αρχείο άνοιξε ως η συγχωνευμένη εικόνα του @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος @msg the PSD file is cut short το αρχείο PSD είναι περικομμένο @msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae3..914846df7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4959,7 +4959,13 @@ axis Both Ambos @msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer @msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) -@msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción +@msg zipped 1-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 1 bit comprimidos en ZIP con predicción +@msg the PSD file's layer data uses an unknown compression ({m}) los datos de capa del archivo PSD usan una compresión desconocida ({m}) +@msg the PSD file's layer records are damaged los registros de capas del archivo PSD están dañados +@msg the PSD file's layer groups are nested too deeply los grupos de capas del archivo PSD están anidados a demasiada profundidad +@msg a layer of the PSD file is too large to read una capa del archivo PSD es demasiado grande para leerla +@msg the PSD file's layers take more memory to read than allowed las capas del archivo PSD necesitan más memoria de la permitida para leerlas +@msg the layers can't be read ({e}): the file opened as its merged image no se pueden leer las capas ({e}): el archivo se abrió como su imagen combinada @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer @msg the PSD file is cut short el archivo PSD está incompleto @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f60..dfe917ba8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5143,7 +5143,13 @@ imageTrace Outline Ääriviiva @msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea @msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) -@msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea +@msg zipped 1-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 1-bittistä PSD-kuvadataa ei voi lukea +@msg the PSD file's layer data uses an unknown compression ({m}) tämän PSD-tiedoston tasodata käyttää tuntematonta pakkausta ({m}) +@msg the PSD file's layer records are damaged tämän PSD-tiedoston tasotietueet ovat vahingoittuneet +@msg the PSD file's layer groups are nested too deeply tämän PSD-tiedoston tasoryhmät ovat sisäkkäin liian syvällä +@msg a layer of the PSD file is too large to read tämän PSD-tiedoston taso on liian suuri luettavaksi +@msg the PSD file's layers take more memory to read than allowed tämän PSD-tiedoston tasojen lukeminen vaatii enemmän muistia kuin sallitaan +@msg the layers can't be read ({e}): the file opened as its merged image tasoja ei voi lukea ({e}): tiedosto avattiin yhdistettynä kuvanaan @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea @msg the PSD file is cut short tämä PSD-tiedosto on katkennut @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b..209e9d6ad 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4966,7 +4966,13 @@ axis Both Les deux @msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu @msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues +@msg zipped 1-bit PSD image data with prediction can't be read les données d'image PSD 1 bit compressées en ZIP avec prédiction ne peuvent pas être lues +@msg the PSD file's layer data uses an unknown compression ({m}) les données de calque du fichier PSD utilisent une compression inconnue ({m}) +@msg the PSD file's layer records are damaged les enregistrements de calques du fichier PSD sont endommagés +@msg the PSD file's layer groups are nested too deeply les groupes de calques du fichier PSD sont imbriqués trop profondément +@msg a layer of the PSD file is too large to read un calque du fichier PSD est trop grand pour être lu +@msg the PSD file's layers take more memory to read than allowed les calques du fichier PSD demandent plus de mémoire que permis pour être lus +@msg the layers can't be read ({e}): the file opened as its merged image les calques ne peuvent pas être lus ({e}) : le fichier a été ouvert comme son image fusionnée @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus @msg the PSD file is cut short le fichier PSD est tronqué @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779..fdf5934e6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8000,7 +8000,13 @@ antiAlias Sharp शार्प @msg x and y must both be numbers x और y दोनों संख्या होना चाहिए @msg You must choose a side pattern. आपको एक साइड पैटर्न चुनना होगा। @msg Your unsaved changes are kept in this browser: reload the page to get them back. इस ब्राउज़र में आपके बिना सेव किए गए बदलाव को रखा गया है: उन्हें वापस पाने के लिए पेज को फिर से लोड करें। -@msg zipped 32-bit PSD image data with prediction can't be read भविष्यवाणी के साथ 32-bit PSD इमेज डेटा zipped नहीं किया जा सकता +@msg zipped 1-bit PSD image data with prediction can't be read भविष्यवाणी के साथ zipped 1-bit PSD इमेज डेटा पढ़ा नहीं जा सकता +@msg the PSD file's layer data uses an unknown compression ({m}) PSD फ़ाइल का लेयर डेटा एक अज्ञात संपीड़न ({m}) का उपयोग करता है +@msg the PSD file's layer records are damaged PSD फ़ाइल के लेयर रिकॉर्ड क्षतिग्रस्त हैं +@msg the PSD file's layer groups are nested too deeply PSD फ़ाइल के लेयर समूह बहुत गहराई तक नेस्ट किए गए हैं +@msg a layer of the PSD file is too large to read PSD फ़ाइल की एक लेयर पढ़ने के लिए बहुत बड़ी है +@msg the PSD file's layers take more memory to read than allowed PSD फ़ाइल की लेयर पढ़ने के लिए अनुमति से अधिक मेमोरी चाहिए +@msg the layers can't be read ({e}): the file opened as its merged image लेयर पढ़ी नहीं जा सकतीं ({e}): फ़ाइल अपनी मर्ज की गई इमेज के रूप में खुली @msg zone must be a string zone: क्षेत्र एक स्ट्रिंग होना चाहिए @msg zone must be tab, center, left, right, top or bottom zone को tab, center, left, right, top या bottom होना चाहिए @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: जोन को एक एंकर की आवश्यकता होती है: एंकर के साथ एक पैनल के बगल में, या साइड (स्टार्ट या एंड) के साथ एक नए डॉक कॉलम में diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339b..ba6eb51ad 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5566,7 +5566,13 @@ imageTrace Outline Körvonal @msg x and y must be provided together az x és az y csak együtt adható meg @msg x and y must both be numbers az x és az y is szám kell legyen @msg Your unsaved changes are kept in this browser: reload the page to get them back. A nem mentett módosításaidat ez a böngésző megőrizte: töltsd újra az oldalt a visszakapásukhoz. -@msg zipped 32-bit PSD image data with prediction can't be read a predikcióval tömörített 32 bites PSD-képadatok nem olvashatók +@msg zipped 1-bit PSD image data with prediction can't be read a predikcióval tömörített 1 bites PSD-képadatok nem olvashatók +@msg the PSD file's layer data uses an unknown compression ({m}) a PSD-fájl rétegadatai ismeretlen tömörítést használnak ({m}) +@msg the PSD file's layer records are damaged a PSD-fájl rétegrekordjai sérültek +@msg the PSD file's layer groups are nested too deeply a PSD-fájl rétegcsoportjai túl mélyen vannak egymásba ágyazva +@msg a layer of the PSD file is too large to read a PSD-fájl egyik rétege túl nagy a beolvasáshoz +@msg the PSD file's layers take more memory to read than allowed a PSD-fájl rétegeinek beolvasása több memóriát igényel a megengedettnél +@msg the layers can't be read ({e}): the file opened as its merged image a rétegek nem olvashatók ({e}): a fájl az egyesített képeként nyílt meg @msg zone must be a string a zone értéke szöveg lehet @msg zone must be tab, center, left, right, top or bottom a zone értéke tab, center, left, right, top vagy bottom lehet @msg {_1} failed: {e} sikertelen: {_1}: {e} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97..bb79cffbc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4822,7 +4822,13 @@ imageTrace Outline Kontur @msg the PSD file has an unknown colour mode ({m}) berkas PSD memiliki mode warna yang tidak dikenal ({m}) @msg the PSD file's image data uses an unknown compression ({m}) data gambar berkas PSD menggunakan kompresi yang tidak dikenal ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) gambar PSD terlalu besar untuk dibaca ({_1} × {_2} piksel) -@msg zipped 32-bit PSD image data with prediction can't be read data gambar PSD 32-bit terkompresi zip dengan prediksi tidak dapat dibaca +@msg zipped 1-bit PSD image data with prediction can't be read data gambar PSD 1-bit terkompresi zip dengan prediksi tidak dapat dibaca +@msg the PSD file's layer data uses an unknown compression ({m}) data lapisan berkas PSD menggunakan kompresi yang tidak dikenal ({m}) +@msg the PSD file's layer records are damaged catatan lapisan berkas PSD rusak +@msg the PSD file's layer groups are nested too deeply grup lapisan berkas PSD bersarang terlalu dalam +@msg a layer of the PSD file is too large to read sebuah lapisan berkas PSD terlalu besar untuk dibaca +@msg the PSD file's layers take more memory to read than allowed lapisan berkas PSD memerlukan lebih banyak memori untuk dibaca daripada yang diizinkan +@msg the layers can't be read ({e}): the file opened as its merged image lapisan tidak dapat dibaca ({e}): berkas dibuka sebagai gambar gabungannya @msg {depth}-bit PSD files in this colour mode can't be read berkas PSD {depth}-bit dalam mode warna ini tidak dapat dibaca @msg the PSD file is cut short berkas PSD terpotong @msg the PSD file's header is damaged header berkas PSD rusak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b5..3f23f9a5b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5002,7 +5002,13 @@ axis Both Entrambi @msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto @msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) -@msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti +@msg zipped 1-bit PSD image data with prediction can't be read i dati immagine PSD a 1 bit compressi ZIP con previsione non possono essere letti +@msg the PSD file's layer data uses an unknown compression ({m}) i dati dei livelli del file PSD usano una compressione sconosciuta ({m}) +@msg the PSD file's layer records are damaged i record dei livelli del file PSD sono danneggiati +@msg the PSD file's layer groups are nested too deeply i gruppi di livelli del file PSD sono annidati troppo in profondità +@msg a layer of the PSD file is too large to read un livello del file PSD è troppo grande per essere letto +@msg the PSD file's layers take more memory to read than allowed i livelli del file PSD richiedono più memoria del consentito per essere letti +@msg the layers can't be read ({e}): the file opened as its merged image i livelli non possono essere letti ({e}): il file è stato aperto come immagine unita @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti @msg the PSD file is cut short il file PSD è troncato @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcad..211ef2783 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5043,7 +5043,13 @@ swatch Bright Violet ビビッドバイオレット @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています @msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) -@msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません +@msg zipped 1-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 1 bit PSD 画像データは読み込めません +@msg the PSD file's layer data uses an unknown compression ({m}) PSD ファイルのレイヤーデータの圧縮方式が不明です({m}) +@msg the PSD file's layer records are damaged PSD ファイルのレイヤーレコードが破損しています +@msg the PSD file's layer groups are nested too deeply PSD ファイルのレイヤーグループの入れ子が深すぎます +@msg a layer of the PSD file is too large to read PSD ファイルのレイヤーが大きすぎて読み込めません +@msg the PSD file's layers take more memory to read than allowed PSD ファイルのレイヤーの読み込みに許容量を超えるメモリーが必要です +@msg the layers can't be read ({e}): the file opened as its merged image レイヤーを読み込めません({e}):ファイルは統合画像として開かれました @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません @msg the PSD file is cut short PSD ファイルが途中で切れています @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c42..d2be9115b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4658,7 +4658,13 @@ font-technology Variable ცვლადი @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია @msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) -@msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება +@msg zipped 1-bit PSD image data with prediction can't be read zip-ით შეკუმშული 1-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება +@msg the PSD file's layer data uses an unknown compression ({m}) PSD ფაილის შრეების მონაცემები უცნობ შეკუმშვას იყენებს ({m}) +@msg the PSD file's layer records are damaged PSD ფაილის შრეების ჩანაწერები დაზიანებულია +@msg the PSD file's layer groups are nested too deeply PSD ფაილის შრეების ჯგუფები ზედმეტად ღრმადაა ჩადგმული +@msg a layer of the PSD file is too large to read PSD ფაილის შრე ზედმეტად დიდია წასაკითხად +@msg the PSD file's layers take more memory to read than allowed PSD ფაილის შრეების წასაკითხად დაშვებულზე მეტი მეხსიერებაა საჭირო +@msg the layers can't be read ({e}): the file opened as its merged image შრეების წაკითხვა ვერ ხერხდება ({e}): ფაილი გაიხსნა როგორც მისი გაერთიანებული გამოსახულება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება @msg the PSD file is cut short PSD ფაილი შეკვეცილია @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2..2edafef8e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4970,7 +4970,13 @@ theme Appearance 외관 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 @msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) -@msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 +@msg zipped 1-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 1비트 PSD 이미지 데이터는 읽을 수 없습니다 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 파일의 레이어 데이터가 알 수 없는 압축({m})을 사용합니다 +@msg the PSD file's layer records are damaged PSD 파일의 레이어 레코드가 손상되었습니다 +@msg the PSD file's layer groups are nested too deeply PSD 파일의 레이어 그룹이 너무 깊이 중첩되어 있습니다 +@msg a layer of the PSD file is too large to read PSD 파일의 레이어가 너무 커서 읽을 수 없습니다 +@msg the PSD file's layers take more memory to read than allowed PSD 파일의 레이어를 읽는 데 허용된 것보다 많은 메모리가 필요합니다 +@msg the layers can't be read ({e}): the file opened as its merged image 레이어를 읽을 수 없습니다({e}): 파일이 병합된 이미지로 열렸습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 @msg the PSD file is cut short PSD 파일이 잘려 있습니다 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc..c80b77d6c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4834,7 +4834,13 @@ imageTrace Outline Contour @msg the PSD file has an unknown colour mode ({m}) het PSD-bestand heeft een onbekende kleurmodus ({m}) @msg the PSD file's image data uses an unknown compression ({m}) de afbeeldingsgegevens van het PSD-bestand gebruiken een onbekende compressie ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) de PSD-afbeelding is te groot om te lezen ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read gezipte 32-bits PSD-afbeeldingsgegevens met voorspelling kunnen niet worden gelezen +@msg zipped 1-bit PSD image data with prediction can't be read gezipte 1-bits PSD-afbeeldingsgegevens met voorspelling kunnen niet worden gelezen +@msg the PSD file's layer data uses an unknown compression ({m}) de laaggegevens van het PSD-bestand gebruiken een onbekende compressie ({m}) +@msg the PSD file's layer records are damaged de laagrecords van het PSD-bestand zijn beschadigd +@msg the PSD file's layer groups are nested too deeply de laaggroepen van het PSD-bestand zijn te diep genest +@msg a layer of the PSD file is too large to read een laag van het PSD-bestand is te groot om te lezen +@msg the PSD file's layers take more memory to read than allowed de lagen van het PSD-bestand hebben meer geheugen nodig om te lezen dan toegestaan +@msg the layers can't be read ({e}): the file opened as its merged image de lagen kunnen niet worden gelezen ({e}): het bestand is geopend als zijn samengevoegde afbeelding @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bits PSD-bestanden in deze kleurmodus kunnen niet worden gelezen @msg the PSD file is cut short het PSD-bestand is afgekapt @msg the PSD file's header is damaged de header van het PSD-bestand is beschadigd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4..f43cf163a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5067,7 +5067,13 @@ imageTrace Outline Kontur @msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać @msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) -@msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją +@msg zipped 1-bit PSD image data with prediction can't be read nie można odczytać spakowanych 1-bitowych danych obrazu PSD z predykcją +@msg the PSD file's layer data uses an unknown compression ({m}) dane warstw pliku PSD używają nieznanej kompresji ({m}) +@msg the PSD file's layer records are damaged rekordy warstw pliku PSD są uszkodzone +@msg the PSD file's layer groups are nested too deeply grupy warstw pliku PSD są zagnieżdżone zbyt głęboko +@msg a layer of the PSD file is too large to read warstwa pliku PSD jest zbyt duża, aby ją odczytać +@msg the PSD file's layers take more memory to read than allowed odczytanie warstw pliku PSD wymaga więcej pamięci, niż jest dozwolone +@msg the layers can't be read ({e}): the file opened as its merged image nie można odczytać warstw ({e}): plik otwarto jako jego scalony obraz @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru @msg the PSD file is cut short plik PSD jest ucięty @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda..6206fac3f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4467,7 +4467,13 @@ axis Both Ambos @msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido @msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos +@msg zipped 1-bit PSD image data with prediction can't be read dados de imagem PSD de 1 bit compactados em zip com previsão não podem ser lidos +@msg the PSD file's layer data uses an unknown compression ({m}) os dados de camada do arquivo PSD usam uma compactação desconhecida ({m}) +@msg the PSD file's layer records are damaged os registros de camadas do arquivo PSD estão danificados +@msg the PSD file's layer groups are nested too deeply os grupos de camadas do arquivo PSD estão aninhados profundamente demais +@msg a layer of the PSD file is too large to read uma camada do arquivo PSD é grande demais para ser lida +@msg the PSD file's layers take more memory to read than allowed as camadas do arquivo PSD precisam de mais memória do que o permitido para serem lidas +@msg the layers can't be read ({e}): the file opened as its merged image as camadas não podem ser lidas ({e}): o arquivo foi aberto como sua imagem mesclada @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos @msg the PSD file is cut short o arquivo PSD está incompleto @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb542582..8d0a6dc46 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4998,7 +4998,13 @@ axis Both Оба @msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать @msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) -@msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются +@msg zipped 1-bit PSD image data with prediction can't be read 1-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются +@msg the PSD file's layer data uses an unknown compression ({m}) данные слоёв файла PSD используют неизвестное сжатие ({m}) +@msg the PSD file's layer records are damaged записи слоёв файла PSD повреждены +@msg the PSD file's layer groups are nested too deeply группы слоёв файла PSD вложены слишком глубоко +@msg a layer of the PSD file is too large to read слой файла PSD слишком велик для чтения +@msg the PSD file's layers take more memory to read than allowed для чтения слоёв файла PSD нужно больше памяти, чем разрешено +@msg the layers can't be read ({e}): the file opened as its merged image не удаётся прочитать слои ({e}): файл открыт как сведённое изображение @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются @msg the PSD file is cut short файл PSD обрезан @msg the PSD file's header is damaged заголовок файла PSD повреждён diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de..0a04f632a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4875,7 +4875,13 @@ axis Both Båda @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas @msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) -@msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas +@msg zipped 1-bit PSD image data with prediction can't be read zippad 1-bitars PSD-bilddata med prediktion kan inte läsas +@msg the PSD file's layer data uses an unknown compression ({m}) PSD-filens lagerdata använder en okänd komprimering ({m}) +@msg the PSD file's layer records are damaged PSD-filens lagerposter är skadade +@msg the PSD file's layer groups are nested too deeply PSD-filens lagergrupper är kapslade för djupt +@msg a layer of the PSD file is too large to read ett lager i PSD-filen är för stort för att läsas +@msg the PSD file's layers take more memory to read than allowed PSD-filens lager kräver mer minne för att läsas än vad som tillåts +@msg the layers can't be read ({e}): the file opened as its merged image lagren kan inte läsas ({e}): filen öppnades som sin sammanslagna bild @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas @msg the PSD file is cut short PSD-filen är avkortad @msg the PSD file's header is damaged PSD-filens huvud är skadat diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc..d723f8ed3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4704,7 +4704,13 @@ axis Both Her İkisi @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor @msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) -@msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor +@msg zipped 1-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 1 bit PSD görüntü verileri okunamıyor +@msg the PSD file's layer data uses an unknown compression ({m}) PSD dosyasının katman verileri bilinmeyen bir sıkıştırma kullanıyor ({m}) +@msg the PSD file's layer records are damaged PSD dosyasının katman kayıtları hasarlı +@msg the PSD file's layer groups are nested too deeply PSD dosyasının katman grupları çok derin iç içe geçmiş +@msg a layer of the PSD file is too large to read PSD dosyasının bir katmanı okunamayacak kadar büyük +@msg the PSD file's layers take more memory to read than allowed PSD dosyasının katmanlarını okumak izin verilenden fazla bellek gerektiriyor +@msg the layers can't be read ({e}): the file opened as its merged image katmanlar okunamıyor ({e}): dosya birleştirilmiş görüntüsü olarak açıldı @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz @msg the PSD file is cut short PSD dosyası kısa kesildi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b..6983f0d06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4986,7 +4986,13 @@ axis Both Обидві @msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати @msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) -@msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються +@msg zipped 1-bit PSD image data with prediction can't be read 1-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються +@msg the PSD file's layer data uses an unknown compression ({m}) дані шарів файлу PSD використовують невідоме стиснення ({m}) +@msg the PSD file's layer records are damaged записи шарів файлу PSD пошкоджено +@msg the PSD file's layer groups are nested too deeply групи шарів файлу PSD вкладено надто глибоко +@msg a layer of the PSD file is too large to read шар файлу PSD завеликий для читання +@msg the PSD file's layers take more memory to read than allowed для читання шарів файлу PSD потрібно більше пам’яті, ніж дозволено +@msg the layers can't be read ({e}): the file opened as its merged image не вдається прочитати шари ({e}): файл відкрито як зведене зображення @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються @msg the PSD file is cut short файл PSD обрізаний @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a6..c977136b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5234,7 +5234,13 @@ theme Appearance Giao diện @msg x and y must be provided together x và y phải được cung cấp cùng nhau @msg x and y must both be numbers x và y đều phải là số @msg Your unsaved changes are kept in this browser: reload the page to get them back. Các thay đổi chưa lưu được giữ trong trình duyệt này: tải lại trang để lấy lại. -@msg zipped 32-bit PSD image data with prediction can't be read dữ liệu ảnh PSD 32-bit nén với dự đoán không thể đọc được +@msg zipped 1-bit PSD image data with prediction can't be read dữ liệu ảnh PSD 1-bit nén với dự đoán không thể đọc được +@msg the PSD file's layer data uses an unknown compression ({m}) dữ liệu lớp của tệp PSD dùng nén không xác định ({m}) +@msg the PSD file's layer records are damaged bản ghi lớp của tệp PSD bị hỏng +@msg the PSD file's layer groups are nested too deeply các nhóm lớp của tệp PSD lồng nhau quá sâu +@msg a layer of the PSD file is too large to read một lớp của tệp PSD quá lớn để đọc +@msg the PSD file's layers take more memory to read than allowed các lớp của tệp PSD cần nhiều bộ nhớ để đọc hơn mức cho phép +@msg the layers can't be read ({e}): the file opened as its merged image không thể đọc các lớp ({e}): tệp được mở dưới dạng ảnh đã gộp @msg zone must be a string zone phải là chuỗi @msg zone must be tab, center, left, right, top or bottom zone phải là tab, center, left, right, top hoặc bottom @msg {_1} failed: {e} {_1} thất bại: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe..09ad05970 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4747,7 +4747,13 @@ imageTrace Outline 轮廓 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 @msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) -@msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 +@msg zipped 1-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 1 位 PSD 图像数据 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 文件的图层数据使用了未知的压缩方式 ({m}) +@msg the PSD file's layer records are damaged PSD 文件的图层记录已损坏 +@msg the PSD file's layer groups are nested too deeply PSD 文件的图层组嵌套过深 +@msg a layer of the PSD file is too large to read PSD 文件的某个图层过大,无法读取 +@msg the PSD file's layers take more memory to read than allowed 读取 PSD 文件的图层所需内存超过允许值 +@msg the layers can't be read ({e}): the file opened as its merged image 无法读取图层 ({e}):文件已作为合并图像打开 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 @msg the PSD file is cut short PSD 文件不完整 @msg the PSD file's header is damaged PSD 文件头已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e770..55641591b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2556,7 +2556,13 @@ file-filter Templates 範本 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 @msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) -@msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 +@msg zipped 1-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 1 位元 PSD 影像資料 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 檔案的圖層資料使用了不明的壓縮方式 ({m}) +@msg the PSD file's layer records are damaged PSD 檔案的圖層記錄已損毀 +@msg the PSD file's layer groups are nested too deeply PSD 檔案的圖層群組巢狀過深 +@msg a layer of the PSD file is too large to read PSD 檔案的某個圖層過大,無法讀取 +@msg the PSD file's layers take more memory to read than allowed 讀取 PSD 檔案的圖層所需記憶體超過允許值 +@msg the layers can't be read ({e}): the file opened as its merged image 無法讀取圖層 ({e}):檔案已以合併影像開啟 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 @msg the PSD file is cut short PSD 檔案不完整 @msg the PSD file's header is damaged PSD 檔頭已損毀 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df..16576600e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -293,8 +293,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (merged image, every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | -| PSB | open | yes (merged image) | no | | +| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSB | open | yes (layers as layers, or the merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c..15c4fbb11 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -490,7 +490,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6198,12 +6198,67 @@ appearance of their own or knockout stay one pixel layer. Hidden layers and obje `hiddenLayers: true` writes them as hidden layers. `layers: false` writes one flat image, on white where nothing is drawn. Files are at most 30000 pixels a side. -Photoshop documents (`.psd`, and `.psb`, the large document format) open and place as their merged image, the -flattened picture every Photoshop file carries (layers aren't read): Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK +PSD files (`.psd`, and `.psb`, the large document format) place as their merged image, the +flattened picture every PSD file carries: Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK (converted with the colour settings' CMYK) and Lab, at 1, 8, 16 and 32 bits per channel, sized by their resolution. A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +`document.open` converts a PSD or PSB file's layers to objects. In +[PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html), +the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a +clipping mask into one layer and discards hidden layers that require merging; the option that flattens the layers +imports one image, and Import Hidden Layers brings hidden layers in. `document.open` does the same, with +`layers: false` for the single image and `hiddenLayers: true` for the hidden layers. The page also merges +adjustment layers, layers with a knockout option, and layers that hold transparent pixels in some blend modes with +the layers below them. `document.open` applies neither adjustments nor knockouts, and it merges a layer by its blend +mode alone, as the rules below say. + +The rest are VectorCraft's own rules: + +- Each layer becomes a layer of the document holding its pixels as an image, with the layer's name, visibility, + opacity and blend mode. +- A layer group becomes a layer of sublayers. A Pass Through group is not isolated; a group in any other mode is. +- Layers clipped to the layer below are merged into it as one image, shown where that layer has pixels. Layers + clipped to an adjustment layer, or to a fill layer that stores no pixels and isn't drawn, show over the whole + canvas, through that layer's mask. +- Hidden layers are left out unless `hiddenLayers: true`, which brings them in hidden. A hidden layer that would + need a merge is always left out, with the layers clipped to it. The `warnings` count the layers left out. +- `layers: false` opens the merged image. +- A layer or group in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, + Divide, Darker Color or Lighter Color is merged with the visible layers below it in its group, whether or not it + holds transparent pixels. Layers in Color Dodge, Color Burn and Difference keep their mode. In such a merge, the + layers of a Pass Through group blend with the layers below the group, at the group's opacity and through its layer + mask. +- A Dissolve layer's partly transparent pixels are merged as opaque or transparent, opaque at the share of its + pixels their transparency gives, in a fixed pattern that differs from layer to layer. +- A merged layer is named after the layers it holds, joined with ` + `. +- A layer name the file stores only as a one-byte Pascal string, without its Unicode name, is read as Mac Roman. +- A layer's opacity is multiplied by its fill opacity, in every blend mode. A layer that others are clipped to keeps + its own opacity, and its fill opacity fades only its own pixels. +- A layer mask becomes the layer's opacity mask: a gray image that hides the layer outside it when the mask's + default color is black and shows it when white. A disabled mask is kept, switched off. +- Type and shape layers, and gradient and pattern fill layers that store pixels, come in as the pixels the file + stores. A solid color fill layer that stores no pixels and has no vector mask comes in as an image of its color + (RGB, CMYK, Grayscale, Lab or HSB) over the canvas. One whose shape is a vector mask isn't drawn, and the layers + clipped to it are left out. +- Adjustment layers, fill layers that store no pixels and whose fill isn't read, layer effects that are switched on, + knockouts and vector masks are not applied; the `warnings` count them. +- Layer channels may be raw, PackBits or zipped (with or without prediction) at 8, 16 and 32 bits. +- A file without layers opens as its merged image. A file whose layers can't be read opens as its merged image + with a warning, and when the merged image can't be read either, the file is refused with an error. + +Each layer's pixels must fit the memory allowed for one image, and the pixels of all the layers and masks, with the +canvases the merges make, must together fit eight times that; a file past either limit opens as its merged image, +with a warning. The canvases one step of a merge holds at once must also fit the memory allowed for one image; when +they don't, the layer that needed the merge comes in alone, with a warning: in the Normal mode, or as a layer of its +own instead of clipped. File › Open in the app has no PSD import options dialog: it converts the layers and leaves +hidden layers out. + +```json +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/poster.psd","hiddenLayers":true}}} +``` + JPEG XL images (`.jxl`, a bare codestream or the ISOBMFF container) open and place as their first frame, with the orientation their header gives, decoded with jxl-oxide and stored as PNG. 16-bit and floating-point samples become 8 bits, and alpha is kept. Colors are converted to sRGB in XYB-encoded images and when the header names the color space diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..3bee725c3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1462,6 +1462,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). + - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`).