diff --git a/AGENTS.md b/AGENTS.md
index 736ba5e..9c13ac8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -121,14 +121,15 @@ Platform adaptations, each forced by a missing Qt analog:
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and reaches a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Import. The first preview develops at once, each change after 60 ms (Swift's `task(id:)`), at 800 pixels through the queue on a worker; a newer change drops the older's result, and a failed preview keeps the last one, logged. Left out as dead: LibRaw's size check after identification (`adjust_sizes_info_only` fails only when called before), the second green's multiplier (LibRaw copies the first's), the lower clamp of unsigned samples, and a check of the output's colours and bits (three colours and 16 bits are asked for). Equivalent mutants: a response of exactly zero refused as below zero (a double lands on it only by chance), Import's explicit default (a dialog makes its first auto-default button the default) and the fit at exactly the limit (the scale keeps the size). Untested: a stale preview dropped while a newer one waits is ordered by the test through the wait timer alone. A dropped picture takes a RAW type after Photoshop's; the importer's filter and the desktop entry (`image/x-dcraw`) take RAW. Tests: `RawImporterTests` (matching, the locus and its round trips, the camera's balance, curves, exposure, colour, orientation, the preview's limit, the queue's cache), `RawDevelopTests` (the session, refusals and budgets, a develop that fails, the sheet), a tiny DNG writer in `DNGFixture.h`, the drop order in `ImageFileDropTests`, the filter in `ContentViewTests`, the entry in `DesktopIntegrationTests`. `tests/Tests.cmake` holds the test binaries, keeping `CMakeLists.txt` under 500 lines.
- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above.
+- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- 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.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update).
- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`.
- `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible.
-- `SeparableBlend` has no twin. It exists because CoreGraphics gets Color Dodge and Color Burn wrong, and since 1.2.0 (`121a15f`) it blends them in sRGB, as QPainter does; QPainter's follow the PDF formulas (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` proves all fourteen modes within 2/255).
-- `Rendering/HslBlend` is Linux-only: Hue, Saturation, Color and Luminosity, which QPainter lacks, blended by hand over a copy of the backdrop.
+- `Rendering/SeparableBlend` twins Swift's file for the modes QPainter lacks since 1.2.2: `needsSurface` names Linear Burn, Linear Dodge (Add), Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract and Divide, and `channel` is Photoshop's formula for each on one channel (in Vivid Light a zero divisor is infinity, clamped to the end; Divide by black is white, zero over zero included, a choice of the twin's, since neither Photoshop nor Core Image publishes it). Swift reaches Core Image's filters, in sRGB as Color Burn and Dodge have been since 1.2.0 (`121a15f`); Color Burn and Dodge themselves stay QPainter's, which follow the PDF formulas, as do its Hard Light and Exclusion (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` holds all twenty-four modes within 2/255 against formulas written apart). Known difference: Core Image's code is not published, so its edges (Hard Mix exactly at one, a divisor of zero) follow Photoshop's definitions, not measured Core Image. Hard Mix is Photoshop's at full fill, white where backdrop and source reach one together.
+- `Rendering/HslBlend` is Linux-only: the modes QPainter lacks (Hue, Saturation, Color and Luminosity, and since 1.2.2 `SeparableBlend`'s eight), blended by hand over a copy of the backdrop with the PDF's compositing, so every path that hands a mode to `HslBlend::handles` (the export, the canvas's surface, live masks, effects) takes the new ones.
- Known difference: CoreGraphics enlarges "High quality" layers with a sharper kernel; QPainter offers bilinear only, so `InterpolationQuality::high` and `low` draw alike when enlarging. Reductions are sharp on both (halvings).
- Where an Apple framework threads internally (vImage, Core Image, Metal), the twin threads with QtConcurrent.
- A Swift error enum becomes an exception class with a `Kind` enum; `what()` carries the Swift `errorDescription`.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a7ae2b9..2f0b66f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -174,6 +174,7 @@ add_library(compositor_core STATIC
src/Rendering/SampleRingOverlay.cpp
src/Rendering/TransformOverlay.cpp
src/Rendering/HslBlend.cpp
+ src/Rendering/SeparableBlend.cpp
src/Rendering/LayerEffectsKernel.cpp
src/Rendering/LayerEffectsSurface.cpp
src/Rendering/LayerRenderer.cpp
diff --git a/TASKS.md b/TASKS.md
index dc0e214..b8ed4b6 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -337,7 +337,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.6b Version 1.2.1, part two: middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`.
- 12.6b round 1: FAIL, two findings: the middle pan's per-move point and the Auto Select branch's `underPointer` guard were held by no test (both tested now); the cosmetic notes taken (test order, the distort test's Swift name, the double pan named). A parallel run showed `SelectionCanvasTests` pressing before a nudge's move had ended; it waits for the move now.
- 12.6b round 2: PASS.
-- [ ] 12.7 Version 1.2.2: Photoshop's remaining blend modes in its groups; Black & White, Color Balance and Invert adjustments.
+- [x] 12.7a Version 1.2.2, part one: Photoshop's remaining blend modes in its groups.
+- 12.7a round 1: FAIL, one finding. A clipping stack's base and an adjustment in one of the eight new modes blend by it in the twin, where Swift's `cgMode` draws them as Normal; AGENTS.md had called Swift's cases dead. Named now as a fix beyond Swift and tested on both paths; three wordings taken. A parallel run showed `GradientCanvasTests` reading the edit a turn early; it waits now.
+- 12.7a round 2: PASS. One note taken: the stack test asserts the exact 136.
+- [ ] 12.7b Version 1.2.2, part two: Black & White, Color Balance and Invert adjustments.
- [ ] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels.
- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas, its grading wheels turning the way their hue runs (1.2.9's `96edd5f`).
- [ ] 12.8c Version 1.2.3, part three: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`).
diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c
index 90b76b2..1348a3a 100644
--- a/kernels/AdjustPixels.c
+++ b/kernels/AdjustPixels.c
@@ -94,3 +94,94 @@ void rgba_clamp_premultiplied(uint8_t *rgba, size_t count) {
if (rgba[2] > a) rgba[2] = a;
}
}
+
+/// Which of the six ranges a color's primary and secondary fall in, and how much of each it holds.
+/// A color is min(r,g,b) of gray, plus (mid-min) of the secondary between its two brightest channels,
+/// plus (max-mid) of the primary of its brightest — so the weights below are exactly Photoshop's.
+void adjust_black_white(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *weights,
+ int tint, double tintHue, double tintSaturation) {
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ float alpha = p[3];
+ if (!alpha) continue;
+ float r = p[0] * 255.0f / alpha, g = p[1] * 255.0f / alpha, b = p[2] * 255.0f / alpha;
+ r = fminf(255.0f, r) / 255.0f; g = fminf(255.0f, g) / 255.0f; b = fminf(255.0f, b) / 255.0f;
+ float mx = fmaxf(r, fmaxf(g, b)), mn = fminf(r, fminf(g, b));
+ float md = r + g + b - mx - mn;
+ // weights: 0 red, 1 yellow, 2 green, 3 cyan, 4 blue, 5 magenta
+ int primary, secondary;
+ if (mx == r) { primary = 0; secondary = (g >= b) ? 1 : 5; }
+ else if (mx == g) { primary = 2; secondary = (r >= b) ? 1 : 3; }
+ else { primary = 4; secondary = (g >= r) ? 3 : 5; }
+ float gray = mn + (md - mn) * weights[secondary] + (mx - md) * weights[primary];
+ gray = fminf(1.0f, fmaxf(0.0f, gray));
+ float outR = gray, outG = gray, outB = gray;
+ if (tint && tintSaturation > 0) {
+ // The gray becomes the lightness of a color at the chosen hue.
+ double c = (1.0 - fabs(2.0 * gray - 1.0)) * tintSaturation;
+ double hp = fmod(tintHue, 360.0) / 60.0;
+ double xx = c * (1.0 - fabs(fmod(hp, 2.0) - 1.0));
+ double r1 = 0, g1 = 0, b1 = 0;
+ if (hp < 1) { r1 = c; g1 = xx; }
+ else if (hp < 2) { r1 = xx; g1 = c; }
+ else if (hp < 3) { g1 = c; b1 = xx; }
+ else if (hp < 4) { g1 = xx; b1 = c; }
+ else if (hp < 5) { r1 = xx; b1 = c; }
+ else { r1 = c; b1 = xx; }
+ double m = gray - c / 2.0;
+ outR = (float)fmin(1.0, fmax(0.0, r1 + m));
+ outG = (float)fmin(1.0, fmax(0.0, g1 + m));
+ outB = (float)fmin(1.0, fmax(0.0, b1 + m));
+ }
+ p[0] = (uint8_t)fminf(alpha, fmaxf(0.0f, roundf(outR * alpha)));
+ p[1] = (uint8_t)fminf(alpha, fmaxf(0.0f, roundf(outG * alpha)));
+ p[2] = (uint8_t)fminf(alpha, fmaxf(0.0f, roundf(outB * alpha)));
+ }
+ }
+}
+
+/// How much a tone belongs to the shadows, midtones and highlights: three overlapping curves that sum
+/// to about one across the range, so a shift fades in and out rather than banding at a threshold.
+static void tonal_weights(float v, float *shadow, float *mid, float *highlight) {
+ const float a = 0.25f, b = 0.333f, scale = 0.7f;
+ float s = (v - b) / -a + 0.5f;
+ float h = (v + b - 1.0f) / a + 0.5f;
+ s = fminf(1.0f, fmaxf(0.0f, s));
+ h = fminf(1.0f, fmaxf(0.0f, h));
+ float m1 = fminf(1.0f, fmaxf(0.0f, (v - b) / a + 0.5f));
+ float m2 = fminf(1.0f, fmaxf(0.0f, (v + b - 1.0f) / -a + 0.5f));
+ *shadow = s * scale;
+ *mid = m1 * m2 * scale;
+ *highlight = h * scale;
+}
+
+void adjust_color_balance(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *shadows,
+ const float *midtones, const float *highlights, int preserveLuminosity) {
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ float alpha = p[3];
+ if (!alpha) continue;
+ float c[3];
+ for (int i = 0; i < 3; ++i) c[i] = fminf(255.0f, p[i] * 255.0f / alpha) / 255.0f;
+ float before = 0.299f * c[0] + 0.587f * c[1] + 0.114f * c[2];
+ for (int i = 0; i < 3; ++i) {
+ float s, m, h;
+ tonal_weights(c[i], &s, &m, &h);
+ c[i] += shadows[i] * s + midtones[i] * m + highlights[i] * h;
+ c[i] = fminf(1.0f, fmaxf(0.0f, c[i]));
+ }
+ if (preserveLuminosity) {
+ float after = 0.299f * c[0] + 0.587f * c[1] + 0.114f * c[2];
+ if (after > 0.0001f) {
+ float ratio = before / after;
+ for (int i = 0; i < 3; ++i) c[i] = fminf(1.0f, fmaxf(0.0f, c[i] * ratio));
+ }
+ }
+ for (int i = 0; i < 3; ++i) p[i] = (uint8_t)fminf(alpha, fmaxf(0.0f, roundf(c[i] * alpha)));
+ }
+ }
+}
diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h
index 9abeb3e..d02049f 100644
--- a/kernels/AdjustPixels.h
+++ b/kernels/AdjustPixels.h
@@ -13,6 +13,20 @@ void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stri
// piece of an image gets the same grain as that part of the whole.
void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size,
double roughness, uint32_t seed, double originX, double originY, double unitsPerPixel);
+// Black & White on premultiplied RGBA pixels, the way Photoshop's is: a color is split into the gray
+// it contains, the secondary (cyan/magenta/yellow) between its two brightest channels, and the primary
+// (red/green/blue) of its brightest, and each of those six ranges has its own weight. `weights` is six
+// floats in the order red, yellow, green, cyan, blue, magenta, as fractions (Photoshop's 40% is 0.4).
+// Pure red at the default 40% comes out 40% gray, as it does there. With `tint`, the result is colored
+// at `tintHue` degrees and `tintSaturation` (0–1) while keeping that gray as its lightness.
+void adjust_black_white(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *weights,
+ int tint, double tintHue, double tintSaturation);
+// Color Balance on premultiplied RGBA pixels. `shadows`, `midtones` and `highlights` are each three
+// floats — cyan/red, magenta/green, yellow/blue — from -1 to 1 (Photoshop's -100 to 100). Each pixel is
+// shifted by however much it belongs to each tonal range, and with `preserveLuminosity` its original
+// brightness is put back afterwards, so only the color moves.
+void adjust_color_balance(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *shadows,
+ const float *midtones, const float *highlights, int preserveLuminosity);
// After resampling with a filter that rings (Lanczos), premultiplied RGBA colors can exceed their alpha;
// this clamps each channel back to its pixel's alpha. `count` is the number of pixels.
void rgba_clamp_premultiplied(uint8_t *rgba, size_t count);
diff --git a/src/Document/LayerAppearance.cpp b/src/Document/LayerAppearance.cpp
index cb5b357..8959b44 100644
--- a/src/Document/LayerAppearance.cpp
+++ b/src/Document/LayerAppearance.cpp
@@ -6,12 +6,15 @@
#include
namespace {
-const std::array, 14> names{{
- {LayerBlendMode::normal, "Normal"}, {LayerBlendMode::multiply, "Multiply"}, {LayerBlendMode::screen, "Screen"},
- {LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::softLight, "Soft Light"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"},
- {LayerBlendMode::difference, "Difference"}, {LayerBlendMode::colorDodge, "Color Dodge"},
- {LayerBlendMode::colorBurn, "Color Burn"}, {LayerBlendMode::hue, "Hue"}, {LayerBlendMode::saturation, "Saturation"},
- {LayerBlendMode::color, "Color"}, {LayerBlendMode::luminosity, "Luminosity"},
+const std::array, 24> names{{
+ {LayerBlendMode::normal, "Normal"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::multiply, "Multiply"},
+ {LayerBlendMode::colorBurn, "Color Burn"}, {LayerBlendMode::linearBurn, "Linear Burn"}, {LayerBlendMode::lighten, "Lighten"},
+ {LayerBlendMode::screen, "Screen"}, {LayerBlendMode::colorDodge, "Color Dodge"}, {LayerBlendMode::linearDodge, "Linear Dodge (Add)"},
+ {LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::softLight, "Soft Light"}, {LayerBlendMode::hardLight, "Hard Light"},
+ {LayerBlendMode::vividLight, "Vivid Light"}, {LayerBlendMode::linearLight, "Linear Light"}, {LayerBlendMode::pinLight, "Pin Light"},
+ {LayerBlendMode::hardMix, "Hard Mix"}, {LayerBlendMode::difference, "Difference"}, {LayerBlendMode::exclusion, "Exclusion"},
+ {LayerBlendMode::subtract, "Subtract"}, {LayerBlendMode::divide, "Divide"}, {LayerBlendMode::hue, "Hue"},
+ {LayerBlendMode::saturation, "Saturation"}, {LayerBlendMode::color, "Color"}, {LayerBlendMode::luminosity, "Luminosity"},
}};
}
@@ -20,6 +23,16 @@ QString rawValue(LayerBlendMode mode)
return QString::fromLatin1(names.at(size_t(mode)).second);
}
+int blendGroup(LayerBlendMode mode)
+{
+ // Each group's modes sit together in the enum.
+ for (const auto &[first, group] : std::array, 5>{{{LayerBlendMode::hue, 5},
+ {LayerBlendMode::difference, 4}, {LayerBlendMode::overlay, 3}, {LayerBlendMode::lighten, 2}, {LayerBlendMode::darken, 1}}})
+ if (mode >= first)
+ return group;
+ return 0;
+}
+
std::optional layerBlendMode(const QString &rawValue)
{
for (const auto &[mode, name] : names) {
diff --git a/src/Document/LayerAppearance.h b/src/Document/LayerAppearance.h
index 1a54289..7b325fe 100644
--- a/src/Document/LayerAppearance.h
+++ b/src/Document/LayerAppearance.h
@@ -4,16 +4,24 @@
#include
enum class LayerBlendMode {
- normal, multiply, screen, overlay, softLight, darken, lighten, difference,
- colorDodge, colorBurn, hue, saturation, color, luminosity
+ normal,
+ darken, multiply, colorBurn, linearBurn,
+ lighten, screen, colorDodge, linearDodge,
+ overlay, softLight, hardLight, vividLight, linearLight, pinLight, hardMix,
+ difference, exclusion, subtract, divide,
+ hue, saturation, color, luminosity
};
+// Swift's `groups`, Photoshop's: the menu draws a line between.
+int blendGroup(LayerBlendMode mode);
+
// Swift's `allCases`: the order of the blend menu.
inline constexpr std::array allLayerBlendModes{
- LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::screen, LayerBlendMode::overlay,
- LayerBlendMode::softLight, LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge,
- LayerBlendMode::colorBurn, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color,
- LayerBlendMode::luminosity};
+ LayerBlendMode::normal, LayerBlendMode::darken, LayerBlendMode::multiply, LayerBlendMode::colorBurn, LayerBlendMode::linearBurn,
+ LayerBlendMode::lighten, LayerBlendMode::screen, LayerBlendMode::colorDodge, LayerBlendMode::linearDodge,
+ LayerBlendMode::overlay, LayerBlendMode::softLight, LayerBlendMode::hardLight, LayerBlendMode::vividLight, LayerBlendMode::linearLight,
+ LayerBlendMode::pinLight, LayerBlendMode::hardMix, LayerBlendMode::difference, LayerBlendMode::exclusion, LayerBlendMode::subtract,
+ LayerBlendMode::divide, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color, LayerBlendMode::luminosity};
// The name users see and the manifest stores.
QString rawValue(LayerBlendMode mode);
diff --git a/src/IO/PSD/PSDTypes.cpp b/src/IO/PSD/PSDTypes.cpp
index 5e0581c..70b1f8e 100644
--- a/src/IO/PSD/PSDTypes.cpp
+++ b/src/IO/PSD/PSDTypes.cpp
@@ -31,6 +31,12 @@ std::optional fromPSD(const QString &key)
{QStringLiteral("div "), LayerBlendMode::colorDodge}, {QStringLiteral("idiv"), LayerBlendMode::colorBurn},
{QStringLiteral("hue "), LayerBlendMode::hue}, {QStringLiteral("sat "), LayerBlendMode::saturation},
{QStringLiteral("colr"), LayerBlendMode::color}, {QStringLiteral("lum "), LayerBlendMode::luminosity},
+ {QStringLiteral("lbrn"), LayerBlendMode::linearBurn}, {QStringLiteral("lddg"), LayerBlendMode::linearDodge},
+ {QStringLiteral("hLit"), LayerBlendMode::hardLight}, {QStringLiteral("vLit"), LayerBlendMode::vividLight},
+ {QStringLiteral("lLit"), LayerBlendMode::linearLight}, {QStringLiteral("pLit"), LayerBlendMode::pinLight},
+ {QStringLiteral("hMix"), LayerBlendMode::hardMix}, {QStringLiteral("smud"), LayerBlendMode::exclusion},
+ {QStringLiteral("fsub"), LayerBlendMode::subtract}, {QStringLiteral("fdiv"), LayerBlendMode::divide},
+ // Dissolve, Darker Color and Lighter Color have no twin: Normal.
};
if (!modes.contains(key))
return std::nullopt;
diff --git a/src/Rendering/HslBlend.cpp b/src/Rendering/HslBlend.cpp
index fb58d15..09e4e8b 100644
--- a/src/Rendering/HslBlend.cpp
+++ b/src/Rendering/HslBlend.cpp
@@ -1,5 +1,6 @@
#include "Rendering/HslBlend.h"
#include "Rendering/PoolMap.h"
+#include "Rendering/SeparableBlend.h"
#include "IO/ImageExporter.h"
#include
#include
@@ -66,7 +67,8 @@ Color mixed(LayerBlendMode mode, const Color &backdrop, const Color &source)
case LayerBlendMode::luminosity:
return setLum(backdrop, lum(source));
default:
- throw std::logic_error("HslBlend handles only the four non-separable modes");
+ return {SeparableBlend::channel(mode, backdrop[0], source[0]), SeparableBlend::channel(mode, backdrop[1], source[1]),
+ SeparableBlend::channel(mode, backdrop[2], source[2])};
}
}
}
@@ -74,7 +76,7 @@ Color mixed(LayerBlendMode mode, const Color &backdrop, const Color &source)
bool HslBlend::handles(LayerBlendMode mode)
{
return mode == LayerBlendMode::hue || mode == LayerBlendMode::saturation || mode == LayerBlendMode::color
- || mode == LayerBlendMode::luminosity;
+ || mode == LayerBlendMode::luminosity || SeparableBlend::needsSurface(mode);
}
QImage HslBlend::blend(const QImage &backdrop, const QImage &source, LayerBlendMode mode, double opacity)
@@ -84,7 +86,7 @@ QImage HslBlend::blend(const QImage &backdrop, const QImage &source, LayerBlendM
throw std::logic_error("HslBlend needs two RGBA8888 premultiplied images of one size");
// Checked here: a worker's exception would come back wrapped.
if (!handles(mode))
- throw std::logic_error("HslBlend blends hue, saturation, color and luminosity alone");
+ throw std::logic_error("HslBlend blends only the modes QPainter lacks");
QImage result(backdrop.size(), QImage::Format_RGBA8888_Premultiplied);
if (result.isNull())
throw ExportError(ExportError::Kind::render);
diff --git a/src/Rendering/HslBlend.h b/src/Rendering/HslBlend.h
index 5463723..add8d91 100644
--- a/src/Rendering/HslBlend.h
+++ b/src/Rendering/HslBlend.h
@@ -2,7 +2,7 @@
#include "Document/LayerAppearance.h"
#include
-// Hue, Saturation, Color, Luminosity: PDF blend modes QPainter lacks.
+// Blend modes QPainter lacks: the four non-separable, SeparableBlend's eight.
namespace HslBlend {
bool handles(LayerBlendMode mode);
// Source over backdrop, same size, both RGBA8888 premultiplied.
diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp
index 89fcb9f..114680c 100644
--- a/src/Rendering/LayerRenderer.cpp
+++ b/src/Rendering/LayerRenderer.cpp
@@ -32,6 +32,10 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode)
return QPainter::CompositionMode_ColorDodge;
case LayerBlendMode::colorBurn:
return QPainter::CompositionMode_ColorBurn;
+ case LayerBlendMode::hardLight:
+ return QPainter::CompositionMode_HardLight;
+ case LayerBlendMode::exclusion:
+ return QPainter::CompositionMode_Exclusion;
default:
throw std::logic_error("QPainter has no composition mode for this blend mode");
}
@@ -214,7 +218,7 @@ void LayerRenderer::composite(QPainter &context, const QTransform &placement, co
if (byHand) {
const QImage *target = dynamic_cast(context.device());
if (!target)
- throw std::logic_error("hue, saturation, color and luminosity need an image-backed painter");
+ throw std::logic_error("modes blended by hand need an image-backed painter");
const QImage backdrop = target->copy(area).convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (backdrop.isNull())
throw ExportError(ExportError::Kind::render);
diff --git a/src/Rendering/SeparableBlend.cpp b/src/Rendering/SeparableBlend.cpp
new file mode 100644
index 0000000..d8779ce
--- /dev/null
+++ b/src/Rendering/SeparableBlend.cpp
@@ -0,0 +1,61 @@
+#include "Rendering/SeparableBlend.h"
+#include
+#include
+
+namespace {
+// A zero divisor gives infinity; the clamp takes it home.
+float burn(float backdrop, float source)
+{
+ if (backdrop >= 1)
+ return 1;
+ return 1 - std::min(1.0f, (1 - backdrop) / source);
+}
+
+float dodge(float backdrop, float source)
+{
+ if (backdrop <= 0)
+ return 0;
+ return std::min(1.0f, backdrop / (1 - source));
+}
+}
+
+bool SeparableBlend::needsSurface(LayerBlendMode mode)
+{
+ switch (mode) {
+ case LayerBlendMode::linearBurn:
+ case LayerBlendMode::linearDodge:
+ case LayerBlendMode::vividLight:
+ case LayerBlendMode::linearLight:
+ case LayerBlendMode::pinLight:
+ case LayerBlendMode::hardMix:
+ case LayerBlendMode::subtract:
+ case LayerBlendMode::divide:
+ return true;
+ default:
+ return false;
+ }
+}
+
+float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source)
+{
+ switch (mode) {
+ case LayerBlendMode::linearBurn:
+ return std::max(0.0f, backdrop + source - 1);
+ case LayerBlendMode::linearDodge:
+ return std::min(1.0f, backdrop + source);
+ case LayerBlendMode::vividLight:
+ return source <= 0.5f ? burn(backdrop, 2 * source) : dodge(backdrop, 2 * source - 1);
+ case LayerBlendMode::linearLight:
+ return std::clamp(backdrop + 2 * source - 1, 0.0f, 1.0f);
+ case LayerBlendMode::pinLight:
+ return source <= 0.5f ? std::min(backdrop, 2 * source) : std::max(backdrop, 2 * source - 1);
+ case LayerBlendMode::hardMix:
+ return backdrop + source >= 1 ? 1 : 0;
+ case LayerBlendMode::subtract:
+ return std::max(0.0f, backdrop - source);
+ case LayerBlendMode::divide:
+ return source <= 0 ? 1 : std::min(1.0f, backdrop / source);
+ default:
+ throw std::logic_error("SeparableBlend blends only the modes QPainter lacks");
+ }
+}
diff --git a/src/Rendering/SeparableBlend.h b/src/Rendering/SeparableBlend.h
new file mode 100644
index 0000000..a603e01
--- /dev/null
+++ b/src/Rendering/SeparableBlend.h
@@ -0,0 +1,10 @@
+#pragma once
+#include "Document/LayerAppearance.h"
+
+// Swift's SeparableBlend: channel-by-channel modes QPainter lacks.
+namespace SeparableBlend {
+// Blended by hand against a surface, Swift's `needsSurface`.
+bool needsSurface(LayerBlendMode mode);
+// Photoshop's formula on one channel: backdrop, source, 0 to 1.
+float channel(LayerBlendMode mode, float backdrop, float source);
+}
diff --git a/src/UI/BlendModePicker.cpp b/src/UI/BlendModePicker.cpp
index e020357..0bc0ac3 100644
--- a/src/UI/BlendModePicker.cpp
+++ b/src/UI/BlendModePicker.cpp
@@ -4,8 +4,12 @@ BlendModePicker::BlendModePicker(EditorSession &session, QWidget *parent) : QCom
{
setAccessibleName(QStringLiteral("Blend mode"));
setObjectName(QStringLiteral("blendMode"));
- for (const LayerBlendMode mode : allLayerBlendModes)
+ // Grouped as Photoshop groups them, a line between.
+ for (const LayerBlendMode mode : allLayerBlendModes) {
+ if (count() && blendGroup(mode) != blendGroup(*layerBlendMode(itemText(count() - 1))))
+ insertSeparator(count());
addItem(rawValue(mode));
+ }
connect(this, &QComboBox::highlighted, this, &BlendModePicker::highlight);
connect(this, &QComboBox::activated, this, &BlendModePicker::choose);
connect(&m_session, &EditorSession::changed, this, &BlendModePicker::synchronize);
diff --git a/tests/AdjustmentLayerTests.cpp b/tests/AdjustmentLayerTests.cpp
index 10a8506..46a46e6 100644
--- a/tests/AdjustmentLayerTests.cpp
+++ b/tests/AdjustmentLayerTests.cpp
@@ -186,6 +186,10 @@ void AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage()
s.updateAdjustment(id, value);
s.setLayerBlendMode(LayerBlendMode::multiply);
QVERIFY((rendered(s) == std::vector{0, 0, 0, 255, 0, 0, 0, 128, 0, 0, 0, 32, 0, 0, 0, 0}));
+ // Green over red adds to yellow; Swift adjusts as Normal.
+ s.setLayerBlendMode(LayerBlendMode::linearDodge);
+ QVERIFY((part(rendered(s), 0, 4) == std::vector{255, 255, 0, 255}));
+ QCOMPARE(rendered(s)[7], 128);
s.setLayerBlendMode(LayerBlendMode::normal);
QImage gray(1, 1, QImage::Format_Grayscale8);
gray.fill(128);
diff --git a/tests/BlendModePickerTests.cpp b/tests/BlendModePickerTests.cpp
index f915a69..f11616c 100644
--- a/tests/BlendModePickerTests.cpp
+++ b/tests/BlendModePickerTests.cpp
@@ -26,13 +26,16 @@ void BlendModePickerTests::theMenuListsEveryModeAndFollowsTheActiveLayer()
{
EditorSession session;
BlendModePicker picker(session);
- QCOMPARE(picker.count(), int(allLayerBlendModes.size()));
+ // Photoshop's six groups, a separator between each.
+ QCOMPARE(picker.count(), int(allLayerBlendModes.size()) + 5);
QStringList titles;
for (int index = 0; index < picker.count(); ++index)
titles << picker.itemText(index);
- QCOMPARE(titles.first(), QString("Normal"));
- QCOMPARE(titles.last(), QString("Luminosity"));
- QCOMPARE(titles[1], QString("Multiply"));
+ QCOMPARE(titles, (QStringList{"Normal", "", "Darken", "Multiply", "Color Burn", "Linear Burn", "", "Lighten", "Screen", "Color Dodge",
+ "Linear Dodge (Add)", "", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light",
+ "Hard Mix", "", "Difference", "Exclusion", "Subtract", "Divide", "", "Hue", "Saturation", "Color", "Luminosity"}));
+ for (const int separator : {1, 6, 11, 19, 24})
+ QCOMPARE(picker.itemData(separator, Qt::AccessibleDescriptionRole).toString(), QString("separator"));
QVERIFY(!picker.isEnabled());
session.createDocument(4, 4);
session.insert(white());
@@ -63,20 +66,20 @@ void BlendModePickerTests::hoveringTriesAModeOnAndChoosingKeepsIt()
picker.showPopup();
QTRY_VERIFY(picker.view()->isVisible());
// Hovering previews on the layer the menu opened for.
- emit picker.highlighted(1);
+ emit picker.highlighted(3);
QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::multiply);
QCOMPARE(session.blendPreview().value().layerID, layer);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal);
- emit picker.highlighted(2);
+ emit picker.highlighted(8);
QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::screen);
// The session's news leaves an open menu alone.
- picker.view()->setCurrentIndex(picker.model()->index(2, 0));
+ picker.view()->setCurrentIndex(picker.model()->index(8, 0));
session.setLayerOpacity(0.5);
QVERIFY(session.blendPreview().has_value());
- QCOMPARE(picker.view()->currentIndex().row(), 2);
+ QCOMPARE(picker.view()->currentIndex().row(), 8);
// A click closes the menu, then chooses; preview clears after.
picker.hidePopup();
- emit picker.activated(2);
+ emit picker.activated(8);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen);
QCOMPARE(picker.currentText(), QString("Screen"));
QCOMPARE(session.history.undoName(), QString("Layer Blend Mode"));
@@ -85,8 +88,14 @@ void BlendModePickerTests::hoveringTriesAModeOnAndChoosingKeepsIt()
// Keys on the closed menu choose for the active layer.
picker.setFocus();
QTest::keyClick(&picker, Qt::Key_Down);
- QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
+ QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
+ QCOMPARE(picker.currentText(), QString("Color Dodge"));
+ // Keys step over the lines between groups.
+ QTest::keyClick(&picker, Qt::Key_Down);
+ QTest::keyClick(&picker, Qt::Key_Down);
QCOMPARE(picker.currentText(), QString("Overlay"));
+ session.undo();
+ session.undo();
// Closed, the menu follows the session again.
session.undo();
QCOMPARE(picker.currentText(), QString("Screen"));
@@ -104,19 +113,19 @@ void BlendModePickerTests::aMenuClosedWithoutAChoiceLeavesTheLayerAlone()
picker.show();
QVERIFY(QTest::qWaitForWindowExposed(&picker));
picker.showPopup();
- emit picker.highlighted(4);
+ emit picker.highlighted(3);
picker.hidePopup();
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal);
QTRY_VERIFY(!session.blendPreview().has_value());
QCOMPARE(picker.currentText(), QString("Normal"));
// Another layer active at the choice: nothing changes.
picker.showPopup();
- emit picker.highlighted(4);
+ emit picker.highlighted(3);
session.selectLayer(session.document().value().layers.front().id);
// Open, the menu keeps its text; closed, the active layer.
QCOMPARE(picker.currentText(), QString("Normal"));
picker.hidePopup();
- emit picker.activated(4);
+ emit picker.activated(3);
QCOMPARE(session.document().value().layers.front().blendMode, LayerBlendMode::multiply);
QCOMPARE(session.document().value().layers.back().blendMode, LayerBlendMode::normal);
QCOMPARE(session.history.undoName(), QString("Import Image"));
@@ -131,7 +140,7 @@ void BlendModePickerTests::aMenuClosedWithoutAChoiceLeavesTheLayerAlone()
session.selectLayer(session.document().value().layers.front().id);
picker.setFocus();
QTest::keyClick(&picker, Qt::Key_Down);
- QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen);
+ QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorBurn);
}
void BlendModePickerTests::theMaskButtonAddsAWhiteMaskOnce()
diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp
index c535b4c..c2878ef 100644
--- a/tests/BlendModeTests.cpp
+++ b/tests/BlendModeTests.cpp
@@ -80,6 +80,20 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source)
case LayerBlendMode::difference: result[channel] = std::abs(b - s); break;
case LayerBlendMode::colorDodge: result[channel] = b == 0 ? 0 : s >= 1 ? 1 : std::min(1.0, b / (1 - s)); break;
case LayerBlendMode::colorBurn: result[channel] = b >= 1 ? 1 : s <= 0 ? 0 : 1 - std::min(1.0, (1 - b) / s); break;
+ // Photoshop's separable modes that QPainter lacks.
+ case LayerBlendMode::hardLight: result[channel] = hardLight(b, s); break;
+ case LayerBlendMode::exclusion: result[channel] = b + s - 2 * b * s; break;
+ case LayerBlendMode::linearBurn: result[channel] = std::max(0.0, b + s - 1); break;
+ case LayerBlendMode::linearDodge: result[channel] = std::min(1.0, b + s); break;
+ case LayerBlendMode::vividLight:
+ result[channel] = s <= 0.5 ? (b >= 1 ? 1 : s <= 0 ? 0 : 1 - std::min(1.0, (1 - b) / (2 * s)))
+ : (b <= 0 ? 0 : s >= 1 ? 1 : std::min(1.0, b / (2 - 2 * s)));
+ break;
+ case LayerBlendMode::linearLight: result[channel] = std::clamp(b + 2 * s - 1, 0.0, 1.0); break;
+ case LayerBlendMode::pinLight: result[channel] = s <= 0.5 ? std::min(b, 2 * s) : std::max(b, 2 * s - 1); break;
+ case LayerBlendMode::hardMix: result[channel] = b + s >= 1 ? 1 : 0; break;
+ case LayerBlendMode::subtract: result[channel] = std::max(0.0, b - s); break;
+ case LayerBlendMode::divide: result[channel] = s <= 0 ? 1 : std::min(1.0, b / s); break;
default: break;
}
}
@@ -140,7 +154,10 @@ void BlendModeTests::everyBlendModeMatchesThePdfFormulas_data()
{"overlay", LayerBlendMode::overlay}, {"softLight", LayerBlendMode::softLight}, {"darken", LayerBlendMode::darken}, {"lighten", LayerBlendMode::lighten},
{"difference", LayerBlendMode::difference}, {"colorDodge", LayerBlendMode::colorDodge},
{"colorBurn", LayerBlendMode::colorBurn}, {"hue", LayerBlendMode::hue}, {"saturation", LayerBlendMode::saturation},
- {"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}};
+ {"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}, {"hardLight", LayerBlendMode::hardLight},
+ {"exclusion", LayerBlendMode::exclusion}, {"linearBurn", LayerBlendMode::linearBurn}, {"linearDodge", LayerBlendMode::linearDodge},
+ {"vividLight", LayerBlendMode::vividLight}, {"linearLight", LayerBlendMode::linearLight}, {"pinLight", LayerBlendMode::pinLight},
+ {"hardMix", LayerBlendMode::hardMix}, {"subtract", LayerBlendMode::subtract}, {"divide", LayerBlendMode::divide}};
for (const auto &[name, mode] : modes)
QTest::newRow(name) << mode;
}
@@ -224,6 +241,10 @@ void BlendModeTests::hslBlendChecksItsInputs()
QVERIFY(HslBlend::handles(LayerBlendMode::hue) && HslBlend::handles(LayerBlendMode::saturation));
QVERIFY(HslBlend::handles(LayerBlendMode::color) && HslBlend::handles(LayerBlendMode::luminosity));
QVERIFY(!HslBlend::handles(LayerBlendMode::normal) && !HslBlend::handles(LayerBlendMode::colorBurn));
+ QVERIFY(!HslBlend::handles(LayerBlendMode::hardLight) && !HslBlend::handles(LayerBlendMode::exclusion));
+ for (const LayerBlendMode mode : {LayerBlendMode::linearBurn, LayerBlendMode::linearDodge, LayerBlendMode::vividLight, LayerBlendMode::linearLight,
+ LayerBlendMode::pinLight, LayerBlendMode::hardMix, LayerBlendMode::subtract, LayerBlendMode::divide})
+ QVERIFY(HslBlend::handles(mode));
const QImage large(8192, 8192, QImage::Format_RGBA8888_Premultiplied), other(8192, 8192, QImage::Format_RGBA8888_Premultiplied);
std::optional limit(std::in_place, 16 * 1024 * 1024);
diff --git a/tests/BlendShortcutTests.cpp b/tests/BlendShortcutTests.cpp
index 9118b39..2335ad9 100644
--- a/tests/BlendShortcutTests.cpp
+++ b/tests/BlendShortcutTests.cpp
@@ -29,8 +29,9 @@ void BlendShortcutTests::shiftPlusAndMinusStepTheActiveLayersBlendModeWhereverFo
session.selectTool(NavigationTool::brush);
canvas->setFocus();
QTRY_VERIFY(canvas->hasFocus());
+ // Photoshop's order: the first darkening mode follows Normal.
QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier);
- QCOMPARE(mode(), LayerBlendMode::multiply);
+ QCOMPARE(mode(), LayerBlendMode::darken);
// From the list, back past Normal to the last mode.
session.selectTool(NavigationTool::lasso);
list->setFocus();
diff --git a/tests/CanvasDisplayTests.cpp b/tests/CanvasDisplayTests.cpp
index 95a1474..cd237af 100644
--- a/tests/CanvasDisplayTests.cpp
+++ b/tests/CanvasDisplayTests.cpp
@@ -75,6 +75,7 @@ class CanvasDisplayTests : public QObject {
private slots:
void theBackgroundCheckerboardShadowAndBorderSurroundTheDocument();
void layersCompositeAsTheExportDoes();
+ void photoshopsOwnModesShowAsTheExportDoes();
void blendModesMeetNoCheckerboardButTranslucencyDoes();
void thePixelGridAppearsFromEightHundredPercent();
void fromTwoHundredPercentPixelsAreHardEdged();
@@ -190,6 +191,24 @@ void CanvasDisplayTests::layersCompositeAsTheExportDoes()
session.cancelTransform();
}
+void CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes()
+{
+ // Blended by hand on the canvas's surface, as exported.
+ for (const LayerBlendMode mode : {LayerBlendMode::linearDodge, LayerBlendMode::subtract, LayerBlendMode::hardMix, LayerBlendMode::hardLight}) {
+ Scene scene(20, 10, 60, 40);
+ scene.session.insert(filled(20, 10, qRgba(90, 140, 200, 255), "Ground"), QPointF(10, 5));
+ scene.session.insert(filled(10, 6, qRgba(120, 60, 160, 255), "Top"), QPointF(8, 5));
+ scene.session.setLayerBlendMode(mode);
+ scene.session.zoom(1);
+ const QImage exported = ImageExporter::render(scene.session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
+ const QImage shot = scene.shot();
+ const QPointF origin = scene.origin();
+ for (const QPoint at : {QPoint(5, 4), QPoint(16, 4)})
+ QCOMPARE(shot.pixel(int(origin.x()) + at.x(), int(origin.y()) + at.y()), exported.pixel(at));
+ QVERIFY(exported.pixel(5, 4) != qRgb(120, 60, 160) && exported.pixel(5, 4) != qRgb(90, 140, 200));
+ }
+}
+
void CanvasDisplayTests::blendModesMeetNoCheckerboardButTranslucencyDoes()
{
Scene multiplied(20, 10, 60, 40);
diff --git a/tests/ClippingStackTests.cpp b/tests/ClippingStackTests.cpp
index b78567a..e152709 100644
--- a/tests/ClippingStackTests.cpp
+++ b/tests/ClippingStackTests.cpp
@@ -99,6 +99,13 @@ void ClippingStackTests::aStackBlendsOnceWithItsBasesMode()
QCOMPARE(drawn.pixel(0, 0), qRgba(100, 50, 25, 255));
QCOMPARE(drawn.pixel(1, 1), qRgba(200, 100, 50, 255));
QCOMPARE(drawn.pixel(1, 0), qRgba(150, 75, 37, 255));
+ // A hand-blended mode too, where Swift's group falls to Normal.
+ scene.layers[base].blendMode = LayerBlendMode::linearBurn;
+ const QImage burnt = scene.render({background, base, top});
+ QCOMPARE(burnt.pixel(0, 0), qRgba(73, 0, 0, 255));
+ QCOMPARE(burnt.pixel(1, 1), qRgba(200, 100, 50, 255));
+ QCOMPARE(qRed(burnt.pixel(1, 0)), 136);
+ QCOMPARE(qGreen(burnt.pixel(1, 0)), 50);
}
void ClippingStackTests::laterChildrenCoverEarlierOnes()
diff --git a/tests/EditorCanvasTests.cpp b/tests/EditorCanvasTests.cpp
index 0ab83a9..2b3db11 100644
--- a/tests/EditorCanvasTests.cpp
+++ b/tests/EditorCanvasTests.cpp
@@ -319,7 +319,7 @@ void EditorCanvasTests::shiftPlusAndMinusCycleTheBlendModeAcrossTheWindow()
const auto mode = [&] { return session.activeLayer().value().blendMode; };
QCOMPARE(mode(), LayerBlendMode::normal);
QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier);
- QCOMPARE(mode(), LayerBlendMode::multiply);
+ QCOMPARE(mode(), LayerBlendMode::darken);
QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier);
QCOMPARE(mode(), LayerBlendMode::normal);
QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier);
diff --git a/tests/GradientCanvasTests.cpp b/tests/GradientCanvasTests.cpp
index 6c32bfb..0c5b797 100644
--- a/tests/GradientCanvasTests.cpp
+++ b/tests/GradientCanvasTests.cpp
@@ -144,7 +144,8 @@ void GradientCanvasTests::escapeCancelsAndReturnApplies()
shown.drag(QPointF(50, 150), QPointF(350, 150));
QTest::keyClick(shown.canvas, Qt::Key_Return);
QTRY_COMPARE(shown.session.history.undoCount(), count + 1);
- QVERIFY(!shown.session.gradientEdit());
+ // The edit goes a turn after its step lands.
+ QTRY_VERIFY(!shown.session.gradientEdit());
shown.drag(QPointF(50, 150), QPointF(350, 150));
QTest::keyClick(shown.canvas, Qt::Key_Enter);
QTRY_COMPARE(shown.session.history.undoCount(), count + 2);
diff --git a/tests/LayerAppearanceTests.cpp b/tests/LayerAppearanceTests.cpp
index 22d04ef..a1c0ab2 100644
--- a/tests/LayerAppearanceTests.cpp
+++ b/tests/LayerAppearanceTests.cpp
@@ -287,8 +287,9 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
const QUuid below = session.activeLayerID().value();
session.insert(asset(0.8));
const int count = session.history.undoCount();
+ // Photoshop's order: the first darkening mode follows Normal.
session.cycleBlendMode(true);
- QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::multiply);
+ QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::darken);
// The mode is the active layer's alone.
QCOMPARE(layerWith(session, below).blendMode, LayerBlendMode::normal);
session.cycleBlendMode(false);
@@ -302,7 +303,7 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
session.setLayerBlendMode(LayerBlendMode::screen);
session.previewBlendMode(LayerBlendMode::hue, top);
session.cycleBlendMode(true);
- QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
+ QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
QVERIFY(!session.blendPreview().has_value());
// A refused cycle leaves a preview where it was.
session.previewBlendMode(LayerBlendMode::hue, top);
@@ -311,8 +312,8 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
session.cycleBlendMode(true);
QCOMPARE(session.blendPreview(), std::optional(EditorSession::BlendPreview{top, LayerBlendMode::hue}));
QCOMPARE(int(changes.count()), 0);
- QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
- QCOMPARE(int(allLayerBlendModes.size()), 14);
+ QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
+ QCOMPARE(int(allLayerBlendModes.size()), 24);
for (size_t index = 0; index < allLayerBlendModes.size(); ++index)
QCOMPARE(int(allLayerBlendModes[index]), int(index));
}
diff --git a/tests/PSDImportTests.cpp b/tests/PSDImportTests.cpp
index 7844cd8..bad33ab 100644
--- a/tests/PSDImportTests.cpp
+++ b/tests/PSDImportTests.cpp
@@ -106,17 +106,17 @@ void PSDImportTests::importCreatesDocumentAndExistingCanvasGetsAGroup()
void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged()
{
const QTemporaryDir folder;
- const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
+ const QUrl url = photoshop(folder, "Dissolved.psd", {layer("Dissolved", QRectF(0, 0, 2, 2), "diss")}, 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.title, QString("Open “Dissolved.psd”?"));
QCOMPARE(request.confirmTitle, QString("Import"));
QCOMPARE(request.conversions.size(), size_t(1));
- QCOMPARE(request.conversions[0].layerName, QString("Vivid"));
+ QCOMPARE(request.conversions[0].layerName, QString("Dissolved"));
QVERIFY(session.isImporting() && !done);
session.finishConversion(false);
QVERIFY(!session.showsConversionSheet() && !session.conversionRequest());
@@ -127,14 +127,14 @@ void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged()
void PSDImportTests::aConfirmedConversionImportsAndCancelWhileReadingStops()
{
const QTemporaryDir folder;
- const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
+ const QUrl url = photoshop(folder, "Dissolved.psd", {layer("Dissolved", QRectF(0, 0, 2, 2), "diss")}, 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"});
+ QCOMPARE(names(session), QStringList{"Dissolved"});
QVERIFY(session.document().value().layers[0].blendMode == LayerBlendMode::normal);
// Cancel while reading: nothing lands, nothing is said.
EditorSession stopped;
diff --git a/tests/PSDRoundTripTests.cpp b/tests/PSDRoundTripTests.cpp
index 998a9a2..c8da8e6 100644
--- a/tests/PSDRoundTripTests.cpp
+++ b/tests/PSDRoundTripTests.cpp
@@ -103,7 +103,7 @@ private slots:
void unusedSpotChannelsAreSkippedBeforeDecode();
void unsupportedCompressionOnColorChannelsIsStillRejected();
void matchesRequiresPhotoshopMagic();
- void unknownBlendProducesConversionReport();
+ void unsupportedBlendProducesConversionReport();
void softLightImportsWithoutConversion();
void folderOpacityImportsOntoTheFolder();
void unsupportedHeadersAreRejected();
@@ -239,16 +239,26 @@ void PSDRoundTripTests::matchesRequiresPhotoshopMagic()
QVERIFY(PSDReader::matches(QByteArray("8BPS")) && !PSDReader::matches(QByteArray("8BP")));
}
-void PSDRoundTripTests::unknownBlendProducesConversionReport()
+void PSDRoundTripTests::unsupportedBlendProducesConversionReport()
{
+ // Dissolve scatters pixels by opacity: no twin, so Normal.
const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0);
- PSDRecord layer = PSDFixture::record("Vivid", fill, QRectF(0, 0, 2, 2));
- layer.blendKey = "vLit";
+ PSDRecord layer = PSDFixture::record("Dissolved", fill, QRectF(0, 0, 2, 2));
+ layer.blendKey = "diss";
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."));
+ QCOMPARE(imported.conversions[0].layerName, QString("Dissolved"));
+ QCOMPARE(imported.conversions[0].message, QString("Blend mode “diss” isn’t supported and will be applied as Normal."));
QVERIFY(imported.layers[0].blendMode == LayerBlendMode::normal);
+ for (const char *key : {"dkCl", "lgCl"})
+ QVERIFY2(!fromPSD(key), key);
+ const std::vector> keys{
+ {"lbrn", LayerBlendMode::linearBurn}, {"lddg", LayerBlendMode::linearDodge}, {"hLit", LayerBlendMode::hardLight},
+ {"vLit", LayerBlendMode::vividLight}, {"lLit", LayerBlendMode::linearLight}, {"pLit", LayerBlendMode::pinLight},
+ {"hMix", LayerBlendMode::hardMix}, {"smud", LayerBlendMode::exclusion}, {"fsub", LayerBlendMode::subtract},
+ {"fdiv", LayerBlendMode::divide}};
+ for (const auto &[key, mode] : keys)
+ QCOMPARE(fromPSD(key), std::optional(mode));
}
void PSDRoundTripTests::softLightImportsWithoutConversion()
diff --git a/tests/ProjectManifestTests.cpp b/tests/ProjectManifestTests.cpp
index 7cdad47..147c47e 100644
--- a/tests/ProjectManifestTests.cpp
+++ b/tests/ProjectManifestTests.cpp
@@ -202,8 +202,9 @@ void ProjectManifestTests::encodingWritesSwiftsKeysAndLeavesAbsentOnesOut()
void ProjectManifestTests::everyRawValueRoundTrips()
{
- const QStringList blends{"Normal", "Multiply", "Screen", "Overlay", "Soft Light", "Darken", "Lighten", "Difference", "Color Dodge", "Color Burn",
- "Hue", "Saturation", "Color", "Luminosity"};
+ const QStringList blends{"Normal", "Darken", "Multiply", "Color Burn", "Linear Burn", "Lighten", "Screen", "Color Dodge",
+ "Linear Dodge (Add)", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light", "Hard Mix",
+ "Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity"};
for (int index = 0; index < blends.size(); ++index) {
QCOMPARE(rawValue(LayerBlendMode(index)), blends[index]);
QCOMPARE(layerBlendMode(blends[index]), std::optional(LayerBlendMode(index)));