From 2cd0d1b86d72cc5442dd281969f3c64e6179d77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 04:41:08 -0400 Subject: [PATCH] Photoshop PSD import (Compositor 1.2.0, part one) --- AGENTS.md | 3 +- CMakeLists.txt | 12 + TASKS.md | 5 +- ...io.github.ZacharyZhang_NY.OmaPhoto.desktop | 2 +- src/ContentView+Panels.cpp | 38 +- src/ContentView.cpp | 1 + src/ContentView.h | 5 + src/Document/EditorSession+Imports.cpp | 161 ++++++- src/Document/EditorSession+Jobs.h | 7 + src/Document/EditorSession.cpp | 1 + src/Document/EditorSession.h | 17 + src/IO/ImageFileDrop.cpp | 17 +- src/IO/ImageImporter.cpp | 14 +- src/IO/ImageImporter.h | 5 + src/IO/PSD/PSDChannelCoder.cpp | 94 ++++ src/IO/PSD/PSDChannelCoder.h | 13 + src/IO/PSD/PSDDocumentBuilder.cpp | 122 +++++ src/IO/PSD/PSDDocumentBuilder.h | 18 + src/IO/PSD/PSDReader+Adjustments.cpp | 97 ++++ src/IO/PSD/PSDReader.cpp | 438 ++++++++++++++++++ src/IO/PSD/PSDReader.h | 20 + src/IO/PSD/PSDTypes.cpp | 43 ++ src/IO/PSD/PSDTypes.h | 58 +++ src/IO/PSD/PSDVector.cpp | 311 +++++++++++++ src/IO/PSD/PSDVector.h | 37 ++ src/UI/PSDConversionSheet.cpp | 89 ++++ src/UI/PSDConversionSheet.h | 20 + tests/ContentViewTests.cpp | 8 +- tests/DesktopIntegrationTests.cpp | 2 +- tests/ImageFileDropTests.cpp | 80 +++- tests/ImageImportSessionTests.cpp | 2 +- tests/ImageImportTests.cpp | 2 +- tests/PSDFixture.h | 361 +++++++++++++++ tests/PSDImportTests.cpp | 402 ++++++++++++++++ tests/PSDReaderFixtures.h | 130 ++++++ tests/PSDReaderLayerTests.cpp | 223 +++++++++ tests/PSDReaderTests.cpp | 369 +++++++++++++++ tests/PSDRoundTripTests.cpp | 297 ++++++++++++ tests/PSDVectorBuilders.h | 110 +++++ tests/PSDVectorFixtures.h | 61 +++ tests/PSDVectorTests.cpp | 248 ++++++++++ 41 files changed, 3912 insertions(+), 31 deletions(-) create mode 100644 src/IO/PSD/PSDChannelCoder.cpp create mode 100644 src/IO/PSD/PSDChannelCoder.h create mode 100644 src/IO/PSD/PSDDocumentBuilder.cpp create mode 100644 src/IO/PSD/PSDDocumentBuilder.h create mode 100644 src/IO/PSD/PSDReader+Adjustments.cpp create mode 100644 src/IO/PSD/PSDReader.cpp create mode 100644 src/IO/PSD/PSDReader.h create mode 100644 src/IO/PSD/PSDTypes.cpp create mode 100644 src/IO/PSD/PSDTypes.h create mode 100644 src/IO/PSD/PSDVector.cpp create mode 100644 src/IO/PSD/PSDVector.h create mode 100644 src/UI/PSDConversionSheet.cpp create mode 100644 src/UI/PSDConversionSheet.h create mode 100644 tests/PSDFixture.h create mode 100644 tests/PSDImportTests.cpp create mode 100644 tests/PSDReaderFixtures.h create mode 100644 tests/PSDReaderLayerTests.cpp create mode 100644 tests/PSDReaderTests.cpp create mode 100644 tests/PSDRoundTripTests.cpp create mode 100644 tests/PSDVectorBuilders.h create mode 100644 tests/PSDVectorFixtures.h create mode 100644 tests/PSDVectorTests.cpp diff --git a/AGENTS.md b/AGENTS.md index e99fffe..1201f97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,10 +113,11 @@ Platform adaptations, each forced by a missing Qt analog: - Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. - Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files. - Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`. -- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `compositor.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`. +- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `omaphoto.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`. - Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`. - Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`. - The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`. +- Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past the buffer), the `reserve` calls, `painter.end()` before the raster returns, the drop copy's `close()` before its static removal, and the logs. Untested failure path: a read that fails after the file opened (an I/O error; Qt 6.4 refuses to open a directory). Tests: `PSDRoundTripTests` and `PSDVectorTests` (Swift's, through `tests/PSDFixture.h`, Swift's writer, and `PSDVectorFixtures.h`), `PSDReaderTests` and `PSDReaderLayerTests` (the reader's edges, every cut before the last layer byte, budgets and vector layers read whole, built by `PSDReaderFixtures.h` and `PSDVectorBuilders.h`), `PSDImportTests` (the session and the sheet), and the drop, importer, message and desktop checks in their files. - 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 cf48fd6..d508f19 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,7 @@ add_library(compositor_core STATIC src/UI/KeyboardShortcuts.cpp src/UI/KeyboardShortcuts+Sheet.cpp src/UI/NewCanvasSheet.cpp + src/UI/PSDConversionSheet.cpp src/UI/OmarchyTheme.cpp src/UI/ProjectTabs.cpp src/UI/ProjectWorkspaceView.cpp @@ -128,6 +129,12 @@ add_library(compositor_core STATIC src/IO/ProjectController+Resize.cpp src/IO/ProjectController+Export.cpp src/IO/ImageFileDrop.cpp + src/IO/PSD/PSDChannelCoder.cpp + src/IO/PSD/PSDDocumentBuilder.cpp + src/IO/PSD/PSDReader.cpp + src/IO/PSD/PSDReader+Adjustments.cpp + src/IO/PSD/PSDTypes.cpp + src/IO/PSD/PSDVector.cpp src/IO/ProjectStore.cpp src/IO/ProjectStore+Adjustment.cpp src/IO/ProjectStore+Effects.cpp @@ -443,6 +450,11 @@ compositor_test(SubjectRemovalFailureTests) compositor_test(RemoveBackgroundTests) compositor_test(SelectSubjectTests) compositor_test(ObjectSelectionTests) +compositor_test(PSDRoundTripTests) +compositor_test(PSDVectorTests) +compositor_test(PSDReaderTests) +compositor_test(PSDReaderLayerTests) +compositor_test(PSDImportTests) compositor_test(LayerEffectsRendererTests) compositor_test(LayerFlipTests) compositor_test(LayerMergeTests) diff --git a/TASKS.md b/TASKS.md index 0fe257d..65cc534 100644 --- a/TASKS.md +++ b/TASKS.md @@ -321,7 +321,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 12.4 work: Object mode (U²-Net regions on the model grid, the guided edge, edge steps, smoothing), the session call, W and Tab, the Magic bar (modes, source, a signed Edge field), the canvas press and cursor, the rail icon and hints. Evidence: every test green in the dev image, lint clean; statement deletion over `ObjectSelection.cpp` and targeted mutants over the model, session, bar, canvas, list, hints, wand landing and icons: all caught but the equivalents AGENTS.md names. - 12.4 round 1: FAIL, five findings. A trace the wand kernel refuses left the Object worker uncaught and aborted the app (the worker catches `MagicWandError` now; no test reaches it, named in AGENTS.md); three details passed their tests broken: the press handing the view point on (the click now lands through a zoom), the cell row read through the width (a disc low on a 4:3 picture now), the bounds rounding where Swift floors (a click in the last half pixel now), and a minus after a digit (typed now); the icon sheet saved in the working directory (beside the binary now, the stray file gone); Qt's repeated start counted toward three points (dropped first now, Swift's later drops and Chaikin's guard dead and gone); the signal record lacked the mode and settings (read now). - 12.4 round 2: PASS. Its residuals: the three-point threshold named equivalent in AGENTS.md; the launch test's screenshot now saved beside its binary, the same cause as the icon sheet. -- [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet). +- [x] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet). +- 12.5a work: the PSD reader, PackBits, vector masks and live shapes, the document builder, the session's Photoshop import with its sheet, the importer's filter, dropped pictures named and kept apart, the desktop MIME type. Evidence: every test green in the dev image, lint clean; statement deletion over every new and changed file, then targeted mutants for the survivors: all caught but the equivalents AGENTS.md names. +- 12.5a round 1: FAIL, two findings. A file that opens but cannot be mapped (a pipe, a mount without `mmap`) was refused where Swift reads it (read when the map fails, a pipe tested); the folder's place beside an active layer inside a folder passed its test broken (pinned). Beyond Swift, recorded and fixed for the twin's graph rule: a clipped folder took a link the store refuses (no link now, the base note). AGENTS.md's log categories read `omaphoto`. +- 12.5a round 2: PASS. Its residual: a read failing after the open is named untested in AGENTS.md (Qt 6.4 refuses to open a directory, probed). - [ ] 12.5b Version 1.2.0, part two: the Outer Glow effect, brush Smoothing, and the release's other changes to what the twin holds. - [ ] 12.6 Version 1.2.1: camera RAW import through LibRaw with its develop step, middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`. - [ ] 12.7 Version 1.2.2: Photoshop's remaining blend modes in its groups; Black & White, Color Balance and Invert adjustments. diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop index 4c75bd7..32a5b51 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; +MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop; StartupWMClass=OmaPhoto diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index d688add..d29b9ab 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -5,6 +5,7 @@ #include "UI/HueSaturationSheet.h" #include "UI/LevelsSheet.h" #include <QFileDialog> +#include <QVBoxLayout> #include <QMessageBox> void ContentView::synchronizePanels() @@ -84,7 +85,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)")); + m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)")); 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) @@ -124,3 +125,38 @@ void ContentView::showAlert(QPointer<QMessageBox> &alert, const QString &title, }); alert->open(); } + +void ContentView::showConversionSheet() +{ + const std::optional<PSDConversionRequest> &request = m_session.conversionRequest(); + if (!m_session.showsConversionSheet() || !request) { + // Ours no longer, so the close answers nothing. + if (QDialog *shown = std::exchange(m_conversionSheet, nullptr)) + shown->reject(); + return; + } + if (m_conversionSheet && m_conversionShown == *request) + return; + m_conversionShown = *request; + if (!m_conversionSheet) { + m_conversionSheet = new QDialog(this); + m_conversionSheet->setObjectName(QStringLiteral("conversionSheet")); + m_conversionSheet->setAttribute(Qt::WA_DeleteOnClose); + auto *layout = new QVBoxLayout(m_conversionSheet); + layout->setContentsMargins(0, 0, 0, 0); + // Escape and the close button cancel, as Swift's dismissal. + connect(m_conversionSheet, &QDialog::finished, this, [this, shown = m_conversionSheet.data()] { + if (m_conversionSheet != shown) + return; + m_session.finishConversion(false); + }); + } + // Reading gives way to the list: new content. + for (PSDConversionSheet *old : m_conversionSheet->findChildren<PSDConversionSheet *>(Qt::FindDirectChildrenOnly)) { + old->hide(); + old->deleteLater(); + } + m_conversionSheet->setWindowTitle(request->title); + m_conversionSheet->layout()->addWidget(new PSDConversionSheet(*request, [this](bool confirmed) { m_session.finishConversion(confirmed); }, m_conversionSheet)); + m_conversionSheet->open(); +} diff --git a/src/ContentView.cpp b/src/ContentView.cpp index 36ce0e7..1d50776 100644 --- a/src/ContentView.cpp +++ b/src/ContentView.cpp @@ -398,6 +398,7 @@ void ContentView::synchronize() m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…") : hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind())); showImporter(); + showConversionSheet(); showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); }); showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); }); showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); }); diff --git a/src/ContentView.h b/src/ContentView.h index b048b5e..846ae26 100644 --- a/src/ContentView.h +++ b/src/ContentView.h @@ -4,6 +4,7 @@ #include "Rendering/EditorCanvas.h" #include "UI/FloatingPanel.h" #include "UI/ToolIcons.h" +#include <QDialog> #include <QGridLayout> #include <QLabel> #include <QPointer> @@ -51,6 +52,7 @@ private: void synchronize(); // Swift's onChange of levels: its panel opens and closes. void synchronizePanels(); + void showConversionSheet(); ToolIconKind iconKind(NavigationTool tool) const; void showHeader(NavigationTool tool); void showWelcome(bool shown); @@ -81,6 +83,9 @@ private: QLabel *const m_activity; QProgressBar *const m_spinner; QPointer<QFileDialog> m_importer; + // The Photoshop sheet and the request it shows. + QPointer<QDialog> m_conversionSheet; + std::optional<PSDConversionRequest> m_conversionShown; QPointer<QMessageBox> m_importAlert; QPointer<QMessageBox> m_brushAlert; QPointer<QMessageBox> m_cropAlert; diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp index ab573e9..6010f01 100644 --- a/src/Document/EditorSession+Imports.cpp +++ b/src/Document/EditorSession+Imports.cpp @@ -1,4 +1,6 @@ #include "Document/EditorSession.h" +#include "Document/ProjectWorkspace.h" +#include "IO/PSD/PSDReader.h" #include "Logging.h" #include <QtConcurrent> @@ -73,7 +75,9 @@ void EditorSession::drainImports() setImportError(std::exchange(m_importFailures, {}).join("\n\n")); return; } - beginEdit(QStringLiteral("Import Images")); + const QList<QUrl> &urls = m_pendingImports.front().urls; + const bool photoshop = std::all_of(urls.begin(), urls.end(), [](const QUrl &url) { return url.isLocalFile() && PSDReader::matches(url.toLocalFile()); }); + beginEdit(photoshop ? QStringLiteral("Import Photoshop File") : QStringLiteral("Import Images")); // Without a canvas the first image makes it. m_importPoint = m_document ? m_pendingImports.front().point : std::nullopt; m_importIndex = 0; @@ -101,6 +105,23 @@ void EditorSession::decodeNext() } } const QUrl url = request.urls[m_importIndex]; + 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 = 100'000'000 - usedPixels]() -> PhotoshopRead { + try { + PSDDocument document = ImageImporter::loadPhotoshop(path, remaining); + std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document); + return {std::move(document), std::move(assets), QString()}; + } catch (const ImageImportError &error) { + return {std::nullopt, {}, QString::fromUtf8(error.what())}; + } catch (const PSDError &error) { + return {std::nullopt, {}, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return {std::nullopt, {}, QString::fromUtf8(error.what())}; + } + })); + return; + } m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded { try { if (!url.isLocalFile()) @@ -122,3 +143,141 @@ void EditorSession::finishDecode() m_importIndex += 1; decodeNext(); } + +void EditorSession::finishPhotoshopRead() +{ + const PhotoshopRead read = m_photoshopReader.result(); + const QUrl url = m_pendingImports.front().urls[m_importIndex]; + const auto next = [this] { + m_importIndex += 1; + decodeNext(); + }; + // 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) { + endPSDReading(); + m_importFailures << url.fileName() + ": " + read.failure; + next(); + return; + } + const std::vector<PSDConversion> conversions = imported->conversions; + finishPSDReading(conversions, [this, imported = std::move(*imported), url, next](bool confirmed) { + if (confirmed) { + try { + insertPhotoshop(imported, ProjectTab::nameWithoutSuffix(url.toLocalFile()), m_importPoint); + } catch (const std::runtime_error &error) { + m_importFailures << url.fileName() + ": " + QString::fromUtf8(error.what()); + } + } + next(); + }); +} + +void EditorSession::beginPSDReading(const QString &title, const QString &confirmTitle) +{ + m_conversionCancelled = false; + m_conversionRequest = PSDConversionRequest{title, confirmTitle, {}, true}; + m_showsConversionSheet = true; + notify(); +} + +void EditorSession::finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer) +{ + if (m_conversionCancelled || conversions.empty()) { + const bool confirmed = !m_conversionCancelled; + endPSDReading(); + answer(confirmed); + return; + } + // The sheet waits for Cancel or the confirm button. + m_conversionAnswer = std::move(answer); + m_conversionRequest->conversions = conversions; + m_conversionRequest->isReading = false; + notify(); +} + +void EditorSession::endPSDReading() +{ + if (m_conversionAnswer) + return; + m_showsConversionSheet = false; + m_conversionRequest = std::nullopt; + notify(); +} + +void EditorSession::finishConversion(bool confirmed) +{ + if (!confirmed && m_conversionRequest && m_conversionRequest->isReading) + m_conversionCancelled = true; + m_showsConversionSheet = false; + m_conversionRequest = std::nullopt; + notify(); + // The import resumes from the event loop, as a task. + if (const std::function<void(bool)> answer = std::exchange(m_conversionAnswer, {})) + QMetaObject::invokeMethod(this, [answer, confirmed] { answer(confirmed); }, Qt::QueuedConnection); +} + +void EditorSession::insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt) +{ + std::vector<ImageLayer> incoming = imported.layers; + const size_t existing = m_document ? m_document->layers.size() : 0; + if (existing + incoming.size() + (m_document ? 1 : 0) > 10'000) + throw ImageImportError(ImageImportError::Kind::tooLarge); + std::vector<ImageLayer> layers = m_document ? m_document->layers : std::vector<ImageLayer>(); + std::optional<ImageLayer> group; + if (m_document) { + // An open canvas takes the file as one folder. + group = ImageLayer(named, m_document->size()); + group->isGroup = true; + const std::optional<ImageLayer> active = activeLayer(); + group->parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt; + std::optional<QRectF> box; + for (const ImageLayer &layer : incoming) + if (!layer.isGroup) + box = box ? box->united(QRectF(layer.origin(), layer.size())) : QRectF(layer.origin(), layer.size()); + if (centeredAt && box) { + const QPointF shift = *centeredAt - box->center(); + for (ImageLayer &layer : incoming) + layer.transform.origin += shift; + } + for (ImageLayer &layer : incoming) + if (!layer.parentID) + layer.parentID = group->id; + layers.push_back(*group); + } + layers.insert(layers.end(), incoming.begin(), incoming.end()); + // Folders nested past Swift's limit refuse the file. + std::vector<ProjectLayerRecord> records; + for (const ImageLayer &layer : layers) + records.push_back(layer.hierarchyRecord()); + try { + LayerHierarchy::validate(records); + } catch (const ProjectError &) { + qCWarning(lcIO) << "a Photoshop file nests its folders too deep"; + throw PSDError(PSDError::Kind::truncated); + } + beginEdit(QStringLiteral("Import Photoshop File")); + if (!group) { + CanvasDocument document(imported.width, imported.height); + document.layers = std::move(layers); + document.resolution = imported.resolution; + m_document = std::move(document); + viewport.fit(m_document->size()); + // The topmost root layer, else the topmost layer. + std::optional<QUuid> active = incoming.empty() ? std::nullopt : std::optional(incoming.back().id); + for (size_t index = incoming.size(); index-- > 0;) { + if (!incoming[index].parentID) { + active = incoming[index].id; + break; + } + } + setActiveLayerID(active); + } else { + m_document->layers = std::move(layers); + if (group->parentID) + m_collapsedGroupIDs.remove(*group->parentID); + setActiveLayerID(group->id); + } + endEdit(); + qCInfo(lcIO) << "imported a Photoshop file of" << incoming.size() << "layers"; +} diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index 0a623d9..84cc1be 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -5,6 +5,7 @@ #include "Document/Selection.h" #include "IO/ImageImporter.h" #include "IO/ProjectStore.h" +#include "IO/PSD/PSDTypes.h" #include <QImage> #include <QPainterPath> #include <QSet> @@ -30,6 +31,12 @@ struct SessionJobs { std::optional<ImportedImage> asset; QString failure; }; + // A Photoshop file read with its assets, or why not. + struct PhotoshopRead { + std::optional<PSDDocument> document; + std::map<QUuid, ImportedImage> assets; + QString failure; + }; struct Baked { std::map<QUuid, ImportedImage> images; std::optional<QString> failure; diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 052fc38..4de17fb 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -13,6 +13,7 @@ EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(thi notify(); }); connect(&m_decoder, &QFutureWatcher<Decoded>::finished, this, &EditorSession::finishDecode); + connect(&m_photoshopReader, &QFutureWatcher<PhotoshopRead>::finished, this, &EditorSession::finishPhotoshopRead); connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake); connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert); connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand); diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 331da10..e9c96d7 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -16,6 +16,8 @@ #include "Document/EditorSession+Jobs.h" #include "Document/EditorSession+Model.h" #include "IO/ProjectStore.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "UI/PSDConversionSheet.h" #include "Rendering/CanvasViewport.h" #include "Rendering/EffectsPreviewCache.h" #include <QFutureWatcher> @@ -66,6 +68,8 @@ public: void setIsImporting(bool importing); std::optional<QString> importError() const { return m_importError; } void setImportError(std::optional<QString> error); + bool showsConversionSheet() const { return m_showsConversionSheet; } + const std::optional<PSDConversionRequest> &conversionRequest() const { return m_conversionRequest; } std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; } void setRenamingLayerID(std::optional<QUuid> id); @@ -233,6 +237,12 @@ public: // Files decode off the UI thread; `done` follows this request. void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt); + // The sheet shows while a Photoshop file is read. + void beginPSDReading(const QString &title, const QString &confirmTitle); + void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer); + void endPSDReading(); + void finishConversion(bool confirmed); + void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt); void createDocument(int width, int height, bool emptyLayer = false); void fit(); void zoom(double value, std::optional<QPointF> anchor = std::nullopt); @@ -312,6 +322,7 @@ private: void drainImports(); void decodeNext(); void finishDecode(); + void finishPhotoshopRead(); void endDuplicateTransform(); // Clone Stamp is family 1, the Smear 2, others 0. static int tipFamily(NavigationTool tool); @@ -371,6 +382,12 @@ private: std::optional<QPointF> m_importPoint; QStringList m_importFailures; QFutureWatcher<Decoded> m_decoder; + QFutureWatcher<PhotoshopRead> m_photoshopReader; + bool m_showsConversionSheet = false; + std::optional<PSDConversionRequest> m_conversionRequest; + std::function<void(bool)> m_conversionAnswer; + // Cancel pressed while the file was still read. + bool m_conversionCancelled = false; std::optional<QString> m_brushError; // The layers a bake under way will delete. std::vector<QUuid> m_bakingIDs; diff --git a/src/IO/ImageFileDrop.cpp b/src/IO/ImageFileDrop.cpp index 00a3ec2..90872c7 100644 --- a/src/IO/ImageFileDrop.cpp +++ b/src/IO/ImageFileDrop.cpp @@ -7,11 +7,12 @@ #include <algorithm> namespace { -// Swift's order: PNG, JPEG, HEIC, TIFF, then any picture. +// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, then any picture. QStringList pictureFormats(const QMimeData &data) { QStringList formats; - for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff")}) + for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff"), + QStringLiteral("image/vnd.adobe.photoshop")}) if (data.hasFormat(format)) formats << format; for (const QString &format : data.formats()) @@ -27,14 +28,20 @@ std::optional<QUrl> temporaryFile(const QMimeData &data) if (formats.isEmpty()) return std::nullopt; const QString suffix = QMimeDatabase().mimeTypeForName(formats.first()).preferredSuffix(); - const QString name = QStringLiteral("Dropped-%1.%2").arg(QUuid::createUuid().toString(QUuid::WithoutBraces).toUpper(), suffix.isEmpty() ? QStringLiteral("png") : suffix); - QFile file(QDir::temp().filePath(name)); + // Its own folder keeps the name the layers show. + const QString folder = QDir::temp().filePath(QUuid::createUuid().toString(QUuid::WithoutBraces).toUpper()); + if (!QDir().mkpath(folder)) { + qCWarning(lcIO).noquote() << "could not make a folder for a dropped picture:" << folder; + return std::nullopt; + } + QFile file(QDir(folder).filePath(QStringLiteral("Dropped.") + (suffix.isEmpty() ? QStringLiteral("png") : suffix))); const QByteArray bytes = data.data(formats.first()); if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.flush()) { qCWarning(lcIO) << "could not keep a dropped picture:" << file.errorString(); // A failed flush fails `remove()` too: close, then unlink. file.close(); QFile::remove(file.fileName()); + QDir().rmdir(folder); return std::nullopt; } return QUrl::fromLocalFile(file.fileName()); @@ -91,7 +98,7 @@ void ImageFileDrop::importProviders(const QMimeData &data, EditorSession &sessio } const auto finished = [&session, unreadable, done] { if (unreadable) { - const QString message = QStringLiteral("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager."); + const QString message = QStringLiteral("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager."); session.setImportError(session.importError() ? *session.importError() + QStringLiteral("\n\n") + message : message); } if (done) diff --git a/src/IO/ImageImporter.cpp b/src/IO/ImageImporter.cpp index 8cebe66..d396914 100644 --- a/src/IO/ImageImporter.cpp +++ b/src/IO/ImageImporter.cpp @@ -1,4 +1,6 @@ #include "IO/ImageImporter.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" #include "Logging.h" #include "Rendering/RasterSnapshot.h" #include <QColorSpace> @@ -16,7 +18,7 @@ QString description(ImageImportError::Kind kind) case ImageImportError::Kind::unreadable: return QStringLiteral("The image could not be read. It may be damaged or unavailable."); case ImageImportError::Kind::unsupported: - return QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image."); + return QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file."); case ImageImportError::Kind::tooLarge: return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."); } @@ -241,3 +243,13 @@ ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels) qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height(); return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name); } + +PSDDocument ImageImporter::loadPhotoshop(const QString &path, qint64 remainingPixels) +{ + return PSDReader::read(path, remainingPixels); +} + +std::map<QUuid, ImportedImage> ImageImporter::photoshopAssets(const PSDDocument &document) +{ + return PSDDocumentBuilder::assets(document); +} diff --git a/src/IO/ImageImporter.h b/src/IO/ImageImporter.h index 9318f83..87928ae 100644 --- a/src/IO/ImageImporter.h +++ b/src/IO/ImageImporter.h @@ -1,11 +1,14 @@ #pragma once #include <QImage> #include <QString> +#include <QUuid> #include <compare> +#include <map> #include <memory> #include <stdexcept> class RasterSnapshot; +struct PSDDocument; // Which pixels an asset holds, as CGImage identity did. struct ImageIdentity { @@ -45,4 +48,6 @@ namespace ImageImporter { void liftAllocationLimit(); // JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied. ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000); +PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = 100'000'000); +std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document); } diff --git a/src/IO/PSD/PSDChannelCoder.cpp b/src/IO/PSD/PSDChannelCoder.cpp new file mode 100644 index 0000000..dfb55d9 --- /dev/null +++ b/src/IO/PSD/PSDChannelCoder.cpp @@ -0,0 +1,94 @@ +#include "IO/PSD/PSDChannelCoder.h" +#include "Document/BrushStroke.h" +#include "IO/PSD/PSDTypes.h" +#include <QColorSpace> + +namespace { +std::vector<uchar> unpackRLE(int width, int height, const QByteArray &data) +{ + qsizetype offset = 0; + const auto next = [&]() -> uchar { + 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()) + throw PSDError(PSDError::Kind::truncated); + int written = 0; + uchar *line = plane.data() + size_t(row) * size_t(width); + while (written < width) { + if (offset >= end) + throw PSDError(PSDError::Kind::truncated); + const int n = qint8(data[offset++]); + if (n >= 0) { + const int count = n + 1; + if (written + count > width || offset + count > end) + throw PSDError(PSDError::Kind::truncated); + std::copy_n(data.constData() + offset, count, line + written); + offset += count; + written += count; + } else if (n != -128) { + const int count = 1 - n; + if (written + count > width || offset >= end) + throw PSDError(PSDError::Kind::truncated); + std::fill_n(line + written, count, uchar(data[offset++])); + written += count; + } + } + offset = end; + } + return plane; +} +} + +std::vector<uchar> PSDChannelCoder::decode(int compression, int width, int height, const QByteArray &data) +{ + if (width <= 0 || height <= 0) + return {}; + const qsizetype expected = qsizetype(width) * height; + 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); + default: + throw PSDError(PSDError::Kind::unsupportedCompression); + } +} + +QImage PSDChannelCoder::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) +{ + QImage image = BrushRaster::context(width, height, false); + image.setColorSpace(QColorSpace::SRgb); + for (int y = 0; y < height; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x) { + const size_t i = size_t(y) * size_t(width) + size_t(x); + const unsigned a = alpha[i]; + row[x * 4] = uchar((red[i] * a + 127) / 255); + row[x * 4 + 1] = uchar((green[i] * a + 127) / 255); + row[x * 4 + 2] = uchar((blue[i] * a + 127) / 255); + row[x * 4 + 3] = uchar(a); + } + } + return image; +} + +QImage PSDChannelCoder::maskImage(int width, int height, const std::vector<uchar> &gray) +{ + QImage image = BrushRaster::context(width, height, true); + for (int y = 0; y < height; ++y) + std::copy_n(gray.data() + size_t(y) * size_t(width), width, image.scanLine(y)); + return image; +} diff --git a/src/IO/PSD/PSDChannelCoder.h b/src/IO/PSD/PSDChannelCoder.h new file mode 100644 index 0000000..dadd56a --- /dev/null +++ b/src/IO/PSD/PSDChannelCoder.h @@ -0,0 +1,13 @@ +#pragma once +#include <QByteArray> +#include <QImage> +#include <vector> + +// 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); +// 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); +QImage maskImage(int width, int height, const std::vector<uchar> &gray); +} diff --git a/src/IO/PSD/PSDDocumentBuilder.cpp b/src/IO/PSD/PSDDocumentBuilder.cpp new file mode 100644 index 0000000..086698c --- /dev/null +++ b/src/IO/PSD/PSDDocumentBuilder.cpp @@ -0,0 +1,122 @@ +#include "IO/PSD/PSDDocumentBuilder.h" +#include "Document/PixelAdjust.h" +#include "IO/ProjectStore.h" + +namespace { +ImportedImage imported(const QImage &image, const QString &name) +{ + return ImportedImage(image, PixelAdjust::thumbnail(image), name); +} + +std::vector<QString> notes(const PSDRecord &record) +{ + std::vector<QString> result; + if (record.kind == PSDLayerKind::text) + result.push_back(QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped.")); + if (record.kind == PSDLayerKind::smartObject) + result.push_back(QStringLiteral("The smart object was rasterized. Linked contents can’t be edited.")); + if (record.kind == PSDLayerKind::effects) + result.push_back(QStringLiteral("Layer effects were discarded, so the appearance may differ.")); + if (record.kind == PSDLayerKind::vector) { + if (record.shape) + result.insert(result.end(), record.shapeNotes.begin(), record.shapeNotes.end()); + else + result.push_back(QStringLiteral("Vector shape was rasterized to pixels.")); + } + if (record.isGroup) { + if (record.blendKey != QLatin1String("pass") && record.blendKey != QLatin1String("norm")) + result.push_back(QStringLiteral("Folder blend mode “%1” isn’t supported. The folder will be pass-through.").arg(record.blendKey)); + } else if (!record.blendMode() && record.blendKey != QLatin1String("pass")) { + // Swift trims `.whitespaces`: spaces and tabs. + QString key = record.blendKey; + while (!key.isEmpty() && (key.front() == QLatin1Char(' ') || key.front() == QLatin1Char('\t'))) + key.remove(0, 1); + while (!key.isEmpty() && (key.back() == QLatin1Char(' ') || key.back() == QLatin1Char('\t'))) + key.chop(1); + result.push_back(QStringLiteral("Blend mode “%1” isn’t supported and will be applied as Normal.").arg(key)); + } + if (record.kind == PSDLayerKind::adjustment) + result.push_back(record.adjustment ? QStringLiteral("Adjustment parameters may not match Photoshop exactly.") + : QStringLiteral("This adjustment type isn’t supported and was skipped.")); + return result; +} + +ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets) +{ + ImageLayer layer(record.name, canvas); + if (record.image && !record.isGroup && !record.adjustment) { + const ImportedImage asset = assets.contains(record.id) ? assets.at(record.id) : imported(*record.image, record.name); + const QSizeF size = record.bounds.width() > 0 && record.bounds.height() > 0 ? record.bounds.size() : QSizeF(record.image->size()); + layer = ImageLayer(asset, record.bounds.topLeft()); + layer.transform.size = size; + if (record.shape) + layer.shape = LayerShape{*record.shape, asset.identity()}; + } + layer.id = record.id; + layer.isVisible = record.isVisible; + layer.parentID = record.parentID; + layer.isGroup = record.isGroup; + layer.opacity = std::clamp(record.opacity, 0.0, 1.0); + if (!record.isGroup) { + layer.blendMode = record.blendMode().value_or(LayerBlendMode::normal); + layer.adjustment = record.adjustment; + } + return layer; +} +} + +std::map<QUuid, ImportedImage> PSDDocumentBuilder::assets(const PSDDocument &document) +{ + std::map<QUuid, ImportedImage> result; + for (const PSDRecord &record : document.layers) + if (record.image) + result.emplace(record.id, imported(*record.image, record.name)); + return result; +} + +PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets) +{ + std::vector<PSDConversion> conversions; + std::vector<ImageLayer> layers; + const QSizeF canvas(document.width, document.height); + for (const PSDRecord &record : document.layers) { + for (const QString ¬e : notes(record)) + conversions.push_back(PSDConversion{.layerName = record.name, .message = note}); + if (record.kind == PSDLayerKind::adjustment && !record.adjustment) + continue; + ImageLayer layer = layerFor(record, canvas, assets); + if (record.mask) { + try { + layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked); + } catch (const std::runtime_error &) { + // Swift's `try?`: a mask that fails is reported, not fatal. + conversions.push_back(PSDConversion{.layerName = record.name, + .message = QStringLiteral("The layer mask couldn’t be converted to 8-bit grayscale and was skipped.")}); + } + } + layers.push_back(std::move(layer)); + } + // A clipped layer takes the nearest unclipped pixels below. + std::map<std::optional<QUuid>, QUuid> baseForParent; + for (const PSDRecord &record : document.layers) { + const int index = indexOf(layers, record.id); + if (index < 0) + continue; + ImageLayer &layer = layers[size_t(index)]; + if (record.clipping) { + // A clipped folder breaks the live mask graph: never linked. + const int source = !layer.isGroup && baseForParent.contains(record.parentID) ? indexOf(layers, baseForParent.at(record.parentID)) : -1; + if (source >= 0) { + layer.maskSourceID = layers[size_t(source)].id; + } else { + conversions.push_back(PSDConversion{.layerName = record.name, + .message = QStringLiteral("This clipping mask’s base isn’t supported, so clipping was skipped.")}); + } + } else if (!layer.isGroup && !layer.adjustment) { + baseForParent[record.parentID] = record.id; + } else { + baseForParent.erase(record.parentID); + } + } + return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)}; +} diff --git a/src/IO/PSD/PSDDocumentBuilder.h b/src/IO/PSD/PSDDocumentBuilder.h new file mode 100644 index 0000000..0645721 --- /dev/null +++ b/src/IO/PSD/PSDDocumentBuilder.h @@ -0,0 +1,18 @@ +#pragma once +#include "IO/PSD/PSDTypes.h" +#include <map> + +struct PSDImport { + int width; + int height; + double resolution; + std::vector<ImageLayer> layers; + std::vector<PSDConversion> conversions; +}; + +// A read Photoshop file as layers, with its conversions. +namespace PSDDocumentBuilder { +// Each pixel record's asset and thumbnail, off the UI thread. +std::map<QUuid, ImportedImage> assets(const PSDDocument &document); +PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {}); +} diff --git a/src/IO/PSD/PSDReader+Adjustments.cpp b/src/IO/PSD/PSDReader+Adjustments.cpp new file mode 100644 index 0000000..2ef0b1a --- /dev/null +++ b/src/IO/PSD/PSDReader+Adjustments.cpp @@ -0,0 +1,97 @@ +#include "IO/PSD/PSDReader.h" +#include <QtEndian> +#include <algorithm> + +namespace { +quint16 u16(const QByteArray &data, qsizetype at) +{ + return qFromBigEndian<quint16>(data.constData() + at); +} + +qint16 i16(const QByteArray &data, qsizetype at) +{ + return qFromBigEndian<qint16>(data.constData() + at); +} + +std::optional<LayerAdjustment> levels(const QByteArray &data) +{ + if (data.size() < 292) + return std::nullopt; + LevelsSettings settings; + for (int channel = 0; channel < 4; ++channel) { + const qsizetype base = 2 + channel * 10; + settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)), + double(u16(data, base + 4)), double(u16(data, base + 6))} + .normalized(); + } + return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings}; +} + +std::optional<LayerAdjustment> curves(const QByteArray &data) +{ + if (data.size() < 5) + return std::nullopt; + qsizetype at = data[0] == 0 ? 1 : 0; + const quint16 version = u16(data, at); + at += 2; + if (version != 1 && version != 4) + return std::nullopt; + // Five bytes at least: the version and count always fit. + const int count = u16(data, at); + at += 2; + CurvesSettings settings; + for (int channel = 0; channel < std::min(4, count); ++channel) { + if (at + 2 > data.size()) + return std::nullopt; + const int points = u16(data, at); + at += 2; + std::vector<CurvePoint> curve; + for (int point = 0; point < points; ++point) { + if (at + 4 > data.size()) + return std::nullopt; + const double output = u16(data, at), input = u16(data, at + 2); + at += 4; + curve.push_back(CurvePoint{std::min(255.0, input), std::min(255.0, output)}); + } + if (curve.size() >= 2) { + std::stable_sort(curve.begin(), curve.end(), [](const CurvePoint &lhs, const CurvePoint &rhs) { return lhs.x < rhs.x; }); + if (curve.front().x != 0) + curve.insert(curve.begin(), CurvePoint{0, curve.front().y}); + if (curve.back().x != 255) + curve.push_back(CurvePoint{255, curve.back().y}); + settings.channels[size_t(channel)] = curve; + } + } + if (!settings.isValid()) + return std::nullopt; + return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings}; +} + +std::optional<LayerAdjustment> hue(const QByteArray &data) +{ + if (data.size() < 4) + return std::nullopt; + HueSaturationSettings settings(0, 0, 0, data[2] != 0); + qsizetype at = 4; + for (const ColorRange range : allColorRanges) { + if (at + 6 > data.size()) + break; + settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; + at += 6; + } + return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings}; +} +} + +std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra) +{ + if (extra.contains(QStringLiteral("levl"))) + return levels(extra.at(QStringLiteral("levl"))); + if (extra.contains(QStringLiteral("curv"))) + return curves(extra.at(QStringLiteral("curv"))); + if (extra.contains(QStringLiteral("hue2"))) + return hue(extra.at(QStringLiteral("hue2"))); + if (extra.contains(QStringLiteral("hue "))) + return hue(extra.at(QStringLiteral("hue "))); + return std::nullopt; +} diff --git a/src/IO/PSD/PSDReader.cpp b/src/IO/PSD/PSDReader.cpp new file mode 100644 index 0000000..ff236c9 --- /dev/null +++ b/src/IO/PSD/PSDReader.cpp @@ -0,0 +1,438 @@ +#include "IO/PSD/PSDReader.h" +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDVector.h" +#include "Logging.h" +#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 > 30'000 || height > 30'000 || width * height > budget)) + throw ImageImportError(ImageImportError::Kind::tooLarge); + if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > 30'000 || maskHeight > 30'000 || 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)); +} + +PSDLayerKind kindOf(const RawLayer &layer, bool isGroup) +{ + if (isGroup) + return PSDLayerKind::group; + if (layer.has({"TySh", "tySh", "txt2"})) + return PSDLayerKind::text; + if (layer.has({"vmsk", "vsms", "vogk"})) + return PSDLayerKind::vector; + if (layer.has({"SoLd", "SoLE"})) + return PSDLayerKind::smartObject; + if (layer.has({"lfx2", "lrFX", "lmfx"})) + return PSDLayerKind::effects; + for (const QString &key : PSDReader::adjustmentKeys) + if (layer.extra.contains(key)) + return PSDLayerKind::adjustment; + return PSDLayerKind::raster; +} + +std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas, qint64 remainingPixels) +{ + std::vector<PSDRecord> result; + std::vector<QUuid> groups; + qint64 remaining = std::max<qint64>(0, remainingPixels); + for (const RawLayer &layer : raw) { + // Groups run bottom to top: divider, children, then the folder. + if (layer.section == 3) { + groups.push_back(QUuid::createUuid()); + continue; + } + const bool isGroup = layer.section == 1 || layer.section == 2; + QUuid id = QUuid::createUuid(); + if (isGroup && !groups.empty()) { + id = groups.back(); + groups.pop_back(); + } + PSDRecord record; + record.id = id; + record.name = layer.name.isEmpty() ? QStringLiteral("Layer") : layer.name; + if (!groups.empty()) + record.parentID = groups.back(); + record.isGroup = isGroup; + record.isVisible = !layer.hidden; + const bool passes = layer.blendKey == QLatin1String("pass") || layer.blendKey == QLatin1String("norm"); + record.blendKey = isGroup && passes ? QStringLiteral("pass") : layer.blendKey; + record.clipping = layer.clipping; + record.kind = kindOf(layer, isGroup); + 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)); + record.image = isGroup ? std::nullopt : layer.image; + std::optional<PSDVector::Live> live = isGroup ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining); + if (live) { + record.image = live->image; + record.bounds = live->bounds; + record.shape = live->style; + record.shapeNotes = live->notes; + record.kind = PSDLayerKind::vector; + remaining = std::max<qint64>(0, remaining - qint64(live->image.width()) * live->image.height()); + } else if (!record.image && !isGroup) { + if (const std::optional<PSDVector::Raster> raster = PSDVector::raster(layer.extra, canvas, remaining)) { + record.image = raster->image; + record.bounds = raster->bounds; + record.kind = PSDLayerKind::vector; + remaining = std::max<qint64>(0, remaining - qint64(raster->image.width()) * raster->image.height()); + } + } + record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage; + record.maskEnabled = !layer.maskDisabled; + record.maskLinked = layer.maskLinked; + if (!isGroup) + record.adjustment = PSDAdjustments::parse(layer.extra); + if (record.adjustment) + record.kind = PSDLayerKind::adjustment; + result.push_back(std::move(record)); + } + if (!groups.empty()) + throw PSDError(PSDError::Kind::truncated); + return result; +} + +PSDDocument parse(const QByteArray &data, qint64 remainingPixels) +{ + Cursor cursor{data}; + if (cursor.string(4) != QLatin1String("8BPS")) + throw ImageImportError(ImageImportError::Kind::unreadable); + if (cursor.u16() != 1) + throw PSDError(PSDError::Kind::unsupportedVersion); + cursor.skip(6); + cursor.u16(); + const qint64 canvasHeight = cursor.u32(), canvasWidth = cursor.u32(); + const int depth = cursor.u16(), mode = cursor.u16(); + if (canvasWidth < 1 || canvasWidth > 30'000 || canvasHeight < 1 || canvasHeight > 30'000 || canvasWidth * canvasHeight > 100'000'000) + throw ImageImportError(ImageImportError::Kind::tooLarge); + if (depth != 8) + throw PSDError(PSDError::Kind::unsupportedDepth); + if (mode != 3) + throw PSDError(PSDError::Kind::unsupportedColorMode); + 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; + const int id = cursor.u16(); + const int nameLength = cursor.u8(); + cursor.skip(nameLength); + if ((nameLength + 1) % 2 == 1) + cursor.skip(1); + const qint64 length = cursor.u32(); + const qint64 dataStart = cursor.offset; + if (id == 1005 && length >= 4) { + 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); + } + 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, {}}; + if (layerSection < 4) + return empty; + // The layer info length is skipped, as in Swift. + cursor.u32(); + 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)); + qint64 usedPixels = 0; + for (RawLayer &layer : raw) { + decodeChannels(cursor, layer, remainingPixels - usedPixels); + 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)}; +} +} + +const std::set<QString> PSDReader::adjustmentKeys{ + QStringLiteral("levl"), QStringLiteral("curv"), QStringLiteral("hue2"), QStringLiteral("hue "), QStringLiteral("expA"), QStringLiteral("grdm"), + QStringLiteral("brit"), QStringLiteral("blnc"), QStringLiteral("nvrt"), QStringLiteral("thrs"), QStringLiteral("post"), QStringLiteral("mixr"), + QStringLiteral("selc"), QStringLiteral("blwh"), QStringLiteral("phfl"), QStringLiteral("vibA"), +}; + +bool PSDReader::matches(const QString &path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) && file.read(4) == "8BPS"; +} + +bool PSDReader::matches(const QByteArray &data) +{ + return data.startsWith("8BPS"); +} + +PSDDocument PSDReader::read(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's `mappedIfSafe`: mapped where it can be, else read. + if (uchar *mapped = file.map(0, file.size())) + return read(QByteArray::fromRawData(reinterpret_cast<const char *>(mapped), file.size()), remainingPixels); + 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 read(bytes, remainingPixels); +} + +PSDDocument PSDReader::read(const QByteArray &data, qint64 remainingPixels) +{ + try { + return parse(data, remainingPixels); + } catch (const std::runtime_error &error) { + qCWarning(lcIO) << "a Photoshop file was refused:" << error.what(); + throw; + } +} diff --git a/src/IO/PSD/PSDReader.h b/src/IO/PSD/PSDReader.h new file mode 100644 index 0000000..834aad3 --- /dev/null +++ b/src/IO/PSD/PSDReader.h @@ -0,0 +1,20 @@ +#pragma once +#include "IO/PSD/PSDTypes.h" +#include <QByteArray> +#include <QString> +#include <map> +#include <set> + +// Photoshop `.psd` from Adobe's 2019 specification; no GPL code. +namespace PSDReader { +bool matches(const QString &path); +bool matches(const QByteArray &data); +PSDDocument read(const QString &path, qint64 remainingPixels = 100'000'000); +PSDDocument read(const QByteArray &data, qint64 remainingPixels = 100'000'000); +extern const std::set<QString> adjustmentKeys; +} + +// Levels, Curves and Hue/Saturation layers, as Swift reads them. +namespace PSDAdjustments { +std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra); +} diff --git a/src/IO/PSD/PSDTypes.cpp b/src/IO/PSD/PSDTypes.cpp new file mode 100644 index 0000000..5e0581c --- /dev/null +++ b/src/IO/PSD/PSDTypes.cpp @@ -0,0 +1,43 @@ +#include "IO/PSD/PSDTypes.h" +#include <map> + +namespace { +QString description(PSDError::Kind kind) +{ + switch (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."); + case PSDError::Kind::unsupportedColorMode: + case PSDError::Kind::unsupportedDepth: + return QStringLiteral("Only 8-bit RGB Photoshop files can be imported."); + case PSDError::Kind::unsupportedCompression: + return QStringLiteral("This Photoshop file uses a layer compression method that isn’t supported."); + } + Q_UNREACHABLE(); +} +} + +PSDError::PSDError(Kind kind) : std::runtime_error(description(kind).toStdString()), kind(kind) {} + +std::optional<LayerBlendMode> fromPSD(const QString &key) +{ + static const std::map<QString, LayerBlendMode> modes{ + {QStringLiteral("norm"), LayerBlendMode::normal}, {QStringLiteral("mul "), LayerBlendMode::multiply}, + {QStringLiteral("scrn"), LayerBlendMode::screen}, {QStringLiteral("over"), LayerBlendMode::overlay}, + {QStringLiteral("sLit"), LayerBlendMode::softLight}, {QStringLiteral("dark"), LayerBlendMode::darken}, + {QStringLiteral("lite"), LayerBlendMode::lighten}, {QStringLiteral("diff"), LayerBlendMode::difference}, + {QStringLiteral("div "), LayerBlendMode::colorDodge}, {QStringLiteral("idiv"), LayerBlendMode::colorBurn}, + {QStringLiteral("hue "), LayerBlendMode::hue}, {QStringLiteral("sat "), LayerBlendMode::saturation}, + {QStringLiteral("colr"), LayerBlendMode::color}, {QStringLiteral("lum "), LayerBlendMode::luminosity}, + }; + if (!modes.contains(key)) + return std::nullopt; + return modes.at(key); +} + +std::optional<LayerBlendMode> PSDRecord::blendMode() const +{ + return fromPSD(blendKey); +} diff --git a/src/IO/PSD/PSDTypes.h b/src/IO/PSD/PSDTypes.h new file mode 100644 index 0000000..698f1e7 --- /dev/null +++ b/src/IO/PSD/PSDTypes.h @@ -0,0 +1,58 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include <QImage> +#include <QRectF> +#include <QString> +#include <QUuid> +#include <optional> +#include <stdexcept> +#include <vector> + +class PSDError : public std::runtime_error { +public: + enum class Kind { truncated, unsupportedVersion, unsupportedColorMode, unsupportedDepth, unsupportedCompression }; + explicit PSDError(Kind kind); + Kind kind; +}; + +// A note on what the import changes, by layer. +struct PSDConversion { + QUuid id = QUuid::createUuid(); + QString layerName; + QString message; + friend bool operator==(const PSDConversion &, const PSDConversion &) = default; +}; + +enum class PSDLayerKind { raster, group, adjustment, text, smartObject, effects, vector }; + +struct PSDRecord { + QUuid id; + std::optional<QUuid> parentID; + QString name; + bool isGroup = false; + bool isVisible = true; + double opacity = 1; + QString blendKey = QStringLiteral("norm"); + bool clipping = false; + QRectF bounds; + std::optional<QImage> image; + std::optional<QImage> mask; + bool maskEnabled = true; + bool maskLinked = true; + std::optional<LayerAdjustment> adjustment; + PSDLayerKind kind = PSDLayerKind::raster; + std::optional<LayerShapeStyle> shape; + std::vector<QString> shapeNotes; + std::optional<LayerBlendMode> blendMode() const; +}; + +struct PSDDocument { + int width; + int height; + double resolution; + // Bottom to top, folders included; hidden dividers are not stored. + std::vector<PSDRecord> layers; +}; + +// Swift's `LayerBlendMode.fromPSD`: Photoshop's four-letter keys. +std::optional<LayerBlendMode> fromPSD(const QString &key); diff --git a/src/IO/PSD/PSDVector.cpp b/src/IO/PSD/PSDVector.cpp new file mode 100644 index 0000000..82f875a --- /dev/null +++ b/src/IO/PSD/PSDVector.cpp @@ -0,0 +1,311 @@ +#include "IO/PSD/PSDVector.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include <QPainter> +#include <QtEndian> +#include <bit> +#include <cmath> + +namespace { +std::optional<QByteArray> entry(const PSDVector::Extra &extra, const char *key) +{ + const QString name = QString::fromLatin1(key); + if (!extra.contains(name)) + return std::nullopt; + return extra.at(name); +} + +qint32 i32(const QByteArray &bytes, qsizetype at) +{ + return qFromBigEndian<qint32>(bytes.constData() + at); +} + +qint16 i16(const QByteArray &bytes, qsizetype at) +{ + return qFromBigEndian<qint16>(bytes.constData() + at); +} + +std::optional<qsizetype> offset(const QByteArray &key, const QByteArray &data, qsizetype start = 0) +{ + const qsizetype found = start < data.size() ? data.indexOf(key, start) : -1; + if (found < 0) + return std::nullopt; + return found; +} + +std::optional<double> doubleAt(qsizetype at, const QByteArray &data) +{ + if (at + 8 > data.size()) + return std::nullopt; + return std::bit_cast<double>(qFromBigEndian<quint64>(data.constData() + at)); +} + +std::optional<double> number(const QByteArray &data, const QByteArray &key) +{ + const std::optional<qsizetype> start = offset(key, data); + if (!start) + return std::nullopt; + const qsizetype type = *start + key.size(); + if (type + 12 > data.size() || data.mid(type, 4) != "doub") + return std::nullopt; + return doubleAt(type + 4, data); +} + +std::optional<qint32> int32(const QByteArray &data, const QByteArray &key) +{ + const std::optional<qsizetype> start = offset(key, data); + if (!start) + return std::nullopt; + const qsizetype type = *start + key.size(); + if (type + 8 > data.size() || data.mid(type, 4) != "long") + return std::nullopt; + return i32(data, type + 4); +} + +QPointF point(const QByteArray &bytes, qsizetype at, QSizeF canvas) +{ + const double y = double(i32(bytes, at)) / 0x1000000; + const double x = double(i32(bytes, at + 4)) / 0x1000000; + return QPointF(x * canvas.width(), y * canvas.height()); +} + +// CGRect.integral, in doubles, so a wild box never overflows. +QRectF integral(const QRectF &box) +{ + const double left = std::floor(box.left()), top = std::floor(box.top()); + return QRectF(left, top, std::ceil(box.right()) - left, std::ceil(box.bottom()) - top); +} + +// Within 30,000 a side and the pixel budget, else tooLarge. +QSize pixelSize(QSizeF size, qint64 remainingPixels) +{ + if (std::abs(size.width()) > 30'000 || std::abs(size.height()) > 30'000) + throw ImageImportError(ImageImportError::Kind::tooLarge); + const qint64 budget = std::min(EditorSession::maxShapePixels, std::max<qint64>(0, remainingPixels)); + // Boxes are whole here, so the whole pixels decide alone. + const int width = std::max(1, int(size.width())), height = std::max(1, int(size.height())); + if (qint64(width) * height > budget) + throw ImageImportError(ImageImportError::Kind::tooLarge); + return QSize(width, height); +} + +struct Origination { + ShapeKind kind; + QRectF bounds; + double cornerRadius = 0; +}; + +// Photoshop's `vogk`: 1 and 2 rectangles, 5 an ellipse. +std::optional<Origination> origination(const std::optional<QByteArray> &data) +{ + const std::optional<qint32> type = data ? int32(*data, "keyOriginType") : std::nullopt; + if (!type) + return std::nullopt; + ShapeKind kind; + switch (*type) { + case 1: + case 2: + kind = ShapeKind::rectangle; + break; + case 5: + kind = ShapeKind::ellipse; + break; + default: + return std::nullopt; + } + const qsizetype from = offset("keyOriginShapeBBox", *data).value_or(0); + const std::optional<double> left = PSDVector::unit(*data, "Left", from), top = PSDVector::unit(*data, "Top ", from), + right = PSDVector::unit(*data, "Rght", from), bottom = PSDVector::unit(*data, "Btom", from); + if (!left || !top || !right || !bottom) + return std::nullopt; + const QRectF bounds(*left, *top, *right - *left, *bottom - *top); + if (!(bounds.width() >= 1) || !(bounds.height() >= 1) || !std::isfinite(bounds.x()) || !std::isfinite(bounds.y()) + || !std::isfinite(bounds.width()) || !std::isfinite(bounds.height())) + return std::nullopt; + Origination origin{kind, bounds}; + const std::optional<qsizetype> radiiAt = kind == ShapeKind::rectangle ? offset("keyOriginRRectRadii", *data) : std::nullopt; + if (radiiAt) { + std::vector<double> radii; + for (const char *key : {"topLeft", "topRight", "bottomRight", "bottomLeft"}) + if (const std::optional<double> radius = PSDVector::unit(*data, key, *radiiAt)) + radii.push_back(*radius); + if (radii.size() == 4) { + const auto [lo, hi] = std::minmax_element(radii.begin(), radii.end()); + if (*hi - *lo > 0.5) + return std::nullopt; + origin.cornerRadius = *hi; + } + } + return origin; +} + +// Four sharp anchors in one subpath read as a rectangle. +std::optional<Origination> sharpRect(const std::optional<QByteArray> &data, QSizeF canvas) +{ + const std::optional<QPainterPath> outline = data ? PSDVector::path(*data, canvas) : std::nullopt; + if (!outline) + return std::nullopt; + qsizetype at = 8; + int remaining = 0; + std::vector<QPointF> anchors; + bool sharp = true; + while (at + 26 <= data->size()) { + const int type = i16(*data, at); + const QByteArray body = data->mid(at + 2, 24); + at += 26; + if (type == 0 || type == 3) { + if (!anchors.empty()) + return std::nullopt; + remaining = i16(body, 0); + } else if ((type == 1 || type == 2 || type == 4 || type == 5) && remaining > 0) { + remaining -= 1; + const QPointF incoming = point(body, 0, canvas), anchor = point(body, 8, canvas), outgoing = point(body, 16, canvas); + if (std::hypot(incoming.x() - anchor.x(), incoming.y() - anchor.y()) > 0.5 + || std::hypot(outgoing.x() - anchor.x(), outgoing.y() - anchor.y()) > 0.5) + sharp = false; + anchors.push_back(anchor); + } + } + if (!sharp || anchors.size() != 4) + return std::nullopt; + const QRectF box = outline->boundingRect(); + if (!(box.width() >= 1) || !(box.height() >= 1)) + return std::nullopt; + return Origination{ShapeKind::rectangle, box}; +} +} + +std::optional<PSDVector::Live> PSDVector::live(const Extra &extra, QSizeF canvas, qint64 remainingPixels) +{ + const std::optional<QByteArray> stroke = entry(extra, "vstk"), solid = entry(extra, "SoCo"); + const bool fillEnabled = (stroke ? boolean(*stroke, "fillEnabled") : std::nullopt).value_or(solid.has_value()); + const bool strokeEnabled = (stroke ? boolean(*stroke, "strokeEnabled") : std::nullopt).value_or(false); + const std::optional<RGB> fill = solid ? rgb(*solid) : std::nullopt; + if (!fillEnabled || !fill) + return std::nullopt; + std::optional<Origination> origin = origination(entry(extra, "vogk")); + if (!origin) + origin = sharpRect(extra.contains(QStringLiteral("vmsk")) ? entry(extra, "vmsk") : entry(extra, "vsms"), canvas); + if (!origin) + return std::nullopt; + QRectF box = integral(origin->bounds); + box.setSize(QSizeF(pixelSize(box.size(), remainingPixels))); + const LayerShapeStyle style{origin->kind, fill->r, fill->g, fill->b, origin->cornerRadius}; + const QImage image = EditorSession::shapeImage(style.kind, box.size(), style.color(), style.cornerRadius); + std::vector<QString> notes; + if (strokeEnabled) + notes.push_back(QStringLiteral("The Photoshop stroke isn’t supported on shape layers and was omitted.")); + return Live{style, box, image, notes}; +} + +std::optional<PSDVector::Raster> PSDVector::raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels) +{ + const std::optional<QByteArray> mask = extra.contains(QStringLiteral("vmsk")) ? entry(extra, "vmsk") : entry(extra, "vsms"); + std::optional<QPainterPath> outline = mask ? path(*mask, canvas) : std::nullopt; + if (!outline) + return std::nullopt; + const std::optional<QByteArray> solid = entry(extra, "SoCo"), stroke = entry(extra, "vstk"); + const std::optional<RGB> fill = solid ? rgb(*solid) : std::nullopt; + const bool fillEnabled = (stroke ? boolean(*stroke, "fillEnabled") : std::nullopt).value_or(fill.has_value()); + const bool strokeEnabled = (stroke ? boolean(*stroke, "strokeEnabled") : std::nullopt).value_or(false); + const std::optional<RGB> strokeColor = stroke ? rgb(*stroke) : std::nullopt; + const double strokeWidth = (stroke ? unit(*stroke, "strokeStyleLineWidth") : std::nullopt).value_or(1); + if (!((fillEnabled && fill) || (strokeEnabled && strokeColor))) + return std::nullopt; + if (!std::isfinite(strokeWidth)) + return std::nullopt; + if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= 30'000)) + throw ImageImportError(ImageImportError::Kind::tooLarge); + QRectF box = outline->boundingRect(); + if (strokeEnabled) { + const double grow = std::ceil(strokeWidth / 2 + 1); + box.adjust(-grow, -grow, grow, grow); + } + box = integral(box); + const QSize size = pixelSize(box.size(), remainingPixels); + QImage image = BrushRaster::context(size.width(), size.height(), false); + QPainter painter(&image); + painter.translate(-box.x(), -box.y()); + painter.setRenderHint(QPainter::Antialiasing); + // Both CoreGraphics fills here are nonzero winding. + outline->setFillRule(Qt::WindingFill); + if (fillEnabled && fill) + painter.fillPath(*outline, QColor::fromRgbF(float(fill->r), float(fill->g), float(fill->b))); + if (strokeEnabled && strokeColor) { + // SVG's miter rule is CoreGraphics': a bevel past the limit. + QPen pen(QColor::fromRgbF(float(strokeColor->r), float(strokeColor->g), float(strokeColor->b)), strokeWidth, Qt::SolidLine, Qt::FlatCap, + Qt::SvgMiterJoin); + pen.setMiterLimit(10); + painter.strokePath(*outline, pen); + } + painter.end(); + return Raster{image, QRectF(box.topLeft(), QSizeF(size))}; +} + +std::optional<QPainterPath> PSDVector::path(const QByteArray &data, QSizeF canvas) +{ + if (data.size() < 8 || !(canvas.width() > 0) || !(canvas.height() > 0)) + return std::nullopt; + QPainterPath result; + qsizetype at = 8; + int remaining = 0; + bool closed = true, first = true; + QPointF previousOut; + while (at + 26 <= data.size()) { + const int type = i16(data, at); + const QByteArray body = data.mid(at + 2, 24); + at += 26; + if (type == 0 || type == 3) { + if (!first && closed) + result.closeSubpath(); + remaining = i16(body, 0); + closed = type == 0; + first = true; + } else if ((type == 1 || type == 2 || type == 4 || type == 5) && remaining > 0) { + remaining -= 1; + const QPointF incoming = point(body, 0, canvas), anchor = point(body, 8, canvas), outgoing = point(body, 16, canvas); + if (first) { + result.moveTo(anchor); + first = false; + } else { + result.cubicTo(previousOut, incoming, anchor); + } + previousOut = outgoing; + } + } + if (!first && closed) + result.closeSubpath(); + // A CGPath is empty only without elements. + if (result.elementCount() == 0) + return std::nullopt; + return result; +} + +std::optional<PSDVector::RGB> PSDVector::rgb(const QByteArray &data) +{ + const std::optional<double> r = number(data, "Rd "), g = number(data, "Grn "), b = number(data, "Bl "); + if (!r || !g || !b) + return std::nullopt; + const auto channel = [](double value) { return value > 1 ? std::min(255.0, std::max(0.0, value)) / 255 : std::min(1.0, std::max(0.0, value)); }; + return RGB{channel(*r), channel(*g), channel(*b)}; +} + +std::optional<bool> PSDVector::boolean(const QByteArray &data, const QByteArray &key) +{ + const std::optional<qsizetype> start = offset(key, data); + if (!start) + return std::nullopt; + const qsizetype type = *start + key.size(); + if (type + 5 > data.size() || data.mid(type, 4) != "bool") + return std::nullopt; + return data[type + 4] != 0; +} + +std::optional<double> PSDVector::unit(const QByteArray &data, const QByteArray &key, qsizetype from) +{ + const std::optional<qsizetype> keyAt = offset(key, data, from); + const std::optional<qsizetype> units = keyAt ? offset("UntF", data, *keyAt) : std::nullopt; + if (!units) + return std::nullopt; + return doubleAt(*units + 8, data); +} diff --git a/src/IO/PSD/PSDVector.h b/src/IO/PSD/PSDVector.h new file mode 100644 index 0000000..6ea7439 --- /dev/null +++ b/src/IO/PSD/PSDVector.h @@ -0,0 +1,37 @@ +#pragma once +#include "Document/ShapeTool.h" +#include <QByteArray> +#include <QImage> +#include <QPainterPath> +#include <QRectF> +#include <map> +#include <optional> + +// Photoshop vector masks rasterized, or fills mapped onto live shapes. +namespace PSDVector { +struct Raster { + QImage image; + QRectF bounds; +}; + +struct Live { + LayerShapeStyle style; + QRectF bounds; + QImage image; + std::vector<QString> notes; +}; + +using Extra = std::map<QString, QByteArray>; + +std::optional<Live> live(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000); +std::optional<Raster> raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000); +std::optional<QPainterPath> path(const QByteArray &data, QSizeF canvas); +struct RGB { + double r; + double g; + double b; +}; +std::optional<RGB> rgb(const QByteArray &data); +std::optional<bool> boolean(const QByteArray &data, const QByteArray &key); +std::optional<double> unit(const QByteArray &data, const QByteArray &key, qsizetype from = 0); +} diff --git a/src/UI/PSDConversionSheet.cpp b/src/UI/PSDConversionSheet.cpp new file mode 100644 index 0000000..8241dd6 --- /dev/null +++ b/src/UI/PSDConversionSheet.cpp @@ -0,0 +1,89 @@ +#include "UI/PSDConversionSheet.h" +#include <QHBoxLayout> +#include <QLabel> +#include <QProgressBar> +#include <QPushButton> +#include <QScrollArea> +#include <QVBoxLayout> + +namespace { +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QWidget *parent, bool secondary = false) +{ + auto *label = new QLabel(words, parent); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + if (secondary) + label->setForegroundRole(QPalette::PlaceholderText); + return label; +} + +// Swift's `List`: each layer's name over its note. +QWidget *list(const std::vector<PSDConversion> &conversions, QWidget *parent) +{ + auto *scroll = new QScrollArea(parent); + scroll->setObjectName(QStringLiteral("conversionList")); + scroll->setWidgetResizable(true); + auto *rows = new QWidget(scroll); + auto *column = new QVBoxLayout(rows); + for (const PSDConversion &item : conversions) { + auto *row = new QVBoxLayout; + row->setSpacing(4); + row->setContentsMargins(8, 4, 8, 4); + row->addWidget(text(item.layerName, 13, QFont::Bold, rows)); + row->addWidget(text(item.message, 13, QFont::Normal, rows)); + column->addLayout(row); + } + column->addStretch(); + scroll->setWidget(rows); + scroll->setMinimumHeight(180); + return scroll; +} +} + +PSDConversionSheet::PSDConversionSheet(const PSDConversionRequest &request, std::function<void(bool)> finish, QWidget *parent) : QWidget(parent) +{ + setMinimumSize(520, 360); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 24); + layout->setSpacing(16); + layout->addWidget(text(request.title, 17, QFont::Bold, this)); + layout->addWidget(text(request.isReading ? QStringLiteral("Reading the file to see what needs converting.") + : QStringLiteral("OmaPhoto will convert these Photoshop features. Nothing is applied until you continue."), + 13, QFont::Normal, this, true)); + if (request.isReading) { + auto *reading = new QWidget(this); + reading->setMinimumHeight(180); + auto *row = new QHBoxLayout(reading); + row->setSpacing(10); + row->addStretch(); + auto *spinner = new QProgressBar(reading); + spinner->setObjectName(QStringLiteral("conversionSpinner")); + spinner->setRange(0, 0); + spinner->setTextVisible(false); + spinner->setFixedWidth(16); + row->addWidget(spinner); + row->addWidget(text(QStringLiteral("Reading the Photoshop file…"), 13, QFont::Normal, reading, true)); + row->addStretch(); + layout->addWidget(reading, 1); + } else { + layout->addWidget(list(request.conversions, this), 1); + } + auto *buttons = new QHBoxLayout; + buttons->addStretch(); + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("conversionCancel")); + cancel->setAutoDefault(false); + auto *confirm = new QPushButton(request.confirmTitle, this); + confirm->setObjectName(QStringLiteral("conversionConfirm")); + confirm->setDefault(true); + confirm->setEnabled(!request.isReading); + connect(cancel, &QPushButton::clicked, this, [finish] { finish(false); }); + connect(confirm, &QPushButton::clicked, this, [finish] { finish(true); }); + buttons->addWidget(cancel); + buttons->addWidget(confirm); + layout->addLayout(buttons); +} diff --git a/src/UI/PSDConversionSheet.h b/src/UI/PSDConversionSheet.h new file mode 100644 index 0000000..97385fb --- /dev/null +++ b/src/UI/PSDConversionSheet.h @@ -0,0 +1,20 @@ +#pragma once +#include "IO/PSD/PSDTypes.h" +#include <QWidget> +#include <functional> + +struct PSDConversionRequest { + QString title; + QString confirmTitle; + std::vector<PSDConversion> conversions; + // Still read: the sheet answers the click, reporting nothing. + bool isReading = false; + friend bool operator==(const PSDConversionRequest &, const PSDConversionRequest &) = default; +}; + +// What an import converts, confirmed before anything applies. +class PSDConversionSheet : public QWidget { + Q_OBJECT +public: + PSDConversionSheet(const PSDConversionRequest &request, std::function<void(bool)> finish, QWidget *parent = nullptr); +}; diff --git a/tests/ContentViewTests.cpp b/tests/ContentViewTests.cpp index 7a2c386..d3b1af7 100644 --- a/tests/ContentViewTests.cpp +++ b/tests/ContentViewTests.cpp @@ -300,7 +300,10 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag() desk.note = [&] { session.selectTool(NavigationTool::hand); const QList<QFileDialog *> panels = view.findChildren<QFileDialog *>(); - return QString("%1 %2").arg(panels.size()).arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one"); + return QString("%1 %2 %3") + .arg(panels.size()) + .arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one") + .arg(panels.value(0) ? panels.value(0)->nameFilters().join(';') : QString()); }; session.setShowsImporter(true); QTRY_COMPARE(desk.seen.size(), 1); @@ -319,7 +322,8 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag() QTest::qWait(50); QCOMPARE(int(session.document().value().layers.size()), 2); desk.typeBeforeCancel.clear(); - QCOMPARE(desk.seen.mid(0, 2), (QStringList{"panel|Open|open|file|||1 many", "panel|Open|open|file|||1 many"})); + const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)"; + QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel})); QCOMPARE(desk.seen.size(), 3); // The flag cleared from elsewhere takes the panel down. desk.replies.clear(); diff --git a/tests/DesktopIntegrationTests.cpp b/tests/DesktopIntegrationTests.cpp index 07e7169..c9b7d5b 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;")); + QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;")); } void DesktopIntegrationTests::aProjectFolderIsAType() diff --git a/tests/ImageFileDropTests.cpp b/tests/ImageFileDropTests.cpp index 4c21a58..448f33c 100644 --- a/tests/ImageFileDropTests.cpp +++ b/tests/ImageFileDropTests.cpp @@ -1,5 +1,6 @@ #include "ContentView.h" #include "IO/ImageFileDrop.h" +#include "PSDFixture.h" #include "UI/ProjectTabs.h" #include "UI/ProjectWorkspaceView.h" #include <QDragEnterEvent> @@ -48,6 +49,28 @@ QMimeData *files(const QStringList &paths, const QList<QUrl> &others = {}) return data; } +// Each dropped picture's copy, in a temporary folder. +QStringList droppedCopies() +{ + QStringList found; + for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot)) + for (const QString &file : QDir(QDir::temp().filePath(folder)).entryList({"Dropped.*"}, QDir::Files)) + found << QDir::temp().filePath(folder + "/" + file); + return found; +} + +// The copy one drop made, taken away again. +QString takeCopy(const QStringList &before) +{ + QStringList made = droppedCopies(); + for (const QString &old : before) + made.removeAll(old); + if (made.size() != 1) + throw std::runtime_error("a drop made no single copy"); + QDir(QFileInfo(made.front()).path()).removeRecursively(); + return QFileInfo(made.front()).fileName(); +} + QStringList names(const EditorSession &session) { QStringList listed; @@ -96,20 +119,40 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst() data.setData("image/png", png); EditorSession session; bool done = false; + const QStringList before = droppedCopies(); ImageFileDrop::importProviders(data, session, QPointF(2, 2), nullptr, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(done); QVERIFY(!session.importError()); - const QStringList listed = names(session); - QCOMPARE(listed.size(), 1); - QVERIFY(listed.value(0).startsWith("Dropped-")); + // The copy keeps the layer's name, in its own folder. + QCOMPARE(names(session), QStringList{"Dropped"}); QCOMPARE(session.document().value().size(), QSize(3, 5)); - QVERIFY(QFileInfo::exists(QDir::temp().filePath(listed.value(0) + ".png"))); - QFile::remove(QDir::temp().filePath(listed.value(0) + ".png")); + QCOMPARE(takeCopy(before), QString("Dropped.png")); + // A Photoshop picture comes after TIFF and opens. + PSDRecord sky; + sky.id = QUuid::createUuid(); + sky.name = "Sky"; + sky.image = PSDFixture::colorImage(4, 2, 0, 0, 1); + sky.bounds = QRectF(0, 0, 4, 2); + QMimeData photoshop; + photoshop.setData("image/vnd.adobe.photoshop", PSDFixture::data(PSDDocument{4, 2, 72, {sky}}, *sky.image)); + photoshop.setData("application/x-other", "ignored"); + QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/vnd.adobe.photoshop"}); + photoshop.setData("image/tiff", "tiff first"); + QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/tiff"}); + photoshop.removeFormat("image/tiff"); + EditorSession opened; + done = false; + ImageFileDrop::importProviders(photoshop, opened, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!opened.importError()); + QCOMPARE(names(opened), QStringList{"Sky"}); + QCOMPARE(opened.history.undoName(), QString("Import Photoshop File")); + QCOMPARE(takeCopy(before), QString("Dropped.psd")); } void ImageFileDropTests::whatCannotBeReadIsSaid() { - const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager."; + const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager."; EditorSession session; bool done = false; std::unique_ptr<QMimeData> link(files({}, {QUrl("https://example.com/page")})); @@ -270,8 +313,15 @@ void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable() QVERIFY(png.size() > 4096); QMimeData data; data.setData("image/png", png); - const auto dropped = [] { return QDir::temp().entryList({"Dropped-*"}, QDir::Files).size(); }; - const qsizetype before = dropped(); + // No copy stays behind, and no folder named for one. + const auto dropped = [] { + QStringList kept = droppedCopies(); + for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot)) + if (!QUuid::fromString(folder).isNull()) + kept << folder; + return kept; + }; + const QStringList before = dropped(); EditorSession session; bool done = false; signal(SIGXFSZ, SIG_IGN); @@ -283,7 +333,7 @@ void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable() ImageFileDrop::importProviders(data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); QCOMPARE(setrlimit(RLIMIT_FSIZE, &room), 0); QTRY_VERIFY(done); - QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager.")); + QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager.")); QVERIFY(!session.document()); QCOMPARE(dropped(), before); } @@ -304,13 +354,12 @@ void ImageFileDropTests::aPictureReachesATabWhole() EditorSession &session = workspace.current().session; session.createDocument(8, 8); bool done = false; + const QStringList before = droppedCopies(); workspace.receiveProviders(data, workspace.current().id, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(done); QVERIFY(!session.importError()); - const QStringList listed = names(session); - QCOMPARE(listed.size(), 1); - QVERIFY(listed.value(0).startsWith("Dropped-")); - QFile::remove(QDir::temp().filePath(listed.value(0) + ".png")); + QCOMPARE(names(session), QStringList{"Dropped"}); + QCOMPARE(takeCopy(before), QString("Dropped.png")); } namespace { @@ -344,11 +393,12 @@ void ImageFileDropTests::aLazyDragGivesOnlyThePictureChosen() EditorSession &session = workspace.current().session; session.createDocument(8, 8); bool done = false; + const QStringList before = droppedCopies(); workspace.receiveProviders(drag, workspace.current().id, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(done); QVERIFY(!session.importError()); - QCOMPARE(names(session).size(), 1); - QFile::remove(QDir::temp().filePath(names(session).value(0) + ".png")); + QCOMPARE(names(session), QStringList{"Dropped"}); + QCOMPARE(takeCopy(before), QString("Dropped.png")); drag.asked.removeDuplicates(); drag.asked.sort(); QCOMPARE(drag.asked, (QStringList{"image/png", "text/uri-list"})); diff --git a/tests/ImageImportSessionTests.cpp b/tests/ImageImportSessionTests.cpp index 945481a..2349b9a 100644 --- a/tests/ImageImportSessionTests.cpp +++ b/tests/ImageImportSessionTests.cpp @@ -7,7 +7,7 @@ // The session cases of Swift's ImageImportTests, and the queue. namespace { const QString unreadable = QStringLiteral("The image could not be read. It may be damaged or unavailable."); -const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image."); +const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file."); const QString tooLarge = QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."); // A 64 by 32 PNG, as Swift's fixture is. diff --git a/tests/ImageImportTests.cpp b/tests/ImageImportTests.cpp index d7547d6..14de2ac 100644 --- a/tests/ImageImportTests.cpp +++ b/tests/ImageImportTests.cpp @@ -459,7 +459,7 @@ void ImageImportTests::thumbnailsAreSmoothed() void ImageImportTests::errorsCarrySwiftsDescriptions() { QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unreadable).what()), QString("The image could not be read. It may be damaged or unavailable.")); - QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, or TIFF image.")); + QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.")); QCOMPARE(QString(ImageImportError(ImageImportError::Kind::tooLarge).what()), QString("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.")); QCOMPARE(ImageImportError(ImageImportError::Kind::tooLarge).kind, ImageImportError::Kind::tooLarge); diff --git a/tests/PSDFixture.h b/tests/PSDFixture.h new file mode 100644 index 0000000..af4897f --- /dev/null +++ b/tests/PSDFixture.h @@ -0,0 +1,361 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "IO/PSD/PSDTypes.h" +#include <QPainter> + +// Swift's PSDFixture: tiny Photoshop files; the app writes none. +namespace PSDFixture { +struct Buffer { + QByteArray data; + void u8(uchar value) { data.append(char(value)); } + void u16(quint16 value) + { + u8(uchar(value >> 8)); + u8(uchar(value)); + } + void i16(qint16 value) { u16(quint16(value)); } + void u32(quint32 value) + { + u16(quint16(value >> 16)); + u16(quint16(value)); + } + void i32(qint32 value) { u32(quint32(value)); } + void bytes(const QByteArray &value) { data.append(value); } + void string(const char *value) { data.append(value); } +}; + +struct Planes { + std::vector<uchar> red, green, blue, alpha; +}; + +struct Channel { + qint16 id; + QByteArray payload; +}; + +struct Prepared { + PSDRecord record; + bool isDivider = false; + std::vector<Channel> channels; + int top = 0, left = 0, bottom = 0, right = 0; + int maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0; +}; + +// Straight colour from premultiplied pixels, rounded as Swift rounds. +inline Planes planes(const QImage &image) +{ + const QImage pixels = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + Planes result; + for (int y = 0; y < pixels.height(); ++y) { + const uchar *row = pixels.constScanLine(y); + for (int x = 0; x < pixels.width(); ++x) { + const int r = row[x * 4], g = row[x * 4 + 1], b = row[x * 4 + 2], a = row[x * 4 + 3]; + result.alpha.push_back(uchar(a)); + const auto straight = [a](int value) { return a == 0 ? uchar(0) : uchar(std::min(255, (value * 255 + a / 2) / a)); }; + result.red.push_back(straight(r)); + result.green.push_back(straight(g)); + result.blue.push_back(straight(b)); + } + } + return result; +} + +inline std::vector<uchar> grayPlane(const QImage &image) +{ + const QImage gray = image.convertToFormat(QImage::Format_Grayscale8); + std::vector<uchar> plane; + for (int y = 0; y < gray.height(); ++y) + plane.insert(plane.end(), gray.constScanLine(y), gray.constScanLine(y) + gray.width()); + return plane; +} + +inline QByteArray packBits(const std::vector<uchar> &row) +{ + QByteArray output; + size_t i = 0; + while (i < row.size()) { + if (i + 1 < row.size() && row[i] == row[i + 1]) { + size_t run = 2; + while (i + run < row.size() && row[i + run] == row[i] && run < 128) + ++run; + output.append(char(qint8(1 - int(run)))); + output.append(char(row[i])); + i += run; + } else { + const size_t start = i++; + while (i < row.size() && i - start < 128) { + if (i + 1 < row.size() && row[i] == row[i + 1]) + break; + ++i; + } + output.append(char(i - start - 1)); + output.append(reinterpret_cast<const char *>(row.data() + start), qsizetype(i - start)); + } + } + return output; +} + +// PackBits by row: the counts, then the rows. +inline QByteArray encode(const std::vector<uchar> &plane, int width, int height) +{ + 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())); + packed.append(line); + } + return counts.data + packed; +} + +inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height) +{ + return QByteArray("\0\1", 2) + encode(plane, width, height); +} + +inline std::vector<Channel> emptyChannels() +{ + const QByteArray raw("\0\0", 2); + return {{-1, raw}, {0, raw}, {1, raw}, {2, raw}}; +} + +inline Prepared layer(const PSDRecord &record) +{ + 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())); + Prepared prepared; + prepared.record = 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)}); + } else { + prepared.channels = emptyChannels(); + } + if (record.mask) + prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())}); + prepared.top = top; + prepared.left = left; + prepared.bottom = top + height; + prepared.right = left + width; + prepared.maskTop = top; + prepared.maskLeft = left; + prepared.maskBottom = top + (record.mask ? record.mask->height() : 0); + prepared.maskRight = left + (record.mask ? record.mask->width() : 0); + return prepared; +} + +inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group = nullptr) +{ + PSDRecord record; + record.id = group ? group->id : QUuid::createUuid(); + record.parentID = parent; + record.name = name; + record.isGroup = section != 3; + record.isVisible = group ? group->isVisible : true; + record.opacity = group ? group->opacity : 1; + record.blendKey = blendKey; + record.mask = group ? group->mask : std::nullopt; + record.maskEnabled = group ? group->maskEnabled : true; + record.kind = PSDLayerKind::group; + Prepared prepared; + prepared.record = record; + 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.maskBottom = record.mask->height(); + prepared.maskRight = record.mask->width(); + } + return prepared; +} + +inline void additional(Buffer &buffer, const char *key, const QByteArray &payload) +{ + buffer.string("8BIM"); + buffer.string(key); + buffer.u32(quint32(payload.size())); + buffer.bytes(payload); + if (payload.size() % 2 == 1) + buffer.u8(0); +} + +inline QByteArray luni(const QString &name) +{ + Buffer data; + data.u32(quint32(name.size())); + for (const QChar unit : name) + data.u16(unit.unicode()); + return data.data; +} + +inline QByteArray extraData(const Prepared &item) +{ + Buffer extra; + if (item.record.mask && item.maskRight > item.maskLeft && item.maskBottom > item.maskTop) { + extra.u32(20); + extra.i32(item.maskTop); + extra.i32(item.maskLeft); + extra.i32(item.maskBottom); + extra.i32(item.maskRight); + extra.u8(255); + extra.u8(uchar((item.record.maskLinked ? 0 : 1) | (item.record.maskEnabled ? 0 : 2))); + extra.u16(0); + } else { + extra.u32(0); + } + extra.u32(0); + const QByteArray pascal = item.record.name.toUtf8().left(255); + 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 (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); + } + return extra.data; +} + +inline void writeRecord(Buffer &buffer, const Prepared &item) +{ + buffer.i32(item.top); + buffer.i32(item.left); + buffer.i32(item.bottom); + buffer.i32(item.right); + buffer.u16(quint16(item.channels.size())); + for (const Channel &channel : item.channels) { + buffer.i16(channel.id); + buffer.u32(quint32(channel.payload.size())); + } + buffer.string("8BIM"); + buffer.bytes((item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1()); + buffer.u8(uchar(std::clamp(int(std::round(item.record.opacity * 255)), 0, 255))); + buffer.u8(item.record.clipping ? 1 : 0); + buffer.u8(item.record.isVisible ? 0 : 2); + buffer.u8(0); + const QByteArray extra = extraData(item); + buffer.u32(quint32(extra.size())); + buffer.bytes(extra); +} + +inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared) +{ + // 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)); + } else { + prepared.push_back(layer(record)); + } + } +} + +inline QByteArray layerSection(const PSDDocument &document) +{ + std::vector<Prepared> prepared; + emitLayers(document, std::nullopt, prepared); + Buffer records, payloads; + records.i16(qint16(prepared.size())); + for (const Prepared &item : prepared) { + writeRecord(records, item); + 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 length; + length.u32(quint32(info.data.size() - 4)); + info.data.replace(0, 4, length.data); + info.u32(0); + return info.data; +} + +inline QByteArray resolutionResource(double resolution) +{ + Buffer resource; + resource.string("8BIM"); + resource.u16(1005); + resource.u8(0); + resource.u8(0); + resource.u32(16); + const quint32 fixed = quint32(std::round(std::clamp(resolution, 1.0, 9600.0) * 65536)); + for (int axis = 0; axis < 2; ++axis) { + resource.u32(fixed); + resource.u16(1); + resource.u16(1); + } + return resource.data; +} + +inline QByteArray data(const PSDDocument &document, const QImage &composite) +{ + Buffer file; + file.string("8BPS"); + file.u16(1); + file.bytes(QByteArray(6, '\0')); + file.u16(4); + file.u32(quint32(document.height)); + file.u32(quint32(document.width)); + file.u16(8); + file.u16(3); + file.u32(0); + const QByteArray resources = resolutionResource(document.resolution); + file.u32(quint32(resources.size())); + file.bytes(resources); + const QByteArray layers = layerSection(document); + file.u32(quint32(layers.size())); + file.bytes(layers); + // The composite, PackBits by channel. + QImage flattened = BrushRaster::context(document.width, document.height, false); + QPainter painter(&flattened); + BrushRaster::draw(composite, QRectF(0, 0, document.width, document.height), painter); + painter.end(); + const Planes split = planes(flattened); + 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)); + } + file.bytes(counts); + file.bytes(packed); + return file.data; +} + +inline QImage colorImage(int width, int height, double red, double green, double blue, double alpha = 1) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(QColor::fromRgbF(float(red), float(green), float(blue), float(alpha))); + return image; +} + +inline QImage grayImage(int width, int height, int value) +{ + QImage image = BrushRaster::context(width, height, true); + image.fill(value); + return image; +} + +inline PSDRecord record(const QString &name, std::optional<QImage> image = std::nullopt, QRectF bounds = QRectF()) +{ + PSDRecord result; + result.id = QUuid::createUuid(); + result.name = name; + result.image = image; + result.bounds = bounds; + return result; +} +} diff --git a/tests/PSDImportTests.cpp b/tests/PSDImportTests.cpp new file mode 100644 index 0000000..7844cd8 --- /dev/null +++ b/tests/PSDImportTests.cpp @@ -0,0 +1,402 @@ +#include "ContentView.h" +#include "PSDFixture.h" +#include <QLabel> +#include <QProgressBar> +#include <QPushButton> +#include <QScrollArea> +#include <QTemporaryDir> +#include <QtTest> + +// A Photoshop file through the session's import and the sheet. +namespace { +PSDRecord layer(const QString &name, QRectF bounds, const char *blend = "norm") +{ + PSDRecord record = PSDFixture::record(name, PSDFixture::colorImage(int(bounds.width()), int(bounds.height()), 0, 0, 1), bounds); + record.blendKey = blend; + return record; +} + +QUrl write(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 QUrl::fromLocalFile(file.fileName()); +} + +QUrl photoshop(const QTemporaryDir &folder, const QString &name, const std::vector<PSDRecord> &layers, QSize size = QSize(4, 2), double dpi = 72) +{ + return write(folder, name, PSDFixture::data(PSDDocument{size.width(), size.height(), dpi, layers}, PSDFixture::colorImage(size.width(), size.height(), 0, 0, 0, 0))); +} + +QStringList names(const EditorSession &session) +{ + QStringList listed; + for (const ImageLayer &each : session.document().value().layers) + listed << each.name; + return listed; +} + +void import(EditorSession &session, const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt) +{ + bool done = false; + session.importImages(urls, point, [&done] { done = true; }); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); +} + +PSDImport blanks(size_t count) +{ + PSDImport imported{8, 8, 72, {}, {}}; + for (size_t index = 0; index < count; ++index) + imported.layers.push_back(ImageLayer(QStringLiteral("Blank"), QSizeF(8, 8))); + return imported; +} + +std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const ImageImportError &error) { + return error.kind; + } + return std::nullopt; +} +} + +class PSDImportTests : public QObject { + Q_OBJECT +private slots: + void importCreatesDocumentAndExistingCanvasGetsAGroup(); + void cancelledConversionLeavesTheDocumentUnchanged(); + void aConfirmedConversionImportsAndCancelWhileReadingStops(); + void failuresAreReportedAndStepsNamedByTheFiles(); + void aFileLandsAtThePointInsideTheActiveFolder(); + void layerLimitsAndDeepFoldersRefuseTheFile(); + void theSheetReadsThenListsAndAnswers(); + void aFileTheSessionRefusesIsReported(); + void aDirectInsertIsOneStepAndFitsTheView(); + void theSheetKeepsItsLayoutAndItsContent(); +}; + +void PSDImportTests::importCreatesDocumentAndExistingCanvasGetsAGroup() +{ + const QTemporaryDir folder; + const QUrl url = photoshop(folder, "Trip.psd", {layer("Sky", QRectF(0, 0, 4, 2))}, QSize(4, 2), 144); + EditorSession session; + QSignalSpy changed(&session, &EditorSession::changed); + import(session, {url}); + QCOMPARE(session.document().value().size(), QSizeF(4, 2)); + QCOMPARE(session.document().value().resolution, 144.0); + QCOMPARE(names(session), QStringList{"Sky"}); + QVERIFY(!session.importError() && !session.showsConversionSheet() && !session.conversionRequest()); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[0].id)); + QCOMPARE(session.history.undoName(), QString("Import Photoshop File")); + QVERIFY(changed.count() > 0); + import(session, {url}); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(names(session), (QStringList{"Sky", "Trip", "Sky"})); + QVERIFY(layers[1].isGroup && !layers[1].parentID); + QCOMPARE(layers[1].size(), QSizeF(4, 2)); + QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); + QCOMPARE(session.activeLayerID(), std::optional(layers[1].id)); + session.undo(); + QCOMPARE(names(session), QStringList{"Sky"}); +} + +void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged() +{ + const QTemporaryDir folder; + const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2)); + EditorSession session; + bool done = false; + session.importImages({url}, std::nullopt, [&done] { done = true; }); + QVERIFY(session.showsConversionSheet() && session.conversionRequest().value().isReading); + QTRY_VERIFY(!session.conversionRequest().value().isReading); + const PSDConversionRequest &request = session.conversionRequest().value(); + QCOMPARE(request.title, QString("Open “Vivid.psd”?")); + QCOMPARE(request.confirmTitle, QString("Import")); + QCOMPARE(request.conversions.size(), size_t(1)); + QCOMPARE(request.conversions[0].layerName, QString("Vivid")); + QVERIFY(session.isImporting() && !done); + session.finishConversion(false); + QVERIFY(!session.showsConversionSheet() && !session.conversionRequest()); + QTRY_VERIFY(done); + QVERIFY(!session.document() && !session.isImporting() && !session.importError()); +} + +void PSDImportTests::aConfirmedConversionImportsAndCancelWhileReadingStops() +{ + const QTemporaryDir folder; + const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2)); + EditorSession session; + bool done = false; + session.importImages({url}, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(session.conversionRequest() && !session.conversionRequest().value().isReading); + session.finishConversion(true); + QTRY_VERIFY(done); + QCOMPARE(names(session), QStringList{"Vivid"}); + QVERIFY(session.document().value().layers[0].blendMode == LayerBlendMode::normal); + // Cancel while reading: nothing lands, nothing is said. + EditorSession stopped; + done = false; + stopped.importImages({url}, std::nullopt, [&done] { done = true; }); + QVERIFY(stopped.conversionRequest().value().isReading); + stopped.finishConversion(false); + QVERIFY(!stopped.showsConversionSheet()); + QTRY_VERIFY(done); + QVERIFY(!stopped.document() && !stopped.importError() && !stopped.showsConversionSheet()); + // Nothing to convert: the sheet goes unasked. + const QUrl plain = photoshop(folder, "Plain.psd", {layer("Plain", QRectF(0, 0, 4, 2))}); + done = false; + stopped.importImages({plain}, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(names(stopped), QStringList{"Plain"}); +} + +void PSDImportTests::failuresAreReportedAndStepsNamedByTheFiles() +{ + const QTemporaryDir folder; + const QUrl broken = write(folder, "Broken.psd", PSDFixture::data(PSDDocument{4, 2, 72, {}}, PSDFixture::colorImage(4, 2, 0, 0, 0)).left(40)); + EditorSession session; + import(session, {broken}); + QCOMPARE(session.importError(), std::optional<QString>("Broken.psd: The Photoshop file could not be read. It may be damaged or incomplete.")); + QVERIFY(!session.document() && !session.showsConversionSheet()); + session.setImportError(std::nullopt); + // A request of other files keeps the images' step name. + QImage png(3, 3, QImage::Format_RGBA8888); + png.fill(Qt::red); + QVERIFY(png.save(folder.filePath("Red.png"))); + const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))}); + import(session, {QUrl::fromLocalFile(folder.filePath("Red.png")), url}); + QCOMPARE(session.history.undoName(), QString("Import Images")); + QCOMPARE(names(session), (QStringList{"Red", "Sky", "Sky"})); +} + +void PSDImportTests::aFileLandsAtThePointInsideTheActiveFolder() +{ + const QTemporaryDir folder; + const QUrl url = photoshop(folder, "Pair.psd", {layer("Left", QRectF(0, 0, 2, 2)), layer("Right", QRectF(6, 4, 2, 2))}, QSize(8, 8)); + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid active = session.activeLayerID().value(); + session.toggleGroupExpansion(active); + QVERIFY(session.collapsedGroupIDs().contains(active)); + import(session, {url}, QPointF(50, 50)); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(names(session), (QStringList{"Folder 1", "Pair", "Left", "Right"})); + QCOMPARE(layers[1].parentID, std::optional(active)); + QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); + // The pixels, (0, 0) to (8, 6), centre there. + QCOMPARE(layers[2].origin(), QPointF(46, 47)); + QCOMPARE(layers[3].origin(), QPointF(52, 51)); + QVERIFY(!session.collapsedGroupIDs().contains(active) && !session.collapsedGroupIDs().contains(layers[1].id)); + QCOMPARE(session.activeLayerID(), std::optional(layers[1].id)); + // Beside an active layer inside a folder, within that folder. + session.selectLayer(layers[2].id); + import(session, {url}); + const std::vector<ImageLayer> &again = session.document().value().layers; + QCOMPARE(again[again.size() - 3].name, QString("Pair")); + QCOMPARE(again[again.size() - 3].parentID, std::optional(layers[1].id)); +} + +void PSDImportTests::layerLimitsAndDeepFoldersRefuseTheFile() +{ + EditorSession session; + QCOMPARE(refusal([&] { session.insertPhotoshop(blanks(10'001), "Many"); }), std::optional(ImageImportError::Kind::tooLarge)); + session.insertPhotoshop(blanks(10'000), "Many"); + QCOMPARE(session.document().value().layers.size(), size_t(10'000)); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers.back().id)); + EditorSession open; + open.createDocument(8, 8); + QCOMPARE(refusal([&] { open.insertPhotoshop(blanks(10'000), "Many"); }), std::optional(ImageImportError::Kind::tooLarge)); + open.insertPhotoshop(blanks(9'999), "Many"); + QCOMPARE(open.document().value().layers.size(), size_t(10'000)); + // Folders nested past the hierarchy's depth refuse the whole file. + PSDImport deep = blanks(0); + std::optional<QUuid> parent; + for (int depth = 0; depth < 65; ++depth) { + ImageLayer folder(QStringLiteral("Folder"), QSizeF(8, 8)); + folder.isGroup = true; + folder.parentID = parent; + parent = folder.id; + deep.layers.push_back(folder); + } + EditorSession refused; + try { + refused.insertPhotoshop(deep, "Deep"); + QFAIL("a hierarchy past its depth was taken"); + } catch (const PSDError &error) { + QVERIFY(error.kind == PSDError::Kind::truncated); + } + QVERIFY(!refused.document()); + deep.layers.pop_back(); + refused.insertPhotoshop(deep, "Deep"); + QCOMPARE(refused.document().value().layers.size(), size_t(64)); + QCOMPARE(refused.activeLayerID(), std::optional(deep.layers.front().id)); +} + +void PSDImportTests::theSheetReadsThenListsAndAnswers() +{ + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.beginPSDReading("Open “Trip.psd”?", "Import"); + QDialog *sheet = view.findChild<QDialog *>("conversionSheet"); + QVERIFY(sheet && sheet->isVisible() && sheet->windowModality() == Qt::WindowModal); + QCOMPARE(sheet->windowTitle(), QString("Open “Trip.psd”?")); + QVERIFY(sheet->findChild<QProgressBar *>("conversionSpinner")); + QVERIFY(!sheet->findChild<QPushButton *>("conversionConfirm")->isEnabled()); + QCOMPARE(sheet->findChild<QPushButton *>("conversionConfirm")->text(), QString("Import")); + QStringList words; + for (const QLabel *label : sheet->findChildren<QLabel *>()) + words << label->text(); + QVERIFY(words.contains("Reading the file to see what needs converting.") && words.contains("Reading the Photoshop file…")); + // The list replaces the reading row; Return answers yes. + std::optional<bool> answer; + session.finishPSDReading({PSDConversion{.layerName = "Vivid", .message = "Blend mode “vLit” isn’t supported."}}, [&answer](bool yes) { answer = yes; }); + QCOMPARE(view.findChild<QDialog *>("conversionSheet"), sheet); + QTRY_VERIFY(!sheet->findChild<QProgressBar *>("conversionSpinner")); + QScrollArea *list = sheet->findChild<QScrollArea *>("conversionList"); + QVERIFY(list); + words.clear(); + for (const QLabel *label : list->findChildren<QLabel *>()) + words << label->text(); + QCOMPARE(words, (QStringList{"Vivid", "Blend mode “vLit” isn’t supported."})); + QVERIFY(sheet->findChild<QPushButton *>("conversionConfirm")->isEnabled()); + QTest::keyClick(sheet, Qt::Key_Return); + QTRY_COMPARE(answer, std::optional(true)); + QVERIFY(!session.showsConversionSheet()); + QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet")); + // Escape and Cancel answer no. + for (const bool escape : {true, false}) { + answer = std::nullopt; + session.beginPSDReading("Open “Trip.psd”?", "Import"); + session.finishPSDReading({PSDConversion{.layerName = "Words", .message = "Text"}}, [&answer](bool yes) { answer = yes; }); + QDialog *shown = view.findChild<QDialog *>("conversionSheet"); + QTRY_VERIFY(shown && shown->findChild<QScrollArea *>("conversionList")); + if (escape) + QTest::keyClick(shown, Qt::Key_Escape); + else + shown->findChild<QPushButton *>("conversionCancel")->click(); + QTRY_COMPARE(answer, std::optional(false)); + QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet")); + } + // Taken away by the session: the sheet goes, answering nothing. + session.beginPSDReading("Open “Trip.psd”?", "Import"); + QVERIFY(view.findChild<QDialog *>("conversionSheet")); + session.endPSDReading(); + QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet")); + QVERIFY(!session.showsConversionSheet()); +} + +void PSDImportTests::aFileTheSessionRefusesIsReported() +{ + const QTemporaryDir folder; + const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))}); + EditorSession session; + session.insertPhotoshop(blanks(9'999), "Many"); + import(session, {url}); + QCOMPARE(session.importError(), + std::optional<QString>("Sky.psd: This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.")); + QCOMPARE(session.document().value().layers.size(), size_t(9'999)); +} + +void PSDImportTests::aDirectInsertIsOneStepAndFitsTheView() +{ + EditorSession session; + session.viewport.viewSize = QSizeF(400, 200); + PSDImport imported = blanks(2); + imported.resolution = 300; + session.insertPhotoshop(imported, "Blanks"); + CanvasViewport fitted; + fitted.viewSize = QSizeF(400, 200); + fitted.fit(QSizeF(8, 8)); + QVERIFY(session.viewport == fitted); + QCOMPARE(session.document().value().resolution, 300.0); + QCOMPARE(session.history.undoName(), QString("Import Photoshop File")); + session.undo(); + QVERIFY(!session.document()); + session.redo(); + QCOMPARE(names(session), (QStringList{"Blank", "Blank"})); +} + +void PSDImportTests::theSheetKeepsItsLayoutAndItsContent() +{ + EditorSession session; + session.createDocument(8, 8); + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.beginPSDReading("Open “Trip.psd”?", "Import"); + QDialog *sheet = view.findChild<QDialog *>("conversionSheet"); + QVERIFY(sheet); + PSDConversionSheet *reading = sheet->findChild<PSDConversionSheet *>(); + QTRY_VERIFY(reading && reading->isVisible()); + QCOMPARE(reading->minimumSize(), QSize(520, 360)); + QVERIFY(sheet->width() >= 520 && sheet->height() >= 360); + QCOMPARE(reading->layout()->contentsMargins(), QMargins(24, 24, 24, 24)); + QCOMPARE(reading->layout()->spacing(), 16); + const QProgressBar *spinner = sheet->findChild<QProgressBar *>("conversionSpinner"); + QVERIFY(spinner->minimum() == 0 && spinner->maximum() == 0 && !spinner->isTextVisible() && spinner->width() == 16); + QCOMPARE(spinner->parentWidget()->minimumHeight(), 180); + // Unrelated changes keep the content; the spinner is centred. + session.selectTool(NavigationTool::hand); + QCOMPARE(sheet->findChildren<PSDConversionSheet *>(), QList<PSDConversionSheet *>{reading}); + const QRect row = spinner->parentWidget()->rect(); + const QLabel *words = spinner->parentWidget()->findChild<QLabel *>(); + QCOMPARE(words->geometry().left() - spinner->geometry().right() - 1, 10); + QVERIFY(std::abs(spinner->geometry().left() - (row.width() - 1 - words->geometry().right())) <= 1); + // A title as text, not markup, bold at 17 pixels. + std::optional<bool> answer; + session.finishPSDReading({PSDConversion{.layerName = "<b>Bold</b>", .message = "A note that runs long enough to wrap over the sheet's width, " + "which the list keeps whole instead of cutting it off at the edge."}}, + [&answer](bool yes) { answer = yes; }); + // The reading content hides at once, before it is deleted. + QVERIFY(!reading->isVisible()); + PSDConversionSheet *listed = nullptr; + QTRY_VERIFY((listed = [&] { + for (PSDConversionSheet *each : sheet->findChildren<PSDConversionSheet *>()) + if (each->isVisible()) + return each; + return static_cast<PSDConversionSheet *>(nullptr); + }()) != nullptr && listed != reading); + int visible = 0; + for (PSDConversionSheet *each : sheet->findChildren<PSDConversionSheet *>()) + visible += each->isVisible(); + QCOMPARE(visible, 1); + const QList<QLabel *> labels = listed->findChildren<QLabel *>(); + const QLabel *title = labels.value(0); + QCOMPARE(title->text(), QString("Open “Trip.psd”?")); + QCOMPARE(title->font().pixelSize(), 17); + QCOMPARE(title->font().weight(), QFont::Bold); + QCOMPARE(labels.value(1)->foregroundRole(), QPalette::PlaceholderText); + const QScrollArea *list = listed->findChild<QScrollArea *>("conversionList"); + QVERIFY(list->widgetResizable() && list->minimumHeight() == 180 && list->widget()); + const QLabel *name = list->widget()->findChildren<QLabel *>().value(0), *note = list->widget()->findChildren<QLabel *>().value(1); + QCOMPARE(name->textFormat(), Qt::PlainText); + QVERIFY(name->wordWrap() && note->wordWrap()); + QCOMPARE(name->font().weight(), QFont::Bold); + QCOMPARE(note->font().pixelSize(), 13); + // Rows stack in the list, the name above its note. + QCOMPARE(note->geometry().top() - name->geometry().bottom() - 1, 4); + QCOMPARE(name->geometry().top(), list->widget()->layout()->contentsMargins().top() + 4); + QCOMPARE(name->geometry().left(), list->widget()->layout()->contentsMargins().left() + 8); + QVERIFY(note->height() > note->fontMetrics().height()); + // Rows keep the height their words need, no more. + QCOMPARE(note->height(), note->heightForWidth(note->width())); + // Cancel stands left of confirm, both at the right. + const QPushButton *cancel = listed->findChild<QPushButton *>("conversionCancel"), *confirm = listed->findChild<QPushButton *>("conversionConfirm"); + QVERIFY(!cancel->autoDefault() && confirm->isDefault()); + QVERIFY(cancel->geometry().right() < confirm->geometry().left()); + QCOMPARE(confirm->geometry().right(), listed->width() - 25); + QVERIFY(cancel->geometry().left() > listed->width() / 2); + session.finishConversion(false); + QTRY_COMPARE(answer, std::optional(false)); +} + +QTEST_MAIN(PSDImportTests) +#include "PSDImportTests.moc" diff --git a/tests/PSDReaderFixtures.h b/tests/PSDReaderFixtures.h new file mode 100644 index 0000000..515c624 --- /dev/null +++ b/tests/PSDReaderFixtures.h @@ -0,0 +1,130 @@ +#pragma once +#include "PSDFixture.h" + +// Raw Photoshop records built byte by byte for edges. +namespace PSDReaderFixtures { +struct Extra { + const char *signature; + const char *key; + QByteArray payload; + // An 8B64 block's upper length word. + quint32 high = 0; +}; + +struct Record { + QRect bounds; + std::vector<PSDFixture::Channel> channels; + const char *signature = "8BIM"; + const char *blendKey = "norm"; + uchar opacity = 255; + bool clipping = false; + uchar flags = 0; + std::optional<QRect> mask; + uchar maskFlags = 0; + QByteArray name = "Layer"; + std::vector<Extra> extras; +}; + +inline QByteArray solidChannels(const QRect &bounds, std::vector<PSDFixture::Channel> &channels) +{ + const QByteArray plane(bounds.width() * bounds.height(), '\x80'); + for (const qint16 id : {-1, 0, 1, 2}) + channels.push_back({id, QByteArray("\0\0", 2) + plane}); + return plane; +} + +inline QByteArray header(quint32 width, quint32 height) +{ + PSDFixture::Buffer data; + data.string("8BPS"); + data.u16(1); + data.bytes(QByteArray(6, '\0')); + data.u16(3); + data.u32(height); + data.u32(width); + data.u16(8); + data.u16(3); + data.u32(0); + return data.data; +} + +inline QByteArray file(const std::vector<Record> &records, const QByteArray &resources = QByteArray(), quint32 width = 8, quint32 height = 8, + std::optional<qint16> count = std::nullopt) +{ + PSDFixture::Buffer data; + data.bytes(header(width, height)); + data.u32(quint32(resources.size())); + data.bytes(resources); + PSDFixture::Buffer body, payloads; + body.i16(count.value_or(qint16(records.size()))); + for (const Record &record : records) { + body.i32(record.bounds.top()); + body.i32(record.bounds.left()); + body.i32(record.bounds.top() + record.bounds.height()); + body.i32(record.bounds.left() + record.bounds.width()); + body.u16(quint16(record.channels.size())); + for (const PSDFixture::Channel &channel : record.channels) { + body.i16(channel.id); + body.u32(quint32(channel.payload.size())); + payloads.bytes(channel.payload); + } + body.string(record.signature); + body.string(record.blendKey); + body.u8(record.opacity); + body.u8(record.clipping ? 1 : 0); + body.u8(record.flags); + body.u8(0); + PSDFixture::Buffer extra; + if (record.mask) { + extra.u32(20); + extra.i32(record.mask->top()); + extra.i32(record.mask->left()); + extra.i32(record.mask->top() + record.mask->height()); + extra.i32(record.mask->left() + record.mask->width()); + extra.u8(255); + extra.u8(record.maskFlags); + extra.u16(0); + } else { + extra.u32(0); + } + extra.u32(0); + extra.u8(uchar(record.name.size())); + extra.bytes(record.name); + extra.bytes(QByteArray((4 - ((record.name.size() + 1) % 4)) % 4, '\0')); + for (const Extra &block : record.extras) { + extra.string(block.signature); + extra.string(block.key); + if (QByteArray(block.signature) == "8B64") + extra.u32(block.high); + extra.u32(quint32(block.payload.size())); + extra.bytes(block.payload); + if (block.payload.size() % 2 == 1) + extra.u8(0); + } + body.u32(quint32(extra.data.size())); + body.bytes(extra.data); + } + PSDFixture::Buffer info; + info.u32(quint32(body.data.size() + payloads.data.size())); + info.bytes(body.data); + info.bytes(payloads.data); + info.u32(0); + data.u32(quint32(info.data.size())); + data.bytes(info.data); + return data.data; +} + +inline QByteArray section(quint32 type, const char *blend = "norm") +{ + PSDFixture::Buffer payload; + payload.u32(type); + payload.string("8BIM"); + payload.string(blend); + return payload.data; +} + +inline QByteArray unicode(const QString &name) +{ + return PSDFixture::luni(name); +} +} diff --git a/tests/PSDReaderLayerTests.cpp b/tests/PSDReaderLayerTests.cpp new file mode 100644 index 0000000..3682387 --- /dev/null +++ b/tests/PSDReaderLayerTests.cpp @@ -0,0 +1,223 @@ +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include "PSDReaderFixtures.h" +#include "PSDVectorBuilders.h" +#include "PSDVectorFixtures.h" +#include <QTemporaryDir> +#include <sys/stat.h> +#include <thread> +#include <QtTest> + +// The reader's bounds and budgets, and vector layers read whole. +using PSDReaderFixtures::Record; + +namespace { +template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const Error &error) { + return error.kind; + } + return std::nullopt; +} + +Record pixels(QByteArray name, QRect bounds = QRect(0, 0, 2, 2)) +{ + Record record; + record.bounds = bounds; + record.name = name; + PSDReaderFixtures::solidChannels(bounds, record.channels); + return record; +} + +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]); +} +} + +class PSDReaderLayerTests : public QObject { + Q_OBJECT +private slots: + void everyCutBeforeTheLastLayerByteIsTruncated(); + void aRecordWithoutItsSignatureIsTruncated(); + void masksAndLayersSpendOneBudget(); + void resourcesEndWhereTheyDeclare(); + void packBitsRowsResumeAtTheirDeclaredEnd(); + void vectorLayersAreReadWholeAndSpendTheBudget(); + void textWithLevelsReadsAsAnAdjustment(); + void aMissingFileIsUnreadableAndAnEmptyOneTruncated(); + void blendKeysTrimAndAssetsKeepTheirIdentity(); +}; + +void PSDReaderLayerTests::everyCutBeforeTheLastLayerByteIsTruncated() +{ + PSDRecord group = PSDFixture::record("Folder"); + group.isGroup = true; + PSDRecord inside = PSDFixture::record("Inside", PSDFixture::colorImage(3, 2, 1, 0, 0), QRectF(1, 1, 3, 2)); + inside.parentID = group.id; + inside.mask = PSDFixture::grayImage(3, 2, 200); + const QByteArray data = PSDFixture::data(PSDDocument{8, 8, 72, {group, inside}}, PSDFixture::colorImage(8, 8, 0, 0, 0)); + QCOMPARE(PSDReader::read(data).layers.size(), size_t(2)); + // The header, the resources, the section's length, its info's length. + const qsizetype resources = u32(data, 30); + const qsizetype info = 26 + 4 + 4 + resources + 4; + const qsizetype last = info + 4 + qsizetype(u32(data, info)) - 1; + for (qsizetype cut = 0; cut < last; ++cut) + QCOMPARE(refusal<PSDError>([&] { PSDReader::read(data.left(cut)); }), std::optional(PSDError::Kind::truncated)); +} + +void PSDReaderLayerTests::aRecordWithoutItsSignatureIsTruncated() +{ + Record record = pixels("Unsigned"); + record.signature = "8BIX"; + QCOMPARE(refusal<PSDError>([&] { PSDReader::read(PSDReaderFixtures::file({record})); }), std::optional(PSDError::Kind::truncated)); +} + +void PSDReaderLayerTests::masksAndLayersSpendOneBudget() +{ + const QByteArray pair = PSDReaderFixtures::file({pixels("One"), pixels("Two")}); + QCOMPARE(PSDReader::read(pair, 8).layers.size(), size_t(2)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(pair, 7); }), std::optional(ImageImportError::Kind::tooLarge)); + Record masked = pixels("Masked", QRect(0, 0, 1, 1)); + masked.mask = QRect(0, 0, 2, 2); + 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)); + masked.mask = QRect(0, 0, 30'001, 1); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked})); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDReaderLayerTests::resourcesEndWhereTheyDeclare() +{ + PSDFixture::Buffer fixed; + fixed.string("8BIM"); + fixed.u16(1005); + fixed.u16(0); + fixed.u32(8); + fixed.u32(150 * 65536); + fixed.u32(0); + // Eight bytes, too few for a resource, end the loop. + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({pixels("After")}, fixed.data + "junk1234")); + QCOMPARE(document.resolution, 150.0); + QCOMPARE(document.layers.at(0).name, QString("After")); +} + +void PSDReaderLayerTests::packBitsRowsResumeAtTheirDeclaredEnd() +{ + // Row 0 holds two unread bytes; row 1 follows them. + const QByteArray rows("\0\x04\0\x02\xff\x07\x55\x55\xff\x09", 10); + QCOMPARE(PSDChannelCoder::decode(1, 2, 2, rows), (std::vector<uchar>{7, 7, 9, 9})); +} + +void PSDReaderLayerTests::vectorLayersAreReadWholeAndSpendTheBudget() +{ + using namespace PSDVectorBuilders; + Record ellipse = pixels("Ellipse"); + ellipse.extras = {{"8BIM", "SoCo", colorDescriptor(0, 110, 255)}, {"8BIM", "vogk", originationData(5, QRectF(10, 20, 30, 40))}}; + Record circle; + circle.name = "Circle"; + for (const auto &[key, payload] : PSDVectorFixtures::circle()) + circle.extras.push_back({"8BIM", key == "vmsk" ? "vmsk" : key == "SoCo" ? "SoCo" : "vstk", payload}); + const QByteArray data = PSDReaderFixtures::file({ellipse, circle}, QByteArray(), 1920, 1080); + const PSDDocument document = PSDReader::read(data); + // A live shape takes its own pixels over the layer's. + const PSDRecord &shape = document.layers[0]; + QVERIFY(shape.kind == PSDLayerKind::vector && shape.shape.value().kind == ShapeKind::ellipse); + QCOMPARE(shape.bounds, QRectF(10, 20, 30, 40)); + QCOMPARE(shape.image.value().size(), QSize(30, 40)); + QVERIFY(shape.shapeNotes.empty()); + const PSDRecord &raster = document.layers[1]; + QVERIFY(raster.kind == PSDLayerKind::vector && !raster.shape); + QVERIFY(std::abs(raster.bounds.width() - 328) < 12); + QCOMPARE(raster.image.value().size(), raster.bounds.size().toSize()); + // The channels, the shape and the raster share one budget. + const qint64 spent = 4 + 30 * 40 + qint64(raster.image.value().width()) * raster.image.value().height(); + QCOMPARE(PSDReader::read(data, spent).layers.size(), size_t(2)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, spent - 1); }), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 4 + 30 * 40 - 1); }), std::optional(ImageImportError::Kind::tooLarge)); + // Text that draws a shape reads as one, stroke noted. + Record styled = ellipse; + styled.extras.push_back({"8BIM", "TySh", QByteArray("t")}); + styled.extras.push_back({"8BIM", "vstk", strokeStyle(true, true, 3, 255, 0, 0)}); + Record outlined = circle; + outlined.extras.push_back({"8BIM", "TySh", QByteArray("t")}); + const QByteArray texts = PSDReaderFixtures::file({styled, outlined, circle}, QByteArray(), 1920, 1080); + const PSDDocument typed = PSDReader::read(texts); + QVERIFY(typed.layers[0].kind == PSDLayerKind::vector && typed.layers[1].kind == PSDLayerKind::vector); + QCOMPARE(typed.layers[0].shapeNotes, std::vector<QString>{"The Photoshop stroke isn’t supported on shape layers and was omitted."}); + // A second raster spends what the first left. + const qint64 each = qint64(raster.image.value().width()) * raster.image.value().height(); + QCOMPARE(PSDReader::read(texts, 4 + 30 * 40 + 2 * each).layers.size(), size_t(3)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(texts, 4 + 30 * 40 + 2 * each - 1); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDReaderLayerTests::textWithLevelsReadsAsAnAdjustment() +{ + Record text = pixels("Words"); + text.extras = {{"8BIM", "TySh", QByteArray("t")}, {"8BIM", "levl", QByteArray(292, '\0')}}; + const PSDRecord record = PSDReader::read(PSDReaderFixtures::file({text})).layers.at(0); + QVERIFY(record.kind == PSDLayerKind::adjustment && record.adjustment.has_value()); +} + +void PSDReaderLayerTests::aMissingFileIsUnreadableAndAnEmptyOneTruncated() +{ + const QTemporaryDir folder; + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(folder.filePath("missing.psd")); }), std::optional(ImageImportError::Kind::unreadable)); + QFile empty(folder.filePath("empty.psd")); + QVERIFY(empty.open(QIODevice::WriteOnly)); + empty.close(); + QCOMPARE(refusal<PSDError>([&] { PSDReader::read(empty.fileName()); }), std::optional(PSDError::Kind::truncated)); + QFile written(folder.filePath("one.psd")); + QVERIFY(written.open(QIODevice::WriteOnly) && written.write(PSDReaderFixtures::file({pixels("From disk")})) > 0); + written.close(); + QCOMPARE(PSDReader::read(written.fileName()).layers.at(0).name, QString("From disk")); + // A pipe cannot be mapped: its bytes are read instead. + const QString pipe = folder.filePath("pipe.psd"); + QCOMPARE(mkfifo(QFile::encodeName(pipe).constData(), 0600), 0); + const QByteArray bytes = PSDReaderFixtures::file({pixels("Through a pipe")}); + std::thread writer([&] { + QFile end(pipe); + if (end.open(QIODevice::WriteOnly)) + end.write(bytes); + }); + const PSDDocument piped = PSDReader::read(pipe); + writer.join(); + QCOMPARE(piped.layers.at(0).name, QString("Through a pipe")); +} + +void PSDReaderLayerTests::blendKeysTrimAndAssetsKeepTheirIdentity() +{ + Record spaced = pixels("Spaced"); + spaced.blendKey = " xy "; + Record tabbed = pixels("Tabbed"); + tabbed.blendKey = "\txy\t"; + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({spaced, tabbed})); + const PSDImport imported = PSDDocumentBuilder::makeImport(document); + QCOMPARE(imported.conversions.at(0).message, QString("Blend mode “xy” isn’t supported and will be applied as Normal.")); + QCOMPARE(imported.conversions.at(1).message, QString("Blend mode “xy” isn’t supported and will be applied as Normal.")); + // The worker's assets are the layers' own, named for them. + const std::map<QUuid, ImportedImage> assets = PSDDocumentBuilder::assets(document); + QCOMPARE(assets.size(), size_t(2)); + QCOMPARE(assets.at(document.layers[1].id).name, QString("Tabbed")); + const PSDImport given = PSDDocumentBuilder::makeImport(document, assets); + QCOMPARE(given.layers[1].asset.value().identity(), assets.at(document.layers[1].id).identity()); + QCOMPARE(given.layers[1].name, QString("Tabbed")); + // A folder clipped to the layer below is never linked. + Record clipped; + clipped.name = "Clipped folder"; + clipped.clipping = true; + clipped.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(1)}); + Record divider; + divider.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(3)}); + const PSDImport folders = PSDDocumentBuilder::makeImport(PSDReader::read(PSDReaderFixtures::file({pixels("Base"), divider, clipped}))); + QVERIFY(folders.layers.at(1).isGroup && !folders.layers.at(1).maskSourceID); + QCOMPARE(folders.conversions.size(), size_t(1)); + QCOMPARE(folders.conversions[0].layerName + folders.conversions[0].message, + QString("Clipped folderThis clipping mask’s base isn’t supported, so clipping was skipped.")); +} + +QTEST_GUILESS_MAIN(PSDReaderLayerTests) +#include "PSDReaderLayerTests.moc" diff --git a/tests/PSDReaderTests.cpp b/tests/PSDReaderTests.cpp new file mode 100644 index 0000000..293b5ec --- /dev/null +++ b/tests/PSDReaderTests.cpp @@ -0,0 +1,369 @@ +#include "IO/PSD/PSDChannelCoder.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include "PSDReaderFixtures.h" +#include <QColorSpace> +#include <QtTest> + +// The reader's edges Swift's tests leave: bytes, names, flags, kinds. +using PSDReaderFixtures::Record; + +namespace { +template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const Error &error) { + return error.kind; + } + return std::nullopt; +} + +Record pixels(QByteArray name = "Layer", QRect bounds = QRect(0, 0, 2, 2)) +{ + Record record; + record.bounds = bounds; + record.name = name; + PSDReaderFixtures::solidChannels(bounds, record.channels); + return record; +} + +Record folder(quint32 type, QByteArray name = "Folder", const char *blend = "norm") +{ + Record record; + record.name = name; + record.blendKey = blend; + record.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(type)}); + return record; +} + +QByteArray resource(quint16 id, const QByteArray &name, const QByteArray &body) +{ + PSDFixture::Buffer data; + data.string("8BIM"); + data.u16(id); + data.u8(uchar(name.size())); + data.bytes(name); + if ((name.size() + 1) % 2 == 1) + data.u8(0); + data.u32(quint32(body.size())); + data.bytes(body); + if (body.size() % 2 == 1) + data.u8(0); + return data.data; +} + +QByteArray resolution(double dpi) +{ + PSDFixture::Buffer fixed; + fixed.u32(quint32(dpi * 65536)); + fixed.u32(0); + return resource(1005, QByteArray(), fixed.data); +} + +std::vector<QString> messages(const PSDImport &imported) +{ + std::vector<QString> result; + for (const PSDConversion &conversion : imported.conversions) + result.push_back(conversion.layerName + ": " + conversion.message); + return result; +} +} + +class PSDReaderTests : public QObject { + Q_OBJECT +private slots: + void packBitsDecodesRunsLiteralsAndNoOps(); + void planesPremultiplyAndMasksCopy(); + void namesReadMacRomanAndPreferUnicode(); + void fillMultipliesOpacityUnlessEffectsKeepIt(); + void maskFlagsAndMasksFromRenderedPixels(); + void longBlocksReadAndRefuseWildLengths(); + void resolutionComesFromItsResource(); + void layerCountsAndSections(); + void foldersPairWithTheirDividers(); + void kindsReportWhatTheImportConverts(); + void clippingNeedsAPixelBaseBelow(); + void levelsCurvesAndHueParse(); +}; + +void PSDReaderTests::packBitsDecodesRunsLiteralsAndNoOps() +{ + // Row 0: literal pair, run; row 1: no-op, run. + const QByteArray rows("\0\x05\0\x03\x01\x0a\x14\xff\x1e\x80\xfd\x07", 12); + QCOMPARE(PSDChannelCoder::decode(1, 4, 2, rows), (std::vector<uchar>{10, 20, 30, 30, 7, 7, 7, 7})); + const auto truncated = [](const QByteArray &data) { + return refusal<PSDError>([&] { PSDChannelCoder::decode(1, 4, 1, data); }); + }; + const std::optional kind(PSDError::Kind::truncated); + QCOMPARE(truncated(QByteArray("\0", 1)), kind); + QCOMPARE(truncated(QByteArray("\0\x09\x01\x0a", 4)), kind); + QCOMPARE(truncated(QByteArray("\0\x06\x04\1\2\3\4\5", 8)), kind); + QCOMPARE(truncated(QByteArray("\0\x01\xfd", 3)), kind); + QCOMPARE(truncated(QByteArray("\0\x02\xff\x07", 4)), kind); + QCOMPARE(PSDChannelCoder::decode(0, 2, 2, QByteArray("abcde")), (std::vector<uchar>{'a', 'b', 'c', 'd'})); + QCOMPARE(refusal<PSDError>([] { PSDChannelCoder::decode(0, 2, 2, QByteArray("abc")); }), kind); + QCOMPARE(refusal<PSDError>([] { PSDChannelCoder::decode(2, 2, 2, QByteArray("abcd")); }), std::optional(PSDError::Kind::unsupportedCompression)); + QVERIFY(PSDChannelCoder::decode(2, 0, 2, QByteArray()).empty()); +} + +void PSDReaderTests::planesPremultiplyAndMasksCopy() +{ + const QImage image = PSDChannelCoder::rgbaImage(1, 1, {200}, {255}, {0}, {128}); + QCOMPARE(image.format(), QImage::Format_RGBA8888_Premultiplied); + QVERIFY(image.colorSpace() == QColorSpace(QColorSpace::SRgb)); + const uchar *pixel = image.constScanLine(0); + QCOMPARE((std::vector<uchar>(pixel, pixel + 4)), (std::vector<uchar>{100, 128, 0, 128})); + const QImage mask = PSDChannelCoder::maskImage(3, 2, {1, 2, 3, 4, 5, 6}); + QCOMPARE(mask.format(), QImage::Format_Grayscale8); + QCOMPARE(mask.constScanLine(1)[2], uchar(6)); + // A missing plane reads black, or opaque for alpha. + Record colourless; + colourless.bounds = QRect(0, 0, 1, 1); + colourless.channels = {{0, QByteArray("\0\0\xff", 3)}}; + const QImage red = PSDReader::read(PSDReaderFixtures::file({colourless})).layers.at(0).image.value(); + QCOMPARE(red.pixel(0, 0), qRgba(255, 0, 0, 255)); +} + +void PSDReaderTests::namesReadMacRomanAndPreferUnicode() +{ + Record roman = pixels(QByteArray("Caf\x8e \xa5", 6)); + Record unicode = pixels("ignored"); + unicode.extras.push_back({"8BIM", "luni", PSDReaderFixtures::unicode(QString::fromUtf8("\0Übung\0\0", 9))}); + Record empty = pixels(""); + Record noUnits = pixels("Kept"); + noUnits.extras.push_back({"8BIM", "luni", QByteArray(4, '\0')}); + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({roman, unicode, empty, noUnits})); + QCOMPARE(document.layers[0].name, QString::fromUtf8("Café •")); + QCOMPARE(document.layers[1].name, QString::fromUtf8("Übung")); + QCOMPARE(document.layers[2].name, QString("Layer")); + QCOMPARE(document.layers[3].name, QString("Kept")); +} + +void PSDReaderTests::fillMultipliesOpacityUnlessEffectsKeepIt() +{ + Record filled = pixels(); + filled.opacity = 128; + filled.extras.push_back({"8BIM", "iOpa", QByteArray("\x80", 1)}); + Record effects = pixels(); + effects.extras = {{"8BIM", "iOpa", QByteArray("\x80", 1)}, {"8BIM", "lfx2", QByteArray("fx")}}; + Record full = pixels(); + full.extras = {{"8BIM", "iOpa", QByteArray("\xff", 1)}, {"8BIM", "lrFX", QByteArray("fx")}}; + full.opacity = 51; + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({filled, effects, full})); + QCOMPARE(document.layers[0].opacity, (128 / 255.0) * (128 / 255.0)); + QCOMPARE(document.layers[1].opacity, 1.0); + QVERIFY(document.layers[1].kind == PSDLayerKind::effects); + QCOMPARE(document.layers[2].opacity, 0.2); + QCOMPARE(messages(PSDDocumentBuilder::makeImport(document)), + (std::vector<QString>{"Layer: Layer effects were discarded, so the appearance may differ.", + "Layer: Layer effects were discarded, so the appearance may differ."})); +} + +void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels() +{ + std::vector<Record> records; + for (const uchar flags : {0, 1, 2, 8}) { + Record record = pixels(); + record.mask = QRect(1, 1, 3, 1); + record.maskFlags = flags; + record.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30", 5)}); + records.push_back(record); + } + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file(records)); + const QImage mask = document.layers[0].mask.value(); + QCOMPARE(mask.size(), QSize(3, 1)); + QCOMPARE((std::vector<uchar>(mask.constScanLine(0), mask.constScanLine(0) + 3)), (std::vector<uchar>{0x10, 0x20, 0x30})); + QVERIFY(document.layers[0].maskEnabled && document.layers[0].maskLinked); + QVERIFY(document.layers[1].maskEnabled && !document.layers[1].maskLinked); + QVERIFY(!document.layers[2].maskEnabled && document.layers[2].maskLinked); + QVERIFY(!document.layers[3].mask); + const PSDImport imported = PSDDocumentBuilder::makeImport(document); + QVERIFY(imported.layers[1].mask.value().isEnabled && !imported.layers[1].mask.value().isLinked); + QVERIFY(!imported.layers[2].mask.value().isEnabled); + // A mask that is not gray is reported, left off. + PSDDocument coloured = document; + coloured.layers[0].mask = PSDFixture::colorImage(3, 1, 1, 1, 1); + const PSDImport skipped = PSDDocumentBuilder::makeImport(coloured); + QVERIFY(!skipped.layers[0].mask); + QCOMPARE(messages(skipped), std::vector<QString>{"Layer: The layer mask couldn’t be converted to 8-bit grayscale and was skipped."}); +} + +void PSDReaderTests::longBlocksReadAndRefuseWildLengths() +{ + Record wide = pixels("pascal"); + wide.extras.push_back({"8B64", "luni", PSDReaderFixtures::unicode("Long")}); + QCOMPARE(PSDReader::read(PSDReaderFixtures::file({wide})).layers[0].name, QString("Long")); + wide.extras[0].high = 0x80000000; + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({wide})); }), std::optional(ImageImportError::Kind::tooLarge)); + wide.extras[0].high = 1; + QCOMPARE(refusal<PSDError>([&] { PSDReader::read(PSDReaderFixtures::file({wide})); }), std::optional(PSDError::Kind::truncated)); + // A block of another signature ends the extras. + Record odd = pixels("pascal"); + odd.extras = {{"XXXX", "luni", PSDReaderFixtures::unicode("Never")}}; + QCOMPARE(PSDReader::read(PSDReaderFixtures::file({odd})).layers[0].name, QString("pascal")); +} + +void PSDReaderTests::resolutionComesFromItsResource() +{ + const auto dpi = [](const QByteArray &resources) { return PSDReader::read(PSDReaderFixtures::file({pixels()}, resources)).resolution; }; + QCOMPARE(dpi(QByteArray()), 72.0); + QCOMPARE(dpi(resolution(300)), 300.0); + QCOMPARE(dpi(resolution(0.5)), 72.0); + QCOMPARE(dpi(resolution(20'000)), 9600.0); + // Odd names and bodies are padded before the next resource. + QCOMPARE(dpi(resource(1000, "ab", "xyz") + resolution(150)), 150.0); + QCOMPARE(dpi(resource(1005, QByteArray(), "abc") + resolution(150)), 150.0); +} + +void PSDReaderTests::layerCountsAndSections() +{ + const QByteArray noLayers = PSDReaderFixtures::header(8, 8) + QByteArray(8, '\0'); + const PSDDocument empty = PSDReader::read(noLayers); + QVERIFY(empty.layers.empty()); + QCOMPARE(empty.width, 8); + // A negative count reads as its size. + QCOMPARE(PSDReader::read(PSDReaderFixtures::file({pixels()}, QByteArray(), 8, 8, -1)).layers.size(), size_t(1)); + QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'001)); }), + std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'000)); }), std::optional(PSDError::Kind::truncated)); + QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::header(8, 8)); }), std::optional(PSDError::Kind::truncated)); +} + +void PSDReaderTests::foldersPairWithTheirDividers() +{ + Record closed = folder(2, "Closed"); + closed.extras[0].key = "lsdk"; + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({folder(3), pixels("Inside"), closed, folder(1, "Lone", "mul ")})); + QCOMPARE(document.layers.size(), size_t(3)); + QVERIFY(document.layers[1].isGroup && document.layers[1].blendKey == "pass"); + QCOMPARE(document.layers[0].parentID, std::optional(document.layers[1].id)); + QCOMPARE(document.layers[1].bounds, QRectF(0, 0, 8, 8)); + QVERIFY(!document.layers[1].image); + QVERIFY(document.layers[2].isGroup && !document.layers[2].parentID); + QCOMPARE(document.layers[2].blendKey, QString("mul ")); + QCOMPARE(messages(PSDDocumentBuilder::makeImport(document)), + std::vector<QString>{"Lone: Folder blend mode “mul ” isn’t supported. The folder will be pass-through."}); + QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::file({folder(3), pixels()})); }), std::optional(PSDError::Kind::truncated)); +} + +void PSDReaderTests::kindsReportWhatTheImportConverts() +{ + Record text = pixels("Words"); + text.extras.push_back({"8BIM", "TySh", QByteArray("t")}); + Record smart = pixels("Smart"); + smart.extras.push_back({"8BIM", "SoLd", QByteArray("s")}); + Record outline = pixels("Outline"); + outline.extras.push_back({"8BIM", "vsms", QByteArray("v")}); + Record exposure; + exposure.name = "Exposure"; + exposure.extras.push_back({"8BIM", "expA", QByteArray("e")}); + Record weird = pixels("Weird"); + weird.blendKey = "\xff\xff\xff\xff"; + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({text, smart, outline, exposure, weird})); + QVERIFY(document.layers[0].kind == PSDLayerKind::text); + QVERIFY(document.layers[1].kind == PSDLayerKind::smartObject); + QVERIFY(document.layers[2].kind == PSDLayerKind::vector); + QVERIFY(document.layers[3].kind == PSDLayerKind::adjustment && !document.layers[3].adjustment); + // A key that is no ASCII reads as nothing. + QCOMPARE(document.layers[4].blendKey, QString()); + const PSDImport imported = PSDDocumentBuilder::makeImport(document); + QCOMPARE(messages(imported), (std::vector<QString>{ + "Words: Editable Photoshop text becomes pixels and can’t be retyped.", + "Smart: The smart object was rasterized. Linked contents can’t be edited.", + "Outline: Vector shape was rasterized to pixels.", + "Exposure: This adjustment type isn’t supported and was skipped.", + "Weird: Blend mode “” isn’t supported and will be applied as Normal.", + })); + QCOMPARE(imported.layers.size(), size_t(4)); + QCOMPARE(imported.layers[3].name, QString("Weird")); +} + +void PSDReaderTests::clippingNeedsAPixelBaseBelow() +{ + Record bottom = pixels("Bottom"); + bottom.clipping = true; + Record under = pixels("Under"); + Record levels; + levels.name = "Levels"; + levels.extras.push_back({"8BIM", "levl", QByteArray(292, '\0')}); + Record onLevels = pixels("On Levels"); + onLevels.clipping = true; + Record base = pixels("Base"); + Record first = pixels("First"); + first.clipping = true; + Record second = pixels("Second"); + second.clipping = true; + // An adjustment between parts clipping from the base below. + const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({bottom, under, levels, onLevels, base, first, second})); + const PSDImport imported = PSDDocumentBuilder::makeImport(document); + QVERIFY(imported.layers[2].adjustment.has_value()); + QVERIFY(!imported.layers[0].maskSourceID && !imported.layers[3].maskSourceID); + QCOMPARE(imported.layers[5].maskSourceID, std::optional(imported.layers[4].id)); + QCOMPARE(imported.layers[6].maskSourceID, std::optional(imported.layers[4].id)); + const QString skipped = QStringLiteral(": This clipping mask’s base isn’t supported, so clipping was skipped."); + QCOMPARE(messages(imported), (std::vector<QString>{"Levels: Adjustment parameters may not match Photoshop exactly.", "Bottom" + skipped, + "On Levels" + skipped})); +} + +void PSDReaderTests::levelsCurvesAndHueParse() +{ + PSDFixture::Buffer levels; + levels.u16(2); + for (int channel = 0; channel < 4; ++channel) { + for (const quint16 value : {10, 240, 5, 250, 512}) + levels.u16(quint16(value + channel)); + } + levels.bytes(QByteArray(292 - levels.data.size(), '\0')); + const LayerAdjustment level = PSDAdjustments::parse({{"levl", levels.data}}).value(); + QVERIFY(level.kind == AdjustmentKind::levels); + QCOMPARE(level.levels.ranges[0], (LevelRange{10, 2, 240, 5, 250})); + QCOMPARE(level.levels.ranges[3], (LevelRange{13, 515 / 256.0, 243, 8, 253})); + QVERIFY(!PSDAdjustments::parse({{"levl", levels.data.left(291)}})); + PSDFixture::Buffer curves; + curves.u8(0); + curves.u16(1); + curves.u16(1); + curves.u16(2); + for (const quint16 value : {255, 200, 0, 50}) + curves.u16(value); + const LayerAdjustment curve = PSDAdjustments::parse({{"curv", curves.data}}).value(); + QVERIFY(curve.kind == AdjustmentKind::curves); + QCOMPARE(curve.curves.channels[0], (std::vector<CurvePoint>{{0, 0}, {50, 0}, {200, 255}, {255, 255}})); + QCOMPARE(curve.curves.channels[1], (std::vector<CurvePoint>{{0, 0}, {255, 255}})); + QByteArray version = curves.data; + version[2] = 2; + QVERIFY(!PSDAdjustments::parse({{"curv", version}})); + QVERIFY(!PSDAdjustments::parse({{"curv", curves.data.left(curves.data.size() - 1)}})); + QVERIFY(!PSDAdjustments::parse({{"curv", QByteArray("\0\0\x01\0", 4)}})); + PSDFixture::Buffer twice; + twice.u8(0); + twice.u16(4); + twice.u16(1); + twice.u16(2); + for (const quint16 value : {10, 50, 20, 50}) + twice.u16(value); + QVERIFY(!PSDAdjustments::parse({{"curv", twice.data}})); + PSDFixture::Buffer hue; + hue.u16(0); + hue.u8(1); + hue.u8(0); + for (const qint16 value : {30, -20, 10, -5, 40, 0}) + hue.i16(value); + const LayerAdjustment shift = PSDAdjustments::parse({{"hue ", hue.data}}).value(); + QVERIFY(shift.kind == AdjustmentKind::hsv); + const HueSaturationSettings settings = shift.hsvSettings.value(); + QVERIFY(settings.colorize); + QCOMPARE(settings.adjustments.at(ColorRange::master), (RangeAdjustment{30, -20, 10})); + QCOMPARE(settings.adjustments.at(ColorRange::reds), (RangeAdjustment{-5, 40, 0})); + QVERIFY(!settings.adjustments.contains(ColorRange::yellows)); + // `hue2` comes before `hue `. + PSDFixture::Buffer plain; + plain.u32(0); + QVERIFY(!PSDAdjustments::parse({{"hue2", plain.data}, {"hue ", hue.data}}).value().hsvSettings.value().colorize); + QVERIFY(!PSDAdjustments::parse({{"hue ", QByteArray("\0\0\1", 3)}})); + QVERIFY(!PSDAdjustments::parse({{"expA", hue.data}})); +} + +QTEST_GUILESS_MAIN(PSDReaderTests) +#include "PSDReaderTests.moc" diff --git a/tests/PSDRoundTripTests.cpp b/tests/PSDRoundTripTests.cpp new file mode 100644 index 0000000..998a9a2 --- /dev/null +++ b/tests/PSDRoundTripTests.cpp @@ -0,0 +1,297 @@ +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include "PSDFixture.h" +#include <QTemporaryDir> +#include <QtTest> + +// Swift's PSDRoundTripTests: files written by the fixture, read back. +namespace { +QByteArray header(quint16 version = 1, quint32 width = 8, quint32 height = 8, quint16 depth = 8, quint16 mode = 3) +{ + PSDFixture::Buffer data; + data.string("8BPS"); + data.u16(version); + data.bytes(QByteArray(6, '\0')); + data.u16(3); + data.u32(height); + data.u32(width); + data.u16(depth); + data.u16(mode); + return data.data; +} + +QByteArray rawChannel(const QByteArray &plane) +{ + return QByteArray("\0\0", 2) + plane; +} + +// One layer "Big" at the origin with the given channels. +QByteArray layerFile(qint32 layerWidth, qint32 layerHeight, const std::vector<PSDFixture::Channel> &channels, quint32 canvasWidth = 8, + quint32 canvasHeight = 8) +{ + PSDFixture::Buffer data; + data.bytes(header(1, canvasWidth, canvasHeight)); + data.u32(0); + data.u32(0); + PSDFixture::Buffer records, payloads; + records.i16(1); + records.i32(0); + records.i32(0); + records.i32(layerHeight); + records.i32(layerWidth); + records.u16(quint16(channels.size())); + for (const PSDFixture::Channel &channel : channels) { + records.i16(channel.id); + records.u32(quint32(channel.payload.size())); + payloads.bytes(channel.payload); + } + records.string("8BIMnorm"); + records.bytes(QByteArray("\xff\0\0\0", 4)); + records.u32(12); + records.u32(0); + records.u32(0); + records.u8(3); + records.string("Big"); + PSDFixture::Buffer info; + info.u32(quint32(records.data.size() + payloads.data.size())); + info.bytes(records.data); + info.bytes(payloads.data); + info.u32(0); + data.u32(quint32(info.data.size())); + data.bytes(info.data); + return data.data; +} + +std::vector<quint32> lsctTypes(const QByteArray &data) +{ + std::vector<quint32> types; + qsizetype search = 0; + for (qsizetype found = data.indexOf("lsct", search); found >= 0 && found + 12 <= data.size(); found = data.indexOf("lsct", search)) { + const qsizetype type = found + 8; + types.push_back(quint32(uchar(data[type])) << 24 | quint32(uchar(data[type + 1])) << 16 | quint32(uchar(data[type + 2])) << 8 | uchar(data[type + 3])); + search = found + 4; + } + return types; +} + +ImageLayer named(const PSDImport &imported, const QString &name) +{ + const auto found = std::find_if(imported.layers.begin(), imported.layers.end(), [&](const ImageLayer &layer) { return layer.name == name; }); + if (found == imported.layers.end()) + throw std::runtime_error("no layer named " + name.toStdString()); + return *found; +} + +template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const Error &error) { + return error.kind; + } + return std::nullopt; +} +} + +class PSDRoundTripTests : public QObject { + Q_OBJECT +private slots: + void roundTripLayersOrderVisibilityOpacityAndBlend(); + void roundTripGroupsMasksAndClipping(); + void importedGroupsFollowPhotoshopLsctOrder(); + void oversizedLayerBoundsAreRejected(); + void unusedSpotChannelsAreSkippedBeforeDecode(); + void unsupportedCompressionOnColorChannelsIsStillRejected(); + void matchesRequiresPhotoshopMagic(); + void unknownBlendProducesConversionReport(); + void softLightImportsWithoutConversion(); + void folderOpacityImportsOntoTheFolder(); + void unsupportedHeadersAreRejected(); +}; + +void PSDRoundTripTests::roundTripLayersOrderVisibilityOpacityAndBlend() +{ + PSDRecord bottom = PSDFixture::record("Red", PSDFixture::colorImage(2, 2, 1, 0, 0), QRectF(0, 0, 2, 2)); + bottom.opacity = 0.5; + bottom.blendKey = "mul "; + PSDRecord top = PSDFixture::record("Blue", PSDFixture::colorImage(2, 2, 0, 0, 1), QRectF(2, 0, 2, 2)); + top.isVisible = false; + const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 144, {bottom, top}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0)); + QVERIFY(data.startsWith("8BPS")); + const PSDDocument document = PSDReader::read(data); + QCOMPARE(document.width, 4); + QCOMPARE(document.height, 4); + QCOMPARE(document.resolution, 144.0); + QCOMPARE(document.layers.size(), size_t(2)); + QCOMPARE(document.layers[0].name, QString("Red")); + QCOMPARE(document.layers[1].name, QString("Blue")); + QVERIFY(document.layers[0].isVisible && !document.layers[1].isVisible); + QVERIFY(std::abs(document.layers[0].opacity - 0.5) < 0.01); + QCOMPARE(document.layers[0].blendKey, QString("mul ")); + QCOMPARE(document.layers[0].image.value().width(), 2); + // The pixels come back as written, premultiplied. + QCOMPARE(document.layers[0].image.value().pixel(1, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(document.layers[0].bounds, QRectF(0, 0, 2, 2)); + QCOMPARE(document.layers[1].bounds, QRectF(2, 0, 2, 2)); + const PSDImport imported = PSDDocumentBuilder::makeImport(document); + QVERIFY(imported.conversions.empty()); + QCOMPARE(imported.layers.size(), size_t(2)); + QCOMPARE(imported.layers[0].name, QString("Red")); + QCOMPARE(imported.layers[1].name, QString("Blue")); + QVERIFY(imported.layers[0].blendMode == LayerBlendMode::multiply); + QVERIFY(!imported.layers[1].isVisible); + QCOMPARE(imported.layers[1].origin(), QPointF(2, 0)); + QCOMPARE(imported.layers[1].size(), QSizeF(2, 2)); + QCOMPARE(imported.layers[0].id, document.layers[0].id); + QCOMPARE(imported.layers[0].asset.value().name, QString("Red")); + QVERIFY(!imported.layers[0].asset.value().thumbnail.isNull()); +} + +void PSDRoundTripTests::roundTripGroupsMasksAndClipping() +{ + const QUuid groupID = QUuid::createUuid(); + PSDRecord group = PSDFixture::record("Stack"); + group.id = groupID; + group.isGroup = true; + group.blendKey = "pass"; + PSDRecord base = PSDFixture::record("Base", PSDFixture::colorImage(2, 2, 0, 1, 0), QRectF(0, 0, 2, 2)); + base.parentID = groupID; + base.mask = PSDFixture::grayImage(2, 2, 255); + PSDRecord child = PSDFixture::record("Clipped", PSDFixture::colorImage(2, 2, 1, 1, 0), QRectF(0, 0, 2, 2)); + child.parentID = groupID; + child.clipping = true; + const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 72, {group, base, child}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0)); + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(data)); + QVERIFY(imported.conversions.empty()); + const ImageLayer folder = named(imported, "Stack"); + QVERIFY(folder.isGroup); + QCOMPARE(folder.size(), QSizeF(4, 4)); + const ImageLayer importedBase = named(imported, "Base"), importedChild = named(imported, "Clipped"); + QCOMPARE(importedBase.parentID, std::optional(folder.id)); + QCOMPARE(importedChild.parentID, std::optional(folder.id)); + QVERIFY(importedBase.mask.has_value()); + QCOMPARE(importedBase.mask.value().asset.image().pixel(0, 0), qRgb(255, 255, 255)); + QCOMPARE(importedChild.maskSourceID, std::optional(importedBase.id)); +} + +void PSDRoundTripTests::importedGroupsFollowPhotoshopLsctOrder() +{ + PSDRecord group = PSDFixture::record("Stack"); + group.isGroup = true; + PSDRecord child = PSDFixture::record("Base", PSDFixture::colorImage(2, 2, 0, 1, 0), QRectF(0, 0, 2, 2)); + child.parentID = group.id; + const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 72, {group, child}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0)); + QCOMPARE(lsctTypes(data), (std::vector<quint32>{3, 1})); + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(data)); + const ImageLayer folder = named(imported, "Stack"); + QVERIFY(folder.isGroup); + QCOMPARE(named(imported, "Base").parentID, std::optional(folder.id)); +} + +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)); + 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)); + // The budget holds at its edge: 400 read, 399 not. + QCOMPARE(PSDReader::read(data, 400).layers.size(), size_t(1)); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 399); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDRoundTripTests::unusedSpotChannelsAreSkippedBeforeDecode() +{ + std::vector<PSDFixture::Channel> channels; + for (const qint16 id : {-1, 0, 1, 2}) + channels.push_back({id, rawChannel(QByteArray(4, '\xff'))}); + // Compression 99 throws if unpacked; 56 channels at most. + for (qint16 id = 3; id <= 54; ++id) + channels.push_back({id, QByteArray("\0\x63\0\0", 4)}); + const PSDDocument document = PSDReader::read(layerFile(2, 2, channels)); + QCOMPARE(document.layers.size(), size_t(1)); + QCOMPARE(document.layers[0].image.value().size(), QSize(2, 2)); + QCOMPARE(document.layers[0].name, QString("Big")); + channels.push_back({55, QByteArray("\0\0", 2)}); + QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(layerFile(2, 2, channels)); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDRoundTripTests::unsupportedCompressionOnColorChannelsIsStillRejected() +{ + const QByteArray pixels(4, '\xff'); + const std::vector<PSDFixture::Channel> channels{{-1, rawChannel(pixels)}, {0, QByteArray("\0\x63\0\0", 4)}, {1, rawChannel(pixels)}, {2, rawChannel(pixels)}}; + QCOMPARE(refusal<PSDError>([&] { PSDReader::read(layerFile(2, 2, channels)); }), std::optional(PSDError::Kind::unsupportedCompression)); +} + +void PSDRoundTripTests::matchesRequiresPhotoshopMagic() +{ + const QTemporaryDir folder; + QFile jpeg(folder.filePath("picture.psd")); + QVERIFY(jpeg.open(QIODevice::WriteOnly) && jpeg.write("\xff\xd8\xff\xe0", 4) == 4); + jpeg.close(); + QVERIFY(!PSDReader::matches(jpeg.fileName())); + QFile psd(folder.filePath("picture.bin")); + QVERIFY(psd.open(QIODevice::WriteOnly) && psd.write("8BPS") == 4); + psd.close(); + QVERIFY(PSDReader::matches(psd.fileName())); + QVERIFY(!PSDReader::matches(folder.filePath("missing.psd"))); + QVERIFY(PSDReader::matches(QByteArray("8BPS")) && !PSDReader::matches(QByteArray("8BP"))); +} + +void PSDRoundTripTests::unknownBlendProducesConversionReport() +{ + const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0); + PSDRecord layer = PSDFixture::record("Vivid", fill, QRectF(0, 0, 2, 2)); + layer.blendKey = "vLit"; + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, fill))); + QCOMPARE(imported.conversions.size(), size_t(1)); + QCOMPARE(imported.conversions[0].layerName, QString("Vivid")); + QCOMPARE(imported.conversions[0].message, QString("Blend mode “vLit” isn’t supported and will be applied as Normal.")); + QVERIFY(imported.layers[0].blendMode == LayerBlendMode::normal); +} + +void PSDRoundTripTests::softLightImportsWithoutConversion() +{ + const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0); + PSDRecord layer = PSDFixture::record("Soft", fill, QRectF(0, 0, 2, 2)); + layer.blendKey = "sLit"; + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, fill))); + QVERIFY(imported.conversions.empty()); + QVERIFY(imported.layers[0].blendMode == LayerBlendMode::softLight); +} + +void PSDRoundTripTests::folderOpacityImportsOntoTheFolder() +{ + const QImage fill = PSDFixture::colorImage(2, 2, 0, 1, 0); + PSDRecord group = PSDFixture::record("Stack"); + group.isGroup = true; + group.opacity = 0.5; + PSDRecord child = PSDFixture::record("Base", fill, QRectF(0, 0, 2, 2)); + child.parentID = group.id; + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{4, 4, 72, {group, child}}, fill))); + // One byte of opacity: half comes back as 128/255. + QCOMPARE(named(imported, "Stack").opacity, 128 / 255.0); + QVERIFY(imported.conversions.empty()); +} + +void PSDRoundTripTests::unsupportedHeadersAreRejected() +{ + QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(2)); }), 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)); + QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 10'001, 10'000)); }), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 0, 10)); }), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(QByteArray("8BPX")); }), std::optional(ImageImportError::Kind::unreadable)); + 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::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.")); +} + +QTEST_GUILESS_MAIN(PSDRoundTripTests) +#include "PSDRoundTripTests.moc" diff --git a/tests/PSDVectorBuilders.h b/tests/PSDVectorBuilders.h new file mode 100644 index 0000000..04744bf --- /dev/null +++ b/tests/PSDVectorBuilders.h @@ -0,0 +1,110 @@ +#pragma once +#include "PSDFixture.h" +#include <bit> + +// Photoshop's vector extras built byte by byte, as Swift's. +namespace PSDVectorBuilders { +inline void appendDouble(QByteArray &data, double value) +{ + PSDFixture::Buffer bits; + const quint64 raw = std::bit_cast<quint64>(value); + bits.u32(quint32(raw >> 32)); + bits.u32(quint32(raw)); + data.append(bits.data); +} + +inline void appendUnit(QByteArray &data, const char *key, double value) +{ + data.append(key); + data.append("UntF#Pxl"); + appendDouble(data, value); +} + +inline QByteArray originationData(quint32 type, const QRectF &rect, const std::vector<double> &radii = {}) +{ + QByteArray data("keyOriginTypelong"); + PSDFixture::Buffer kind; + kind.u32(type); + data.append(kind.data); + data.append("keyOriginShapeBBox"); + appendUnit(data, "Left", rect.left()); + appendUnit(data, "Top ", rect.top()); + appendUnit(data, "Rght", rect.left() + rect.width()); + appendUnit(data, "Btom", rect.top() + rect.height()); + if (radii.size() == 4) { + data.append("keyOriginRRectRadii"); + const char *keys[] = {"topLeft", "topRight", "bottomRight", "bottomLeft"}; + for (size_t index = 0; index < 4; ++index) + appendUnit(data, keys[index], radii[index]); + } + return data; +} + +struct Subpath { + std::vector<QPointF> corners; + bool closed = true; + // The knot count the record declares, else the corners'. + std::optional<int> count = std::nullopt; + // Handles this far either side of each anchor. + QPointF handle = QPointF(); +}; + +// Subpaths of knots, in Photoshop's fractions of the canvas. +inline QByteArray vectorMask(QSizeF canvas, const std::vector<Subpath> &subpaths) +{ + PSDFixture::Buffer data; + data.u32(3); + data.u32(0); + data.i16(6); + data.bytes(QByteArray(24, '\0')); + data.i16(8); + data.bytes(QByteArray(24, '\0')); + const auto point = [&](QPointF at) { + data.u32(quint32(qint32(at.y() / canvas.height() * 0x1000000))); + data.u32(quint32(qint32(at.x() / canvas.width() * 0x1000000))); + }; + for (const Subpath &subpath : subpaths) { + data.i16(subpath.closed ? 0 : 3); + data.i16(qint16(subpath.count.value_or(int(subpath.corners.size())))); + data.bytes(QByteArray(22, '\0')); + for (const QPointF corner : subpath.corners) { + data.i16(1); + point(corner - subpath.handle); + point(corner); + point(corner + subpath.handle); + } + } + return data.data; +} + +inline QByteArray vectorMask(QSizeF canvas, const std::vector<QPointF> &corners) +{ + return vectorMask(canvas, {Subpath{corners}}); +} + +inline QByteArray colorDescriptor(double red, double green, double blue) +{ + QByteArray data("RGBC"); + for (const auto &[key, value] : {std::pair("Rd ", red), {"Grn ", green}, {"Bl ", blue}}) { + data.append(key); + data.append("doub"); + appendDouble(data, value); + } + return data; +} + +inline QByteArray strokeStyle(bool fill, bool stroke, double width, double red, double green, double blue) +{ + QByteArray data; + for (const auto &[key, value] : {std::pair("strokeEnabled", stroke), {"fillEnabled", fill}}) { + data.append(key); + data.append("bool"); + data.append(char(value ? 1 : 0)); + } + data.append("strokeStyleLineWidthUntF#Pxl"); + appendDouble(data, width); + data.append(colorDescriptor(red, green, blue)); + return data; +} + +} diff --git a/tests/PSDVectorFixtures.h b/tests/PSDVectorFixtures.h new file mode 100644 index 0000000..4669d70 --- /dev/null +++ b/tests/PSDVectorFixtures.h @@ -0,0 +1,61 @@ +#pragma once +#include <QByteArray> +#include <QSizeF> +#include <map> +#include <QString> + +// Photoshop shape extras, as Swift's PSDVectorFixtures holds them. +namespace PSDVectorFixtures { +inline const QSizeF canvas(1920, 1080); + +inline std::map<QString, QByteArray> circle() +{ + return { + {QStringLiteral("vmsk"), QByteArray::fromBase64( + "AAAAAwAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAABAAEAAAAAAAAAAAAAAAAAAA" + "AAAAAAAQDHWSAAXn4FAMdZIABSZmYAx1kgAEZOxwABALX4sAA8iIgAoHlcADyIiACK+gkAPIiIAAEAeZmZAEZOxwB5mZkAUmZmAHmZmQBefgUA" + "AQCK+gkAaEREAKB5XABoREQAtfiwAGhERAAA")}, + {QStringLiteral("SoCo"), QByteArray::fromBase64( + "AAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YgAAAAAAAAAAAAAAAEdybiBkb3" + "ViQFuAAAAAAAAAAAAAQmwgIGRvdWJAb+AAAAAAAA==")}, + {QStringLiteral("vstk"), QByteArray::fromBase64( + "AAAAEAAAAAEAAAAAAAtzdHJva2VTdHlsZQAAABAAAAASc3Ryb2tlU3R5bGVWZXJzaW9ubG9uZwAAAAIAAAANc3Ryb2tlRW5hYmxlZGJvb2wAAA" + "AAC2ZpbGxFbmFibGVkYm9vbAEAAAAUc3Ryb2tlU3R5bGVMaW5lV2lkdGhVbnRGI1B4bEAjvQfpGNNeAAAAGXN0cm9rZVN0eWxlTGluZURhc2hP" + "ZmZzZXRVbnRGI1BudAAAAAAAAAAAAAAAFXN0cm9rZVN0eWxlTWl0ZXJMaW1pdGRvdWJAWQAAAAAAAAAAABZzdHJva2VTdHlsZUxpbmVDYXBUeX" + "BlZW51bQAAABZzdHJva2VTdHlsZUxpbmVDYXBUeXBlAAAAEnN0cm9rZVN0eWxlQnV0dENhcAAAABdzdHJva2VTdHlsZUxpbmVKb2luVHlwZWVu" + "dW0AAAAXc3Ryb2tlU3R5bGVMaW5lSm9pblR5cGUAAAAUc3Ryb2tlU3R5bGVNaXRlckpvaW4AAAAYc3Ryb2tlU3R5bGVMaW5lQWxpZ25tZW50ZW" + "51bQAAABhzdHJva2VTdHlsZUxpbmVBbGlnbm1lbnQAAAAWc3Ryb2tlU3R5bGVBbGlnbkNlbnRlcgAAABRzdHJva2VTdHlsZVNjYWxlTG9ja2Jv" + "b2wAAAAAF3N0cm9rZVN0eWxlU3Ryb2tlQWRqdXN0Ym9vbAAAAAAWc3Ryb2tlU3R5bGVMaW5lRGFzaFNldFZsTHMAAAAAAAAAFHN0cm9rZVN0eW" + "xlQmxlbmRNb2RlZW51bQAAAABCbG5NAAAAAE5ybWwAAAASc3Ryb2tlU3R5bGVPcGFjaXR5VW50RiNQcmNAWQAAAAAAAAAAABJzdHJva2VTdHls" + "ZUNvbnRlbnRPYmpjAAAAAQAAAAAAD3NvbGlkQ29sb3JMYXllcgAAAAEAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdW" + "JAb+AAAAAAAAAAAABHcm4gZG91YkBv4AAAAAAAAAAAAEJsICBkb3ViAAAAAAAAAAAAAAAVc3Ryb2tlU3R5bGVSZXNvbHV0aW9uZG91YkBSAAAA" + "AAAA")}, + }; +} + +inline std::map<QString, QByteArray> rectangle() +{ + return { + {QStringLiteral("vmsk"), QByteArray::fromBase64( + "AAAAAwAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAABAAEAAAAAAAAAAAAAAAAAAA" + "AAAAAAAQAkREQA1CIiACRERADUIiIAJEREANQiIgABAE9oSwDUIiIAT2hLANQiIgBPaEsA1CIiAAEAT2hLAH4AAABPaEsAfgAAAE9oSwB+AAAA" + "AQAkREQAfgAAACRERAB+AAAAJEREAH4AAAAA")}, + {QStringLiteral("SoCo"), QByteArray::fromBase64( + "AAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YgAAAAAAAAAAAAAAAEdybiBkb3" + "ViAAAAAAAAAAAAAAAAQmwgIGRvdWIAAAAAAAAAAA==")}, + {QStringLiteral("vstk"), QByteArray::fromBase64( + "AAAAEAAAAAEAAAAAAAtzdHJva2VTdHlsZQAAABAAAAASc3Ryb2tlU3R5bGVWZXJzaW9ubG9uZwAAAAIAAAANc3Ryb2tlRW5hYmxlZGJvb2wBAA" + "AAC2ZpbGxFbmFibGVkYm9vbAEAAAAUc3Ryb2tlU3R5bGVMaW5lV2lkdGhVbnRGI1B4bEAjvQfpGNNeAAAAGXN0cm9rZVN0eWxlTGluZURhc2hP" + "ZmZzZXRVbnRGI1BudAAAAAAAAAAAAAAAFXN0cm9rZVN0eWxlTWl0ZXJMaW1pdGRvdWJAWQAAAAAAAAAAABZzdHJva2VTdHlsZUxpbmVDYXBUeX" + "BlZW51bQAAABZzdHJva2VTdHlsZUxpbmVDYXBUeXBlAAAAEnN0cm9rZVN0eWxlQnV0dENhcAAAABdzdHJva2VTdHlsZUxpbmVKb2luVHlwZWVu" + "dW0AAAAXc3Ryb2tlU3R5bGVMaW5lSm9pblR5cGUAAAAUc3Ryb2tlU3R5bGVNaXRlckpvaW4AAAAYc3Ryb2tlU3R5bGVMaW5lQWxpZ25tZW50ZW" + "51bQAAABhzdHJva2VTdHlsZUxpbmVBbGlnbm1lbnQAAAAWc3Ryb2tlU3R5bGVBbGlnbkNlbnRlcgAAABRzdHJva2VTdHlsZVNjYWxlTG9ja2Jv" + "b2wAAAAAF3N0cm9rZVN0eWxlU3Ryb2tlQWRqdXN0Ym9vbAAAAAAWc3Ryb2tlU3R5bGVMaW5lRGFzaFNldFZsTHMAAAAAAAAAFHN0cm9rZVN0eW" + "xlQmxlbmRNb2RlZW51bQAAAABCbG5NAAAAAE5ybWwAAAASc3Ryb2tlU3R5bGVPcGFjaXR5VW50RiNQcmNAWQAAAAAAAAAAABJzdHJva2VTdHls" + "ZUNvbnRlbnRPYmpjAAAAAQAAAAAAD3NvbGlkQ29sb3JMYXllcgAAAAEAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdW" + "JAb+AAAAAAAAAAAABHcm4gZG91YkBv4AAAAAAAAAAAAEJsICBkb3ViAAAAAAAAAAAAAAAVc3Ryb2tlU3R5bGVSZXNvbHV0aW9uZG91YkBSAAAA" + "AAAA")}, + }; +} + +} diff --git a/tests/PSDVectorTests.cpp b/tests/PSDVectorTests.cpp new file mode 100644 index 0000000..df4cb5f --- /dev/null +++ b/tests/PSDVectorTests.cpp @@ -0,0 +1,248 @@ +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDVector.h" +#include "PSDFixture.h" +#include "PSDVectorBuilders.h" +#include "PSDVectorFixtures.h" +#include <QtTest> + +// Swift's vector tests: shapes live, masks rasterized, sizes refused. +using namespace PSDVectorBuilders; + +namespace { +std::vector<QPointF> box() +{ + return {QPointF(120, 30), QPointF(120, 80), QPointF(20, 80), QPointF(20, 30)}; +} + +std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const ImageImportError &error) { + return error.kind; + } + return std::nullopt; +} + +PSDRecord shapeRecord(const QString &name, const PSDVector::Live &live) +{ + PSDRecord record = PSDFixture::record(name, live.image, live.bounds); + record.kind = PSDLayerKind::vector; + record.shape = live.style; + record.shapeNotes = live.notes; + return record; +} +} + +class PSDVectorTests : public QObject { + Q_OBJECT +private slots: + void vectorMaskIsRasterizedWithFillAndStroke(); + void photoshopShapeExtrasRasterizeInPlace(); + void fillEllipseImportsAsALiveShape(); + void strokedRectangleImportsAsALiveShapeAndReportsTheStroke(); + void fourSharpCornersInferARectangleWithoutOrigination(); + void hugeOriginationSizeIsRejectedWithoutTrapping(); + void nonFiniteOriginationSizeIsIgnored(); + void hugeStrokeWidthIsRejectedWithoutTrapping(); + void sidesAndBudgetsRefuseAtTheirEdges(); + void knotCountsAndCurvesDecideTheShape(); + void subpathsCloseOpenAndStandApart(); + void fillsWindAntialiasAndMitersKeepTheirTips(); +}; + +void PSDVectorTests::vectorMaskIsRasterizedWithFillAndStroke() +{ + const QSizeF canvas(200, 200); + PSDVector::Extra extra{{"vmsk", vectorMask(canvas, box())}, + {"SoCo", colorDescriptor(0, 110, 255)}, + {"vstk", strokeStyle(true, false, 1, 255, 255, 0)}}; + const PSDVector::Raster raster = PSDVector::raster(extra, canvas).value(); + // Fractions of 2^24 fall short: the box floors outward. + QCOMPARE(raster.bounds, QRectF(19, 29, 101, 51)); + QCOMPARE(raster.image.size(), QSize(101, 51)); + QCOMPARE(raster.image.pixel(50, 25), qRgb(0, 110, 255)); + extra["vstk"] = strokeStyle(true, true, 10, 255, 255, 0); + extra["SoCo"] = colorDescriptor(0, 0, 0); + const PSDVector::Raster stroked = PSDVector::raster(extra, canvas).value(); + // Half the width and a pixel more on each side. + QCOMPARE(stroked.bounds, QRectF(13, 23, 113, 63)); + QCOMPARE(stroked.image.pixel(56, 31), qRgb(0, 0, 0)); + QCOMPARE(stroked.image.pixel(56, 7), qRgb(255, 255, 0)); + // A miter keeps the square corner; a bevel would not. + QCOMPARE(stroked.image.pixel(2, 2), qRgb(255, 255, 0)); +} + +void PSDVectorTests::photoshopShapeExtrasRasterizeInPlace() +{ + const PSDVector::Raster circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value(); + QVERIFY(std::abs(circle.bounds.center().x() - 618) < 8); + QVERIFY(std::abs(circle.bounds.center().y() - 677) < 8); + QVERIFY(std::abs(circle.bounds.width() - 328) < 12); + QVERIFY(std::abs(circle.bounds.height() - 328) < 12); + const PSDVector::Raster rectangle = PSDVector::raster(PSDVectorFixtures::rectangle(), PSDVectorFixtures::canvas).value(); + QVERIFY(rectangle.bounds.width() > 640); + QVERIFY(rectangle.bounds.height() > 170); + QVERIFY(std::abs(rectangle.bounds.center().x() - 1268) < 20); + QVERIFY(std::abs(rectangle.bounds.center().y() - 244) < 20); +} + +void PSDVectorTests::fillEllipseImportsAsALiveShape() +{ + PSDVector::Extra extra = PSDVectorFixtures::circle(); + extra["vogk"] = originationData(5, QRectF(454, 513, 328, 328)); + const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value(); + QVERIFY(live.style.kind == ShapeKind::ellipse); + QVERIFY(std::abs(live.style.green - 110 / 255.0) < 0.01); + QVERIFY(std::abs(live.style.blue - 1) < 0.01); + QVERIFY(live.notes.empty()); + QCOMPARE(live.bounds, QRectF(454, 513, 328, 328)); + QCOMPARE(live.image.size(), QSize(328, 328)); + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("cercle-bleu", live)}}); + QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::ellipse); + QVERIFY(imported.conversions.empty()); + QCOMPARE(imported.layers[0].liveShape().value().image, imported.layers[0].asset.value().identity()); +} + +void PSDVectorTests::strokedRectangleImportsAsALiveShapeAndReportsTheStroke() +{ + PSDVector::Extra extra = PSDVectorFixtures::rectangle(); + extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {0, 0, 0, 0}); + const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value(); + QVERIFY(live.style.kind == ShapeKind::rectangle); + QCOMPARE(live.style.cornerRadius, 0.0); + QCOMPARE(live.notes, std::vector<QString>{"The Photoshop stroke isn’t supported on shape layers and was omitted."}); + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("rectangle-contour-jaune", live)}}); + QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::rectangle); + QCOMPARE(imported.conversions.size(), size_t(1)); + QCOMPARE(imported.conversions[0].layerName, QString("rectangle-contour-jaune")); + QVERIFY(imported.conversions[0].message.contains("stroke")); + // Radii within half a pixel round by the largest. + extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.4, 12, 12}); + QCOMPARE(PSDVector::live(extra, PSDVectorFixtures::canvas).value().style.cornerRadius, 12.4); + // Wider apart they refuse; the four sharp anchors decide instead. + extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.6, 12, 12}); + const PSDVector::Live sharp = PSDVector::live(extra, PSDVectorFixtures::canvas).value(); + QCOMPARE(sharp.style.cornerRadius, 0.0); + QVERIFY(sharp.bounds != QRectF(945, 153, 646, 182)); +} + +void PSDVectorTests::fourSharpCornersInferARectangleWithoutOrigination() +{ + const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())}, + {"SoCo", colorDescriptor(0, 110, 255)}, + {"vstk", strokeStyle(true, false, 1, 255, 255, 0)}}; + const PSDVector::Live live = PSDVector::live(extra, QSizeF(200, 200)).value(); + QVERIFY(live.style.kind == ShapeKind::rectangle); + QVERIFY(live.notes.empty()); + QCOMPARE(live.bounds, QRectF(19, 29, 101, 51)); +} + +void PSDVectorTests::hugeOriginationSizeIsRejectedWithoutTrapping() +{ + const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(0, 0, 1e20, 1e20))}, + {"SoCo", colorDescriptor(0, 110, 255)}, + {"vstk", strokeStyle(true, false, 1, 255, 255, 0)}}; + QCOMPARE(refusal([&] { PSDVector::live(extra, PSDVectorFixtures::canvas); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDVectorTests::nonFiniteOriginationSizeIsIgnored() +{ + const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(10, 10, qInf(), 100))}, + {"SoCo", colorDescriptor(0, 110, 255)}, + {"vstk", strokeStyle(true, false, 1, 255, 255, 0)}}; + QVERIFY(!PSDVector::live(extra, PSDVectorFixtures::canvas)); +} + +void PSDVectorTests::hugeStrokeWidthIsRejectedWithoutTrapping() +{ + const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())}, + {"SoCo", colorDescriptor(0, 0, 0)}, + {"vstk", strokeStyle(true, true, 1e20, 255, 255, 0)}}; + QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDVectorTests::sidesAndBudgetsRefuseAtTheirEdges() +{ + const auto filled = [](QSizeF canvas, const std::vector<QPointF> &corners) { + return PSDVector::Extra{{"vmsk", vectorMask(canvas, corners)}, {"SoCo", colorDescriptor(0, 0, 0)}}; + }; + // A side past 30,000 pixels, however few pixels in all. + const QSizeF wide(40'000, 10); + const PSDVector::Extra long_ = filled(wide, {QPointF(0, 0), QPointF(30'001, 0), QPointF(30'001, 1), QPointF(0, 1)}); + QCOMPARE(refusal([&] { PSDVector::raster(long_, wide); }), std::optional(ImageImportError::Kind::tooLarge)); + // The budget holds at its edge: 101 by 51 pixels. + const PSDVector::Extra extra = filled(QSizeF(200, 200), box()); + QCOMPARE(PSDVector::raster(extra, QSizeF(200, 200), 5151).value().image.size(), QSize(101, 51)); + QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200), 5150); }), std::optional(ImageImportError::Kind::tooLarge)); + // A lone anchor still spends one pixel of the budget. + const PSDVector::Extra dot = filled(QSizeF(200, 200), {QPointF(10.5, 10.5)}); + QCOMPARE(PSDVector::raster(dot, QSizeF(200, 200), 1).value().image.size(), QSize(1, 1)); + QCOMPARE(refusal([&] { PSDVector::raster(dot, QSizeF(200, 200), 0); }), std::optional(ImageImportError::Kind::tooLarge)); + // A stroke narrower than nothing is refused. + const PSDVector::Extra negative{{"vmsk", vectorMask(QSizeF(200, 200), box())}, {"vstk", strokeStyle(false, true, -5, 255, 0, 0)}}; + QCOMPARE(refusal([&] { PSDVector::raster(negative, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge)); +} + +void PSDVectorTests::knotCountsAndCurvesDecideTheShape() +{ + const QSizeF canvas(200, 200); + // A knot past the declared count stays out. + std::vector<QPointF> five = box(); + five.push_back(QPointF(190, 190)); + const PSDVector::Extra counted{{"vmsk", vectorMask(canvas, {Subpath{five, true, 4}})}, {"SoCo", colorDescriptor(0, 110, 255)}}; + QCOMPARE(PSDVector::live(counted, canvas).value().bounds, QRectF(19, 29, 101, 51)); + QCOMPARE(PSDVector::raster(counted, canvas).value().bounds, QRectF(19, 29, 101, 51)); + // Handles off their anchors make curves, which infer no rectangle. + const PSDVector::Extra curved{{"vmsk", vectorMask(canvas, {Subpath{box(), true, std::nullopt, QPointF(5, 0)}})}, {"SoCo", colorDescriptor(0, 110, 255)}}; + QVERIFY(!PSDVector::live(curved, canvas)); + QVERIFY(PSDVector::raster(curved, canvas).value().bounds.width() > 101); +} + +void PSDVectorTests::subpathsCloseOpenAndStandApart() +{ + const QSizeF canvas(200, 200); + const std::vector<QPointF> far{QPointF(190, 150), QPointF(190, 190), QPointF(150, 190), QPointF(150, 150)}; + const auto stroked = [&](const std::vector<Subpath> &subpaths) { + return PSDVector::raster({{"vmsk", vectorMask(canvas, subpaths)}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value(); + }; + const auto ink = [](const PSDVector::Raster &raster, QPointF at) { return qAlpha(raster.image.pixel((at - raster.bounds.topLeft()).toPoint())); }; + // A closed subpath strokes its closing edge; open ones not. + const PSDVector::Raster both = stroked({Subpath{box()}, Subpath{far}}); + QCOMPARE(ink(both, QPointF(70, 30)), 255); + QCOMPARE(ink(both, QPointF(170, 150)), 255); + const PSDVector::Raster open = stroked({Subpath{box(), false}, Subpath{far}}); + QCOMPARE(ink(open, QPointF(70, 30)), 0); + QCOMPARE(ink(open, QPointF(120, 55)), 255); + // Each subpath begins anew: nothing joins them. + QCOMPARE(ink(both, QPointF(105, 90)), 0); +} + +void PSDVectorTests::fillsWindAntialiasAndMitersKeepTheirTips() +{ + const QSizeF canvas(200, 200); + // Two squares turning the same way: their overlap stays filled. + const std::vector<QPointF> first{QPointF(20, 20), QPointF(80, 20), QPointF(80, 80), QPointF(20, 80)}; + const std::vector<QPointF> second{QPointF(50, 50), QPointF(110, 50), QPointF(110, 110), QPointF(50, 110)}; + const PSDVector::Raster overlap = + PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{first}, Subpath{second}})}, {"SoCo", colorDescriptor(0, 0, 0)}}, canvas).value(); + QCOMPARE(qAlpha(overlap.image.pixel((QPointF(65, 65) - overlap.bounds.topLeft()).toPoint())), 255); + // A circle's rim takes partial coverage. + const QImage circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value().image; + int partial = 0; + for (int y = 0; y < circle.height(); ++y) + for (int x = 0; x < circle.width(); ++x) + partial += qAlpha(circle.pixel(x, y)) > 0 && qAlpha(circle.pixel(x, y)) < 255; + QVERIFY2(partial > 200, qPrintable(QString::number(partial))); + // A 30° apex keeps its miter at limit 10. + const double reach = 100 * std::tan(15 * M_PI / 180); + const std::vector<QPointF> apex{QPointF(100, 20), QPointF(100 + reach, 120), QPointF(100 - reach, 120)}; + const std::vector<QPointF> spot{QPointF(100, 0), QPointF(101, 0), QPointF(101, 1), QPointF(100, 1)}; + const PSDVector::Raster tip = + PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{apex}, Subpath{spot}})}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value(); + // The tip's edge crosses this pixel; a bevel clears it. + QVERIFY(qAlpha(tip.image.pixel((QPointF(100, 14) - tip.bounds.topLeft()).toPoint())) > 100); +} + +QTEST_GUILESS_MAIN(PSDVectorTests) +#include "PSDVectorTests.moc"