diff --git a/AGENTS.md b/AGENTS.md index 0ef099f..1f905b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,7 @@ Platform adaptations, each forced by a missing Qt analog: - Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`. - Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`. - Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`. +- Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6548bbd..aaae12d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,6 +159,7 @@ add_library(compositor_core STATIC src/IO/PSD/PSDChannelCoder.cpp src/IO/PSD/PSDDocumentBuilder.cpp src/IO/PSD/PSDReader.cpp + src/IO/PSD/PSDReader+Records.cpp src/IO/PSD/PSDReader+Adjustments.cpp src/IO/PSD/PSDTypes.cpp src/IO/PSD/PSDVector.cpp diff --git a/TASKS.md b/TASKS.md index 05e380d..957a910 100644 --- a/TASKS.md +++ b/TASKS.md @@ -379,8 +379,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). Codex review: 3 rounds, the third's finding (a history manager re-setting the current entry) fixed and closed. - [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags). - [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS. -- [ ] 12.11b Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`), simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). -- [ ] 12.11c Part three: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). +- [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed. +- [ ] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). +- [ ] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). - [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. - [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. - [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop index e1c254c..04a7186 100644 --- a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop +++ b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop @@ -7,5 +7,5 @@ Exec=omaphoto %F Icon=io.github.ZacharyZhang_NY.OmaPhoto Terminal=false Categories=Graphics;2DGraphics;RasterGraphics; -MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw; +MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw; StartupWMClass=OmaPhoto diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml index 4d956c5..3facc3d 100644 --- a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml +++ b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml @@ -5,4 +5,11 @@ <sub-class-of type="inode/directory"/> <glob pattern="*.comp"/> </mime-type> + <mime-type type="image/x-photoshop-large-image"> + <comment>Photoshop Large Document</comment> + <glob pattern="*.psb"/> + <magic priority="50"> + <match type="string" value="8BPS\x00\x02" offset="0"/> + </magic> + </mime-type> </mime-info> diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index 2d58413..7beafb7 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -87,7 +87,7 @@ void ContentView::showImporter() m_importer = new QFileDialog(this); m_importer->setAttribute(Qt::WA_DeleteOnClose); m_importer->setFileMode(QFileDialog::ExistingFiles); - m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd %1)").arg(RawImporter::globs().join(' '))); + m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb %1)").arg(RawImporter::globs().join(' '))); connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) { // Ours no longer: the flag must not reject it again. if (m_importer != panel) diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp index d4d54cb..3219bdf 100644 --- a/src/Document/EditorSession+Imports.cpp +++ b/src/Document/EditorSession+Imports.cpp @@ -100,23 +100,19 @@ void EditorSession::decodeNext() drainImports(); return; } - qint64 usedPixels = 0; - if (m_document) { - for (const ImageLayer &layer : m_document->layers) { - if (layer.asset) - usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height(); - } - } const QUrl url = request.urls[m_importIndex]; if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) { - decodeRaw(url.toLocalFile(), DocumentLimits::documentPixelBudget() - usedPixels); + decodeRaw(url.toLocalFile(), remainingPixels()); return; } if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) { beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import")); - m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = DocumentLimits::documentPixelBudget() - usedPixels]() -> PhotoshopRead { + m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = remainingPixels()]() -> PhotoshopRead { try { PSDDocument document = ImageImporter::loadPhotoshop(path, remaining); + // No layer records: Photoshop wrote the merged image alone. + if (document.layers.empty()) + return {std::nullopt, {}, QString(), true}; std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document); return {std::move(document), std::move(assets), QString()}; } catch (const ImageImportError &error) { @@ -129,13 +125,35 @@ void EditorSession::decodeNext() })); return; } - m_decoder.setFuture(QtConcurrent::run([url, remaining = DocumentLimits::documentPixelBudget() - usedPixels]() -> Decoded { + decode(url, false); +} + +qint64 EditorSession::remainingPixels() const +{ + qint64 usedPixels = 0; + if (m_document) { + for (const ImageLayer &layer : m_document->layers) { + if (layer.asset) + usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height(); + } + } + return DocumentLimits::documentPixelBudget() - usedPixels; +} + +// `flattened`: a layerless Photoshop file, read as its merged image. +void EditorSession::decode(const QUrl &url, bool flattened) +{ + m_decoder.setFuture(QtConcurrent::run([url, flattened, remaining = remainingPixels()]() -> Decoded { try { if (!url.isLocalFile()) throw ImageImportError(ImageImportError::Kind::unsupported); - return {ImageImporter::decode(url.toLocalFile(), remaining), QString()}; + return {ImageImporter::decode(url.toLocalFile(), remaining, flattened), QString()}; } catch (const ImageImportError &error) { return {std::nullopt, url.fileName() + ": " + error.what()}; + } catch (const PSDError &error) { + return {std::nullopt, url.fileName() + ": " + error.what()}; + } catch (const ExportError &error) { + return {std::nullopt, url.fileName() + ": " + error.what()}; } })); } @@ -210,6 +228,12 @@ void EditorSession::finishPhotoshopRead() m_importIndex += 1; decodeNext(); }; + // Swift ends the reading first, then reads the merged image. + if (read.layerless) { + endPSDReading(); + decode(url, true); + return; + } // Every image has its asset: making layers cannot fail. const std::optional<PSDImport> imported = read.document ? std::optional(PSDDocumentBuilder::makeImport(*read.document, read.assets)) : std::nullopt; if (!imported) { diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index e745708..2fd3b9e 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -36,6 +36,8 @@ struct SessionJobs { std::optional<PSDDocument> document; std::map<QUuid, ImportedImage> assets; QString failure; + // No layer records: a background alone, read merged next. + bool layerless = false; }; struct Baked { std::map<QUuid, ImportedImage> images; diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index e9d1d40..4b7e39f 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -330,6 +330,8 @@ private: void updateBusyIndicator(); void drainImports(); void decodeNext(); + qint64 remainingPixels() const; + void decode(const QUrl &url, bool flattened); void finishDecode(); void finishPhotoshopRead(); void endDuplicateTransform(); diff --git a/src/IO/ImageFileDrop.cpp b/src/IO/ImageFileDrop.cpp index 2aef40b..c60f932 100644 --- a/src/IO/ImageFileDrop.cpp +++ b/src/IO/ImageFileDrop.cpp @@ -8,12 +8,12 @@ #include <algorithm> namespace { -// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, RAW, then any. +// Swift's order: PNG, JPEG, HEIC, TIFF, PSD, PSB, RAW, any. QStringList pictureFormats(const QMimeData &data) { QStringList formats; for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff"), - QStringLiteral("image/vnd.adobe.photoshop")}) + QStringLiteral("image/vnd.adobe.photoshop"), QStringLiteral("image/x-photoshop-large-image")}) if (data.hasFormat(format)) formats << format; for (const QString &format : data.formats()) diff --git a/src/IO/ImageImporter.cpp b/src/IO/ImageImporter.cpp index 7411569..f9e5c03 100644 --- a/src/IO/ImageImporter.cpp +++ b/src/IO/ImageImporter.cpp @@ -148,6 +148,37 @@ QImage decodeHEIC(const QString &path, qint64 remainingPixels) image.setColorSpace(stated); return image; } + +// Into sRGB with the profile still attached, then turned upright. +ImportedImage finish(QImage image, const QString &path, QImageIOHandler::Transformations orientation) +{ + image.convertToColorSpace(QColorSpace::SRgb); + image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied); + // Qt 6.9 renamed mirrored as flipped; the floor stays 6.4. +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + Qt::Orientations flips; + flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror)); + flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip)); + image = std::move(image).flipped(flips); +#else + image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror), + orientation.testFlag(QImageIOHandler::TransformationFlip)); +#endif + if (orientation.testFlag(QImageIOHandler::TransformationRotate90)) + image = image.transformed(QTransform().rotate(90)); + if (image.isNull()) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); + image.setColorSpace(QColorSpace::SRgb); + // Swift: scale = min(1, 96 / longest), extent rounded outward. + const qint64 longest = std::max(image.width(), image.height()); + const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest)); + const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); + const QString name = QFileInfo(path).completeBaseName(); + qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height(); + return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name); +} } ImportedImage::ImportedImage(QImage image, QImage thumbnail, QString name) @@ -190,9 +221,12 @@ void ImageImporter::liftAllocationLimit() Q_UNUSED(lifted) } -ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels) +ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels, bool flattenedPhotoshop) { liftAllocationLimit(); + // A Photoshop file of a background alone: its merged image. + if (flattenedPhotoshop && PSDReader::matches(path)) + return finish(PSDReader::merged(path, remainingPixels), path, QImageIOHandler::TransformationNone); QFile file(path); if (!file.open(QIODevice::ReadOnly)) refuse(ImageImportError::Kind::unreadable, path, file.errorString()); @@ -219,32 +253,7 @@ ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels) if (!reader.read(&image)) refuse(ImageImportError::Kind::unreadable, path, reader.errorString()); } - image.convertToColorSpace(QColorSpace::SRgb); - image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied); - // Qt 6.9 renamed mirrored as flipped; the floor stays 6.4. -#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) - Qt::Orientations flips; - flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror)); - flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip)); - image = std::move(image).flipped(flips); -#else - image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror), - orientation.testFlag(QImageIOHandler::TransformationFlip)); -#endif - if (orientation.testFlag(QImageIOHandler::TransformationRotate90)) - image = image.transformed(QTransform().rotate(90)); - if (image.isNull()) - refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); - image.setColorSpace(QColorSpace::SRgb); - // Swift: scale = min(1, 96 / longest), extent rounded outward. - const qint64 longest = std::max(image.width(), image.height()); - const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest)); - const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - if (thumbnail.isNull()) - refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); - const QString name = QFileInfo(path).completeBaseName(); - qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height(); - return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name); + return finish(std::move(image), path, orientation); } PSDDocument ImageImporter::loadPhotoshop(const QString &path, qint64 remainingPixels) diff --git a/src/IO/ImageImporter.h b/src/IO/ImageImporter.h index 93e1e66..d4be63e 100644 --- a/src/IO/ImageImporter.h +++ b/src/IO/ImageImporter.h @@ -48,8 +48,8 @@ public: namespace ImageImporter { // Qt refuses decodes over 128 MB; the app's budgets rule. void liftAllocationLimit(); -// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied. -ImportedImage decode(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); +// JPEG, PNG, TIFF, HEIC or a layerless PSD: upright sRGB. +ImportedImage decode(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget(), bool flattenedPhotoshop = false); PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document); } diff --git a/src/IO/PSD/PSDChannelCoder.cpp b/src/IO/PSD/PSDChannelCoder.cpp index dfb55d9..7dde5d1 100644 --- a/src/IO/PSD/PSDChannelCoder.cpp +++ b/src/IO/PSD/PSDChannelCoder.cpp @@ -2,65 +2,140 @@ #include "Document/BrushStroke.h" #include "IO/PSD/PSDTypes.h" #include <QColorSpace> +#include <span> namespace { -std::vector<uchar> unpackRLE(int width, int height, const QByteArray &data) -{ +struct Reader { + const QByteArray &data; qsizetype offset = 0; - const auto next = [&]() -> uchar { + uchar next() + { if (offset >= data.size()) throw PSDError(PSDError::Kind::truncated); return uchar(data[offset++]); - }; - std::vector<qsizetype> counts(static_cast<size_t>(height)); - for (int row = 0; row < height; ++row) { - const uchar hi = next(), lo = next(); - counts[size_t(row)] = qsizetype(hi) << 8 | lo; } - std::vector<uchar> plane(size_t(width) * size_t(height)); - for (int row = 0; row < height; ++row) { - const qsizetype end = offset + counts[size_t(row)]; - if (end > data.size()) + // Two bytes a row, or four in a Large Document. + qsizetype count(bool largeDocument) + { + qsizetype value = 0; + for (int index = 0; index < (largeDocument ? 4 : 2); ++index) + value = value << 8 | next(); + return value; + } +}; + +// The rows' counts, first checked to fit in the data. +std::vector<qsizetype> counts(Reader &reader, qint64 height, bool largeDocument) +{ + if (height > (reader.data.size() - reader.offset) / (largeDocument ? 4 : 2)) + throw PSDError(PSDError::Kind::truncated); + std::vector<qsizetype> result(static_cast<size_t>(height)); + for (qsizetype &count : result) + count = reader.count(largeDocument); + return result; +} + +// Only the crop's columns kept: no source-wide row. +std::vector<uchar> unpackRows(Reader &reader, const std::vector<qsizetype> &counts, qint64 width, const PSDCrop &crop) +{ + std::vector<uchar> plane(static_cast<size_t>(crop.width * crop.height)); + for (size_t index = 0; index < counts.size(); ++index) { + const qint64 y = qint64(index); + const qsizetype end = reader.offset + counts[index]; + if (end > reader.data.size()) throw PSDError(PSDError::Kind::truncated); - int written = 0; - uchar *line = plane.data() + size_t(row) * size_t(width); + if (y < crop.y || y >= crop.y + crop.height) { + reader.offset = end; + continue; + } + // Checked: a row past the crop would overrun. + const std::span<uchar> target = std::span(plane).subspan(static_cast<size_t>((y - crop.y) * crop.width), static_cast<size_t>(crop.width)); + // What of a run lies in the crop's columns. + const auto keep = [&](qint64 from, qint64 count, const auto &put) { + const qint64 first = std::max(from, crop.x), last = std::min(from + count, crop.x + crop.width); + for (qint64 x = first; x < last; ++x) + target[static_cast<size_t>(x - crop.x)] = put(x - from); + }; + qint64 written = 0; while (written < width) { - if (offset >= end) + if (reader.offset >= end) throw PSDError(PSDError::Kind::truncated); - const int n = qint8(data[offset++]); + const int n = qint8(reader.data[reader.offset++]); if (n >= 0) { - const int count = n + 1; - if (written + count > width || offset + count > end) + const qint64 count = n + 1; + if (written + count > width || reader.offset + count > end) throw PSDError(PSDError::Kind::truncated); - std::copy_n(data.constData() + offset, count, line + written); - offset += count; + const qsizetype at = reader.offset; + keep(written, count, [&](qint64 i) { return uchar(reader.data[at + i]); }); + reader.offset += count; written += count; } else if (n != -128) { - const int count = 1 - n; - if (written + count > width || offset >= end) + const qint64 count = 1 - n; + if (written + count > width || reader.offset >= end) throw PSDError(PSDError::Kind::truncated); - std::fill_n(line + written, count, uchar(data[offset++])); + const uchar value = uchar(reader.data[reader.offset++]); + keep(written, count, [&](qint64) { return value; }); written += count; } } - offset = end; + reader.offset = end; } return plane; } + +std::vector<uchar> cropRaw(qint64 width, qint64 height, const QByteArray &data, qsizetype start, const PSDCrop &crop) +{ + if (data.size() - start < width * height) + throw PSDError(PSDError::Kind::truncated); + std::vector<uchar> plane(static_cast<size_t>(crop.width * crop.height)); + for (qint64 row = 0; row < crop.height; ++row) + std::copy_n(data.constData() + start + (crop.y + row) * width + crop.x, crop.width, plane.data() + row * crop.width); + return plane; +} } -std::vector<uchar> PSDChannelCoder::decode(int compression, int width, int height, const QByteArray &data) +std::vector<uchar> PSDChannelCoder::decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument, + const std::optional<PSDCrop> &crop) { if (width <= 0 || height <= 0) return {}; - const qsizetype expected = qsizetype(width) * height; + const PSDCrop part = crop.value_or(PSDCrop{0, 0, width, height}); + if (part.x < 0 || part.y < 0 || part.width < 0 || part.height < 0 || part.x + part.width > width || part.y + part.height > height) + throw PSDError(PSDError::Kind::truncated); + if (part.width == 0 || part.height == 0) + return {}; switch (compression) { case 0: - if (data.size() < expected) - throw PSDError(PSDError::Kind::truncated); - return std::vector<uchar>(data.constData(), data.constData() + expected); - case 1: - return unpackRLE(width, height, data); + return cropRaw(width, height, data, 0, part); + case 1: { + Reader reader{data}; + return unpackRows(reader, counts(reader, height, largeDocument), width, part); + } + default: + throw PSDError(PSDError::Kind::unsupportedCompression); + } +} + +std::vector<std::vector<uchar>> PSDChannelCoder::mergedPlanes(int compression, int width, int height, int channels, int wanted, const QByteArray &data, + bool largeDocument) +{ + const PSDCrop whole{0, 0, width, height}; + std::vector<std::vector<uchar>> planes; + switch (compression) { + case 0: + for (int channel = 0; channel < wanted; ++channel) + planes.push_back(cropRaw(width, height, data, qsizetype(channel) * width * height, whole)); + return planes; + case 1: { + // Every channel's counts come first; only the wanted are unpacked. + Reader reader{data}; + std::vector<std::vector<qsizetype>> table; + for (int channel = 0; channel < channels; ++channel) + table.push_back(counts(reader, height, largeDocument)); + for (int channel = 0; channel < wanted; ++channel) + planes.push_back(unpackRows(reader, table[size_t(channel)], width, whole)); + return planes; + } default: throw PSDError(PSDError::Kind::unsupportedCompression); } diff --git a/src/IO/PSD/PSDChannelCoder.h b/src/IO/PSD/PSDChannelCoder.h index dadd56a..f9b64dd 100644 --- a/src/IO/PSD/PSDChannelCoder.h +++ b/src/IO/PSD/PSDChannelCoder.h @@ -1,11 +1,25 @@ #pragma once #include <QByteArray> #include <QImage> +#include <optional> #include <vector> +// What a channel keeps when cut; wide, as sources are. +struct PSDCrop { + qint64 x; + qint64 y; + qint64 width; + qint64 height; +}; + // Photoshop's layer channels: raw and PackBits (Adobe's 2019 specification). namespace PSDChannelCoder { -std::vector<uchar> decode(int compression, int width, int height, const QByteArray &data); +// A Large Document counts PackBits rows in four bytes. +std::vector<uchar> decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument = false, + const std::optional<PSDCrop> &crop = std::nullopt); +// The merged image's first `wanted` of its `channels` planes. +std::vector<std::vector<uchar>> mergedPlanes(int compression, int width, int height, int channels, int wanted, const QByteArray &data, + bool largeDocument); // Straight planes into premultiplied RGBA, sRGB. QImage rgbaImage(int width, int height, const std::vector<uchar> &red, const std::vector<uchar> &green, const std::vector<uchar> &blue, const std::vector<uchar> &alpha); diff --git a/src/IO/PSD/PSDDocumentBuilder.cpp b/src/IO/PSD/PSDDocumentBuilder.cpp index 086698c..273451b 100644 --- a/src/IO/PSD/PSDDocumentBuilder.cpp +++ b/src/IO/PSD/PSDDocumentBuilder.cpp @@ -80,6 +80,9 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std: std::vector<ImageLayer> layers; const QSizeF canvas(document.width, document.height); for (const PSDRecord &record : document.layers) { + if (record.croppedToCanvas) + conversions.push_back(PSDConversion{.layerName = record.name, + .message = QStringLiteral("Cropped to the canvas so the file fits in memory. Pixels outside the canvas weren't imported.")}); for (const QString ¬e : notes(record)) conversions.push_back(PSDConversion{.layerName = record.name, .message = note}); if (record.kind == PSDLayerKind::adjustment && !record.adjustment) diff --git a/src/IO/PSD/PSDReader+Records.cpp b/src/IO/PSD/PSDReader+Records.cpp new file mode 100644 index 0000000..4b55aeb --- /dev/null +++ b/src/IO/PSD/PSDReader+Records.cpp @@ -0,0 +1,294 @@ +#include "IO/PSD/PSDReader+Records.h" +#include "Document/DocumentLimits.h" +#include "IO/ImageImporter.h" +#include <set> + +namespace { +// Bytes 0x80–0xFF in Mac OS Roman, as Python's `mac_roman`. +constexpr char16_t macRoman[128] = { + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, + 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, + 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3, + 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, + 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, + 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, + 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, + 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, + 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, + 0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, + 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, +}; + +QString fromMacRoman(const QByteArray &bytes) +{ + QString text; + text.reserve(bytes.size()); + for (const char byte : bytes) + text += uchar(byte) < 0x80 ? QChar(uchar(byte)) : QChar(macRoman[uchar(byte) - 0x80]); + return text; +} + +std::optional<QString> unicodeName(const QByteArray &data) +{ + if (data.size() < 4) + return std::nullopt; + const qint64 count = PSDRecords::u32(data, 0); + if (count <= 0 || data.size() < 4 + count * 2) + return std::nullopt; + QString name(qsizetype(count), Qt::Uninitialized); + for (qint64 i = 0; i < count; ++i) + name[qsizetype(i)] = QChar(quint16(uchar(data[4 + i * 2]) << 8 | uchar(data[5 + i * 2]))); + // Swift trims NUL from both ends. + qsizetype from = 0, to = name.size(); + while (from < to && name[from] == QChar(0)) + ++from; + while (to > from && name[to - 1] == QChar(0)) + --to; + return name.mid(from, to - from); +} + +// Keys whose lengths a Large Document writes in eight bytes. +const std::set<QString> psbLargeKeys{ + QStringLiteral("LMsk"), QStringLiteral("Lr16"), QStringLiteral("Lr32"), QStringLiteral("Layr"), QStringLiteral("Mt16"), + QStringLiteral("Mt32"), QStringLiteral("Mtrn"), QStringLiteral("Alph"), QStringLiteral("FMsk"), QStringLiteral("lnk2"), + QStringLiteral("FEid"), QStringLiteral("FXid"), QStringLiteral("PxSD"), +}; + +// Transparency, red, green, blue and the user mask only. +bool unpacked(int id) +{ + return id == -1 || id == 0 || id == 1 || id == 2 || id == -2; +} + +// Pixels and mask each within the side and budget. +bool fits(qint64 width, qint64 height, qint64 maskWidth, qint64 maskHeight, bool hasMask, qint64 remainingPixels) +{ + const qint64 budget = std::max<qint64>(0, remainingPixels); + if (width > 0 && height > 0 && (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > budget)) + return false; + return !(hasMask && maskWidth > 0 && maskHeight > 0 + && (maskWidth > DocumentLimits::maxSide || maskHeight > DocumentLimits::maxSide || maskWidth * maskHeight > budget)); +} + +// A box's part on the canvas, from its own corner. +PSDCrop crop(qint64 left, qint64 top, qint64 right, qint64 bottom, qint64 canvasWidth, qint64 canvasHeight) +{ + const qint64 croppedLeft = std::min(canvasWidth, std::max<qint64>(0, left)), croppedTop = std::min(canvasHeight, std::max<qint64>(0, top)); + const qint64 croppedRight = std::max(croppedLeft, std::min(canvasWidth, right)), croppedBottom = std::max(croppedTop, std::min(canvasHeight, bottom)); + return PSDCrop{croppedLeft - left, croppedTop - top, croppedRight - croppedLeft, croppedBottom - croppedTop}; +} +} + +// Swift's `.ascii` decoding: any byte past 127 reads as nothing. +QString PSDRecords::ascii(const QByteArray &bytes) +{ + for (const char byte : bytes) + if (uchar(byte) > 127) + return QString(); + return QString::fromLatin1(bytes); +} + +quint32 PSDRecords::u32(const QByteArray &data, qsizetype at) +{ + return quint32(uchar(data[at])) << 24 | quint32(uchar(data[at + 1])) << 16 | quint32(uchar(data[at + 2])) << 8 | uchar(data[at + 3]); +} + +void PSDRecords::Cursor::need(qint64 count) const +{ + if (offset < 0 || offset > data.size() || count > data.size() - offset) + throw PSDError(PSDError::Kind::truncated); +} + +void PSDRecords::Cursor::skip(qint64 count) +{ + if (count < 0) + throw PSDError(PSDError::Kind::truncated); + need(count); + offset += count; +} + +uchar PSDRecords::Cursor::u8() +{ + need(1); + return uchar(data[offset++]); +} + +quint16 PSDRecords::Cursor::u16() +{ + const quint16 high = u8(); + return quint16(high << 8 | u8()); +} + +quint32 PSDRecords::Cursor::u32() +{ + const quint32 high = u16(); + return high << 16 | u16(); +} + +quint64 PSDRecords::Cursor::u64() +{ + const quint64 high = u32(); + return high << 32 | u32(); +} + +qint64 PSDRecords::Cursor::length(bool isPSB) +{ + const quint64 value = isPSB ? u64() : u32(); + if (value > quint64(std::numeric_limits<qint64>::max())) + throw ImageImportError(ImageImportError::Kind::tooLarge); + return qint64(value); +} + +QByteArray PSDRecords::Cursor::bytes(qint64 count) +{ + need(count); + offset += count; + return data.mid(offset - count, count); +} + +PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB) +{ + RawLayer layer; + layer.top = cursor.i32(); + layer.left = cursor.i32(); + layer.bottom = cursor.i32(); + layer.right = cursor.i32(); + layer.sourceTop = layer.top, layer.sourceLeft = layer.left, layer.sourceBottom = layer.bottom, layer.sourceRight = layer.right; + const int channelCount = cursor.u16(); + if (channelCount > 56) + throw ImageImportError(ImageImportError::Kind::tooLarge); + for (int index = 0; index < channelCount; ++index) { + const int id = cursor.i16(); + layer.channels.push_back({id, cursor.length(isPSB)}); + } + if (cursor.string(4) != QLatin1String("8BIM")) + throw PSDError(PSDError::Kind::truncated); + layer.blendKey = cursor.string(4); + layer.opacity = cursor.u8(); + layer.clipping = cursor.u8() != 0; + layer.hidden = (cursor.u8() & 2) != 0; + cursor.skip(1); + const qint64 extraLength = cursor.u32(); + const qint64 extraEnd = cursor.offset + extraLength; + const qint64 maskLength = cursor.u32(); + const qint64 maskEnd = cursor.offset + maskLength; + if (maskLength >= 20) { + layer.hasMask = true; + layer.maskTop = cursor.i32(); + layer.maskLeft = cursor.i32(); + layer.maskBottom = cursor.i32(); + layer.maskRight = cursor.i32(); + layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft; + layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight; + // The mask's default colour is skipped, as Swift ignores it. + cursor.skip(1); + const uchar flags = cursor.u8(); + layer.maskDisabled = (flags & 2) != 0; + layer.maskLinked = (flags & 1) == 0; + layer.maskFromRender = (flags & 8) != 0; + } + cursor.offset = maskEnd; + cursor.skip(cursor.u32()); + const int nameCount = cursor.u8(); + layer.name = fromMacRoman(cursor.bytes(nameCount)); + cursor.skip((4 - ((nameCount + 1) % 4)) % 4); + while (cursor.offset + 12 <= extraEnd) { + const QString signature = cursor.string(4); + if (signature != QLatin1String("8BIM") && signature != QLatin1String("8B64")) + break; + const QString key = cursor.string(4); + qint64 length; + if (signature == QLatin1String("8B64") || (isPSB && psbLargeKeys.contains(key))) { + if (cursor.offset + 8 > extraEnd) + break; + length = cursor.length(true); + } else { + length = cursor.u32(); + } + const QByteArray payload = cursor.bytes(length); + if (length % 2 == 1) + cursor.skip(1); + layer.extra[key] = payload; + if (key == QLatin1String("luni")) + if (const std::optional<QString> unicode = unicodeName(payload)) + layer.name = *unicode; + if (key == QLatin1String("iOpa") && !payload.isEmpty()) + layer.fill = uchar(payload[0]); + if ((key == QLatin1String("lsct") || key == QLatin1String("lsdk")) && payload.size() >= 4) + layer.section = u32(payload, 0); + } + cursor.offset = extraEnd; + return layer; +} + +bool PSDRecords::fitsBudget(const std::vector<RawLayer> &layers, qint64 remainingPixels) +{ + qint64 usedPixels = 0; + for (const RawLayer &layer : layers) { + const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top); + const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop); + if (!fits(width, height, maskWidth, maskHeight, layer.hasMask, remainingPixels - usedPixels)) + return false; + if (width > 0 && height > 0) + usedPixels += width * height; + } + return true; +} + +void PSDRecords::cropToCanvas(RawLayer &layer, qint64 width, qint64 height) +{ + const PSDCrop image = crop(layer.left, layer.top, layer.right, layer.bottom, width, height); + if (image.x != 0 || image.y != 0 || image.width != layer.right - layer.left || image.height != layer.bottom - layer.top) { + layer.left += image.x; + layer.top += image.y; + layer.right = layer.left + image.width; + layer.bottom = layer.top + image.height; + layer.imageCrop = image; + layer.cropped = true; + } + if (!layer.hasMask) + return; + const PSDCrop mask = crop(layer.maskLeft, layer.maskTop, layer.maskRight, layer.maskBottom, width, height); + if (mask.x != 0 || mask.y != 0 || mask.width != layer.maskRight - layer.maskLeft || mask.height != layer.maskBottom - layer.maskTop) { + layer.maskLeft += mask.x; + layer.maskTop += mask.y; + layer.maskRight = layer.maskLeft + mask.width; + layer.maskBottom = layer.maskTop + mask.height; + layer.maskCrop = mask; + layer.cropped = true; + } +} + +void PSDRecords::decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels, bool isPSB) +{ + std::map<int, std::vector<uchar>> planes; + const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top); + const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop); + if (!fits(width, height, maskWidth, maskHeight, layer.hasMask, remainingPixels)) + throw ImageImportError(ImageImportError::Kind::tooLarge); + const qint64 sourceWidth = std::max<qint64>(0, layer.sourceRight - layer.sourceLeft), sourceHeight = std::max<qint64>(0, layer.sourceBottom - layer.sourceTop); + const qint64 sourceMaskWidth = std::max<qint64>(0, layer.sourceMaskRight - layer.sourceMaskLeft); + const qint64 sourceMaskHeight = std::max<qint64>(0, layer.sourceMaskBottom - layer.sourceMaskTop); + for (const Channel &channel : layer.channels) { + const qint64 start = cursor.offset; + if (unpacked(channel.id) && channel.length >= 2) { + const int compression = cursor.u16(); + const QByteArray payload = cursor.bytes(channel.length - 2); + const bool isMask = channel.id == -2; + // Written at the source bounds; a cut decodes part. + const qint64 w = isMask ? maskWidth : width, h = isMask ? maskHeight : height; + if (w > 0 && h > 0) + planes[channel.id] = PSDChannelCoder::decode(compression, isMask ? sourceMaskWidth : sourceWidth, isMask ? sourceMaskHeight : sourceHeight, + payload, isPSB, isMask ? layer.maskCrop : layer.imageCrop); + } + cursor.offset = start + std::max<qint64>(0, channel.length); + } + // Each plane decodes to exactly its size: no count check. + if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && planes.contains(-2)) + layer.maskImage = PSDChannelCoder::maskImage(int(maskWidth), int(maskHeight), planes.at(-2)); + if (width <= 0 || height <= 0) + return; + const size_t count = size_t(width * height); + const auto plane = [&](int id, uchar fill) { return planes.contains(id) ? planes.at(id) : std::vector<uchar>(count, fill); }; + layer.image = PSDChannelCoder::rgbaImage(int(width), int(height), plane(0, 0), plane(1, 0), plane(2, 0), plane(-1, 255)); +} diff --git a/src/IO/PSD/PSDReader+Records.h b/src/IO/PSD/PSDReader+Records.h new file mode 100644 index 0000000..64b1065 --- /dev/null +++ b/src/IO/PSD/PSDReader+Records.h @@ -0,0 +1,75 @@ +#pragma once +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDTypes.h" +#include <QByteArray> +#include <QImage> +#include <QString> +#include <map> +#include <optional> +#include <vector> + +// Swift's PSDReader, split: the layer records and pixels. +namespace PSDRecords { +QString ascii(const QByteArray &bytes); +quint32 u32(const QByteArray &data, qsizetype at); + +struct Cursor { + const QByteArray &data; + qint64 offset = 0; + + void need(qint64 count) const; + void skip(qint64 count); + uchar u8(); + quint16 u16(); + qint16 i16() { return qint16(u16()); } + quint32 u32(); + qint32 i32() { return qint32(u32()); } + quint64 u64(); + // A Large Document's lengths take eight bytes. + qint64 length(bool isPSB); + QByteArray bytes(qint64 count); + QString string(qint64 count) { return ascii(bytes(count)); } +}; + +struct Channel { + int id; + qint64 length; +}; + +struct RawLayer { + QString name; + qint64 top = 0, left = 0, bottom = 0, right = 0; + // The bounds the channels were written at, before any crop. + qint64 sourceTop = 0, sourceLeft = 0, sourceBottom = 0, sourceRight = 0; + uchar opacity = 255; + uchar fill = 255; + bool clipping = false; + bool hidden = false; + QString blendKey = QStringLiteral("norm"); + std::vector<Channel> channels; + std::map<QString, QByteArray> extra; + qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0; + qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0; + bool maskDisabled = false; + bool maskLinked = true; + bool maskFromRender = false; + bool hasMask = false; + std::optional<qint64> section; + std::optional<QImage> image; + std::optional<QImage> maskImage; + std::optional<PSDCrop> imageCrop; + std::optional<PSDCrop> maskCrop; + bool cropped = false; + bool has(std::initializer_list<const char *> keys) const + { + return std::any_of(keys.begin(), keys.end(), [this](const char *key) { return extra.contains(QString::fromLatin1(key)); }); + } +}; + +RawLayer readRecord(Cursor &cursor, bool isPSB); +// Every layer within the side limit and the budget. +bool fitsBudget(const std::vector<RawLayer> &layers, qint64 remainingPixels); +// Cuts a layer and mask to the canvas, marking it. +void cropToCanvas(RawLayer &layer, qint64 width, qint64 height); +void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels, bool isPSB); +} diff --git a/src/IO/PSD/PSDReader.cpp b/src/IO/PSD/PSDReader.cpp index f5f892b..9eb5e98 100644 --- a/src/IO/PSD/PSDReader.cpp +++ b/src/IO/PSD/PSDReader.cpp @@ -1,253 +1,14 @@ #include "IO/PSD/PSDReader.h" #include "Document/DocumentLimits.h" #include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDReader+Records.h" #include "IO/PSD/PSDVector.h" #include "Logging.h" +#include <QColorSpace> #include <QFile> namespace { -// Bytes 0x80–0xFF in Mac OS Roman, as Python's `mac_roman`. -constexpr char16_t macRoman[128] = { - 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, - 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, - 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3, - 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, - 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, - 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, - 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, - 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, - 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, - 0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, - 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, -}; - -QString fromMacRoman(const QByteArray &bytes) -{ - QString text; - text.reserve(bytes.size()); - for (const char byte : bytes) - text += uchar(byte) < 0x80 ? QChar(uchar(byte)) : QChar(macRoman[uchar(byte) - 0x80]); - return text; -} - -// Swift's `.ascii` decoding: any byte past 127 reads as nothing. -QString ascii(const QByteArray &bytes) -{ - for (const char byte : bytes) - if (uchar(byte) > 127) - return QString(); - return QString::fromLatin1(bytes); -} - -struct Cursor { - const QByteArray &data; - qint64 offset = 0; - - void need(qint64 count) const - { - if (offset < 0 || offset > data.size() || count > data.size() - offset) - throw PSDError(PSDError::Kind::truncated); - } - void skip(qint64 count) - { - if (count < 0) - throw PSDError(PSDError::Kind::truncated); - need(count); - offset += count; - } - uchar u8() - { - need(1); - return uchar(data[offset++]); - } - quint16 u16() - { - const quint16 high = u8(); - return quint16(high << 8 | u8()); - } - qint16 i16() { return qint16(u16()); } - quint32 u32() - { - const quint32 high = u16(); - return high << 16 | u16(); - } - qint32 i32() { return qint32(u32()); } - QByteArray bytes(qint64 count) - { - need(count); - offset += count; - return data.mid(offset - count, count); - } - QString string(qint64 count) { return ascii(bytes(count)); } -}; - -quint32 u32(const QByteArray &data, qsizetype at) -{ - return quint32(uchar(data[at])) << 24 | quint32(uchar(data[at + 1])) << 16 | quint32(uchar(data[at + 2])) << 8 | uchar(data[at + 3]); -} - -struct Channel { - int id; - qint64 length; -}; - -struct RawLayer { - QString name; - qint64 top = 0, left = 0, bottom = 0, right = 0; - uchar opacity = 255; - uchar fill = 255; - bool clipping = false; - bool hidden = false; - QString blendKey = QStringLiteral("norm"); - std::vector<Channel> channels; - std::map<QString, QByteArray> extra; - qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0; - bool maskDisabled = false; - bool maskLinked = true; - bool maskFromRender = false; - bool hasMask = false; - std::optional<qint64> section; - std::optional<QImage> image; - std::optional<QImage> maskImage; - bool has(std::initializer_list<const char *> keys) const - { - return std::any_of(keys.begin(), keys.end(), [this](const char *key) { return extra.contains(QString::fromLatin1(key)); }); - } -}; - -std::optional<QString> unicodeName(const QByteArray &data) -{ - if (data.size() < 4) - return std::nullopt; - const qint64 count = u32(data, 0); - if (count <= 0 || data.size() < 4 + count * 2) - return std::nullopt; - QString name(qsizetype(count), Qt::Uninitialized); - for (qint64 i = 0; i < count; ++i) - name[qsizetype(i)] = QChar(quint16(uchar(data[4 + i * 2]) << 8 | uchar(data[5 + i * 2]))); - // Swift trims NUL from both ends. - qsizetype from = 0, to = name.size(); - while (from < to && name[from] == QChar(0)) - ++from; - while (to > from && name[to - 1] == QChar(0)) - --to; - return name.mid(from, to - from); -} - -RawLayer readRecord(Cursor &cursor) -{ - RawLayer layer; - layer.top = cursor.i32(); - layer.left = cursor.i32(); - layer.bottom = cursor.i32(); - layer.right = cursor.i32(); - const int channelCount = cursor.u16(); - if (channelCount > 56) - throw ImageImportError(ImageImportError::Kind::tooLarge); - for (int index = 0; index < channelCount; ++index) { - const int id = cursor.i16(); - layer.channels.push_back({id, cursor.u32()}); - } - if (cursor.string(4) != QLatin1String("8BIM")) - throw PSDError(PSDError::Kind::truncated); - layer.blendKey = cursor.string(4); - layer.opacity = cursor.u8(); - layer.clipping = cursor.u8() != 0; - layer.hidden = (cursor.u8() & 2) != 0; - cursor.skip(1); - const qint64 extraLength = cursor.u32(); - const qint64 extraEnd = cursor.offset + extraLength; - const qint64 maskLength = cursor.u32(); - const qint64 maskEnd = cursor.offset + maskLength; - if (maskLength >= 20) { - layer.hasMask = true; - layer.maskTop = cursor.i32(); - layer.maskLeft = cursor.i32(); - layer.maskBottom = cursor.i32(); - layer.maskRight = cursor.i32(); - // The mask's default colour is skipped, as Swift ignores it. - cursor.skip(1); - const uchar flags = cursor.u8(); - layer.maskDisabled = (flags & 2) != 0; - layer.maskLinked = (flags & 1) == 0; - layer.maskFromRender = (flags & 8) != 0; - } - cursor.offset = maskEnd; - cursor.skip(cursor.u32()); - const int nameCount = cursor.u8(); - layer.name = fromMacRoman(cursor.bytes(nameCount)); - cursor.skip((4 - ((nameCount + 1) % 4)) % 4); - while (cursor.offset + 12 <= extraEnd) { - const QString signature = cursor.string(4); - if (signature != QLatin1String("8BIM") && signature != QLatin1String("8B64")) - break; - const QString key = cursor.string(4); - qint64 length; - if (signature == QLatin1String("8B64")) { - if (cursor.offset + 8 > extraEnd) - break; - const quint64 raw = quint64(cursor.u32()) << 32 | cursor.u32(); - if (raw > quint64(std::numeric_limits<qint64>::max())) - throw ImageImportError(ImageImportError::Kind::tooLarge); - length = qint64(raw); - } else { - length = cursor.u32(); - } - const QByteArray payload = cursor.bytes(length); - if (length % 2 == 1) - cursor.skip(1); - layer.extra[key] = payload; - if (key == QLatin1String("luni")) - if (const std::optional<QString> unicode = unicodeName(payload)) - layer.name = *unicode; - if (key == QLatin1String("iOpa") && !payload.isEmpty()) - layer.fill = uchar(payload[0]); - if ((key == QLatin1String("lsct") || key == QLatin1String("lsdk")) && payload.size() >= 4) - layer.section = u32(payload, 0); - } - cursor.offset = extraEnd; - return layer; -} -} - -namespace { -// Transparency, red, green, blue and the user mask only. -bool unpacked(int id) -{ - return id == -1 || id == 0 || id == 1 || id == 2 || id == -2; -} - -void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels) -{ - std::map<int, std::vector<uchar>> planes; - const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top); - const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop); - const qint64 budget = std::max<qint64>(0, remainingPixels); - if (width > 0 && height > 0 && (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > budget)) - throw ImageImportError(ImageImportError::Kind::tooLarge); - if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > DocumentLimits::maxSide || maskHeight > DocumentLimits::maxSide || maskWidth * maskHeight > budget)) - throw ImageImportError(ImageImportError::Kind::tooLarge); - for (const Channel &channel : layer.channels) { - const qint64 start = cursor.offset; - if (unpacked(channel.id) && channel.length >= 2) { - const int compression = cursor.u16(); - const QByteArray payload = cursor.bytes(channel.length - 2); - const bool isMask = channel.id == -2; - const qint64 w = isMask ? maskWidth : width, h = isMask ? maskHeight : height; - if (w > 0 && h > 0) - planes[channel.id] = PSDChannelCoder::decode(compression, int(w), int(h), payload); - } - cursor.offset = start + std::max<qint64>(0, channel.length); - } - // Each plane decodes to exactly its size: no count check. - if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && planes.contains(-2)) - layer.maskImage = PSDChannelCoder::maskImage(int(maskWidth), int(maskHeight), planes.at(-2)); - if (width <= 0 || height <= 0) - return; - const size_t count = size_t(width * height); - const auto plane = [&](int id, uchar fill) { return planes.contains(id) ? planes.at(id) : std::vector<uchar>(count, fill); }; - layer.image = PSDChannelCoder::rgbaImage(int(width), int(height), plane(0, 0), plane(1, 0), plane(2, 0), plane(-1, 255)); -} +using namespace PSDRecords; PSDLayerKind kindOf(const RawLayer &layer, bool isGroup) { @@ -295,6 +56,7 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas, record.blendKey = isGroup && passes ? QStringLiteral("pass") : layer.blendKey; record.clipping = layer.clipping; record.kind = kindOf(layer, isGroup); + record.croppedToCanvas = layer.cropped; const bool hasEffects = record.kind == PSDLayerKind::effects || layer.has({"lfx2", "lrFX", "lmfx"}); record.opacity = hasEffects && layer.fill != 255 ? layer.opacity / 255.0 : (layer.opacity / 255.0) * (layer.fill / 255.0); record.bounds = isGroup ? QRectF(QPointF(0, 0), canvas) : QRectF(layer.left, layer.top, std::max<qint64>(0, layer.right - layer.left), std::max<qint64>(0, layer.bottom - layer.top)); @@ -329,18 +91,32 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas, return result; } -PSDDocument parse(const QByteArray &data, qint64 remainingPixels) +// What precedes the layers: the canvas, its channels, its profile. +struct Header { + bool isPSB; + int channels; + qint64 width; + qint64 height; + double resolution = 72; + QByteArray profile; +}; + +// A layered canvas stays one surface; merged, the budget. +Header readHeader(Cursor &cursor, bool surface) { - Cursor cursor{data}; if (cursor.string(4) != QLatin1String("8BPS")) throw ImageImportError(ImageImportError::Kind::unreadable); - if (cursor.u16() != 1) + // Version 2 is a Large Document, some lengths wider. + const int version = cursor.u16(); + if (version != 1 && version != 2) throw PSDError(PSDError::Kind::unsupportedVersion); cursor.skip(6); - cursor.u16(); - const qint64 canvasHeight = cursor.u32(), canvasWidth = cursor.u32(); + Header header{version == 2, cursor.u16(), 0, 0, 72, {}}; + header.height = cursor.u32(); + header.width = cursor.u32(); const int depth = cursor.u16(), mode = cursor.u16(); - if (canvasWidth < 1 || canvasWidth > DocumentLimits::maxSide || canvasHeight < 1 || canvasHeight > DocumentLimits::maxSide || canvasWidth * canvasHeight > DocumentLimits::maxSurfacePixels) + if (header.width < 1 || header.width > DocumentLimits::maxSide || header.height < 1 || header.height > DocumentLimits::maxSide + || (surface && header.width * header.height > DocumentLimits::maxSurfacePixels)) throw ImageImportError(ImageImportError::Kind::tooLarge); if (depth != 8) throw PSDError(PSDError::Kind::unsupportedDepth); @@ -349,7 +125,6 @@ PSDDocument parse(const QByteArray &data, qint64 remainingPixels) cursor.skip(cursor.u32()); const qint64 resourcesLength = cursor.u32(); const qint64 resourcesEnd = cursor.offset + resourcesLength; - double resolution = 72; while (cursor.offset + 12 <= resourcesEnd) { if (cursor.string(4) != QLatin1String("8BIM")) break; @@ -361,35 +136,75 @@ PSDDocument parse(const QByteArray &data, qint64 remainingPixels) const qint64 length = cursor.u32(); const qint64 dataStart = cursor.offset; if (id == 1005 && length >= 4) { - resolution = cursor.u32() / 65536.0; + header.resolution = cursor.u32() / 65536.0; // A fixed-point number is finite; below one reads as 72. - resolution = std::min(9600.0, resolution < 1 ? 72 : resolution); + header.resolution = std::min(9600.0, header.resolution < 1 ? 72 : header.resolution); } + // Only the merged image reads the profile, as Swift's. + if (id == 1039 && !surface) + header.profile = cursor.bytes(length); cursor.offset = dataStart + length; if (length % 2 == 1) cursor.skip(1); } cursor.offset = resourcesEnd; - const qint64 layerSection = cursor.u32(); - const PSDDocument empty{int(canvasWidth), int(canvasHeight), resolution, {}}; + return header; +} + +PSDDocument parse(const QByteArray &data, qint64 remainingPixels) +{ + Cursor cursor{data}; + const Header header = readHeader(cursor, true); + const qint64 layerSection = cursor.length(header.isPSB); + const PSDDocument empty{int(header.width), int(header.height), header.resolution, {}}; if (layerSection < 4) return empty; // The layer info length is skipped, as in Swift. - cursor.u32(); + cursor.length(header.isPSB); const qint64 count = std::abs(qint64(cursor.i16())); if (count > 10'000) throw ImageImportError(ImageImportError::Kind::tooLarge); std::vector<RawLayer> raw; raw.reserve(size_t(count)); for (qint64 index = 0; index < count; ++index) - raw.push_back(readRecord(cursor)); + raw.push_back(readRecord(cursor, header.isPSB)); + // Too large whole: cut to the canvas, judged again. + if (!fitsBudget(raw, remainingPixels)) { + for (RawLayer &layer : raw) + cropToCanvas(layer, header.width, header.height); + if (!fitsBudget(raw, remainingPixels)) + throw ImageImportError(ImageImportError::Kind::tooLarge); + } qint64 usedPixels = 0; for (RawLayer &layer : raw) { - decodeChannels(cursor, layer, remainingPixels - usedPixels); + decodeChannels(cursor, layer, remainingPixels - usedPixels, header.isPSB); if (layer.image) usedPixels += qint64(layer.image->width()) * layer.image->height(); } - return PSDDocument{int(canvasWidth), int(canvasHeight), resolution, assemble(raw, QSizeF(canvasWidth, canvasHeight), remainingPixels - usedPixels)}; + return PSDDocument{int(header.width), int(header.height), header.resolution, + assemble(raw, QSizeF(header.width, header.height), remainingPixels - usedPixels)}; +} + +// The merged image, as ImageIO reads a layerless file. +QImage mergedImage(const QByteArray &data, qint64 remainingPixels) +{ + Cursor cursor{data}; + const Header header = readHeader(cursor, false); + if (header.width * header.height > remainingPixels) + throw ImageImportError(ImageImportError::Kind::tooLarge); + if (header.channels < 3) + throw PSDError(PSDError::Kind::unsupportedColorMode); + cursor.skip(cursor.length(header.isPSB)); + const int compression = cursor.u16(); + const QByteArray planes = data.mid(cursor.offset); + // Opaque: a file of a background alone keeps no transparency. + const std::vector<std::vector<uchar>> decoded = PSDChannelCoder::mergedPlanes(compression, int(header.width), int(header.height), header.channels, 3, planes, header.isPSB); + QImage image = PSDChannelCoder::rgbaImage(int(header.width), int(header.height), decoded[0], decoded[1], decoded[2], + std::vector<uchar>(size_t(header.width * header.height), 255)); + // Its own profile, else sRGB, as ImageIO reads it. + const QColorSpace profile = QColorSpace::fromIccProfile(header.profile); + image.setColorSpace(profile.isValid() ? profile : QColorSpace(QColorSpace::SRgb)); + return image; } } @@ -428,6 +243,29 @@ PSDDocument PSDReader::read(const QString &path, qint64 remainingPixels) return read(bytes, remainingPixels); } +QImage PSDReader::merged(const QString &path, qint64 remainingPixels) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcIO).noquote() << "cannot open the Photoshop file" << path << file.errorString(); + throw ImageImportError(ImageImportError::Kind::unreadable); + } + // Swift asks the size first: the header, before the file. + const QByteArray head = file.peek(26); + Cursor cursor{head}; + cursor.skip(12); + cursor.u16(); + const qint64 height = cursor.u32(), width = cursor.u32(); + if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > remainingPixels) + throw ImageImportError(ImageImportError::Kind::tooLarge); + const QByteArray bytes = file.readAll(); + if (file.error() != QFileDevice::NoError) { + qCWarning(lcIO).noquote() << "cannot read the Photoshop file" << path << file.errorString(); + throw ImageImportError(ImageImportError::Kind::unreadable); + } + return mergedImage(bytes, remainingPixels); +} + PSDDocument PSDReader::read(const QByteArray &data, qint64 remainingPixels) { try { diff --git a/src/IO/PSD/PSDReader.h b/src/IO/PSD/PSDReader.h index 85e0594..e92d3e3 100644 --- a/src/IO/PSD/PSDReader.h +++ b/src/IO/PSD/PSDReader.h @@ -2,6 +2,7 @@ #include "Document/DocumentLimits.h" #include "IO/PSD/PSDTypes.h" #include <QByteArray> +#include <QImage> #include <QString> #include <map> #include <set> @@ -12,6 +13,8 @@ bool matches(const QString &path); bool matches(const QByteArray &data); PSDDocument read(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); PSDDocument read(const QByteArray &data, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); +// A file with no layers: its merged image, profile attached. +QImage merged(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); extern const std::set<QString> adjustmentKeys; } diff --git a/src/IO/PSD/PSDTypes.cpp b/src/IO/PSD/PSDTypes.cpp index 70b1f8e..b66ad2e 100644 --- a/src/IO/PSD/PSDTypes.cpp +++ b/src/IO/PSD/PSDTypes.cpp @@ -8,7 +8,7 @@ QString description(PSDError::Kind kind) case PSDError::Kind::truncated: return QStringLiteral("The Photoshop file could not be read. It may be damaged or incomplete."); case PSDError::Kind::unsupportedVersion: - return QStringLiteral("Large Document (.psb) Photoshop files aren’t supported."); + return QStringLiteral("This Photoshop file uses a format version OmaPhoto can’t read."); case PSDError::Kind::unsupportedColorMode: case PSDError::Kind::unsupportedDepth: return QStringLiteral("Only 8-bit RGB Photoshop files can be imported."); diff --git a/src/IO/PSD/PSDTypes.h b/src/IO/PSD/PSDTypes.h index 698f1e7..b0167af 100644 --- a/src/IO/PSD/PSDTypes.h +++ b/src/IO/PSD/PSDTypes.h @@ -43,6 +43,8 @@ struct PSDRecord { PSDLayerKind kind = PSDLayerKind::raster; std::optional<LayerShapeStyle> shape; std::vector<QString> shapeNotes; + // Cut to the canvas so the file fits the budget. + bool croppedToCanvas = false; std::optional<LayerBlendMode> blendMode() const; }; diff --git a/tests/ContentViewTests.cpp b/tests/ContentViewTests.cpp index 623a47a..6d7c0f8 100644 --- a/tests/ContentViewTests.cpp +++ b/tests/ContentViewTests.cpp @@ -323,7 +323,7 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag() QTest::qWait(50); QCOMPARE(int(session.document().value().layers.size()), 2); desk.typeBeforeCancel.clear(); - const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd " + RawImporter::globs().join(" ") + ")"; + const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb " + RawImporter::globs().join(" ") + ")"; QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel})); QCOMPARE(desk.seen.size(), 3); // The flag cleared from elsewhere takes the panel down. diff --git a/tests/CropToCanvasImportTests.cpp b/tests/CropToCanvasImportTests.cpp new file mode 100644 index 0000000..5f1d4ed --- /dev/null +++ b/tests/CropToCanvasImportTests.cpp @@ -0,0 +1,206 @@ +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include "AddressSpaceLimit.h" +#include "PSDReaderFixtures.h" +#include <QtTest> + +// Swift 1.2.10 (1fcf854): oversized layers cut to the canvas. +namespace { +QImage rgbaImage(int width, int height) +{ + std::vector<uchar> red, green, blue, alpha; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + red.push_back(uchar(x * 30 + y)); + green.push_back(uchar(y * 50)); + blue.push_back(uchar(255 - x * 20)); + alpha.push_back(255); + } + } + return PSDChannelCoder::rgbaImage(width, height, red, green, blue, alpha); +} + +QImage grayImage(int width, int height) +{ + std::vector<uchar> gray; + for (int index = 0; index < width * height; ++index) + gray.push_back(uchar(index * 10)); + return PSDChannelCoder::maskImage(width, height, gray); +} + +// A masked layer hanging off a 4 by 4 canvas. +PSDDocument overhang() +{ + PSDRecord layer = PSDFixture::record(QStringLiteral("Overhang"), rgbaImage(6, 3), QRectF(-2, 1, 6, 3)); + layer.mask = grayImage(6, 3); + return PSDDocument{4, 4, 72, {layer}}; +} + +QByteArray file(const PSDDocument &document) +{ + return PSDFixture::data(document, rgbaImage(4, 4)); +} + +bool cropNoted(const PSDDocument &parsed, const QString &name) +{ + const PSDImport imported = PSDDocumentBuilder::makeImport(parsed, PSDDocumentBuilder::assets(parsed)); + return std::any_of(imported.conversions.begin(), imported.conversions.end(), [&](const PSDConversion ¬e) { + return note.layerName == name && note.message.contains(QStringLiteral("Cropped to the canvas")); + }); +} + +std::vector<uchar> sliced(const std::vector<uchar> &plane, int width, const PSDCrop &crop) +{ + std::vector<uchar> result; + for (int row = 0; row < crop.height; ++row) + result.insert(result.end(), plane.begin() + (crop.y + row) * width + crop.x, plane.begin() + (crop.y + row) * width + crop.x + crop.width); + return result; +} +} + +class CropToCanvasImportTests : public QObject { + Q_OBJECT +private slots: + void fittingDocumentKeepsOversizedLayerPixels(); + void overBudgetDocumentCropsImageAndMaskToCanvas(); + void croppedDocumentStillOverBudgetIsRejected(); + void offCanvasLayerIsKeptWithoutPixelsWhenCropping(); + void channelCropsMatchFullDecodes(); + void theLayersAreSummedBeforeAnyCut(); + void aSourcePastIntStaysWide(); + void theCutIsJudgedBeforeAnyPixel(); + void anInvertedRecordIsCutToNothing(); +}; + +void CropToCanvasImportTests::fittingDocumentKeepsOversizedLayerPixels() +{ + const PSDDocument source = overhang(); + const PSDDocument parsed = PSDReader::read(file(source), 100); + const PSDRecord &layer = parsed.layers.at(0); + QCOMPARE(layer.bounds, QRectF(-2, 1, 6, 3)); + QVERIFY(!layer.croppedToCanvas); + QCOMPARE(layer.image.value(), source.layers[0].image.value()); + QVERIFY(!cropNoted(parsed, QStringLiteral("Overhang"))); +} + +void CropToCanvasImportTests::overBudgetDocumentCropsImageAndMaskToCanvas() +{ + // Eighteen pixels are past twelve; the canvas's twelve fit. + const PSDDocument source = overhang(); + const PSDDocument parsed = PSDReader::read(file(source), 12); + const PSDRecord &layer = parsed.layers.at(0); + QCOMPARE(layer.bounds, QRectF(0, 1, 4, 3)); + QVERIFY(layer.croppedToCanvas); + QCOMPARE(layer.image.value(), source.layers[0].image.value().copy(2, 0, 4, 3)); + QCOMPARE(layer.mask.value(), source.layers[0].mask.value().copy(2, 0, 4, 3)); + QVERIFY(cropNoted(parsed, QStringLiteral("Overhang"))); +} + +void CropToCanvasImportTests::croppedDocumentStillOverBudgetIsRejected() +{ + try { + PSDReader::read(file(overhang()), 11); + QFAIL("a file past the budget once cut was read"); + } catch (const ImageImportError &error) { + QCOMPARE(error.kind, ImageImportError::Kind::tooLarge); + } +} + +void CropToCanvasImportTests::offCanvasLayerIsKeptWithoutPixelsWhenCropping() +{ + // Past the right edge, and wholly before the left. + const PSDRecord right = PSDFixture::record(QStringLiteral("Outside"), rgbaImage(2, 2), QRectF(5, 0, 2, 2)); + const PSDRecord left = PSDFixture::record(QStringLiteral("Before"), rgbaImage(2, 2), QRectF(-5, 0, 2, 2)); + const PSDDocument parsed = PSDReader::read(file(PSDDocument{4, 4, 72, {right, left}}), 1); + for (const PSDRecord &layer : parsed.layers) + QVERIFY(!layer.image && layer.croppedToCanvas); + QVERIFY(cropNoted(parsed, QStringLiteral("Outside")) && cropNoted(parsed, QStringLiteral("Before"))); +} + +void CropToCanvasImportTests::channelCropsMatchFullDecodes() +{ + const int width = 5, height = 4; + QByteArray source; + for (int index = 0; index < width * height; ++index) + source.append(char(index)); + const PSDCrop crop{1, 1, 3, 2}; + const std::vector<uchar> raw = PSDChannelCoder::decode(0, width, height, source); + QCOMPARE(PSDChannelCoder::decode(0, width, height, source, false, crop), sliced(raw, width, crop)); + for (const bool large : {false, true}) { + std::vector<uchar> plane(source.begin(), source.end()); + const QByteArray packed = PSDFixture::encode(plane, width, height, large); + const std::vector<uchar> full = PSDChannelCoder::decode(1, width, height, packed, large); + QCOMPARE(full, raw); + QCOMPARE(PSDChannelCoder::decode(1, width, height, packed, large, crop), sliced(full, width, crop)); + } +} + +void CropToCanvasImportTests::theLayersAreSummedBeforeAnyCut() +{ + // Each fits thirty alone, not both: cut, they do. + PSDDocument document = overhang(); + document.layers[0].mask.reset(); + document.layers.insert(document.layers.begin(), PSDFixture::record(QStringLiteral("Whole"), rgbaImage(4, 4), QRectF(0, 0, 4, 4))); + const PSDDocument parsed = PSDReader::read(file(document), 30); + QVERIFY(!parsed.layers.at(0).croppedToCanvas && parsed.layers.at(1).croppedToCanvas); + QCOMPARE(parsed.layers.at(1).bounds, QRectF(0, 1, 4, 3)); +} + +void CropToCanvasImportTests::aSourcePastIntStaysWide() +{ + // A source 2^31 wide, cut to a one-pixel canvas. + for (const char *payload : {"\0\0", "\0\1"}) { + PSDReaderFixtures::Record record; + record.bounds = QRect(0, 0, 1, 1); + record.channels = {{0, QByteArray(payload, 2)}}; + QByteArray data = PSDReaderFixtures::file({record}, {}, 1, 1); + data.replace(48, 4, QByteArray::fromHex("ffffffff")); + data.replace(56, 4, QByteArray::fromHex("7fffffff")); + // As tall too: its row counts judged before held. + data.replace(44, 4, QByteArray::fromHex("ffffffff")); + data.replace(52, 4, QByteArray::fromHex("7fffffff")); + std::optional<PSDError::Kind> kind; + { + // Nothing the size of the source may be held. + const AddressSpaceLimit limit(64ll * 1024 * 1024); + try { + PSDReader::read(data, 1); + } catch (const PSDError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(PSDError::Kind::truncated)); + } +} + +void CropToCanvasImportTests::theCutIsJudgedBeforeAnyPixel() +{ + // Two pixels in one: refused before the truncated first. + PSDReaderFixtures::Record first, second; + first.bounds = second.bounds = QRect(0, 0, 1, 1); + first.channels = {{0, QByteArray("\0\0", 2)}}; + second.channels = {{0, QByteArray("\0\0\x80", 3)}}; + try { + PSDReader::read(PSDReaderFixtures::file({first, second}, {}, 1, 1), 1); + QFAIL("two pixels were read in a budget of one"); + } catch (const ImageImportError &error) { + QCOMPARE(error.kind, ImageImportError::Kind::tooLarge); + } +} + +void CropToCanvasImportTests::anInvertedRecordIsCutToNothing() +{ + // Right before left: cut to nothing, and reported. + PSDReaderFixtures::Record inverted, wide; + inverted.bounds = QRect(2, 0, -1, 1); + wide.bounds = QRect(-1, 0, 5, 1); + PSDReaderFixtures::solidChannels(wide.bounds, wide.channels); + const PSDDocument parsed = PSDReader::read(PSDReaderFixtures::file({inverted, wide}, {}, 4, 1), 4); + QVERIFY(parsed.layers.at(0).croppedToCanvas && parsed.layers.at(1).croppedToCanvas); + QCOMPARE(parsed.layers.at(0).bounds, QRectF(2, 0, 0, 1)); + QCOMPARE(parsed.layers.at(1).bounds, QRectF(0, 0, 4, 1)); +} + +QTEST_GUILESS_MAIN(CropToCanvasImportTests) +#include "CropToCanvasImportTests.moc" diff --git a/tests/DesktopIntegrationTests.cpp b/tests/DesktopIntegrationTests.cpp index 87167e1..edd9aa3 100644 --- a/tests/DesktopIntegrationTests.cpp +++ b/tests/DesktopIntegrationTests.cpp @@ -70,7 +70,7 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes() QCOMPARE(keys.value("Terminal"), QString("false")); QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics")); // Swift's two document types: its projects, then four pictures. - QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw;")); + QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw;")); } void DesktopIntegrationTests::aProjectFolderIsAType() @@ -87,9 +87,15 @@ void DesktopIntegrationTests::aProjectFolderIsAType() seen << "glob:" + xml.attributes().value("pattern").toString(); else if (name == QLatin1String("comment")) seen << "comment:" + xml.readElementText(); + else if (name == QLatin1String("magic")) + seen << "magic:" + xml.attributes().value("priority").toString(); + else if (name == QLatin1String("match")) + seen << "match:" + xml.attributes().value("value").toString() + "@" + xml.attributes().value("offset").toString(); } QVERIFY2(!xml.hasError(), qPrintable(xml.errorString())); - QCOMPARE(seen, (QStringList{"mime-type:application/x-compositor-project", "comment:OmaPhoto Project", "sub-class-of:inode/directory", "glob:*.comp"})); + QCOMPARE(seen, (QStringList{"mime-type:application/x-compositor-project", "comment:OmaPhoto Project", "sub-class-of:inode/directory", "glob:*.comp", + "mime-type:image/x-photoshop-large-image", "comment:Photoshop Large Document", "glob:*.psb", "magic:50", + "match:8BPS\\x00\\x02@0"})); } void DesktopIntegrationTests::anInstallLaysDownTheAppItsEntryIconsAndLicense() diff --git a/tests/ImageFileDropTests.cpp b/tests/ImageFileDropTests.cpp index e41b1ee..6ae0183 100644 --- a/tests/ImageFileDropTests.cpp +++ b/tests/ImageFileDropTests.cpp @@ -154,9 +154,13 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst() raw.setData("image/gif", "any picture last"); raw.setData("image/x-adobe-dng", DNGFixture::data({})); QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-adobe-dng"}); + // A Large Document: after a PSD, before RAW and others. + raw.setData("image/x-photoshop-large-image", "large first"); + QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-photoshop-large-image"}); raw.setData("image/vnd.adobe.photoshop", "photoshop first"); QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/vnd.adobe.photoshop"}); raw.removeFormat("image/vnd.adobe.photoshop"); + raw.removeFormat("image/x-photoshop-large-image"); EditorSession developed; done = false; ImageFileDrop::importProviders(raw, developed, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); diff --git a/tests/PSBImportTests.cpp b/tests/PSBImportTests.cpp new file mode 100644 index 0000000..5b7e7a4 --- /dev/null +++ b/tests/PSBImportTests.cpp @@ -0,0 +1,254 @@ +#include "AddressSpaceLimit.h" +#include "Document/EditorSession.h" +#include "IO/ImageImporter.h" +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDReader.h" +#include "PSDFixture.h" +#include <QColorSpace> +#include <QTemporaryDir> +#include <QtTest> + +// Swift 1.2.10: Large Documents, and files of a background alone. +namespace { +QString written(const QTemporaryDir &folder, const QString &name, const QByteArray &bytes) +{ + QFile file(folder.filePath(name)); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) + throw std::runtime_error("could not write a fixture"); + return file.fileName(); +} + +// Photoshop's ICC resource, 1039, added after the others. +QByteArray withProfile(QByteArray file, const QByteArray &profile) +{ + PSDFixture::Buffer resource; + resource.string("8BIM"); + resource.u16(1039); + resource.u16(0); + resource.u32(quint32(profile.size())); + resource.bytes(profile); + if (profile.size() % 2 == 1) + resource.u8(0); + // The resources' length sits after the header and colour data. + const quint32 length = quint32(uchar(file[30])) << 24 | quint32(uchar(file[31])) << 16 | quint32(uchar(file[32])) << 8 | uchar(file[33]); + PSDFixture::Buffer grown; + grown.u32(length + quint32(resource.data.size())); + file.replace(30, 4, grown.data); + file.insert(34 + qsizetype(length), resource.data); + return file; +} +} + +class PSBImportTests : public QObject { + Q_OBJECT +private slots: + void PSBRoundTripMatchesPSDLayerContent(); + void PSBExceedingCanvasLimitIsRejected(); + void PSBLargeAdditionalInfoBlockDoesNotHideUnicodeName(); + void aLayerlessFileReadsAsItsMergedImage(); + void theMergedImageTakesItsProfileAndTheBudget(); + void aLayerlessFileImportsAsOneLayer(); + void aMergedImageIsJudgedBeforeTheFileIsRead(); + void theReadingEndsBeforeTheMergedRead(); + void aLayeredReadSkipsTheProfile(); +}; + +void PSBImportTests::PSBRoundTripMatchesPSDLayerContent() +{ + PSDRecord bottom = PSDFixture::record(QStringLiteral("Red"), PSDFixture::colorImage(3, 2, 1, 0, 0), QRectF(0, 0, 3, 2)); + PSDRecord middle = PSDFixture::record(QStringLiteral("Green"), PSDFixture::colorImage(2, 3, 0, 1, 0), QRectF(1, 1, 2, 3)); + PSDRecord top = PSDFixture::record(QStringLiteral("Blue mask"), PSDFixture::colorImage(2, 2, 0, 0, 1), QRectF(2, 2, 2, 2)); + top.mask = PSDFixture::grayImage(2, 2, 128); + const PSDDocument source{5, 5, 144, {bottom, middle, top}}; + const QImage composite = PSDFixture::colorImage(5, 5, 0, 0, 0); + const PSDDocument psd = PSDReader::read(PSDFixture::data(source, composite)); + const PSDDocument psb = PSDReader::read(PSDFixture::data(source, composite, true)); + QCOMPARE(psb.layers.size(), psd.layers.size()); + for (size_t index = 0; index < psd.layers.size(); ++index) { + QCOMPARE(psb.layers[index].name, psd.layers[index].name); + QCOMPARE(psb.layers[index].bounds, psd.layers[index].bounds); + QCOMPARE(psb.layers[index].image.value(), psd.layers[index].image.value()); + } + QCOMPARE(psb.layers[2].mask.value(), psd.layers[2].mask.value()); + QCOMPARE(psb.resolution, 144.0); +} + +void PSBImportTests::PSBExceedingCanvasLimitIsRejected() +{ + const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0); + QByteArray data = PSDFixture::data(PSDDocument{2, 2, 72, {PSDFixture::record(QStringLiteral("Large"), image, QRectF(0, 0, 2, 2))}}, image, true); + data.replace(18, 4, QByteArray("\0\0\x75\x31", 4)); + try { + PSDReader::read(data); + QFAIL("a Large Document past the side limit was read"); + } catch (const ImageImportError &error) { + QCOMPARE(error.kind, ImageImportError::Kind::tooLarge); + } +} + +void PSBImportTests::PSBLargeAdditionalInfoBlockDoesNotHideUnicodeName() +{ + const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0); + const PSDRecord layer = PSDFixture::record(QStringLiteral("Café layer"), image, QRectF(0, 0, 2, 2)); + const QByteArray data = + PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, image, true, PSDFixture::AdditionalLayerInfo{"LMsk", QByteArray("\1\2\3", 3)}); + QCOMPARE(PSDReader::read(data).layers.at(0).name, QStringLiteral("Café layer")); +} + +void PSBImportTests::aLayerlessFileReadsAsItsMergedImage() +{ + // No layer records; each channel's rows pack apart. + const QTemporaryDir folder; + std::vector<uchar> red, green, blue; + for (int index = 0; index < 15; ++index) { + red.push_back(uchar(index * 17)); + green.push_back(uchar(index < 8 ? 40 : 200)); + blue.push_back(uchar(90)); + } + const QImage composite = PSDChannelCoder::rgbaImage(5, 3, red, green, blue, std::vector<uchar>(15, 255)); + for (const bool large : {false, true}) { + const QString path = written(folder, large ? "Flat.psb" : "Flat.psd", PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite, large)); + QVERIFY(PSDReader::read(path).layers.empty()); + const QImage merged = PSDReader::merged(path); + QCOMPARE(merged, composite); + QCOMPARE(merged.colorSpace(), QColorSpace(QColorSpace::SRgb)); + } +} + +void PSBImportTests::theMergedImageTakesItsProfileAndTheBudget() +{ + const QTemporaryDir folder; + const QImage composite = PSDFixture::colorImage(5, 3, 0.5, 0.25, 0.125); + const QByteArray flat = PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite); + // Fifteen pixels against fourteen: refused before any pixel. + try { + PSDReader::merged(written(folder, "Flat.psd", flat), 14); + QFAIL("a merged image past the budget was read"); + } catch (const ImageImportError &error) { + QCOMPARE(error.kind, ImageImportError::Kind::tooLarge); + } + // Past one surface yet in budget: read on, truncated. + if (DocumentLimits::documentPixelBudget() > qint64(20'001) * 10'000) { + PSDFixture::Buffer header; + header.string("8BPS"); + header.u16(1); + header.bytes(QByteArray(6, '\0')); + header.u16(3); + header.u32(10'000); + header.u32(20'001); + header.u16(8); + header.u16(3); + header.bytes(QByteArray(12, '\0')); + header.u16(1); + try { + PSDReader::merged(written(folder, "Wide.psd", header.data)); + QFAIL("a header alone was read"); + } catch (const PSDError &error) { + QCOMPARE(error.kind, PSDError::Kind::truncated); + } + } + // Display P3 numbers arrive as the sRGB they name. + const QString tagged = written(folder, "P3.psd", withProfile(flat, QColorSpace(QColorSpace::DisplayP3).iccProfile())); + QCOMPARE(PSDReader::merged(tagged).colorSpace(), QColorSpace(QColorSpace::DisplayP3)); + const ImportedImage asset = ImageImporter::decode(tagged, DocumentLimits::documentPixelBudget(), true); + const QColor expected = QColorSpace(QColorSpace::DisplayP3).transformationToColorSpace(QColorSpace::SRgb).map(composite.pixelColor(0, 0)); + const QColor got = asset.image().pixelColor(0, 0); + QVERIFY2(std::abs(got.red() - expected.red()) <= 1 && std::abs(got.green() - expected.green()) <= 1 && std::abs(got.blue() - expected.blue()) <= 1, + qPrintable(got.name() + " " + expected.name())); + QVERIFY(got != composite.pixelColor(0, 0)); +} + +void PSBImportTests::aLayerlessFileImportsAsOneLayer() +{ + const QTemporaryDir folder; + const QImage composite = PSDFixture::colorImage(5, 3, 0, 0.5, 1); + const QString path = written(folder, "Background.psb", PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite, true)); + EditorSession session; + session.createDocument(8, 8); + bool done = false; + session.importImages({QUrl::fromLocalFile(path)}, QPointF(6, 5), [&done] { done = true; }); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20'000)); + // No conversion sheet: the merged image comes straight in. + QVERIFY(!session.showsConversionSheet() && !session.importError()); + QCOMPARE(session.document().value().layers.size(), size_t(1)); + const ImageLayer layer = session.activeLayer().value(); + QCOMPARE(layer.name, QString("Background")); + QCOMPARE(layer.asset.value().image().pixelColor(2, 1), composite.pixelColor(2, 1)); + // Dropped at a point, it lands there. + const QPointF centre = layer.transform.center(); + QVERIFY2(std::abs(centre.x() - 6) <= 0.5 && std::abs(centre.y() - 5) <= 0.5, qPrintable(QString("%1, %2").arg(centre.x()).arg(centre.y()))); +} + +void PSBImportTests::aMergedImageIsJudgedBeforeTheFileIsRead() +{ + // Sparse 75 MB planes: past a budget of one. + const QTemporaryDir folder; + for (const bool large : {false, true}) { + PSDFixture::Buffer header; + header.string("8BPS"); + header.u16(large ? 2 : 1); + header.bytes(QByteArray(6, '\0')); + header.u16(3); + header.u32(5'000); + header.u32(5'000); + header.u16(8); + header.u16(3); + header.bytes(QByteArray(large ? 16 : 12, '\0')); + header.u16(0); + const QString path = written(folder, large ? "Big.psb" : "Big.psd", header.data); + QVERIFY(QFile(path).resize(header.data.size() + 75'000'000)); + QVERIFY(PSDReader::read(path, 1).layers.empty()); + std::optional<ImageImportError::Kind> kind; + { + const AddressSpaceLimit limit(32ll * 1024 * 1024); + try { + ImageImporter::decode(path, 1, true); + } catch (const ImageImportError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(ImageImportError::Kind::tooLarge)); + } +} + +void PSBImportTests::theReadingEndsBeforeTheMergedRead() +{ + // Gone once the reading ends: Swift reads the merged after. + const QTemporaryDir folder; + const QString path = written(folder, "Gone.psd", PSDFixture::data(PSDDocument{5, 3, 72, {}}, PSDFixture::colorImage(5, 3, 1, 0, 0))); + EditorSession session; + session.createDocument(8, 8); + bool reading = false; + connect(&session, &EditorSession::changed, this, [&] { + reading = reading || session.showsConversionSheet(); + if (reading && !session.showsConversionSheet() && QFile::exists(path)) + QFile::remove(path); + }); + bool done = false; + session.importImages({QUrl::fromLocalFile(path)}, std::nullopt, [&done] { done = true; }); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20'000)); + QVERIFY(reading && !QFile::exists(path)); + QVERIFY(session.document().value().layers.empty()); + QCOMPARE(session.importError(), std::optional("Gone.psd: " + QString(ImageImportError(ImageImportError::Kind::unreadable).what()))); +} + +void PSBImportTests::aLayeredReadSkipsTheProfile() +{ + // A 64 MB profile, never copied while layers are read. + const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0); + for (const bool large : {false, true}) { + const QByteArray file = withProfile(PSDFixture::data(PSDDocument{2, 2, 72, {PSDFixture::record(QStringLiteral("Red"), image, QRectF(0, 0, 2, 2))}}, image, large), + QByteArray(64 * 1024 * 1024, 'x')); + std::optional<PSDDocument> read; + { + const AddressSpaceLimit limit(32ll * 1024 * 1024); + read = PSDReader::read(file); + } + QCOMPARE(read.value().layers.size(), size_t(1)); + QCOMPARE(read.value().layers[0].image.value(), PSDReader::read(file).layers[0].image.value()); + QCOMPARE(read.value().layers[0].image.value().pixel(1, 1), qRgb(255, 0, 0)); + } +} + +QTEST_MAIN(PSBImportTests) +#include "PSBImportTests.moc" diff --git a/tests/PSDFixture.h b/tests/PSDFixture.h index af4897f..6bcbb9e 100644 --- a/tests/PSDFixture.h +++ b/tests/PSDFixture.h @@ -20,6 +20,13 @@ struct Buffer { u16(quint16(value)); } void i32(qint32 value) { u32(quint32(value)); } + void u64(quint64 value) + { + u32(quint32(value >> 32)); + u32(quint32(value)); + } + // A Large Document's lengths are eight bytes. + void length(quint64 value, bool large) { large ? u64(value) : u32(quint32(value)); } void bytes(const QByteArray &value) { data.append(value); } void string(const char *value) { data.append(value); } }; @@ -96,21 +103,21 @@ inline QByteArray packBits(const std::vector<uchar> &row) } // PackBits by row: the counts, then the rows. -inline QByteArray encode(const std::vector<uchar> &plane, int width, int height) +inline QByteArray encode(const std::vector<uchar> &plane, int width, int height, bool large = false) { Buffer counts; QByteArray packed; for (int row = 0; row < height; ++row) { const QByteArray line = packBits(std::vector<uchar>(plane.begin() + row * width, plane.begin() + (row + 1) * width)); - counts.u16(quint16(line.size())); + large ? counts.u32(quint32(line.size())) : counts.u16(quint16(line.size())); packed.append(line); } return counts.data + packed; } -inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height) +inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height, bool large = false) { - return QByteArray("\0\1", 2) + encode(plane, width, height); + return QByteArray("\0\1", 2) + encode(plane, width, height, large); } inline std::vector<Channel> emptyChannels() @@ -119,7 +126,7 @@ inline std::vector<Channel> emptyChannels() return {{-1, raw}, {0, raw}, {1, raw}, {2, raw}}; } -inline Prepared layer(const PSDRecord &record) +inline Prepared layer(const PSDRecord &record, bool large = false) { const int width = record.image ? record.image->width() : 0, height = record.image ? record.image->height() : 0; const int left = int(std::round(record.bounds.left())), top = int(std::round(record.bounds.top())); @@ -128,12 +135,12 @@ inline Prepared layer(const PSDRecord &record) if (record.image && width > 0 && height > 0) { const Planes split = planes(*record.image); for (const auto &[id, plane] : {std::pair<qint16, const std::vector<uchar> &>(-1, split.alpha), {0, split.red}, {1, split.green}, {2, split.blue}}) - prepared.channels.push_back({id, channelPayload(plane, width, height)}); + prepared.channels.push_back({id, channelPayload(plane, width, height, large)}); } else { prepared.channels = emptyChannels(); } if (record.mask) - prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())}); + prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)}); prepared.top = top; prepared.left = left; prepared.bottom = top + height; @@ -145,7 +152,7 @@ inline Prepared layer(const PSDRecord &record) return prepared; } -inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group = nullptr) +inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group, bool large) { PSDRecord record; record.id = group ? group->id : QUuid::createUuid(); @@ -163,18 +170,25 @@ inline Prepared emptyLayer(const QString &name, const QString &blendKey, int sec prepared.isDivider = section == 3; prepared.channels = emptyChannels(); if (record.mask) { - prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())}); + prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)}); prepared.maskBottom = record.mask->height(); prepared.maskRight = record.mask->width(); } return prepared; } -inline void additional(Buffer &buffer, const char *key, const QByteArray &payload) +// Swift's AdditionalLayerInfo: one more block, before the name's. +struct AdditionalLayerInfo { + const char *key; + QByteArray payload; +}; + +inline void additional(Buffer &buffer, const char *key, const QByteArray &payload, bool large = false) { + static const QStringList largeKeys{"LMsk", "Lr16", "Lr32", "Layr", "Mt16", "Mt32", "Mtrn", "Alph", "FMsk", "lnk2", "FEid", "FXid", "PxSD"}; buffer.string("8BIM"); buffer.string(key); - buffer.u32(quint32(payload.size())); + buffer.length(quint64(payload.size()), large && largeKeys.contains(QString::fromLatin1(key))); buffer.bytes(payload); if (payload.size() % 2 == 1) buffer.u8(0); @@ -189,7 +203,7 @@ inline QByteArray luni(const QString &name) return data.data; } -inline QByteArray extraData(const Prepared &item) +inline QByteArray extraData(const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info) { Buffer extra; if (item.record.mask && item.maskRight > item.maskLeft && item.maskBottom > item.maskTop) { @@ -209,18 +223,20 @@ inline QByteArray extraData(const Prepared &item) extra.u8(uchar(pascal.size())); extra.bytes(pascal); extra.bytes(QByteArray((4 - ((1 + pascal.size()) % 4)) % 4, '\0')); - additional(extra, "luni", luni(item.record.name)); + if (info) + additional(extra, info->key, info->payload, large); + additional(extra, "luni", luni(item.record.name), large); if (item.record.isGroup || item.isDivider) { Buffer payload; payload.u32(item.isDivider ? 3 : 1); payload.string("8BIM"); payload.bytes((item.isDivider ? QStringLiteral("norm") : item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1()); - additional(extra, "lsct", payload.data); + additional(extra, "lsct", payload.data, large); } return extra.data; } -inline void writeRecord(Buffer &buffer, const Prepared &item) +inline void writeRecord(Buffer &buffer, const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info) { buffer.i32(item.top); buffer.i32(item.left); @@ -229,7 +245,7 @@ inline void writeRecord(Buffer &buffer, const Prepared &item) buffer.u16(quint16(item.channels.size())); for (const Channel &channel : item.channels) { buffer.i16(channel.id); - buffer.u32(quint32(channel.payload.size())); + buffer.length(quint64(channel.payload.size()), large); } buffer.string("8BIM"); buffer.bytes((item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1()); @@ -237,49 +253,50 @@ inline void writeRecord(Buffer &buffer, const Prepared &item) buffer.u8(item.record.clipping ? 1 : 0); buffer.u8(item.record.isVisible ? 0 : 2); buffer.u8(0); - const QByteArray extra = extraData(item); + const QByteArray extra = extraData(item, large, info); buffer.u32(quint32(extra.size())); buffer.bytes(extra); } -inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared) +inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared, bool large) { // File order runs bottom to top: divider, children, folder. for (const PSDRecord &record : document.layers) { if (record.parentID != parent) continue; if (record.isGroup) { - prepared.push_back(emptyLayer(QStringLiteral("</Layer group>"), QStringLiteral("norm"), 3, parent)); - emitLayers(document, record.id, prepared); - prepared.push_back(emptyLayer(record.name, record.blendKey, 1, record.parentID, &record)); + prepared.push_back(emptyLayer(QStringLiteral("</Layer group>"), QStringLiteral("norm"), 3, parent, nullptr, large)); + emitLayers(document, record.id, prepared, large); + prepared.push_back(emptyLayer(record.name, record.blendKey, 1, record.parentID, &record, large)); } else { - prepared.push_back(layer(record)); + prepared.push_back(layer(record, large)); } } } -inline QByteArray layerSection(const PSDDocument &document) +inline QByteArray layerSection(const PSDDocument &document, bool large, const std::optional<AdditionalLayerInfo> &info) { std::vector<Prepared> prepared; - emitLayers(document, std::nullopt, prepared); + emitLayers(document, std::nullopt, prepared, large); Buffer records, payloads; records.i16(qint16(prepared.size())); for (const Prepared &item : prepared) { - writeRecord(records, item); + writeRecord(records, item, large, info); for (const Channel &channel : item.channels) payloads.bytes(channel.payload); } - Buffer info; - info.u32(0); - info.bytes(records.data); - info.bytes(payloads.data); - if (info.data.size() % 2 == 1) - info.u8(0); + Buffer section; + section.length(0, large); + section.bytes(records.data); + section.bytes(payloads.data); + if (section.data.size() % 2 == 1) + section.u8(0); + const int field = large ? 8 : 4; Buffer length; - length.u32(quint32(info.data.size() - 4)); - info.data.replace(0, 4, length.data); - info.u32(0); - return info.data; + length.length(quint64(section.data.size() - field), large); + section.data.replace(0, field, length.data); + section.u32(0); + return section.data; } inline QByteArray resolutionResource(double resolution) @@ -299,11 +316,11 @@ inline QByteArray resolutionResource(double resolution) return resource.data; } -inline QByteArray data(const PSDDocument &document, const QImage &composite) +inline QByteArray data(const PSDDocument &document, const QImage &composite, bool large = false, const std::optional<AdditionalLayerInfo> &info = std::nullopt) { Buffer file; file.string("8BPS"); - file.u16(1); + file.u16(large ? 2 : 1); file.bytes(QByteArray(6, '\0')); file.u16(4); file.u32(quint32(document.height)); @@ -314,8 +331,8 @@ inline QByteArray data(const PSDDocument &document, const QImage &composite) const QByteArray resources = resolutionResource(document.resolution); file.u32(quint32(resources.size())); file.bytes(resources); - const QByteArray layers = layerSection(document); - file.u32(quint32(layers.size())); + const QByteArray layers = layerSection(document, large, info); + file.length(quint64(layers.size()), large); file.bytes(layers); // The composite, PackBits by channel. QImage flattened = BrushRaster::context(document.width, document.height, false); @@ -326,9 +343,10 @@ inline QByteArray data(const PSDDocument &document, const QImage &composite) file.u16(1); QByteArray counts, packed; for (const std::vector<uchar> *plane : {&split.red, &split.green, &split.blue, &split.alpha}) { - const QByteArray encoded = encode(*plane, document.width, document.height); - counts.append(encoded.left(document.height * 2)); - packed.append(encoded.mid(document.height * 2)); + const QByteArray encoded = encode(*plane, document.width, document.height, large); + const int countBytes = document.height * (large ? 4 : 2); + counts.append(encoded.left(countBytes)); + packed.append(encoded.mid(countBytes)); } file.bytes(counts); file.bytes(packed); diff --git a/tests/PSDReaderLayerTests.cpp b/tests/PSDReaderLayerTests.cpp index 3682387..3bdf5af 100644 --- a/tests/PSDReaderLayerTests.cpp +++ b/tests/PSDReaderLayerTests.cpp @@ -86,8 +86,9 @@ void PSDReaderLayerTests::masksAndLayersSpendOneBudget() masked.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30\x40", 6)}); QCOMPARE(PSDReader::read(PSDReaderFixtures::file({masked}), 4).layers[0].mask.value().size(), QSize(2, 2)); QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked}), 3); }), std::optional(ImageImportError::Kind::tooLarge)); + // Past the side, then still past three pixels once cut. masked.mask = QRect(0, 0, 30'001, 1); - QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked})); }), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked}), 3); }), std::optional(ImageImportError::Kind::tooLarge)); } void PSDReaderLayerTests::resourcesEndWhereTheyDeclare() diff --git a/tests/PSDRoundTripTests.cpp b/tests/PSDRoundTripTests.cpp index 751b3af..b86372e 100644 --- a/tests/PSDRoundTripTests.cpp +++ b/tests/PSDRoundTripTests.cpp @@ -192,7 +192,8 @@ void PSDRoundTripTests::oversizedLayerBoundsAreRejected() { const QByteArray raw("\0\0", 2); const auto oversized = layerFile(30'000, 30'000, {{-1, raw}, {0, raw}, {1, raw}, {2, raw}}); - QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(oversized); }), std::optional(ImageImportError::Kind::tooLarge)); + // Cut to its canvas it fits: fifty pixels refuse it. + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(oversized, 50); }), std::optional(ImageImportError::Kind::tooLarge)); const QImage fill = PSDFixture::colorImage(20, 20, 1, 0, 0); const QByteArray data = PSDFixture::data(PSDDocument{20, 20, 72, {PSDFixture::record("Huge", fill, QRectF(0, 0, 20, 20))}}, fill); QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 50); }), std::optional(ImageImportError::Kind::tooLarge)); @@ -287,7 +288,7 @@ void PSDRoundTripTests::folderOpacityImportsOntoTheFolder() void PSDRoundTripTests::unsupportedHeadersAreRejected() { - QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(2)); }), std::optional(PSDError::Kind::unsupportedVersion)); + QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(3)); }), std::optional(PSDError::Kind::unsupportedVersion)); QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 8, 4)); }), std::optional(PSDError::Kind::unsupportedColorMode)); QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 16)); }), std::optional(PSDError::Kind::unsupportedDepth)); QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 30'001, 10)); }), std::optional(ImageImportError::Kind::tooLarge)); @@ -297,7 +298,7 @@ void PSDRoundTripTests::unsupportedHeadersAreRejected() QCOMPARE(refusal<PSDError>([] { PSDReader::read(header()); }), std::optional(PSDError::Kind::truncated)); QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedDepth).what()), QString("Only 8-bit RGB Photoshop files can be imported.")); QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedColorMode).what()), QString("Only 8-bit RGB Photoshop files can be imported.")); - QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedVersion).what()), QString("Large Document (.psb) Photoshop files aren’t supported.")); + QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedVersion).what()), QString("This Photoshop file uses a format version OmaPhoto can’t read.")); QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::truncated).what()), QString("The Photoshop file could not be read. It may be damaged or incomplete.")); QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedCompression).what()), QString("This Photoshop file uses a layer compression method that isn’t supported.")); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 443df33..5c97d88 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -207,6 +207,8 @@ compositor_test(RemoveBackgroundTests) compositor_test(SelectSubjectTests) compositor_test(ObjectSelectionTests) compositor_test(PSDRoundTripTests) +compositor_test(PSBImportTests) +compositor_test(CropToCanvasImportTests) compositor_test(PSDVectorTests) compositor_test(PSDReaderTests) compositor_test(PSDReaderLayerTests)