The document's own pixel budget, marching ants at screen resolution (12.11a)

This commit is contained in:
雷电芽衣 committed 2026-09-29 00:19:45 -04:00
1 parent ffba1753c6
commit 8a9c56ee1f
75 files changed
+759 -189

No files matched your search

+3 -2
View File
@@ -92,14 +92,14 @@ Platform adaptations, each forced by a missing Qt analog:
- Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point).
- Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift.
- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held.
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and 100 megapixels at most (`ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and one surface at most (`DocumentLimits`, 200 megapixels since 12.11a; `ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing).
- Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`.
- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does.
- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, 100 million. Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, one surface (200 million since 12.11a). Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels).
- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena.
@@ -133,6 +133,7 @@ Platform adaptations, each forced by a missing Qt analog:
- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu.
- Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`.
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
- Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`.
- 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 <title>"), 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.
+2
View File
@@ -52,6 +52,7 @@ add_library(compositor_core STATIC
src/Document/ProjectWorkspace+Copy.cpp
src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp
src/Document/DocumentLimits.cpp
src/Document/Guides.cpp
src/Document/ToolDefaults.cpp
src/Document/ObjectSelection.cpp
@@ -194,6 +195,7 @@ add_library(compositor_core STATIC
src/Rendering/EyedropperIcon.cpp
src/Rendering/SampleRingOverlay.cpp
src/Rendering/TransformOverlay.cpp
src/Rendering/TransformOverlay+Ants.cpp
src/Rendering/HslBlend.cpp
src/Rendering/SeparableBlend.cpp
src/Rendering/LayerEffectsKernel.cpp
+3 -1
View File
@@ -378,7 +378,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.10a Versions 1.2.5–1.2.7, part one: the project format's version in one place (`4cc9951`); the layer context menu, routed by the list, with its layer actions and validation (`6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`), without the Layer Effects entry (`5d7adba`). Codex review: 3 rounds, PASS.
- [x] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). Codex review: 3 rounds, the third's finding (a history manager re-setting the current entry) fixed and closed.
- [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags).
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG).
- [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS.
- [ ] 12.11b Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`), simple Photoshop text as editable text (`b8cce6e`, `fa2b342`).
- [ ] 12.11c Part three: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`).
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
+3 -2
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Rendering/PoolMap.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
@@ -72,7 +73,7 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
: layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int originalHeight = placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000)
if (originalWidth < 1 || originalWidth > DocumentLimits::maxSide || originalHeight < 1 || originalHeight > DocumentLimits::maxSide)
throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight);
@@ -301,7 +302,7 @@ void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
return;
const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize));
const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect;
if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit)
if (nextBounds.width() > DocumentLimits::maxSide || nextBounds.height() > DocumentLimits::maxSide || nextBounds.width() * nextBounds.height() > pixelLimit)
throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
+2 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "Document/Gradient.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
@@ -98,7 +99,7 @@ public:
const QTransform pixelToDocument;
const LayerTransform paintTransform;
// Swift's Int: the budget left may run below zero.
qint64 pixelLimit = 100'000'000;
qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip;
// Clone Stamp: a document-sized image, and each point's offset.
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/CanvasSize.h"
#include "Document/DocumentLimits.h"
#include <cmath>
QString rawValue(CanvasUnit unit)
@@ -24,7 +25,7 @@ CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution)
bool CanvasSizeDraft::valid() const
{
// NaN and infinity fail both range tests.
return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000;
return std::round(width) >= 1 && std::round(width) <= DocumentLimits::maxSide && std::round(height) >= 1 && std::round(height) <= DocumentLimits::maxSide;
}
double CanvasSizeDraft::displayed(bool widthAxis) const
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Crop.h"
#include "Document/DocumentLimits.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include "IO/CanvasResizer.h"
@@ -19,7 +20,7 @@ QRectF CropGeometry::snapped(const QRectF &rect)
bool CropGeometry::valid(const QRectF &rect)
{
const QRectF standard = rect.normalized();
return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000
return standard.width() >= 1 && standard.width() <= DocumentLimits::maxSide && standard.height() >= 1 && standard.height() <= DocumentLimits::maxSide
&& std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000;
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
@@ -174,7 +175,7 @@ DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform
if (!isUsable(corners))
throw ProjectError(ProjectError::Kind::invalid);
const QRectF bounds = shapeBounds(corners);
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000)
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > DocumentLimits::maxSide || bounds.height() > DocumentLimits::maxSide || bounds.width() * bounds.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling};
// A uniform 1 × 1 mask already covers any shape.
+31
View File
@@ -0,0 +1,31 @@
#include "Document/DocumentLimits.h"
#include <QLocale>
#include <algorithm>
#include <unistd.h>
// Swift's physicalMemory / 16, read once as a static let.
qint64 DocumentLimits::documentPixelBudget()
{
static const qint64 budget = budgetFor(qint64(sysconf(_SC_PHYS_PAGES)) * qint64(sysconf(_SC_PAGE_SIZE)));
return budget;
}
qint64 DocumentLimits::budgetFor(qint64 memory)
{
return std::min<qint64>(800'000'000, std::max(maxSurfacePixels, memory / 16));
}
qint64 DocumentLimits::maxSurfaceMegapixels()
{
return maxSurfacePixels / 1'000'000;
}
qint64 DocumentLimits::documentBudgetMegapixels()
{
return documentPixelBudget() / 1'000'000;
}
QString DocumentLimits::maxSideText()
{
return QLocale(QLocale::English).toString(maxSide);
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <QString>
#include <QtGlobal>
// Swift's DocumentLimits: one surface, and a whole document's raster.
namespace DocumentLimits {
// Longest side of any canvas, layer, mask or generated surface.
inline constexpr int maxSide = 30'000;
// Largest single surface; both ceilings stay under maxSide squared.
inline constexpr qint64 maxSurfacePixels = 200'000'000;
// All layers and masks: a quarter of memory, capped.
qint64 documentPixelBudget();
// The budget for this much memory, in bytes.
qint64 budgetFor(qint64 memory);
qint64 maxSurfaceMegapixels();
qint64 documentBudgetMegapixels();
// The side limit as Swift's `formatted()` prints it: 30,000.
QString maxSideText();
}
+3 -2
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
@@ -50,7 +51,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (image)
used += qint64(image->size().width()) * image->size().height();
}
stroke->pixelLimit = 100'000'000 - used;
stroke->pixelLimit = DocumentLimits::documentPixelBudget() - used;
const std::optional<DocumentSelection> current = selection();
if (current)
stroke->selectionClip = current->clip(m_document->size());
@@ -60,7 +61,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (other.id != layer.id && other.mask)
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
}
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels);
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, DocumentLimits::documentPixelBudget() - maskPixels);
}
return stroke;
}
+5 -4
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/ProjectWorkspace.h"
#include "Document/PixelAdjust.h"
#include "IO/PSD/PSDReader.h"
@@ -108,12 +109,12 @@ void EditorSession::decodeNext()
}
const QUrl url = request.urls[m_importIndex];
if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) {
decodeRaw(url.toLocalFile(), 100'000'000 - usedPixels);
decodeRaw(url.toLocalFile(), DocumentLimits::documentPixelBudget() - usedPixels);
return;
}
if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) {
beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import"));
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = 100'000'000 - usedPixels]() -> PhotoshopRead {
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = DocumentLimits::documentPixelBudget() - usedPixels]() -> PhotoshopRead {
try {
PSDDocument document = ImageImporter::loadPhotoshop(path, remaining);
std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document);
@@ -128,7 +129,7 @@ void EditorSession::decodeNext()
}));
return;
}
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded {
m_decoder.setFuture(QtConcurrent::run([url, remaining = DocumentLimits::documentPixelBudget() - usedPixels]() -> Decoded {
try {
if (!url.isLocalFile())
throw ImageImportError(ImageImportError::Kind::unsupported);
@@ -150,7 +151,7 @@ void EditorSession::decodeRaw(const QString &path, qint64 remaining)
};
if (!size)
return refuse(ImageImportError::Kind::unreadable);
if (size->width() > 30'000 || size->height() > 30'000 || qint64(size->width()) * size->height() > remaining)
if (size->width() > DocumentLimits::maxSide || size->height() > DocumentLimits::maxSide || qint64(size->width()) * size->height() > remaining)
return refuse(ImageImportError::Kind::tooLarge);
developRaw(path, [this, path, name](std::optional<RawDevelopSettings> settings) {
if (!settings) {
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "IO/ProjectStore.h"
#include <algorithm>
@@ -85,7 +86,7 @@ std::optional<int> CanvasDocument::validDimension(const QString &value)
while (last > first && isWhitespace(value[last - 1]))
--last;
const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first));
if (!number || *number < 1 || *number > 30'000)
if (!number || *number < 1 || *number > DocumentLimits::maxSide)
return std::nullopt;
return number;
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Logging.h"
std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
@@ -69,7 +70,7 @@ void EditorSession::clearProject()
void EditorSession::createNewProject(int width, int height)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide)
return;
clearProject();
createDocument(width, height, true);
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/Crop.h"
#include "Logging.h"
#include <cmath>
@@ -282,7 +283,7 @@ void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> ce
void EditorSession::createDocument(int width, int height, bool emptyLayer)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide)
return;
commitTransform();
beginEdit(QStringLiteral("New Canvas"));
+1
View File
@@ -4,6 +4,7 @@
#include "Document/ColorPalette.h"
#include "Document/Gradient.h"
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/Filters.h"
#include "Document/LevelsAutomatic.h"
#include "Document/FloatingSelection.h"
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Filters.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "Document/ContentFill.h"
#include "Document/EditorSession+Model.h"
@@ -244,7 +245,7 @@ void FilterEdit::grow(const QRectF &extent)
const QRectF target = integral(bounds.united(extent));
if (target == bounds)
return;
if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000)
if (target.width() > DocumentLimits::maxSide || target.height() > DocumentLimits::maxSide || target.width() * target.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false);
const QRectF inside = bounds.translated(-target.left(), -target.top());
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/FloatingSelection.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
@@ -168,7 +169,7 @@ FloatingMerge::Merged FloatingMerge::merge(const QImage &pixels, const LayerTran
const QRectF floatingBounds = toPixels.mapRect(BrushRaster::pixelToDocument(transform, pixels.width(), pixels.height()).mapRect(QRectF(pixels.rect())));
const QRectF original(0, 0, width, height);
const QRectF extent = integral(original.united(floatingBounds));
if (extent.width() > 30'000 || extent.height() > 30'000 || extent.width() * extent.height() > 100'000'000)
if (extent.width() > DocumentLimits::maxSide || extent.height() > DocumentLimits::maxSide || extent.width() * extent.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage context = BrushRaster::context(int(extent.width()), int(extent.height()), false);
const QRectF placed = original.translated(-extent.topLeft());
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/LayerEffects+Renderer.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
@@ -116,7 +117,7 @@ LayerEffectsRenderer::Rendered LayerEffectsRenderer::render(const QImage &image,
throw ProjectError(ProjectError::Kind::invalid);
const double inset = margin(effects);
const qint64 width = image.width() + qint64(inset) * 2, height = image.height() + qint64(inset) * 2;
if (width <= 0 || height <= 0 || width * height > 100'000'000)
if (width <= 0 || height <= 0 || width * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
// The pixels with room round them, then the effects.
QImage padded = BrushRaster::context(int(width), int(height), false);
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerMask.h"
#include "Document/PixelAdjust.h"
#include "Document/BrushStroke.h"
@@ -280,7 +281,7 @@ void EditorSession::addMask(bool revealing)
const int width = active->asset ? active->asset->size().width() : int(std::round(active->size().width()));
const int height = active->asset ? active->asset->size().height() : int(std::round(active->size().height()));
try {
if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000)
if (width <= 0 || height <= 0 || qint64(width) * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
// The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
+3 -2
View File
@@ -1,4 +1,5 @@
#include "Document/ProjectWorkspace.h"
#include "Document/DocumentLimits.h"
#include "Document/LiveLayerMask.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
@@ -80,8 +81,8 @@ void ProjectWorkspace::copyLayers(const std::vector<QUuid> &ids, std::optional<Q
copy.layers.push_back(layer);
}
const qint64 used = copy.target->session.document() ? pixels(copy.target->session.document()->layers) : 0;
if (used + pixels(copy.layers) > 100'000'000) {
copy.target->session.setImportError(QStringLiteral("The copied layers exceed this project’s 100-megapixel limit."));
if (used + pixels(copy.layers) > DocumentLimits::documentPixelBudget()) {
copy.target->session.setImportError(QStringLiteral("The copied layers exceed this project’s %1-megapixel limit.").arg(DocumentLimits::documentBudgetMegapixels()));
finish(done);
return;
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "Document/PixelInvert.h"
@@ -11,7 +12,7 @@ namespace {
// A uniform 1×1 mask given the layer's pixel grid.
QImage expandedUniformMask(const QImage &image, int width, int height)
{
if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000)
if (width <= 0 || height <= 0 || qint64(width) * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage expanded = BrushRaster::context(width, height, true);
QPainter painter(&expanded);
+1 -1
View File
@@ -1,7 +1,7 @@
// Swift's ShapeTool extension: shapes dragged out as new layers.
public:
// Pixels one shape layer may hold, an import's budget.
static constexpr qint64 maxShapePixels = 100'000'000;
static constexpr qint64 maxShapePixels = DocumentLimits::maxSurfacePixels;
ShapeKind shapeKind() const { return m_shapeKind; }
void setShapeKind(ShapeKind kind);
// A rectangle's corner radius, in pixels; 0 keeps corners square.
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
@@ -167,7 +168,7 @@ void EditorSession::finishShape()
return;
// Swift's Int(): each side truncated before multiplying.
if (std::trunc(rect.width()) * std::trunc(rect.height()) > double(maxShapePixels)) {
setBrushError(QStringLiteral("That shape is too large. A shape can cover up to 100 megapixels."));
setBrushError(QStringLiteral("That shape is too large. A shape can cover up to %1 megapixels.").arg(DocumentLimits::maxSurfaceMegapixels()));
return;
}
// The ends as fractions of the box, for resizing.
+4 -3
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
@@ -35,7 +36,7 @@ bool LayerTextStyle::boxIsValid() const
if (!boxSize)
return true;
const double width = boxSize->width(), height = boxSize->height();
return width >= 16 && width <= 30'000 && height >= 16 && height <= 30'000 && width * height <= 100'000'000;
return width >= 16 && width <= DocumentLimits::maxSide && height >= 16 && height <= DocumentLimits::maxSide && width * height <= DocumentLimits::maxSurfacePixels;
}
bool LayerTextStyle::isValid() const
@@ -195,7 +196,7 @@ void EditorSession::beginText(const QRectF &rect)
LayerTextStyle style = m_textDefaults;
style.boxSize = QSizeF(std::max(16.0, std::round(rect.width())), std::max(16.0, std::round(rect.height())));
if (!style.boxIsValid()) {
setBrushError(QStringLiteral("That text box exceeds the 30,000-pixel or 100-megapixel limit."));
setBrushError(QStringLiteral("That text box exceeds the %1-pixel or %2-megapixel limit.").arg(DocumentLimits::maxSideText()).arg(DocumentLimits::maxSurfaceMegapixels()));
return;
}
beginText(rect.topLeft(), true);
@@ -297,7 +298,7 @@ QImage EditorSession::textImage(const LayerTextStyle &style)
const QSizeF size = textBoxSize(style);
const double width = std::ceil(size.width()), height = std::ceil(size.height());
// A valid style measures sixteen pixels at least.
if (!(width <= 30'000 && height <= 30'000 && width * height <= 100'000'000))
if (!(width <= DocumentLimits::maxSide && height <= DocumentLimits::maxSide && width * height <= DocumentLimits::maxSurfacePixels))
throw ProjectError(ProjectError::Kind::tooLarge);
QImage context = BrushRaster::context(int(width), int(height), false);
const double padding = LayerTextStyle::padding;
+3 -2
View File
@@ -1,4 +1,5 @@
#include "IO/CanvasResizer.h"
#include "Document/DocumentLimits.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
@@ -9,7 +10,7 @@
namespace {
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options)
{
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 || options.anchor < 0
if (options.width < 1 || options.width > DocumentLimits::maxSide || options.height < 1 || options.height > DocumentLimits::maxSide || options.anchor < 0
|| options.anchor > 8)
throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest;
@@ -49,7 +50,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions
qint64 used = 0;
for (const auto &[id, image] : images)
used += qint64(image.size().width()) * image.size().height();
if (options.width * options.height > 100'000'000 - used || manifest.layers.size() >= 10'000)
if (options.width * options.height > DocumentLimits::documentPixelBudget() - used || manifest.layers.size() >= 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const CanvasExtensionColor &color = *options.fill;
for (const double part : {color.red, color.green, color.blue}) {
+4 -3
View File
@@ -1,4 +1,5 @@
#include "IO/ImageExporter.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerEffects+Renderer.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
@@ -15,11 +16,11 @@
#include <cmath>
namespace {
const char *description(ExportError::Kind kind)
std::string description(ExportError::Kind kind)
{
switch (kind) {
case ExportError::Kind::tooLarge:
return "Image export supports canvases up to 100 megapixels and 30,000 pixels per side.";
return QStringLiteral("Image export supports canvases up to %1 megapixels and %2 pixels per side.").arg(DocumentLimits::maxSurfaceMegapixels()).arg(DocumentLimits::maxSideText()).toStdString();
case ExportError::Kind::render:
return "The canvas could not be rendered. Try a smaller canvas.";
case ExportError::Kind::encode:
@@ -50,7 +51,7 @@ ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kin
ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot)
try {
const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height;
if (width < 1 || width > 30'000 || height < 1 || height > 30'000 || width * height > 100'000'000)
if (width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide || width * height > DocumentLimits::maxSurfacePixels)
throw ExportError(ExportError::Kind::tooLarge);
QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied);
if (image.isNull())
+3 -2
View File
@@ -1,4 +1,5 @@
#include "IO/ImageImporter.h"
#include "Document/DocumentLimits.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "Logging.h"
@@ -20,7 +21,7 @@ QString description(ImageImportError::Kind kind)
case ImageImportError::Kind::unsupported:
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.");
return QStringLiteral("This import exceeds the current %1-megapixel document budget or %2-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels()).arg(DocumentLimits::maxSideText());
case ImageImportError::Kind::whiteBalance:
return QStringLiteral("This camera can’t record that white balance. Move Temperature or Tint back toward the shot.");
}
@@ -37,7 +38,7 @@ void checkSize(const QSize &size, qint64 remainingPixels, const QString &path)
{
if (size.width() <= 0 || size.height() <= 0)
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("it states no size"));
if (size.width() > 30'000 || size.height() > 30'000 || qint64(size.width()) * size.height() > remainingPixels)
if (size.width() > DocumentLimits::maxSide || size.height() > DocumentLimits::maxSide || qint64(size.width()) * size.height() > remainingPixels)
refuse(ImageImportError::Kind::tooLarge, path, QStringLiteral("%1 x %2 pixels").arg(size.width()).arg(size.height()));
}
+3 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include "Document/DocumentLimits.h"
#include <QImage>
#include <QString>
#include <QUuid>
@@ -48,7 +49,7 @@ namespace ImageImporter {
// Qt refuses decodes over 128 MB; the app's budgets rule.
void liftAllocationLimit();
// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied.
ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000);
PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = 100'000'000);
ImportedImage decode(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document);
}
+5 -4
View File
@@ -1,4 +1,5 @@
#include "IO/ImageResizer.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h"
#include "Document/LayerMask.h"
#include "IO/ImageExporter.h"
@@ -28,7 +29,7 @@ QImage drawn(QImage::Format format, qint64 width, qint64 height, QPointF topLeft
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions &options)
{
// NaN fails both tests of a range.
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000
if (options.width < 1 || options.width > DocumentLimits::maxSide || options.height < 1 || options.height > DocumentLimits::maxSide
|| !(options.resolution >= 1 && options.resolution <= 9600))
throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest;
@@ -38,7 +39,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
manifest.layers = old.layers;
return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks};
}
if (options.width * options.height > 100'000'000)
if (options.width * options.height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height);
// Guides scale with the pixels.
@@ -66,7 +67,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
LayerTransform sourceTransform = layer.transform;
sourceTransform.sampling = options.sampling;
if (layer.imageFile) {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedPixels)
if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > DocumentLimits::documentPixelBudget() - usedPixels)
throw ProjectError(ProjectError::Kind::tooLarge);
usedPixels += width * height;
const auto source = snapshot.images.find(layer.id);
@@ -90,7 +91,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
if (source->second.size() == QSize(1, 1) || layer.maskPlacement) {
masks.insert({layer.id, source->second});
} else {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedMaskPixels)
if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > DocumentLimits::documentPixelBudget() - usedMaskPixels)
throw ProjectError(ProjectError::Kind::tooLarge);
usedMaskPixels += width * height;
const QImage image = drawn(QImage::Format_Grayscale8, width, height, {left, top}, sx, sy, [&](QPainter &context) {
+4 -3
View File
@@ -1,4 +1,5 @@
#include "IO/PSD/PSDReader.h"
#include "Document/DocumentLimits.h"
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDVector.h"
#include "Logging.h"
@@ -222,9 +223,9 @@ void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels)
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))
if (width > 0 && height > 0 && (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > 30'000 || maskHeight > 30'000 || maskWidth * maskHeight > budget))
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > DocumentLimits::maxSide || maskHeight > DocumentLimits::maxSide || maskWidth * maskHeight > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (const Channel &channel : layer.channels) {
const qint64 start = cursor.offset;
@@ -339,7 +340,7 @@ PSDDocument parse(const QByteArray &data, qint64 remainingPixels)
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)
if (canvasWidth < 1 || canvasWidth > DocumentLimits::maxSide || canvasHeight < 1 || canvasHeight > DocumentLimits::maxSide || canvasWidth * canvasHeight > DocumentLimits::maxSurfacePixels)
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (depth != 8)
throw PSDError(PSDError::Kind::unsupportedDepth);
+3 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include "Document/DocumentLimits.h"
#include "IO/PSD/PSDTypes.h"
#include <QByteArray>
#include <QString>
@@ -9,8 +10,8 @@
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);
PSDDocument read(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
PSDDocument read(const QByteArray &data, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
extern const std::set<QString> adjustmentKeys;
}
+4 -3
View File
@@ -1,4 +1,5 @@
#include "IO/PSD/PSDVector.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include <QPainter>
@@ -76,10 +77,10 @@ QRectF integral(const QRectF &box)
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.
// Within the side limit 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)
if (std::abs(size.width()) > DocumentLimits::maxSide || std::abs(size.height()) > DocumentLimits::maxSide)
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.
@@ -214,7 +215,7 @@ std::optional<PSDVector::Raster> PSDVector::raster(const Extra &extra, QSizeF ca
return std::nullopt;
if (!std::isfinite(strokeWidth))
return std::nullopt;
if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= 30'000))
if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= DocumentLimits::maxSide))
throw ImageImportError(ImageImportError::Kind::tooLarge);
QRectF box = outline->boundingRect();
if (strokeEnabled) {
+3 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include "Document/DocumentLimits.h"
#include "Document/ShapeTool.h"
#include <QByteArray>
#include <QImage>
@@ -23,8 +24,8 @@ struct Live {
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<Live> live(const Extra &extra, QSizeF canvas, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::optional<Raster> raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::optional<QPainterPath> path(const QByteArray &data, QSizeF canvas);
struct RGB {
double r;
+20 -11
View File
@@ -1,4 +1,5 @@
#include "IO/ProjectStore.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LiveLayerMask.h"
@@ -29,7 +30,7 @@ QString description(ProjectError::Kind kind)
case ProjectError::Kind::missingImage:
return QStringLiteral("An image inside the project is missing or damaged. The current document has not been replaced.");
case ProjectError::Kind::tooLarge:
return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit.");
return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels());
case ProjectError::Kind::encode:
return QStringLiteral("An image could not be saved. The previous project has not been replaced.");
}
@@ -69,7 +70,7 @@ void validate(const ProjectManifest &manifest)
// The range refuses NaN and infinity as well.
if (manifest.resolution && !(*manifest.resolution >= 1 && *manifest.resolution <= 9600))
throw ProjectError(ProjectError::Kind::invalid);
if (manifest.width < 1 || manifest.width > 30'000 || manifest.height < 1 || manifest.height > 30'000 || manifest.layers.size() > 10'000)
if (manifest.width < 1 || manifest.width > DocumentLimits::maxSide || manifest.height < 1 || manifest.height > DocumentLimits::maxSide || manifest.layers.size() > 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const ProjectLayerRecord &layer : manifest.layers) {
// Text needs a valid style and pixels to show it.
@@ -116,11 +117,18 @@ void validate(const ProjectManifest &manifest)
validateGuides(manifest);
}
// Images share 100 million pixels; masks share another.
void checkSize(QSize size, qint64 &used)
// Images share the document budget; masks share another.
struct Budgets {
qint64 images = 0;
qint64 masks = 0;
};
// Save and load count alike: one rule for both.
void checkSize(QSize size, bool isMask, Budgets &budgets)
{
qint64 &used = isMask ? budgets.masks : budgets.images;
const qint64 pixels = qint64(size.width()) * size.height();
if (size.width() < 1 || size.width() > 30'000 || size.height() < 1 || size.height() > 30'000 || pixels > 100'000'000 - used)
if (size.width() < 1 || size.width() > DocumentLimits::maxSide || size.height() < 1 || size.height() > DocumentLimits::maxSide || pixels > DocumentLimits::documentPixelBudget() - used)
throw ProjectError(ProjectError::Kind::tooLarge);
used += pixels;
}
@@ -225,7 +233,7 @@ void replace(const QString &staged, const QString &destination, const QString &p
}
// The pixels and a thumbnail of at most 96 pixels.
ImportedImage asset(const QString &file, bool isMask, const QString &name, qint64 &used)
ImportedImage asset(const QString &file, bool isMask, const QString &name, Budgets &used)
{
ImageImporter::liftAllocationLimit();
QImageReader reader(file);
@@ -234,7 +242,7 @@ ImportedImage asset(const QString &file, bool isMask, const QString &name, qint6
|| stored == QImage::Format_Grayscale16;
if (reader.format() != "png" || !reader.size().isValid() || deep)
throw ProjectError(ProjectError::Kind::missingImage);
checkSize(reader.size(), used);
checkSize(reader.size(), isMask, used);
QImage image = reader.read();
if (!isMask)
image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
@@ -271,7 +279,7 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
{
validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files;
qint64 pixels = 0, maskPixels = 0;
Budgets budgets;
for (const ProjectLayerRecord &layer : snapshot.manifest.layers) {
for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
@@ -281,10 +289,11 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
const auto found = assets.find(layer.id);
if (found == assets.end())
throw ProjectError(ProjectError::Kind::missingImage);
// Swift's lazy image has its size unrendered: judged first.
checkSize(found->second.size(), isMask, budgets);
const QImage image = found->second.image();
if (isMask && !LayerMask::isValid(image))
throw ProjectError(ProjectError::Kind::invalid);
checkSize(image.size(), isMask ? maskPixels : pixels);
files.push_back({*filename, png(image)});
}
}
@@ -340,7 +349,7 @@ ProjectSnapshot ProjectStore::load(const QString &path)
const ProjectManifest manifest = ProjectManifest::decoded(metadata);
validate(manifest);
ProjectSnapshot snapshot{.manifest = manifest, .images = {}, .masks = {}};
qint64 pixels = 0, maskPixels = 0;
Budgets budgets;
for (const ProjectLayerRecord &layer : manifest.layers) {
for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
@@ -348,7 +357,7 @@ ProjectSnapshot ProjectStore::load(const QString &path)
continue;
const QString file = path + QLatin1String("/images/") + *filename;
checkFile(file, path, assetBytes, ProjectError::Kind::missingImage);
(isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, isMask ? maskPixels : pixels)});
(isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, budgets)});
}
}
qCInfo(lcIO) << "loaded" << path << "with" << manifest.layers.size() << "layers and" << snapshot.images.size() + snapshot.masks.size() << "images";
+2 -1
View File
@@ -1,10 +1,11 @@
#pragma once
#include "Document/DocumentLimits.h"
#include <QPainter>
#include <functional>
// Draws through an image surface over what the painter shows.
namespace AdjustmentSurface {
inline constexpr qint64 pixelBudget = 100'000'000;
inline constexpr qint64 pixelBudget = DocumentLimits::maxSurfacePixels;
// Padding, in painter units, is drawn past what shows.
void draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding = 0,
qint64 pixelBudget = AdjustmentSurface::pixelBudget);
+2 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include "Document/DocumentLimits.h"
#include <QImage>
#include <mutex>
#include <optional>
@@ -9,7 +10,7 @@
class DownsampleCache {
public:
// Pixels of halved copies kept at once.
static constexpr qint64 pixelBudget = 100'000'000;
static constexpr qint64 pixelBudget = DocumentLimits::maxSurfacePixels;
// Most halvings ever used; QPainter does the rest.
static constexpr int maxLevel = 6;
+9
View File
@@ -288,6 +288,15 @@ void CanvasView::stopMarqueeAutoscroll()
m_marqueeAutoscrollPoint = std::nullopt;
}
void CanvasView::stepAnts()
{
if (m_antsRepaintPending)
return;
m_overlay.antsPhase = std::fmod(m_overlay.antsPhase + 1, 8.0);
m_antsRepaintPending = true;
update(m_overlay.selectionRect(rect()));
}
// Marching ants animate only while a visible selection exists.
void CanvasView::updateAntsTimer()
{
+3 -4
View File
@@ -67,10 +67,8 @@ CanvasView::CanvasView(EditorSession &session, QWidget *parent)
connect(&m_marqueeAutoscroll, &QTimer::timeout, this, &CanvasView::stepMarqueeAutoscroll);
m_antsTimer.setObjectName(QStringLiteral("antsTimer"));
m_antsTimer.setInterval(120);
connect(&m_antsTimer, &QTimer::timeout, this, [this] {
m_overlay.antsPhase = std::fmod(m_overlay.antsPhase + 1, 8.0);
update(m_overlay.selectionRect(rect()));
});
connect(&m_antsTimer, &QTimer::timeout, this, &CanvasView::stepAnts);
m_overlay.repaintAnts = [this] { update(m_overlay.selectionRect(rect())); };
}
CanvasView::DisplayState CanvasView::displayState() const
@@ -205,6 +203,7 @@ std::optional<QRectF> CanvasView::renderBounds() const
void CanvasView::paintEvent(QPaintEvent *event)
{
m_antsRepaintPending = false;
// The dirty rect, image-backed: hand blends and coverage read pixels.
QPainter painter(this);
painter.setClipRect(event->rect());
+3
View File
@@ -202,6 +202,7 @@ private:
void stepMarqueeAutoscroll();
void stopMarqueeAutoscroll();
void updateAntsTimer();
void stepAnts();
// Swift's brush gestures (EditorCanvas+Brush.cpp).
void brushMouseDown(QPointF point, Qt::KeyboardModifiers modifiers);
bool brushMouseMove(QPointF point, Qt::KeyboardModifiers modifiers, bool dragging);
@@ -291,6 +292,8 @@ private:
QTimer m_marqueeAutoscroll;
std::optional<QPointF> m_marqueeAutoscrollPoint;
QTimer m_antsTimer;
// A repaint still pending skips a tick: slow outlines stutter.
bool m_antsRepaintPending = false;
std::optional<DocumentSelection> m_displayedOutline;
std::optional<LassoDraft> m_displayedDraft;
std::optional<ShapeShown> m_displayedShape;
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Rendering/LayerEffectsKernel.h"
#include "Document/DocumentLimits.h"
#include "Rendering/PoolMap.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
@@ -156,7 +157,7 @@ QImage LayerEffectsKernel::render(const QImage &pixels, const LayerEffects &effe
const int width = pixels.width(), height = pixels.height();
const qint64 count = qint64(width) * height;
// Past Metal's 80 million Swift draws on its CPU.
if (count <= 0 || count > 100'000'000)
if (count <= 0 || count > DocumentLimits::maxSurfacePixels)
throw ExportError(ExportError::Kind::tooLarge);
const QImage source = pixels.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
+2 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include "Document/DocumentLimits.h"
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include <QImage>
@@ -13,7 +14,7 @@
// Per-render dependency cache: a layer shows through its source's alpha.
class LiveMaskRenderer {
public:
static constexpr qint64 pixelBudget = 100'000'000;
static constexpr qint64 pixelBudget = DocumentLimits::maxSurfacePixels;
using Source = std::function<std::optional<QUuid>(QUuid)>;
// Draws a layer through `clip`: device-sized coverage, null for none.
using DrawOwn = std::function<void(QUuid, QPainter &, const QImage &clip)>;
+87
View File
@@ -0,0 +1,87 @@
#include "Document/MagicWand.h"
#include "Rendering/TransformOverlay.h"
#include "Logging.h"
#include <QtConcurrent>
#include <cmath>
// Swift's level of detail for marching ants, zoomed out.
namespace {
// Marquees and lassos stay exact: this many elements draw whole.
constexpr int fullDetailLimit = 20'000;
}
std::optional<QPainterPath> TransformOverlay::antsOutline(const QPainterPath &path, double deviceScale) const
{
if (m_antsSource != path) {
m_antsSource = path;
m_antsTask.reset();
m_antsLevel.reset();
m_antsPendingStep.reset();
m_antsSourceIsComplex = path.elementCount() > fullDetailLimit;
}
const double scale = m_session.viewport.pointsPerPixel() * deviceScale;
if (!m_antsSourceIsComplex || scale >= 1)
return path;
// Screen pixels a document pixel, a power of two above.
const double step = std::min(1.0, std::pow(2, std::ceil(std::log2(std::max(scale, 1.0 / 4096)))));
if ((!m_antsLevel || m_antsLevel->step != step) && m_antsPendingStep != step) {
m_antsPendingStep = step;
m_antsTask = std::make_unique<QFutureWatcher<std::optional<QPainterPath>>>();
QFutureWatcher<std::optional<QPainterPath>> *watcher = m_antsTask.get();
QObject::connect(watcher, &QFutureWatcherBase::finished, [this, watcher, step] {
m_antsPendingStep.reset();
if (const std::optional<QPainterPath> traced = watcher->result())
m_antsLevel = AntsLevel{*traced, step};
if (repaintAnts)
repaintAnts();
});
const QRectF canvas(QPointF(0, 0), m_session.document().value().size());
watcher->setFuture(QtConcurrent::run([path, canvas, step] { return traceOutline(path, canvas, step); }));
}
// The last step stands in until the new one lands.
return m_antsLevel ? std::optional(m_antsLevel->path) : std::nullopt;
}
// Filled, averaged down to `step`, traced along the pixels' edges.
std::optional<QPainterPath> TransformOverlay::traceOutline(const QPainterPath &path, QRectF canvas, double step)
{
const QRectF box = path.boundingRect().intersected(canvas);
const QRectF region(QPointF(std::floor(box.left()), std::floor(box.top())), QPointF(std::ceil(box.right()), std::ceil(box.bottom())));
if (box.isEmpty() || region.width() < 1 || region.height() < 1)
return std::nullopt;
// Half resolution at least, about 40 megapixels at most.
const double fill = std::min(1.0, std::max(step, std::sqrt(40'000'000 / (region.width() * region.height()))));
QImage filled(std::max(1, int(std::ceil(region.width() * fill))), std::max(1, int(std::ceil(region.height() * fill))), QImage::Format_Grayscale8);
if (filled.isNull())
return std::nullopt;
filled.fill(0);
{
QPainter painter(&filled);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.scale(fill, fill);
painter.translate(-region.topLeft());
painter.fillPath(path, Qt::white);
}
const int width = std::max(1, int(std::ceil(region.width() * step))), height = std::max(1, int(std::ceil(region.height() * step)));
const QImage small = filled.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
return std::nullopt;
// Any coverage counts, so thin parts keep their outline.
std::vector<uchar> pixels(size_t(width) * size_t(height), 0);
for (int row = 0; row < height; ++row) {
const uchar *line = small.constScanLine(row);
for (int column = 0; column < width; ++column)
pixels[size_t(row) * size_t(width) + size_t(column)] = line[column] > 0 ? 255 : 0;
}
std::optional<QPainterPath> traced;
try {
traced = MagicWand::outline(pixels, width, height);
} catch (const MagicWandError &error) {
// Swift's `try?` is silent; the ants wait a step.
qCWarning(lcRendering) << "the ants' outline could not be traced:" << error.what();
return std::nullopt;
}
if (!traced)
return std::nullopt;
return QTransform::fromTranslate(region.left(), region.top()).scale(1 / step, 1 / step).map(*traced);
}
+4 -1
View File
@@ -273,7 +273,10 @@ void TransformOverlay::drawSelection(QPainter &context) const
const std::optional<DocumentSelection> selection = m_session.displayedSelection();
if (!selection || selection->isEmpty() || !m_session.document())
return;
const QPainterPath path = documentToView().map(selection->path);
const std::optional<QPainterPath> outline = antsOutline(selection->path, context.device()->devicePixelRatioF());
if (!outline)
return;
const QPainterPath path = documentToView().map(*outline);
context.save();
context.setRenderHint(QPainter::Antialiasing, true);
context.setBrush(Qt::NoBrush);
+17
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Document/EditorSession.h"
#include <QFutureWatcher>
#include <QPainter>
#include <QPalette>
#include <array>
@@ -31,6 +32,8 @@ public:
// The marching ants' dash offset, stepped by the canvas.
double antsPhase = 0;
// Asked for when a traced outline lands: repaint the ants.
std::function<void()> repaintAnts;
std::optional<TransformOverlayGeometry> geometry() const;
// Swift's gradientLine: the pending gradient's ends in view points.
@@ -57,8 +60,22 @@ private:
void drawGradientLine(QPainter &context, QPointF start, QPointF end) const;
void drawCrop(QPainter &context) const;
void drawSelection(QPainter &context) const;
// Swift's antsOutline: the path, or zoomed out, one traced.
std::optional<QPainterPath> antsOutline(const QPainterPath &path, double deviceScale) const;
static std::optional<QPainterPath> traceOutline(const QPainterPath &path, QRectF canvas, double step);
void drawLassoDraft(QPainter &context) const;
void drawSnapGuides(QPainter &context, const QPalette &palette) const;
const EditorSession &m_session;
// Swift's level of detail, cached per power-of-two zoom step.
struct AntsLevel {
QPainterPath path;
double step;
};
mutable std::optional<QPainterPath> m_antsSource;
mutable bool m_antsSourceIsComplex = false;
mutable std::optional<AntsLevel> m_antsLevel;
mutable std::optional<double> m_antsPendingStep;
// A new trace replaces the watcher: the old result drops.
mutable std::unique_ptr<QFutureWatcher<std::optional<QPainterPath>>> m_antsTask;
};
+2 -1
View File
@@ -1,4 +1,5 @@
#include "UI/CanvasSizeSheet.h"
#include "Document/DocumentLimits.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
@@ -280,7 +281,7 @@ void CanvasSizeSheet::synchronize()
const qint64 width = qint64(std::round(m_draft.width)), height = qint64(std::round(m_draft.height));
m_note->setText(QStringLiteral("New: %1 × %2 pixels · %3 uncompressed").arg(grouped(width), grouped(height), ByteCounts::memory(width * height * 4)));
} else {
m_note->setText(QStringLiteral("Final dimensions must be 1–30,000 pixels per side."));
m_note->setText(QStringLiteral("Final dimensions must be 1–%1 pixels per side.").arg(DocumentLimits::maxSideText()));
}
m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText);
for (QAbstractButton *each : m_anchors->buttons()) {
+4 -3
View File
@@ -1,4 +1,5 @@
#include "UI/ImageSizeSheet.h"
#include "Document/DocumentLimits.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
@@ -163,8 +164,8 @@ ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<voi
bool ImageSizeSheet::valid() const
{
const double width = std::round(m_width), height = std::round(m_height);
return m_resolution >= 1 && m_resolution <= 9600 && width >= 1 && width <= 30'000 && height >= 1 && height <= 30'000
&& (!m_resample || width * height <= 100'000'000);
return m_resolution >= 1 && m_resolution <= 9600 && width >= 1 && width <= DocumentLimits::maxSide && height >= 1 && height <= DocumentLimits::maxSide
&& (!m_resample || width * height <= DocumentLimits::maxSurfacePixels);
}
double ImageSizeSheet::display(double pixels, qint64 original) const
@@ -267,7 +268,7 @@ void ImageSizeSheet::synchronize()
: QStringLiteral("Only print dimensions and resolution change. Pixels stay unchanged."));
const bool isValid = valid();
m_result->setText(isValid ? QStringLiteral("Result: %1 × %2 pixels").arg(grouped(qint64(std::round(m_width))), grouped(qint64(std::round(m_height))))
: QStringLiteral("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
: QStringLiteral("Use 1–%1 pixels per side, up to %2 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSideText()).arg(DocumentLimits::maxSurfaceMegapixels()));
m_result->setForegroundRole(isValid ? QPalette::PlaceholderText : QPalette::BrightText);
m_resize->setEnabled(isValid);
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "UI/NewCanvasSheet.h"
#include "Document/DocumentLimits.h"
#include "UI/KeyboardShortcuts.h"
#include <QApplication>
#include <QBuffer>
@@ -99,7 +100,7 @@ void NewCanvasSheet::synchronize()
void NewCanvasSheet::validate()
{
const bool valid = CanvasDocument::validDimension(m_width->text()) && CanvasDocument::validDimension(m_height->text());
m_note->setText(valid ? QStringLiteral("Transparent canvas · sRGB") : QStringLiteral("Enter whole numbers from 1 to 30,000 pixels."));
m_note->setText(valid ? QStringLiteral("Transparent canvas · sRGB") : QStringLiteral("Enter whole numbers from 1 to %1 pixels.").arg(DocumentLimits::maxSideText()));
// The palette's bright text is the theme's warning colour.
m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText);
m_create->setEnabled(valid);
+3 -3
View File
@@ -269,9 +269,9 @@ void AdjustmentEditorTests::theEditorOpensOnlyForItsRequest()
void AdjustmentEditorTests::aFailedRenderEndsTheRequest()
{
// Past the export's hundred million pixels: the render refuses.
// Past the export's one surface: the render refuses.
EditorSession session;
session.createDocument(10'001, 10'000);
session.createDocument(20'001, 10'000);
session.addAdjustment(AdjustmentKind::curves);
const int steps = session.history.undoCount();
int ran = 0;
@@ -481,7 +481,7 @@ void AdjustmentEditorTests::everyStepIsAnnouncedAndItsLastSignalSeesIt()
// A failed render's last signal sees the request end.
EditorSession huge;
connect(&huge, &EditorSession::changed, this, [&] { seen = described(huge); });
huge.createDocument(10'001, 10'000);
huge.createDocument(20'001, 10'000);
huge.addAdjustment(AdjustmentKind::levels);
seen.clear();
QVERIFY(opened(huge, huge.adjustmentEditingID().value()));
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Rendering/RasterSnapshot.h"
#include <vector>
// A layer claiming its size in pixels, holding one.
inline ImportedImage claimed(int width, int height, const QString &name = QStringLiteral("Claimed"))
{
const auto raster = std::make_shared<const RasterSnapshot>(width, height, BrushRaster::context(1, 1, false), QRectF(0, 0, width, height),
std::vector<BrushPatch>{});
return ImportedImage(raster, QImage(), name);
}
// Layers claiming `pixels` in all, each within one surface.
inline std::vector<ImportedImage> claiming(qint64 pixels)
{
std::vector<ImportedImage> layers;
const int side = DocumentLimits::maxSide;
while (pixels > 0) {
const qint64 rows = std::min<qint64>(pixels / side, DocumentLimits::maxSurfacePixels / side);
layers.push_back(rows > 0 ? claimed(side, int(rows)) : claimed(int(pixels), 1));
pixels -= rows > 0 ? side * rows : pixels;
}
return layers;
}
+13 -7
View File
@@ -1,3 +1,4 @@
#include "BudgetFixtures.h"
#include "AddressSpaceLimit.h"
#include "Document/CanvasSize.h"
#include "Document/EditorSession.h"
@@ -264,13 +265,18 @@ void CanvasSizeTests::masksPlacedApartMoveWithTheirLayers()
void CanvasSizeTests::theExtensionCountsAgainstTheBudgets()
{
// A painted 9000 x 9000 layer leaves 19 megapixels.
const QUuid id = QUuid::createUuid();
const std::shared_ptr<const RasterSnapshot> raster = painted(solid(9000, 9000, qRgba(0, 0, 0, 0)), {});
const ProjectLayerRecord record{.id = id, .name = "Painted", .isVisible = true, .transform = {.origin = {0, 0}, .size = {9000, 9000}},
.imageFile = uuidString(id) + ".png"};
const ProjectSnapshot input{.manifest = {.documentID = QUuid::createUuid(), .width = 100, .height = 100, .activeLayerID = id, .layers = {record}},
.images = {{id, ImportedImage(raster, solid(1, 1, 0), "Painted")}}};
// Painted layers leave 19 megapixels of the document budget.
ProjectSnapshot input{.manifest = {.documentID = QUuid::createUuid(), .width = 100, .height = 100, .activeLayerID = std::nullopt, .layers = {}},
.images = {}};
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 19'000'000)) {
const QUuid layerID = QUuid::createUuid();
input.manifest.layers.push_back({.id = layerID, .name = "Painted", .isVisible = true, .transform = {.origin = {0, 0}, .size = QSizeF(layer.size())},
.imageFile = uuidString(layerID) + ".png"});
input.images.insert({layerID, layer});
}
const QUuid id = input.manifest.layers.front().id;
input.manifest.activeLayerID = id;
const std::shared_ptr<const RasterSnapshot> raster = input.images.at(id).raster;
const CanvasExtensionColor white{1, 1, 1};
QVERIFY(!raster->hasMaterializedPixels());
QCOMPARE(resizeError(input, {.width = 4750, .height = 4001, .fill = white}), std::optional(ProjectError::Kind::tooLarge));
+32
View File
@@ -0,0 +1,32 @@
#include "Document/DocumentLimits.h"
#include <QtTest>
#include <unistd.h>
// Swift 1.2.8's DocumentLimits: one surface, and a document's budget.
class DocumentLimitsTests : public QObject {
Q_OBJECT
private slots:
void theCeilingsAreSwifts();
};
void DocumentLimitsTests::theCeilingsAreSwifts()
{
QCOMPARE(DocumentLimits::maxSide, 30'000);
QCOMPARE(DocumentLimits::maxSurfacePixels, qint64(200'000'000));
QCOMPARE(DocumentLimits::maxSurfaceMegapixels(), qint64(200));
QCOMPARE(DocumentLimits::maxSideText(), QString("30,000"));
// A quarter of memory, four bytes a pixel, clamped.
const qint64 memory = qint64(sysconf(_SC_PHYS_PAGES)) * qint64(sysconf(_SC_PAGE_SIZE));
const qint64 budget = std::clamp(memory / 16, qint64(200'000'000), qint64(800'000'000));
QCOMPARE(DocumentLimits::documentPixelBudget(), budget);
QCOMPARE(DocumentLimits::documentBudgetMegapixels(), budget / 1'000'000);
// Swift's 537 MP on 8 GB; the cap, the floor.
QCOMPARE(DocumentLimits::budgetFor(8ll << 30), qint64(536'870'912));
QCOMPARE(DocumentLimits::budgetFor(16ll << 30), qint64(800'000'000));
QCOMPARE(DocumentLimits::budgetFor(2ll << 30), qint64(200'000'000));
// Both stay under a square at the side limit.
QVERIFY(DocumentLimits::documentPixelBudget() < qint64(DocumentLimits::maxSide) * DocumentLimits::maxSide);
}
QTEST_GUILESS_MAIN(DocumentLimitsTests)
#include "DocumentLimitsTests.moc"
+1 -1
View File
@@ -104,7 +104,7 @@ void ExportTests::blankCanvasAndOversizedCanvas()
const auto sized = [](int width, int height) {
return ProjectSnapshot{.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = std::nullopt, .layers = {}}, .images = {}};
};
for (const QSize &size : {QSize(30'000, 30'000), QSize(30'001, 1), QSize(1, 30'001), QSize(0, 5), QSize(5, 0), QSize(10'001, 10'000)}) {
for (const QSize &size : {QSize(30'000, 30'000), QSize(30'001, 1), QSize(1, 30'001), QSize(0, 5), QSize(5, 0), QSize(20'001, 10'000)}) {
try {
ImageExporter::render(sized(size.width(), size.height()));
QFAIL("an oversized or empty canvas must be refused");
+2 -2
View File
@@ -183,10 +183,10 @@ void FilterModelTests::aBlurGrowsTheGridAndNeverShrinksIt()
// A motion blur's reach is half its streak.
const FilterEdit motion(FilterKind::motionBlur, layer(4, 4), std::nullopt, FilterSettings{.distance = 9}, std::nullopt);
QCOMPARE(motion.grownImage.value().size(), QSize(18, 18));
// Past 30,000 a side or 100 million pixels: refused.
// Past 30,000 a side or one surface's pixels: refused.
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(29990, 1), std::nullopt, FilterSettings{.radius = 3}, std::nullopt));
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(1, 29990), std::nullopt, FilterSettings{.radius = 3}, std::nullopt));
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, ImageLayer(painted(9990, 9990), QPointF()), std::nullopt,
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, ImageLayer(painted(14'140, 14'140), QPointF()), std::nullopt,
FilterSettings{.radius = 3}, std::nullopt));
}
+2 -1
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h"
#include <QtTest>
@@ -272,7 +273,7 @@ void GroupTests::projectErrorsCarryTheirMessages()
const ProjectError tooLarge(ProjectError::Kind::tooLarge);
QCOMPARE(tooLarge.kind, ProjectError::Kind::tooLarge);
QCOMPARE(QString(tooLarge.what()),
QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."));
QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels()));
const ProjectError encode(ProjectError::Kind::encode);
QCOMPARE(encode.kind, ProjectError::Kind::encode);
+9 -12
View File
@@ -1,3 +1,4 @@
#include "BudgetFixtures.h"
#include "Document/EditorSession.h"
#include "Rendering/RasterSnapshot.h"
#include <QSignalSpy>
@@ -8,7 +9,7 @@
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, TIFF, or Photoshop (PSD) file.");
const QString tooLarge = QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
const QString tooLarge = QStringLiteral("This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels());
// A 64 by 32 PNG, as Swift's fixture is.
QUrl fixture(const QTemporaryDir &folder, const QString &name = QStringLiteral("fixture.png"))
@@ -52,12 +53,6 @@ public:
return true;
}
};
// Pixels the canvas holds without the memory for them.
ImportedImage hollow(int width, int height)
{
return ImportedImage(std::make_shared<const RasterSnapshot>(width, height, QImage(), QRectF(), std::vector<BrushPatch>()), QImage(), "Hollow");
}
}
class ImageImportSessionTests : public QObject {
@@ -314,17 +309,19 @@ void ImageImportSessionTests::thePixelBudgetCountsWhatTheCanvasHolds()
QTemporaryDir folder;
const QUrl url = fixture(folder);
EditorSession session;
// 100,000,000 less these leaves 2,048: the fixture exactly.
session.insert(hollow(10'000, 9'999));
session.insert(hollow(7'952, 1));
// The budget less these leaves 2,048: the fixture exactly.
const std::vector<ImportedImage> filling = claiming(DocumentLimits::documentPixelBudget() - 2'048);
for (const ImportedImage &layer : filling)
session.insert(layer);
session.addBlankLayer();
const int count = int(filling.size()) + 2;
QVERIFY(imported(session, {url}));
QCOMPARE(int(session.document().value().layers.size()), 4);
QCOMPARE(int(session.document().value().layers.size()), count);
QCOMPARE(session.importError(), std::nullopt);
// Full now: the next pixel is one too many.
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*fixture\\.png: .*"));
QVERIFY(imported(session, {url}));
QCOMPARE(int(session.document().value().layers.size()), 4);
QCOMPARE(int(session.document().value().layers.size()), count);
QCOMPARE(session.importError(), std::optional("fixture.png: " + tooLarge));
QVERIFY(!session.document().value().layers[0].asset.value().raster->hasMaterializedPixels());
}
+2 -1
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "AddressSpaceLimit.h"
#include "IO/ImageImporter.h"
#include <QBuffer>
@@ -461,7 +462,7 @@ 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, 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."));
QStringLiteral("This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels()));
QCOMPARE(ImageImportError(ImageImportError::Kind::tooLarge).kind, ImageImportError::Kind::tooLarge);
}
+6 -5
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ImageSizeSheet.h"
#include <QCheckBox>
@@ -154,7 +155,7 @@ void ImageSizeSheetTests::aNewResolutionKeepsThePrintSize()
// Zero changes nothing but the resolution, which is refused.
shown.unit("Inches");
shown.type("imageResolution", "0");
QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels()));
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
// Centimetres keep the print size alike.
@@ -203,14 +204,14 @@ void ImageSizeSheetTests::anInvalidSizeRestsResize()
QVERIFY(resize.isDefault() && resize.isEnabled());
shown.find<QCheckBox>("imageLocked").click();
shown.type("imageWidth", "30001");
QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels()));
QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::BrightText);
QVERIFY(!resize.isEnabled());
// Past 100 megapixels while resampling.
// Past one surface's 200 megapixels while resampling.
shown.type("imageWidth", "20000");
shown.type("imageHeight", "5001");
shown.type("imageHeight", "10001");
QVERIFY(!resize.isEnabled());
shown.type("imageHeight", "5000");
shown.type("imageHeight", "10000");
QVERIFY(resize.isEnabled());
QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::PlaceholderText);
shown.type("imageResolution", "9601");
+43 -25
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "AddressSpaceLimit.h"
#include "Document/EditorSession.h"
#include "Document/LayerMask.h"
@@ -170,8 +171,8 @@ void ImageSizeTests::limitsAreJudgedAtTheirEdges()
QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), std::nullopt);
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'001, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, {.width = 20'000, .height = 10'000, .resolution = 72}), std::nullopt);
QCOMPARE(resizeError(input, {.width = 20'000, .height = 10'001, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, {.width = 30'000, .height = 1, .resolution = 72}), std::nullopt);
QCOMPARE(resizeError(input, {.width = 1, .height = 30'000, .resolution = 72}), std::nullopt);
// The same size skips every other rule: only resolution changes.
@@ -183,39 +184,56 @@ void ImageSizeTests::limitsAreJudgedAtTheirEdges()
void ImageSizeTests::budgetsCountEveryLayer()
{
// Scaled a hundredfold, the small layer takes a megapixel.
// Widened twice: a narrow layer, the remainder; wide, rows.
const qint64 budget = DocumentLimits::documentPixelBudget();
const qint64 rows = (budget - 1) / 30'000, rest = budget - rows * 30'000;
ProjectSnapshot input = imported();
input.manifest.width = 100;
input.manifest.height = 100;
ProjectLayerRecord small = input.manifest.layers[0];
small.transform = {.origin = {0, 0}, .size = {10, 10}};
ProjectLayerRecord whole = small;
whole.id = QUuid::createUuid();
whole.imageFile = uuidString(whole.id) + ".png";
whole.transform = {.origin = {0, 0}, .size = {100, 100}};
input.images.insert({whole.id, asset(halfRed(), "whole")});
input.manifest.layers = {small, whole};
ProjectLayerRecord narrow = input.manifest.layers[0];
narrow.transform = {.origin = {0, 0}, .size = {double(rest) / 2, 1}};
ProjectLayerRecord wide = narrow;
wide.id = QUuid::createUuid();
wide.imageFile = uuidString(wide.id) + ".png";
wide.transform = {.origin = {0, 0}, .size = {15'000, double(rows)}};
const ImageSizeOptions widened{.width = 200, .height = 100, .resolution = 72};
const auto tooLarge = std::optional(ProjectError::Kind::tooLarge);
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
// A megapixel and 99 more fill the budget exactly.
input.manifest.layers[1].transform.size = {99, 100};
const ProjectSnapshot full = ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(full.images.at(whole.id).size(), QSize(9'900, 10'000));
input.manifest.layers[1].transform.size = {99.01, 100};
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
// Exactly the budget passes: the wide layer's missing image fails.
input.manifest.layers = {narrow, wide};
QCOMPARE(resizeError(input, widened), std::optional(ProjectError::Kind::missingImage));
// One pixel more, counted across both layers, does not.
input.manifest.layers[0].transform.size = {double(rest + 1) / 2, 1};
QCOMPARE(resizeError(input, widened), tooLarge);
input.manifest.layers = {wide};
QCOMPARE(resizeError(input, widened), std::optional(ProjectError::Kind::missingImage));
// Masks have their own budget, counted the same way.
input.manifest.layers = {small, whole};
input.manifest.layers = {narrow, wide};
input.manifest.layers[0].transform.size = {double(rest + 1) / 2, 1};
for (ProjectLayerRecord &layer : input.manifest.layers) {
layer.imageFile = std::nullopt;
layer.maskFile = uuidString(layer.id) + ".mask.png";
input.masks.insert({layer.id, LayerMask::assetFrom(gray(4, 4, 200))});
}
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
input.manifest.layers[1].transform.size = {99, 100};
QCOMPARE(ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest})
.masks.at(whole.id).size(), QSize(9'900, 10'000));
input.manifest.layers[1].transform.size = {99.01, 100};
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
QCOMPARE(resizeError(input, widened), tooLarge);
// Exactly the budget passes: its surface then finds no memory.
input.manifest.layers[0].transform.size = {double(rest) / 2, 1};
std::optional<ExportError::Kind> kind;
{
const AddressSpaceLimit limit(16ll * 1024 * 1024);
try {
ImageResizer::resize(input, widened);
} catch (const ExportError &error) {
kind = error.kind;
}
}
QCOMPARE(kind, std::optional(ExportError::Kind::render));
// Past one surface, within the document's: a mask still fits.
if (budget > DocumentLimits::maxSurfacePixels + 30'000) {
const int past = int(DocumentLimits::maxSurfacePixels / 30'000) + 1;
input.manifest.layers = {input.manifest.layers[1]};
input.manifest.layers[0].transform.size = {15'000, double(past)};
QCOMPARE(ImageResizer::resize(input, widened).masks.at(wide.id).size(), QSize(30'000, past));
}
// A side past 30,000 is refused though the pixels fit.
ProjectSnapshot strip = imported();
strip.manifest.layers[0].transform = {.origin = {0, 0}, .size = {64, 1}};
+2 -2
View File
@@ -282,8 +282,8 @@ void ImageTrimTests::nothingToTrimOrACancelChangesNothing()
void ImageTrimTests::aTrimThatFailsIsExplained()
{
Desk desk;
// Past the render's hundred megapixels, the trim cannot look.
desk.session.createDocument(20'000, 10'000);
// Past the render's one surface, the trim cannot look.
desk.session.createDocument(20'001, 10'000);
const CanvasDocument before = desk.session.document().value();
DialogDesk alerts;
alerts.replies = {"OK"};
+2 -2
View File
@@ -280,8 +280,8 @@ void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels()
void LayerEffectsKernelTests::theKernelRefusesWhatItCannotHold()
{
// Past a hundred million pixels, before any pixel is read.
const QImage vast(10'001, 10'000, QImage::Format_Mono);
// Past one surface's pixels, before any pixel is read.
const QImage vast(20'001, 10'000, QImage::Format_Mono);
LayerEffects effects;
effects.stroke = StrokeEffect();
try {
+2 -2
View File
@@ -128,7 +128,7 @@ void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel()
LayerEffects shadow;
shadow.shadow = ShadowEffect{.distance = 10};
try {
LayerEffectsRenderer::render(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow);
LayerEffectsRenderer::render(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow);
QFAIL("a vast layer rendered");
} catch (const ProjectError &error) {
QCOMPARE(error.kind, ProjectError::Kind::tooLarge);
@@ -188,7 +188,7 @@ void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget()
// Too large to make: the layer draws bare.
LayerEffects shadow;
shadow.shadow = ShadowEffect{.distance = 10};
QVERIFY(!LayerEffectsRenderer::cached(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow));
QVERIFY(!LayerEffectsRenderer::cached(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow));
}
void LayerEffectsRendererTests::theExportAndTheCompositeDrawTheEffects()
+201
View File
@@ -0,0 +1,201 @@
#include "SelectionCanvasFixtures.h"
// Swift 1.2.10 (8eba077): ants from a screen-resolution outline.
namespace {
// Alternate single pixels: far past the 20,000 elements drawn whole.
QPainterPath checkerboard(QRect region)
{
QPainterPath path;
path.setFillRule(Qt::WindingFill);
for (int y = region.top(); y <= region.bottom(); ++y) {
for (int x = region.left(); x <= region.right(); ++x) {
if ((x + y) % 2 == 0)
path.addRect(QRectF(x, y, 1, 1));
}
}
return path;
}
// Pixels the ants changed within `area`, against the bare canvas.
int marked(const QImage &shot, const QImage &bare, QRect area)
{
int count = 0;
for (int y = area.top(); y <= area.bottom(); ++y) {
for (int x = area.left(); x <= area.right(); ++x)
count += shot.pixel(x, y) != bare.pixel(x, y);
}
return count;
}
// A border band around a document rectangle, in view pixels.
QRect band(Canvas &shown, QRect region)
{
const QSizeF size = shown.documentSize();
return QRectF(shown.session.viewport.viewPoint(region.topLeft(), size), shown.session.viewport.viewPoint(region.bottomRight() + QPoint(1, 1), size))
.toAlignedRect()
.adjusted(-2, -2, 2, 2);
}
QTimer &ants(Canvas &shown)
{
return *shown.canvas->findChild<QTimer *>(QStringLiteral("antsTimer"));
}
QRect viewRect(Canvas &shown, QRect region)
{
const QSizeF size = shown.documentSize();
return QRectF(shown.session.viewport.viewPoint(region.topLeft(), size), shown.session.viewport.viewPoint(region.bottomRight() + QPoint(1, 1), size))
.toAlignedRect();
}
}
class MarchingAntsTests : public QObject {
Q_OBJECT
private slots:
void aComplexOutlineZoomedOutIsTracedAtScreenResolution();
void simpleOutlinesAndActualPixelsDrawInFull();
void aTickWaitsForTheRepaintBeforeIt();
void aNewSourceDropsTheOldOutline();
void aNewStepKeepsTheOldOutlineUntilItLands();
void theStepFollowsTheZoomAndTheFillRuleHolds();
};
void MarchingAntsTests::aComplexOutlineZoomedOutIsTracedAtScreenResolution()
{
Canvas shown;
shown.session.zoom(0.25);
shown.canvas->synchronizeDisplay();
const QImage bare = shown.canvas->grab().toImage();
const QRect region(100, 100, 150, 150);
const QPainterPath board = checkerboard(region);
QVERIFY(board.elementCount() > 20'000);
shown.session.setSelection(DocumentSelection{board}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
ants(shown).stop();
// While the first outline is traced, no ants show.
QCOMPARE(shown.canvas->grab().toImage(), bare);
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
// The trace covers the board: its edge alone shows.
const QImage shot = shown.canvas->grab().toImage();
const QRect view = viewRect(shown, region);
QCOMPARE(marked(shot, bare, view.adjusted(3, 3, -3, -3)), 0);
QVERIFY(marked(shot, bare, view.adjusted(-2, -2, 2, 2)) > view.width() * 2);
}
void MarchingAntsTests::simpleOutlinesAndActualPixelsDrawInFull()
{
// At actual pixels the complex outline draws whole, at once.
Canvas shown;
const QImage bare = shown.canvas->grab().toImage();
const QRect region(100, 100, 150, 150);
shown.session.setSelection(DocumentSelection{checkerboard(region)}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
ants(shown).stop();
const QImage full = shown.canvas->grab().toImage();
QVERIFY(marked(full, bare, viewRect(shown, region).adjusted(10, 10, -10, -10)) > 1'000);
// Zoomed out, a marquee's outline draws at once.
Canvas other;
other.session.zoom(0.25);
other.canvas->synchronizeDisplay();
const QImage plain = other.canvas->grab().toImage();
other.session.applySelection(rectPath(QRectF(region)), SelectionMode::replace, QStringLiteral("Select"));
other.canvas->synchronizeDisplay();
ants(other).stop();
QVERIFY(other.canvas->grab().toImage() != plain);
}
void MarchingAntsTests::aTickWaitsForTheRepaintBeforeIt()
{
Canvas first, second;
for (Canvas *shown : {&first, &second}) {
shown->session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, QStringLiteral("Select"));
shown->canvas->synchronizeDisplay();
ants(*shown).stop();
}
QCOMPARE(first.canvas->grab().toImage(), second.canvas->grab().toImage());
// Two ticks before a repaint step once, as one.
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
QVERIFY(QMetaObject::invokeMethod(&ants(second), "timeout"));
const QImage stepped = first.canvas->grab().toImage();
QCOMPARE(stepped, second.canvas->grab().toImage());
// Once painted, the next tick steps again.
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
QVERIFY(first.canvas->grab().toImage() != stepped);
}
void MarchingAntsTests::aNewSourceDropsTheOldOutline()
{
Canvas shown;
shown.session.zoom(0.25);
shown.canvas->synchronizeDisplay();
const QImage bare = shown.canvas->grab().toImage();
const QRect first(10, 10, 120, 120), second(200, 100, 120, 120);
shown.session.setSelection(DocumentSelection{checkerboard(first)}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
ants(shown).stop();
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
// Another outline: the first goes at once; the second shows.
shown.session.setSelection(DocumentSelection{checkerboard(second)}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
QCOMPARE(shown.canvas->grab().toImage(), bare);
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
const QImage shot = shown.canvas->grab().toImage();
QCOMPARE(marked(shot, bare, band(shown, first)), 0);
QVERIFY(marked(shot, bare, band(shown, second)) > 0);
}
void MarchingAntsTests::aNewStepKeepsTheOldOutlineUntilItLands()
{
Canvas shown;
shown.session.zoom(0.5);
shown.canvas->synchronizeDisplay();
const QImage half = shown.canvas->grab().toImage();
shown.session.zoom(0.25);
shown.canvas->synchronizeDisplay();
const QImage quarter = shown.canvas->grab().toImage();
const QRect region(10, 10, 120, 120);
shown.session.setSelection(DocumentSelection{checkerboard(region)}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
ants(shown).stop();
QTRY_VERIFY(shown.canvas->grab().toImage() != quarter);
// A step in, the quarter's outline stands in meanwhile.
shown.session.zoom(0.5);
shown.canvas->synchronizeDisplay();
QVERIFY(marked(shown.canvas->grab().toImage(), half, band(shown, region)) > 0);
}
void MarchingAntsTests::theStepFollowsTheZoomAndTheFillRuleHolds()
{
// Boards two pixels apart: a half step keeps the gap.
Canvas shown;
shown.session.zoom(0.5);
shown.canvas->synchronizeDisplay();
const QImage bare = shown.canvas->grab().toImage();
QPainterPath boards = checkerboard(QRect(10, 10, 60, 180));
boards.addPath(checkerboard(QRect(72, 10, 60, 180)));
shown.session.setSelection(DocumentSelection{boards}, QStringLiteral("Select"));
shown.canvas->synchronizeDisplay();
ants(shown).stop();
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
const QSizeF size = shown.documentSize();
const QPointF gap = shown.session.viewport.viewPoint(QPointF(71, 100), size);
QVERIFY(marked(shown.canvas->grab().toImage(), bare, QRect(gap.toPoint() - QPoint(1, 20), QSize(3, 40))) > 0);
// An odd-even ring keeps its hole through the trace.
Canvas ring;
ring.session.zoom(0.25);
ring.canvas->synchronizeDisplay();
const QImage plain = ring.canvas->grab().toImage();
QPainterPath holed = checkerboard(QRect(40, 40, 200, 50));
holed.addRect(QRectF(40, 40, 200, 200));
holed.addRect(QRectF(100, 100, 80, 80));
holed.setFillRule(Qt::OddEvenFill);
ring.session.setSelection(DocumentSelection{holed}, QStringLiteral("Select"));
ring.canvas->synchronizeDisplay();
ants(ring).stop();
QTRY_VERIFY(ring.canvas->grab().toImage() != plain);
QVERIFY(marked(ring.canvas->grab().toImage(), plain, band(ring, QRect(100, 100, 80, 80))) > 0);
}
QTEST_MAIN(MarchingAntsTests)
#include "MarchingAntsTests.moc"
+2 -1
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "ContentView.h"
#include "PSDFixture.h"
#include <QLabel>
@@ -300,7 +301,7 @@ void PSDImportTests::aFileTheSessionRefusesIsReported()
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."));
std::optional(QStringLiteral("Sky.psd: This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels())));
QCOMPARE(session.document().value().layers.size(), size_t(9'999));
}
+1 -1
View File
@@ -291,7 +291,7 @@ void PSDRoundTripTests::unsupportedHeadersAreRejected()
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, 20'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));
+11 -7
View File
@@ -1,3 +1,4 @@
#include "BudgetFixtures.h"
#include "BrushFixtures.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
@@ -228,12 +229,14 @@ void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEvery
QCOMPARE(session->history.undoCount(), 0);
QCOMPARE(int(layerWith(*session, id).mask.value().asset.image().constScanLine(5)[5]), 0);
QCOMPARE(pixel(layerWith(*session, id).asset.value().image(), 5, 5), (std::vector<int>{255, 0, 0, 255}));
// Six layers of 16 megapixels leave a stroke 4 million.
// The other layers leave a stroke 4 million pixels.
const auto big = std::make_unique<EditorSession>();
big->createDocument(4000, 4000, true);
for (int i = 0; i < 6; ++i) {
const auto raster = std::make_shared<const RasterSnapshot>(4000, 4000, BrushRaster::context(1, 1, false), QRectF(0, 0, 4000, 4000), std::vector<BrushPatch>{});
big->insert(ImportedImage(raster, QImage(), QStringLiteral("Big %1").arg(i)));
const QUuid first = big->activeLayerID().value();
QSet<QUuid> claims;
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 4'000'000)) {
big->insert(layer);
claims.insert(big->activeLayerID().value());
}
big->addBlankLayer();
big->selectTool(NavigationTool::brush);
@@ -248,14 +251,15 @@ void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEvery
QVERIFY(big->brushStroke());
big->cancelBrush();
// A masked layer's budget counts the other masks too.
QImage mask = BrushRaster::context(4000, 4000, true);
const QUuid last = big->activeLayerID().value();
rewrite(*big, [&](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &record : snapshot.manifest.layers)
setMask(snapshot, record.id, LayerMask::assetFrom(mask));
setMask(snapshot, record.id, claims.contains(record.id) ? claimed(snapshot.images.at(record.id).size().width(), snapshot.images.at(record.id).size().height())
: claimed(4000, 4000));
});
QVERIFY(layerWith(*big, first).mask);
big->selectLayerTarget(last, false);
// Seven other 16-million masks: the budget runs below zero.
// The claims' masks and a 16-million one: below zero.
QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(-12'000'000));
}
+15 -5
View File
@@ -1,3 +1,4 @@
#include "BudgetFixtures.h"
#include "Document/ProjectWorkspace.h"
#include "Rendering/RasterSnapshot.h"
#include "SessionFixtures.h"
@@ -45,7 +46,7 @@ class ProjectCopyTests : public QObject {
Q_OBJECT
private slots:
void whatMayNotBeCopiedChangesNothing();
void theHundredMegapixelsCountAcrossBothProjects();
void theBudgetCountsAcrossBothProjects();
void aFolderTravelsWithItsContentsAndItsLinks();
void aMaskFromOutsideTheCopyIsBakedIn();
void anAdjustmentLetsGoOfItsBaseUnbaked();
@@ -104,18 +105,25 @@ void ProjectCopyTests::whatMayNotBeCopiedChangesNothing()
QCOMPARE(edits.count(), before + 6);
}
void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects()
void ProjectCopyTests::theBudgetCountsAcrossBothProjects()
{
ProjectWorkspace workspace;
ProjectTab &from = workspace.current();
from.session.createDocument(8, 8);
from.session.insert(vast(10'000, 10'000));
// A folder claiming the whole document budget.
QSet<QUuid> claims;
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget())) {
from.session.insert(layer);
claims.insert(from.session.activeLayerID().value());
}
from.session.selectLayers(claims, from.session.activeLayerID());
from.session.groupSelectedLayers();
const QUuid id = from.session.activeLayerID().value();
ProjectTab &target = workspace.addTab();
target.session.createDocument(8, 8);
// Exactly the limit fits.
QVERIFY(copied(workspace, id, target.id));
QCOMPARE(names(target.session), (QStringList{"Vast"}));
QCOMPARE(target.session.document().value().layers.size(), size_t(claims.size() + 1));
QCOMPARE(target.session.importError(), std::nullopt);
// One pixel more, counted across both, does not.
target.session.undo();
@@ -123,13 +131,15 @@ void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects()
target.session.setRenamingLayerID(std::nullopt);
QVERIFY(copied(workspace, id, target.id));
QCOMPARE(names(target.session), (QStringList{"Dot"}));
QCOMPARE(target.session.importError(), std::optional(QString("The copied layers exceed this project’s 100-megapixel limit.")));
QCOMPARE(target.session.importError(),
std::optional(QStringLiteral("The copied layers exceed this project’s %1-megapixel limit.").arg(DocumentLimits::documentBudgetMegapixels())));
QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy());
// While that error shows, the front tab lets nothing start.
QVERIFY(!workspace.canSwitch() && copied(workspace, id, std::nullopt));
QCOMPARE(int(workspace.tabs().size()), 2);
target.session.setImportError(std::nullopt);
// Refused for a new tab, that tab stays.
from.session.selectLayers({}, std::nullopt);
from.session.insert(vast(1, 1));
const QUuid extra = from.session.activeLayerID().value();
from.session.addGroup();
+44 -8
View File
@@ -1,10 +1,38 @@
#include "Document/DocumentLimits.h"
#include "Document/LayerMask.h"
#include "BudgetFixtures.h"
#include "ProjectFixtures.h"
#include <QBuffer>
#include <QImageWriter>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
namespace {
// A PNG whose header claims pixels it never holds.
QByteArray claimingPNG(int width, int height)
{
QByteArray data;
QBuffer buffer(&data);
QImageWriter(&buffer, "png").write(QImage(1, 1, QImage::Format_Grayscale8));
for (int index = 0; index < 4; ++index) {
data[16 + index] = char(quint32(width) >> (24 - 8 * index));
data[20 + index] = char(quint32(height) >> (24 - 8 * index));
}
// The header chunk's CRC-32 over its type and fields.
quint32 crc = 0xFFFFFFFF;
for (int index = 12; index < 29; ++index) {
crc ^= uchar(data[index]);
for (int bit = 0; bit < 8; ++bit)
crc = (crc >> 1) ^ (0xEDB88320 & (0u - (crc & 1)));
}
crc = ~crc;
for (int index = 0; index < 4; ++index)
data[29 + index] = char(crc >> (24 - 8 * index));
return data;
}
}
class ProjectTests : public QObject {
Q_OBJECT
private slots:
@@ -381,22 +409,30 @@ void ProjectTests::sizeLimitsHoldOnSaveAndLoad()
ProjectSnapshot long1 = snapshot;
long1.images.insert_or_assign(id, asset(gray(30'001, 1, 9).convertToFormat(QImage::Format_RGBA8888_Premultiplied), "long"));
QCOMPARE(projectError([&] { ProjectStore::save(long1, root.filePath("Long.comp")); }), std::optional(ProjectError::Kind::tooLarge));
// Masks share 100 million pixels, apart from the images'.
// Masks share the document budget, apart from the images'.
ProjectSnapshot heavy = snapshot;
const QUuid second = heavy.manifest.layers[1].id;
heavy.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
heavy.manifest.layers[1].maskFile = uuidString(second) + ".mask.png";
heavy.masks.insert({id, asset(gray(10'000, 10'000, 255), "whole budget")});
heavy.masks.insert({second, asset(gray(1, 1, 255), "one too many")});
// Rows of 30,000 and a remainder fill the budget exactly.
const qint64 budget = DocumentLimits::documentPixelBudget();
const qint64 rows = (budget - 1) / 30'000, rest = budget - rows * 30'000;
// Saving judges sizes before pixels: one over, summed, refused.
heavy.masks.insert({id, asset(gray(int(rest) + 1, 1, 255), "one too many")});
heavy.masks.insert({second, claimed(30'000, int(rows))});
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
// 99,990,000 and 10,000 pixels fill the budget exactly.
heavy.masks.insert_or_assign(id, asset(gray(10'000, 9'999, 255), "nearly all"));
heavy.masks.insert_or_assign(second, asset(gray(100, 100, 255), "the rest"));
QVERIFY(!heavy.masks.at(second).raster->hasMaterializedPixels());
heavy.masks.insert_or_assign(id, asset(gray(int(rest), 1, 255), "the rest"));
heavy.masks.insert_or_assign(second, asset(gray(1, 1, 255), "small"));
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::nullopt);
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::nullopt);
// On load the same sum comes from the files' headers.
// Loaded, headers sum exactly to it; images count apart.
const QString firstMask = root.filePath("Heavy.comp/images/") + uuidString(id) + ".mask.png";
overwrite(firstMask, png(gray(10'000, 10'000, 255)));
const QString secondMask = root.filePath("Heavy.comp/images/") + uuidString(second) + ".mask.png";
overwrite(secondMask, claimingPNG(30'000, int(rows)));
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::missingImage));
// The first mask's one more pixel tips the sum over.
overwrite(firstMask, png(gray(int(rest) + 1, 1, 255)));
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
}
+2 -1
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "Document/DocumentHistory.h"
#include "IO/ImageExporter.h"
#include "Rendering/RasterSnapshot.h"
@@ -78,7 +79,7 @@ void RasterSnapshotTests::contextAllocationFailureThrowsARenderError()
QCOMPARE(QString(error.what()), QString("The canvas could not be rendered. Try a smaller canvas."));
}
QCOMPARE(QString(ExportError(ExportError::Kind::tooLarge).what()),
QString("Image export supports canvases up to 100 megapixels and 30,000 pixels per side."));
QStringLiteral("Image export supports canvases up to %1 megapixels and 30,000 pixels per side.").arg(DocumentLimits::maxSurfaceMegapixels()));
QCOMPARE(QString(ExportError(ExportError::Kind::encode).what()), QString("The image could not be encoded."));
}
+22 -9
View File
@@ -1,3 +1,4 @@
#include "BudgetFixtures.h"
#include "ContentView.h"
#include "DNGFixture.h"
#include "IO/RawImporter.h"
@@ -113,11 +114,13 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
DNGFixture::Options wide, tall, big;
wide.claimed = QSize(30'001, 48);
tall.claimed = QSize(64, 30'001);
big.claimed = QSize(20'000, 5'001);
big.claimed = QSize(30'000, 30'000);
// Rows of 30,000 within the document budget.
const int rows = int(DocumentLimits::documentPixelBudget() / 30'000);
DNGFixture::Options fits;
fits.claimed = QSize(20'000, 5'000);
fits.claimed = QSize(30'000, rows);
const QUrl fitting = dng(folder, "Fits.dng", fits);
QCOMPARE(RawImporter::pixelSize(fitting.toLocalFile()), std::optional(QSize(20'000, 5'000)));
QCOMPARE(RawImporter::pixelSize(fitting.toLocalFile()), std::optional(QSize(30'000, rows)));
EditorSession session;
bool done = false;
const QList<QUrl> urls{QUrl::fromLocalFile(fake.fileName()), dng(folder, "Wide.dng", wide), dng(folder, "Tall.dng", tall), dng(folder, "Big.dng", big), png(folder)};
@@ -127,12 +130,6 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
const QString unreadable = ImageImportError(ImageImportError::Kind::unreadable).what(), tooLarge = ImageImportError(ImageImportError::Kind::tooLarge).what();
QCOMPARE(session.importError().value(), QStringList({"Fake.dng: " + unreadable, "Wide.dng: " + tooLarge, "Tall.dng: " + tooLarge, "Big.dng: " + tooLarge}).join("\n\n"));
QCOMPARE(names(session), QStringList{"Plain"});
// The canvas's 4 by 2 pixels count against the budget.
session.setImportError(std::nullopt);
done = false;
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Fits.dng: " + tooLarge);
// Accepted, then unreadable when the full develop runs.
session.setImportError(std::nullopt);
done = false;
@@ -158,6 +155,22 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Warm.dng: " + QString(ImageImportError(ImageImportError::Kind::whiteBalance).what()));
QCOMPARE(names(session), QStringList{"Plain"});
// Past one surface yet within the document budget, it opens.
session.setImportError(std::nullopt);
done = false;
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
session.finishRawDevelop(std::nullopt);
QTRY_VERIFY(done);
QCOMPARE(session.importError(), std::nullopt);
// The slack filled, the canvas's 8 pixels tip it.
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - qint64(rows) * 30'000))
session.insert(layer);
session.setImportError(std::nullopt);
done = false;
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Fits.dng: " + tooLarge);
}
void RawDevelopTests::theLimitsLetTheirEdgeThrough()
+2 -2
View File
@@ -175,8 +175,8 @@ void ShapeLayerTests::whatIsNotRedrawn()
QCOMPARE(session->activeLayer().value().transform.size, QSizeF(30.4, 19.6));
QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity());
QVERIFY(session->activeLayer().value().liveShape());
// Past 100 megapixels it stretches as it is.
resize(*session, QSizeF(20'001, 5'000));
// Past one surface's 200 megapixels it stretches as it is.
resize(*session, QSizeF(20'001, 10'000));
session->commitTransform();
QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity());
session->undo();
+5 -4
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "BrushFixtures.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
@@ -227,13 +228,13 @@ void ShapeToolTests::shiftSnapsALineToEighthsOfATurn()
void ShapeToolTests::aShapeTooLargeIsRefused()
{
EditorSession session;
session.createDocument(20'000, 12'000, true);
session.createDocument(30'000, 12'000, true);
session.selectTool(NavigationTool::shape);
// Each side truncated first: 10,001 × 10,000 is over.
// Each side truncated first: 20,001 × 10,000 is over.
session.beginShape(QPointF(0, 0));
session.dragShape(QPointF(10'001, 10'000), false, false);
session.dragShape(QPointF(20'001, 10'000), false, false);
session.finishShape();
QCOMPARE(session.brushError().value(), QString("That shape is too large. A shape can cover up to 100 megapixels."));
QCOMPARE(session.brushError().value(), QStringLiteral("That shape is too large. A shape can cover up to %1 megapixels.").arg(DocumentLimits::maxSurfaceMegapixels()));
QCOMPARE(session.document().value().layers.size(), size_t(1));
QVERIFY(!session.shapeDraft());
}
+2
View File
@@ -246,6 +246,8 @@ compositor_test(SelectionSignalTests)
compositor_test(SelectMenuTests)
compositor_test(SelectionCanvasTests)
compositor_test(SelectionOverlayTests)
compositor_test(MarchingAntsTests)
compositor_test(DocumentLimitsTests)
compositor_test(LassoControlsTests)
compositor_test(SelectionAmountSheetTests)
compositor_test(MagicWandTests)
+6 -5
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "BrushFixtures.h"
#include "Document/EditorSession.h"
#include "Rendering/TextLayout.h"
@@ -187,18 +188,18 @@ void TextLayoutTests::imagesKeepSwiftsLimits()
return std::nullopt;
};
QCOMPARE(refused(styled(QStringLiteral("Text"), 0)), std::optional(ProjectError::Kind::invalid));
// A side past 30,000, then the area past 100 megapixels.
// A side past 30,000, then the area past one surface.
const LayerTextStyle wide = styled(QStringLiteral("M").repeated(20), 2000);
const QSizeF wideSize = EditorSession::textBoxSize(wide);
QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < 100'000'000);
QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < DocumentLimits::maxSurfacePixels);
QCOMPARE(refused(wide), std::optional(ProjectError::Kind::tooLarge));
const LayerTextStyle tall = styled(QStringLiteral("a\n").repeated(13), 2000);
const QSizeF tallSize = EditorSession::textBoxSize(tall);
QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < 100'000'000);
QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < DocumentLimits::maxSurfacePixels);
QCOMPARE(refused(tall), std::optional(ProjectError::Kind::tooLarge));
const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM"), 2000);
const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM").repeated(3), 2000);
const QSizeF largeSize = EditorSession::textBoxSize(large);
QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > 100'000'000);
QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > DocumentLimits::maxSurfacePixels);
QCOMPARE(refused(large), std::optional(ProjectError::Kind::tooLarge));
QVERIFY(!refused(styled(QStringLiteral("M").repeated(10), 2000)));
// A fractional box rounds up to whole pixels.
+6 -5
View File
@@ -1,3 +1,4 @@
#include "Document/DocumentLimits.h"
#include "BrushFixtures.h"
#include "SelectionFixtures.h"
#include "SessionRecord.h"
@@ -299,7 +300,7 @@ void TypeSessionTests::aDraggedBoxIsRoundedAndBounded()
session->cancelText();
session->beginText(QRectF(0, 0, 30'001, 50));
QVERIFY(!session->textDraft());
QCOMPARE(session->brushError().value(), QString("That text box exceeds the 30,000-pixel or 100-megapixel limit."));
QCOMPARE(session->brushError().value(), QStringLiteral("That text box exceeds the 30,000-pixel or %1-megapixel limit.").arg(DocumentLimits::maxSurfaceMegapixels()));
// No number at all is refused without a word.
session->setBrushError(std::nullopt);
session->beginText(QRectF(0, 0, INFINITY, 50));
@@ -330,11 +331,11 @@ void TypeSessionTests::aStyleIsValidWithinSwiftsBounds()
for (const double value : {low - 0.01, high + 0.01, double(NAN), double(INFINITY), -double(INFINITY)})
QVERIFY(!valid([&](LayerTextStyle &style) { style.*member = value; }));
}
// A box: 16 to 30,000 a side, 100 megapixels.
// A box: 16 to 30,000 a side, one surface's pixels.
const auto box = [&](double width, double height) { return valid([&](LayerTextStyle &style) { style.boxSize = QSizeF(width, height); }); };
QVERIFY(box(16, 16) && box(30'000, 3'000) && box(10'000, 10'000) && box(3'000, 30'000));
QVERIFY(box(16, 16) && box(30'000, 3'000) && box(20'000, 10'000) && box(3'000, 30'000));
QVERIFY(!box(15.99, 100) && !box(100, 15.99) && !box(30'000.5, 100) && !box(100, 30'000.5));
QVERIFY(!box(10'000, 10'000.01) && !box(NAN, 100) && !box(100, INFINITY));
QVERIFY(!box(20'000, 10'000.01) && !box(NAN, 100) && !box(100, INFINITY));
}
void TypeSessionTests::everyChangeIsAnnouncedAndRefusalsAreSilent()
@@ -461,7 +462,7 @@ void TypeSessionTests::applyingTakesWhatSwiftTakes()
draft.style.boxSize = std::nullopt;
session->setTextDraft(draft);
QVERIFY(!session->finishText());
QCOMPARE(session->brushError().value(), QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."));
QCOMPARE(session->brushError().value(), QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels()));
QCOMPARE(session->textDraft().value().style.content, QString("Kept and grown further"));
QCOMPARE(layerWith(*session, id).liveText().value().style.content, QString("Kept and grown"));
}