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 @@
+
+ Photoshop Large Document
+
+
+
+
+
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 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 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 document;
std::map assets;
QString failure;
+ // No layer records: a background alone, read merged next.
+ bool layerless = false;
};
struct Baked {
std::map 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
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 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
+#include
namespace {
-std::vector 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 counts(static_cast(height));
- for (int row = 0; row < height; ++row) {
- const uchar hi = next(), lo = next();
- counts[size_t(row)] = qsizetype(hi) << 8 | lo;
}
- std::vector 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 counts(Reader &reader, qint64 height, bool largeDocument)
+{
+ if (height > (reader.data.size() - reader.offset) / (largeDocument ? 4 : 2))
+ throw PSDError(PSDError::Kind::truncated);
+ std::vector result(static_cast(height));
+ for (qsizetype &count : result)
+ count = reader.count(largeDocument);
+ return result;
+}
+
+// Only the crop's columns kept: no source-wide row.
+std::vector unpackRows(Reader &reader, const std::vector &counts, qint64 width, const PSDCrop &crop)
+{
+ std::vector plane(static_cast(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 target = std::span(plane).subspan(static_cast((y - crop.y) * crop.width), static_cast(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(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 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 plane(static_cast(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 PSDChannelCoder::decode(int compression, int width, int height, const QByteArray &data)
+std::vector PSDChannelCoder::decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument,
+ const std::optional &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(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> 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> 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> 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
#include
+#include
#include
+// 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 decode(int compression, int width, int height, const QByteArray &data);
+// A Large Document counts PackBits rows in four bytes.
+std::vector decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument = false,
+ const std::optional &crop = std::nullopt);
+// The merged image's first `wanted` of its `channels` planes.
+std::vector> 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 &red, const std::vector &green,
const std::vector &blue, const std::vector &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 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
+
+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 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 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(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(0, left)), croppedTop = std::min(canvasHeight, std::max(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::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 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 &layers, qint64 remainingPixels)
+{
+ qint64 usedPixels = 0;
+ for (const RawLayer &layer : layers) {
+ const qint64 width = std::max(0, layer.right - layer.left), height = std::max(0, layer.bottom - layer.top);
+ const qint64 maskWidth = std::max(0, layer.maskRight - layer.maskLeft), maskHeight = std::max(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> planes;
+ const qint64 width = std::max(0, layer.right - layer.left), height = std::max(0, layer.bottom - layer.top);
+ const qint64 maskWidth = std::max(0, layer.maskRight - layer.maskLeft), maskHeight = std::max(0, layer.maskBottom - layer.maskTop);
+ if (!fits(width, height, maskWidth, maskHeight, layer.hasMask, remainingPixels))
+ throw ImageImportError(ImageImportError::Kind::tooLarge);
+ const qint64 sourceWidth = std::max(0, layer.sourceRight - layer.sourceLeft), sourceHeight = std::max(0, layer.sourceBottom - layer.sourceTop);
+ const qint64 sourceMaskWidth = std::max(0, layer.sourceMaskRight - layer.sourceMaskLeft);
+ const qint64 sourceMaskHeight = std::max(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(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(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
+#include
+#include
+#include