From 9be01073df80c375ca2084b89f6e43cad2438b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 09:10:51 -0400 Subject: [PATCH] 14.18: Export As for PNG, JPEG and PDF, sized, over a checkerboard --- AGENTS.md | 7 +- CMakeLists.txt | 4 +- README.md | 2 +- src/IO/ImageExporter.cpp | 100 +++- src/IO/ImageExporter.h | 10 + src/IO/ProjectController+Export.cpp | 93 ++-- src/IO/ProjectController.h | 16 +- src/UI/CompositorMenus.cpp | 5 +- ...+Preview.cpp => ExportAsSheet+Preview.cpp} | 29 +- src/UI/ExportAsSheet.cpp | 431 ++++++++++++++++++ src/UI/{JPEGExportSheet.h => ExportAsSheet.h} | 68 ++- src/UI/JPEGExportSheet.cpp | 282 ------------ src/UI/KeyboardShortcuts.cpp | 2 +- tests/AcceptanceFinishTests.cpp | 8 +- tests/AcceptanceProjectTests.cpp | 4 +- tests/CompositorMenusTests.cpp | 4 +- tests/ExportAsSheetTests.cpp | 386 ++++++++++++++++ tests/ExportPDFTests.cpp | 133 ++++++ tests/HighDpiCanvasTests.cpp | 6 +- tests/JPEGExportSheetTests.cpp | 273 ----------- tests/JPEGPreviewTests.cpp | 39 +- tests/KeyboardShortcutsTests.cpp | 2 +- tests/ProjectExportFailureTests.cpp | 16 +- tests/ProjectExportTests.cpp | 111 +++-- tests/RemappedKeysTests.cpp | 6 +- tests/Tests.cmake | 3 +- 26 files changed, 1352 insertions(+), 688 deletions(-) rename src/UI/{JPEGExportSheet+Preview.cpp => ExportAsSheet+Preview.cpp} (85%) create mode 100644 src/UI/ExportAsSheet.cpp rename src/UI/{JPEGExportSheet.h => ExportAsSheet.h} (60%) delete mode 100644 src/UI/JPEGExportSheet.cpp create mode 100644 tests/ExportAsSheetTests.cpp create mode 100644 tests/ExportPDFTests.cpp delete mode 100644 tests/JPEGExportSheetTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 8b226b7..0b85501 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Platform adaptations, each forced by a missing Qt analog: - Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counts), `RemoveBackgroundTests`, the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests. - Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's `onChange`), the frame's size as whole pixels (Swift's `Int`, truncated) grouped through the English locale like the status bar (`monospacedDigit` has no Qt 6.4 twin), and Cancel and Apply Crop resting without a frame; the bar rests while busy shows or without a document. `ContentView` shows it for the Crop tool and the "Couldn’t crop" alert over `cropError`. Left out as dead: `renderBounds`' tool test (a frame lives under the Crop tool alone: every other way to another tool cancels it or asks `canEditLayers`, which a frame refuses) and the strips' `max(0, …)` in `cropResizeRegions` (a strip it would clip lies under the corners' regions, which take the point first). Equivalent mutants: `contains` on the visible frame for the drawn one (while a frame is drawn they are one), the tolerance's `max(…, 0.0001)` (the zoom's floor keeps points per pixel above it), `cropMove`'s answer after a lost release and after a step (the rest of the move only refreshes the cursor), the strips' ends (under the corners), the frame's caps and joins (its corners lie under the grips) `drawCrop`'s `setBrush(Qt::NoBrush)` (the painter arrives with none) and `takeResult` for `result()` in the landing (a copy asks only more memory, and its failure is caught the same). Tests: `CropTests` (Swift's six session and geometry tests; symmetric drags, snapping by side, bounds, ratios), `CropSessionTests` (a failed crop, refusals and the busy project, the signals, every hook, a flipped frame, one step that ends redo), `CropFixtures.h` (`committed`), `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay`; drags, Alt and Ctrl mid-drag, keys and lost releases, cursors, the dimming, a frame past the canvas), `CropControlsTests`, `CropFailureTests` (a binary of its own whose `main` shares the main heap among threads, `M_ARENA_MAX` 1 before any thread starts, and maps large asks, so a worker's allocations meet `AddressSpaceLimit`: a worker in an arena of its own grows its reserved heap past the limit; the snapshot, the worker and the landing each run out in turn), the bar and the alert in `ContentViewTests`. - Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom a label and a `DialogColorSwatch` on the app's picker (13.4a); then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `ImageMenuTests` (moved out of `CompositorMenusTests` at 500 lines, 14.4). -- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 `JPEGPreview` on gray 0.12 (13.4b; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), then one row of the raster's size grouped with "px · sRGB", the error in `BrightText`, or the file's size, or "Updating…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). +- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` (since 14.18 `UI/ExportAsSheet`) twins Swift's sheet: the title, the 560 by 330 `JPEGPreview` on gray 0.12 (13.4b; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), then one row of the raster's size grouped with "px · sRGB", the error in `BrightText`, or the file's size, or "Updating…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `ExportAsSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). - File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`. - Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. - Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay (since 13.9a hand-blended by Photoshop's formula, see that bullet). `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files. @@ -163,8 +163,8 @@ Platform adaptations, each forced by a missing Qt analog: - The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sliders' ends and a linear interior, the rows' order and help, a commit's busy line, the menus and characters) pin the code, with chunky pixels against an independent box average under diffusion, exact Bayer counts for Density and Contrast, the halftone angle, custom, blank and missing alphabets, the glyphs' size, smoothing, centring and canonical duplicates, the live picker preview, both swatches, the filter's OK with a picker open, each numeric row and the Characters field on leaving, and `CompositorMenusTests` the Filter menu. - Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`. - Select › Color Range (13.3b, Swift 1.3.4: `c3e360a`): `kernels/WandPixels.c/.h` are v1.3.4's, with `color_range_mask`. `Document/ColorRangeSelection.h/.cpp` twin Swift's file, `ColorRangeSelection+Session.h` its session half; `ColorRangeEdit` is a class in the session's `std::optional`, its `id` standing for Swift's object identity, its `image` the live composite at document size (`selectionSample` with every layer, premultiplied), so the kernel reads it directly where Swift draws it into a context first. `colorAt` averages the 3 × 3 pixels round the floored point, those past the picture clear as in Swift's context, and unpremultiplies with Swift's rounding; a point off the picture, clear or no number samples nothing. Each update is a worker of its own (`QtConcurrent::run` and a watcher a change, as Swift starts a task each) and lands only on the same edit, generation and document; it says so at once and again when its match lands, which sets the selection without a step (antialiased as the selection tools are) or, on a `MagicWandError` or `ExportError`, keeps the selection, shows the error and logs it on `omaphoto.app`. The preview is the mask as `Grayscale8` scaled smooth to twice the panel's fit, where Swift draws it at medium quality. OK applies the match as one `Color Range` step through `setSelection` (or `deselect` for a match of nothing) and always announces the close; with no colour or an error it keeps the old selection without a step; Cancel puts the old selection back. The gates (`canEditLayers`, `canUseHistory`, `canStartProjectOperation`) wait for the panel, and closing it resumes file requests (Swift's `didSet`; on opening it is a no-op, since an open panel blocks every waiter, and is left out). The sheet's writes (`setColorRangeSampleMode`, `setColorRangeHeld`, `setColorRangeFuzziness` clamped whole to 0–200, `setColorRangeInvert`) notify, an unchanged one silent. On the canvas Color Range is `picking`; a left press, Space aside, samples with Shift adding and Alt taking away (Alt first, as Swift's switch reads them) and refocuses the panel, after Levels' checks; the eyedropper cursor carries a black + or − on a white disc bottom right (`eyedropperCursor(ratio, badge)`) for the held key or the chosen eyedropper, and `syncPicking` redraws it when either changes; Shift and Alt reach `setColorRangeHeld` through the modifier monitor. Swift's `keepColorRangeCursor`, `pointerOverCanvas` and cursor-update monitor work round AppKit's cursor rects and have no twin. `UI/ColorRangeSheet` twins Swift's sheet: the three eyedroppers (`UI/SampleButton`, moved unchanged from the Hue/Saturation sheet so both share it), lit for the effective mode; the preview (black, the picture, a 20% white rim, the canvas's shape within 292 by 200); the caption in `PlaceholderText` (Alt for Option); Fuzziness as a scrubbable title, a slider and a 48-point field; Invert; the error in `BrightText`; Cancel and the default OK; 340 wide, 24 of padding. `ContentView` shows it in `colorRangePanel` while an edit is open, its close button and Escape cancelling, and the Select menu's Color Range… follows Subject, enabled by `canSelectColorRange`. Swift has no tests for this commit: `ColorRangeTests` pins the kernel, the sampling, the modes, Fuzziness, Invert, the preview, OK, Cancel, the gates, the announcements and an outline too detailed (a 2100-pixel checkerboard); `ColorRangeCanvasTests` the canvas's clicks and keys, the badged cursors, the sheet and the panel with its menu entry; `ColorRangeSheetTests` the sheet's layout, field, preview, Invert and error. Equivalent mutants: the no-colours branch's reset of the selection and preview (before any colour both hold those values already), the watcher's `deleteLater` (memory alone), the warning and `syncPicking`'s memory of the shown badge (it only spares cursor updates). Swift's `document` and `colorRange == nil` terms in `canSelectColorRange` are dead (`canEditLayers` reads both) and left out. Untested: the preview's two allocation checks and the worker's `ExportError` catch (only memory starvation reaches them), and the sheet's `releaseFocus` (without it the commit reads members that happen to live; no sanitizer runs). -- Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (JPEG Background)`); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `JPEGExportSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds). -- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/JPEGExportSheet.h`, defined in `JPEGExportSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `JPEGExportSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average). +- Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (Export Background)` since 14.18); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `ExportAsSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds). +- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/ExportAsSheet.h`, defined in `ExportAsSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `ExportAsSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average). - Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`. - Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`. - Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`. @@ -197,6 +197,7 @@ Platform adaptations, each forced by a missing Qt analog: - Revealing the chosen layer (14.15, Swift 1.4.8: `d2b8aaf`, `b4e147b`): `selectLayer` and `selectLayers` end with `revealActiveLayer` (declared in `LayerGroups+Session.h`, as Swift's extension), which opens every folder above the active layer and says whether one opened; `selectLayer` then signals again, since its setter signalled before, so the last signal sees the folders open. Swift's `seen` set, a guard against a parent cycle, is left out: a session's document always passes `LayerHierarchy::validate`, so the chain ends at the root. `NativeLayerList::update` remembers the active layer it last scrolled to and scrolls a newly active row into view once its row exists (`ensureWidgetVisible`, no margin), so a later rename or refresh leaves a scroll made by hand: the session's setter signals while a shut folder still hides the row, where SwiftUI sees the selection and the open folders in one update, so the list waits for the row, and first hands the scroll area its posted layout request, since rows made anew have not yet grown the scroll range. Swift's own tests went with `b4e147b`; the twin keeps them. Equivalent mutant: the guard on an empty collapsed set (the walk then removes nothing; Swift's too). Tests: `LayerRevealTests` (Swift's five, the last signal with both and only the inner folder shut, a primary outside every folder opening nothing, a child of a shut folder below the view scrolled to by either call); `ProjectSessionTests` now collapses its folder after choosing a layer inside it. - Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order at ±100 and the dark tones lifted; every level of a gray ramp against the curve written apart, each way at full and partial amounts and both together, Highlights first). - The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and their eyes (an effect's eye keeps `canEditLayers`, as Swift makes each effect row anew with it), the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing. +- Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, the background, a refusal, resizing, flattening), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard), `ProjectExportTests::exportAsWritesEachFormatAndRemembersTheLast`, the entry in `CompositorMenusTests` and `KeyboardShortcutsTests`. Equivalent mutants: none found. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `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` (which takes the toolbar's free width) 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 plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index c50bb16..8698fc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,8 +104,8 @@ add_library(compositor_core STATIC src/UI/GridSettingsSheet.cpp src/UI/ImageSizeSheet.cpp src/UI/TrimSheet.cpp - src/UI/JPEGExportSheet.cpp - src/UI/JPEGExportSheet+Preview.cpp + src/UI/ExportAsSheet.cpp + src/UI/ExportAsSheet+Preview.cpp src/UI/ByteCounts.cpp src/UI/SliderSnap.cpp src/UI/GradientControls.cpp diff --git a/README.md b/README.md index d7aa2c0..2e18344 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b - Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow - Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline) - Last Filter (Ctrl+Meta+F) runs the last filter again with the same settings -- Crop, trim, canvas size, image size; PNG and JPEG export with a zoomable preview; New Canvas presets for screens and social formats +- Crop, trim, canvas size, image size; PNG, JPEG and one-page PDF export, scaled if you like, with a zoomable preview (File › Export As…); New Canvas presets for screens and social formats - Imports JPEG, PNG, TIFF, HEIC, SVG, Photoshop files (PSD and PSB) and camera RAW - Open Recent, and a Keyboard Shortcuts window - Search Commands (Ctrl+F): find every menu command and tool by name and run it with Return diff --git a/src/IO/ImageExporter.cpp b/src/IO/ImageExporter.cpp index bfb2700..0fddb36 100644 --- a/src/IO/ImageExporter.cpp +++ b/src/IO/ImageExporter.cpp @@ -153,10 +153,43 @@ std::optional<QuickLookImages> ImageExporter::quickLookImages(const ProjectSnaps QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot) { - const ExportRaster raster = render(snapshot); + return pngData(render(snapshot)); +} + +QByteArray ImageExporter::pngData(const ExportRaster &raster) +{ return encode(raster.image, "png", -1, raster.resolution); } +ExportRaster ImageExporter::resized(const ExportRaster &raster, int width, int height) +try { + if (width == raster.image.width() && height == raster.image.height()) + return raster; + if (width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide + || qint64(width) * height > DocumentLimits::maxSurfacePixels) + throw ExportError(ExportError::Kind::tooLarge); + // CoreGraphics' high quality: an area average shrinking. + const QImage image = raster.image.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + return {image, raster.resolution}; +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} + +QImage ImageExporter::flattened(const ExportRaster &raster, const QColor &background) +try { + QImage flat(raster.image.size(), QImage::Format_RGB888); + if (flat.isNull()) + throw ExportError(ExportError::Kind::render); + flat.fill(background); + QPainter painter(&flat); + painter.drawImage(QRectF(flat.rect()), raster.image); + return flat; +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} + JPEGResult ImageExporter::jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled) try { const auto check = [&cancelled] { @@ -192,6 +225,71 @@ try { throw ExportError(ExportError::Kind::render); } +QByteArray ImageExporter::pdfData(const ProjectSnapshot &snapshot) +{ + return pdfData(render(snapshot)); +} + +// Swift's Core Graphics PDF context: one page, one Flate image. +QByteArray ImageExporter::pdfData(const ExportRaster &raster, std::optional<QColor> background) +try { + const QImage straight = raster.image.convertToFormat(QImage::Format_RGBA8888); + if (straight.isNull()) + throw ExportError(ExportError::Kind::render); + const int width = straight.width(), height = straight.height(); + QByteArray colour, alpha; + colour.reserve(qsizetype(width) * height * 3); + alpha.reserve(qsizetype(width) * height); + for (int y = 0; y < height; ++y) { + const uchar *row = straight.constScanLine(y); + for (int x = 0; x < width; ++x) { + colour.append(reinterpret_cast<const char *>(row + x * 4), 3); + alpha.append(char(row[x * 4 + 3])); + } + } + // Pixels at the document's resolution: points are 1/72 inch. + const double points = 72 / (raster.resolution > 0 ? raster.resolution : 72); + const auto number = [](double value) { return QByteArray::number(value, 'f', 4); }; + const QByteArray pageWidth = number(width * points), pageHeight = number(height * points); + QByteArray content = "q\n"; + if (background) + content += number(background->redF()) + ' ' + number(background->greenF()) + ' ' + number(background->blueF()) + " rg 0 0 " + pageWidth + ' ' + + pageHeight + " re f\n"; + content += pageWidth + " 0 0 " + pageHeight + " 0 0 cm /Im0 Do\nQ"; + // zlib's stream, without Qt's four-byte length. + const auto stream = [](const QByteArray &dictionary, const QByteArray &bytes, bool deflate) { + const QByteArray body = deflate ? qCompress(bytes, 9).mid(4) : bytes; + return "<< " + dictionary + (deflate ? " /Filter /FlateDecode" : "") + " /Length " + QByteArray::number(body.size()) + " >>\nstream\n" + body + + "\nendstream"; + }; + const QByteArray image = "/Type /XObject /Subtype /Image /Width " + QByteArray::number(width) + " /Height " + QByteArray::number(height) + + " /BitsPerComponent 8"; + const std::vector<QByteArray> objects{"<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + pageWidth + ' ' + pageHeight + + "] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>", + stream("", content, false), stream(image + " /ColorSpace /DeviceRGB /SMask 6 0 R", colour, true), + stream(image + " /ColorSpace /DeviceGray", alpha, true)}; + QByteArray pdf = "%PDF-1.4\n"; + std::vector<qsizetype> offsets; + for (size_t index = 0; index < objects.size(); ++index) { + offsets.push_back(pdf.size()); + pdf += QByteArray::number(qsizetype(index + 1)) + " 0 obj\n" + objects[index] + "\nendobj\n"; + } + const qsizetype table = pdf.size(); + pdf += "xref\n0 " + QByteArray::number(qsizetype(objects.size() + 1)) + "\n0000000000 65535 f \n"; + for (const qsizetype offset : offsets) + pdf += QByteArray::number(offset).rightJustified(10, '0') + " 00000 n \n"; + pdf += "trailer\n<< /Size " + QByteArray::number(qsizetype(objects.size() + 1)) + " /Root 1 0 R >>\nstartxref\n" + QByteArray::number(table) + "\n%%EOF\n"; + return pdf; +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} + +void ImageExporter::exportPDF(const ProjectSnapshot &snapshot, const QString &path) +{ + write(pdfData(snapshot), path); +} + void ImageExporter::exportPNG(const ProjectSnapshot &snapshot, const QString &path) { write(pngData(snapshot), path); diff --git a/src/IO/ImageExporter.h b/src/IO/ImageExporter.h index 3976b15..03066ac 100644 --- a/src/IO/ImageExporter.h +++ b/src/IO/ImageExporter.h @@ -43,6 +43,16 @@ ExportRaster render(const ProjectSnapshot &snapshot); // Flattened on white, 1024 a side; none past 50 megapixels. std::optional<QuickLookImages> quickLookImages(const ProjectSnapshot &snapshot); QByteArray pngData(const ProjectSnapshot &snapshot); +QByteArray pngData(const ExportRaster &raster); +// Export As's size: resampled smooth, the resolution kept. +ExportRaster resized(const ExportRaster &raster, int width, int height); +// The picture over a solid colour, as pages show it. +QImage flattened(const ExportRaster &raster, const QColor &background); +// One page the printed size, the pixels lossless, alpha kept. +QByteArray pdfData(const ProjectSnapshot &snapshot); +// `background` fills the page under the picture; none stays clear. +QByteArray pdfData(const ExportRaster &raster, std::optional<QColor> background = std::nullopt); +void exportPDF(const ProjectSnapshot &snapshot, const QString &path); // Stops at Swift's three points once `cancelled` says so. JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {}); void exportPNG(const ProjectSnapshot &snapshot, const QString &path); diff --git a/src/IO/ProjectController+Export.cpp b/src/IO/ProjectController+Export.cpp index c432868..e75d784 100644 --- a/src/IO/ProjectController+Export.cpp +++ b/src/IO/ProjectController+Export.cpp @@ -1,7 +1,7 @@ #include "Document/ProjectWorkspace.h" #include "IO/ImageExporter.h" #include "IO/ProjectController.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include <QDialog> #include <QFileDialog> #include <QFileInfo> @@ -15,18 +15,45 @@ QString suggested(const std::optional<QString> &project, const QString &suffix) { return (project ? ProjectTab::nameWithoutSuffix(*project) : QStringLiteral("Untitled")) + suffix; } + +// The panel's filter and a name's suffixes, preferred first. +std::pair<QString, QStringList> filtering(ExportFormat format) +{ + switch (format) { + case ExportFormat::png: + return {QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}}; + case ExportFormat::jpeg: + return {QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"), {QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}}; + case ExportFormat::pdf: + return {QStringLiteral("PDF document (*.pdf)"), {QStringLiteral("pdf")}}; + } + throw std::logic_error("unknown export format"); +} +} + +QString rawValue(ExportFormat format) +{ + switch (format) { + case ExportFormat::png: + return QStringLiteral("PNG"); + case ExportFormat::jpeg: + return QStringLiteral("JPEG"); + case ExportFormat::pdf: + return QStringLiteral("PDF"); + } + throw std::logic_error("unknown export format"); } // Swift's NSSavePanel for one type, whose suffix a name needs. -void ProjectController::exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure, - std::function<void(std::optional<QString>)> then) +void ProjectController::exportPanel(ExportFormat format, const QString &failure, std::function<void(std::optional<QString>)> then) { - auto *panel = new QFileDialog(window, title); + const auto [filter, suffixes] = filtering(format); + auto *panel = new QFileDialog(window, QStringLiteral("Export ") + rawValue(format)); panel->setAttribute(Qt::WA_DeleteOnClose); panel->setAcceptMode(QFileDialog::AcceptSave); panel->setNameFilter(filter); panel->setDefaultSuffix(suffixes.first()); - panel->selectFile(name); + panel->selectFile(suggested(session.projectPath(), QLatin1Char('.') + suffixes.first())); // Moved, never copied: `then` may hold a whole snapshot. connect(panel, &QDialog::finished, this, [this, panel, suffixes, failure, then = std::move(then)](int result) mutable { if (result != QDialog::Accepted) { @@ -94,22 +121,21 @@ void ProjectController::exportPNG(std::function<void()> done) showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); return; } - exportPanel(QStringLiteral("Export PNG"), QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}, suggested(session.projectPath(), QStringLiteral(".png")), - failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) { - if (!path) { - end(); - return; - } - // The snapshot's copy may find no memory. - try { - exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end); - } catch (const std::bad_alloc &) { - showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); - } - }); + exportPanel(ExportFormat::png, failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) { + if (!path) { + end(); + return; + } + // The snapshot's copy may find no memory. + try { + exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end); + } catch (const std::bad_alloc &) { + showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); + } + }); } -void ProjectController::exportJPEG(std::function<void()> done) +void ProjectController::exportAs(std::optional<ExportFormat> start, std::function<void()> done) { if (!window || !session.document() || !begin()) { if (done) @@ -121,7 +147,7 @@ void ProjectController::exportJPEG(std::function<void()> done) if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); }; - const QString failure = QStringLiteral("Couldn’t export JPEG"); + const QString failure = QStringLiteral("Couldn’t export"); const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what()); std::optional<ProjectSnapshot> snapshot; try { @@ -131,17 +157,17 @@ void ProjectController::exportJPEG(std::function<void()> done) return; } auto *watcher = new QFutureWatcher<Rendered>(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] { + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end, format = start.value_or(m_lastExportFormat)] { watcher->deleteLater(); Rendered rendered = watcher->future().takeResult(); if (!rendered.raster) { showError(failure, rendered.failure, end); return; } - QDialog *dialog = sheet(QStringLiteral("Export JPEG")); - auto chosen = std::make_shared<std::optional<QByteArray>>(); - dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), session, [dialog, chosen](std::optional<QByteArray> data) { - *chosen = std::move(data); + QDialog *dialog = sheet(QStringLiteral("Export As")); + auto chosen = std::make_shared<std::optional<ExportAsSheet::Chosen>>(); + dialog->layout()->addWidget(new ExportAsSheet(std::move(*rendered.raster), session, format, [dialog, chosen](std::optional<ExportAsSheet::Chosen> picked) { + *chosen = std::move(picked); dialog->done(*chosen ? QDialog::Accepted : QDialog::Rejected); }, dialog)); connect(dialog, &QDialog::finished, this, [this, chosen, failure, end] { @@ -149,15 +175,14 @@ void ProjectController::exportJPEG(std::function<void()> done) end(); return; } - exportPanel(QStringLiteral("Export JPEG"), QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"), - {QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}, suggested(session.projectPath(), QStringLiteral(".jpg")), failure, - [this, data = **chosen, failure, end](std::optional<QString> path) { - if (!path) { - end(); - return; - } - exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end); - }); + m_lastExportFormat = (*chosen)->format; + exportPanel((*chosen)->format, failure, [this, data = (*chosen)->data, failure, end](std::optional<QString> path) { + if (!path) { + end(); + return; + } + exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end); + }); }); dialog->open(); }); diff --git a/src/IO/ProjectController.h b/src/IO/ProjectController.h index 6256a41..3ea2e83 100644 --- a/src/IO/ProjectController.h +++ b/src/IO/ProjectController.h @@ -6,6 +6,7 @@ #include <QPointer> #include <QUrl> #include <QWidget> +#include <array> #include <deque> #include <functional> #include <optional> @@ -13,6 +14,11 @@ class ProjectWorkspace; class QDialog; +// What File › Export As… writes. +enum class ExportFormat { png, jpeg, pdf }; +inline constexpr std::array allExportFormats{ExportFormat::png, ExportFormat::jpeg, ExportFormat::pdf}; +QString rawValue(ExportFormat format); + // One project's file operations: save, open, close, drops. class ProjectController : public QObject { Q_OBJECT @@ -44,8 +50,10 @@ public: void gridSettings(std::function<void()> done = {}); void imageSize(std::function<void()> done = {}); void trim(std::function<void()> done = {}); + // A quick, lossless save of the flattened canvas. void exportPNG(std::function<void()> done = {}); - void exportJPEG(std::function<void()> done = {}); + // PNG, JPEG or PDF, sized and previewed before writing. + void exportAs(std::optional<ExportFormat> start = std::nullopt, std::function<void()> done = {}); // Swift's ProjectController+ExternalChanges: the package kept in step. ExternalChangeState externalChanges; @@ -94,8 +102,8 @@ private: // Off the UI thread, then landed, skipped or explained. void resizeProject(std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land, const QString &failure, std::function<void()> done); - void exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure, - std::function<void(std::optional<QString>)> then); + // Swift's savePanel: the project's name, the format's suffix. + void exportPanel(ExportFormat format, const QString &failure, std::function<void(std::optional<QString>)> then); void exportWork(std::function<void()> work, const QString &failure, std::function<void()> end); void drainIncoming(); void handle(const Incoming &request); @@ -113,4 +121,6 @@ private: std::vector<std::function<void()>> m_writeWaiters; std::deque<Incoming> m_incoming; bool m_processing = false; + // The format Export As last wrote, offered first next time. + ExportFormat m_lastExportFormat = ExportFormat::png; }; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index b4aedbd..e30339e 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -40,7 +40,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, Pro file->addSeparator(); add(file, QStringLiteral("exportPNG"), QStringLiteral("Export PNG…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_E), [this] { projects().exportPNG(); }); add(file, QStringLiteral("exportJPEG"), QStringLiteral("Export JPEG…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_S), - [this] { projects().exportJPEG(); }); + [this] { projects().exportAs(ExportFormat::jpeg); }); + // Swift 1.4.8: Photoshop's Ctrl+Alt+Shift+W. + add(file, QStringLiteral("exportAs"), QStringLiteral("Export As…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_W), [this] { projects().exportAs(); }); file->addSeparator(); add(file, QStringLiteral("closeProject"), QStringLiteral("Close Project"), QKeySequence(Qt::CTRL | Qt::Key_W), [this] { projects().close(&m_window); }); @@ -356,6 +358,7 @@ void CompositorMenus::synchronize() action(QStringLiteral("saveAs"))->setEnabled(drawn && p.canStart()); action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart()); action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("exportAs"))->setEnabled(drawn && p.canStart()); action(QStringLiteral("closeProject"))->setEnabled(p.canStart()); // A step's ampersand, doubled: a lone one is a mnemonic. const auto shown = [](QString name) { return name.replace(QLatin1Char('&'), QStringLiteral("&&")); }; diff --git a/src/UI/JPEGExportSheet+Preview.cpp b/src/UI/ExportAsSheet+Preview.cpp similarity index 85% rename from src/UI/JPEGExportSheet+Preview.cpp rename to src/UI/ExportAsSheet+Preview.cpp index 8d4cae3..fe98a9f 100644 --- a/src/UI/JPEGExportSheet+Preview.cpp +++ b/src/UI/ExportAsSheet+Preview.cpp @@ -1,10 +1,22 @@ -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include <QMouseEvent> #include <QPainter> #include <QScrollBar> #include <algorithm> #include <cmath> +// Swift's tile: 10-point squares of grays 0.30 and 0.35. +QPixmap JPEGPreview::checkerboard(double ratio) +{ + QPixmap tile(QSize(20, 20) * ratio); + tile.setDevicePixelRatio(ratio); + QPainter painter(&tile); + painter.fillRect(QRectF(0, 0, 20, 20), QColor::fromRgbF(0.30f, 0.30f, 0.30f)); + painter.fillRect(QRectF(0, 0, 10, 10), QColor::fromRgbF(0.35f, 0.35f, 0.35f)); + painter.fillRect(QRectF(10, 10, 10, 10), QColor::fromRgbF(0.35f, 0.35f, 0.35f)); + return tile; +} + double JPEGPreview::fitZoom(int width, int height, QSizeF frame, double displayScale) { const double scale = std::max(1.0, displayScale); @@ -21,8 +33,8 @@ std::optional<double> JPEGPreview::step(double zoom, int direction) return next == steps.rend() ? std::nullopt : std::optional(*next); } -JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent) - : QAbstractScrollArea(parent), m_pixels(pixels), m_zoomChanged(std::move(zoomChanged)) +JPEGPreview::JPEGPreview(std::function<void()> zoomChanged, QWidget *parent) + : QAbstractScrollArea(parent), m_zoomChanged(std::move(zoomChanged)) { setFixedSize(frame); setFrameShape(QFrame::NoFrame); @@ -31,13 +43,17 @@ JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidge verticalScrollBar()->setSingleStep(20); } -void JPEGPreview::setImage(const QImage &image, bool updating) +void JPEGPreview::setImage(const QImage &image, QSize pixels, bool updating) { // A new image only: the averaged copy still holds. if (image.cacheKey() != m_image.cacheKey()) m_reduced = QImage(); m_image = image; m_updating = updating; + if (pixels != m_pixels) { + m_pixels = pixels; + updateRanges(); + } viewport()->update(); } @@ -118,6 +134,11 @@ void JPEGPreview::paintEvent(QPaintEvent *) target = QRectF(QPointF(), QSizeF(m_image.size()).scaled(view.size(), Qt::KeepAspectRatio)); target.moveCenter(view.center()); } + // Swift 1.4.8: the canvas's checkerboard shows what is clear. + painter.save(); + painter.setBrushOrigin(target.topLeft()); + painter.fillRect(target, QBrush(checkerboard(devicePixelRatioF()))); + painter.restore(); // Nearest from 100% up: each JPEG pixel as it is. if (m_zoom && *m_zoom >= 1) { painter.drawImage(target, m_image); diff --git a/src/UI/ExportAsSheet.cpp b/src/UI/ExportAsSheet.cpp new file mode 100644 index 0000000..5cb3dc2 --- /dev/null +++ b/src/UI/ExportAsSheet.cpp @@ -0,0 +1,431 @@ +#include "UI/ExportAsSheet.h" +#include "UI/KeyboardShortcuts.h" +#include "UI/ByteCounts.h" +#include "UI/ColorPickerSheet+Dialog.h" +#include "UI/LayerIcons.h" +#include "Document/DocumentLimits.h" +#include "UI/ColorPickerSheet.h" +#include <QButtonGroup> +#include <QFutureWatcher> +#include <QGridLayout> +#include <QMenu> +#include <QToolButton> +#include <QHBoxLayout> +#include <QLabel> +#include <QPainter> +#include <QProgressBar> +#include <QPushButton> +#include <QSettings> +#include <QShortcut> +#include <QSlider> +#include <QTimer> +#include <QVBoxLayout> +#include <QtConcurrent> +#include <algorithm> +#include <cmath> + +const QString ExportAsSheet::qualityKey = QStringLiteral("jpegExportQuality"); + +namespace { +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + label->setTextFormat(Qt::PlainText); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + label->setForegroundRole(role); + return label; +} + +// Swift reads a finite number alone, clamped to 0–1. +double startQuality() +{ + bool number = false; + const double saved = QSettings().value(ExportAsSheet::qualityKey).toDouble(&number); + return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality; +} + +// Swift's 0...2 decimals, trailing zeros dropped. +QString inches(double value) +{ + QString shown = QLocale(QLocale::English, QLocale::UnitedStates).toString(value, 'f', 2); + while (shown.contains(QLatin1Char('.')) && (shown.endsWith(QLatin1Char('0')) || shown.endsWith(QLatin1Char('.')))) + shown.chop(1); + return shown; +} + +struct Made { + std::optional<QByteArray> data; + QImage preview; + std::optional<QString> failure; +}; +} + +ExportAsSheet::ExportAsSheet(ExportRaster raster, EditorSession &session, ExportFormat format, std::function<void(std::optional<Chosen>)> finish, QWidget *parent) + : QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), + m_settings{format, JPEGOptions(), m_raster.image.width(), m_raster.image.height()}, m_wait(new QTimer(this)), + m_preview(new JPEGPreview([this] { synchronize(); }, this)), m_spinner(new QProgressBar(m_preview)), m_formats(new QButtonGroup(this)), + m_width(new PickerField([this] { + bool number = false; + const int value = m_width->text().toInt(&number); + if (number) + setScale(double(value) / m_raster.image.width()); + // A number applies; other text goes back. + showSize(); + synchronize(); + }, [](int) {}, this)), + m_height(new PickerField([this] { + bool number = false; + const int value = m_height->text().toInt(&number); + if (number) + setScale(double(value) / m_raster.image.height()); + // A number applies; other text goes back. + showSize(); + synchronize(); + }, [](int) {}, this)), + m_scale(new QToolButton(this)), m_quality(new QSlider(Qt::Horizontal, this)), m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), + m_page(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), + m_matteSwatch(new DialogColorSwatch( + QStringLiteral("Export Background"), [this] { return PaletteColor{m_settings.options.red, m_settings.options.green, m_settings.options.blue}; }, + [this](const PaletteColor &matte) { setMatte(matte); }, session, this)), + m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), + m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), + m_updating(text(QStringLiteral("Updating…"), 13, QFont::Normal, QPalette::PlaceholderText, this)), + m_export(new QPushButton(QStringLiteral("Export…"), this)), m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)), + m_zoomOut(new QPushButton(this)) +{ + m_settings.options.quality = startQuality(); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + // The title row sits 8 above; it inherits 8. + auto *top = new QVBoxLayout; + top->setSpacing(8); + auto *title = new QHBoxLayout; + title->addWidget(text(QStringLiteral("Export As"), 17, QFont::Bold, QPalette::WindowText, this)); + title->addStretch(1); + m_fit->setObjectName(QStringLiteral("exportFit")); + m_zoomIn->setObjectName(QStringLiteral("exportZoomIn")); + m_zoomIn->setAccessibleName(QStringLiteral("Zoom in")); + m_zoomOut->setObjectName(QStringLiteral("exportZoomOut")); + m_zoomOut->setAccessibleName(QStringLiteral("Zoom out")); + for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) { + button->setAutoDefault(false); + title->addWidget(button); + } + connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); }); + connect(m_zoomIn, &QPushButton::clicked, this, [this] { zoomBy(1); }); + connect(m_zoomOut, &QPushButton::clicked, this, [this] { zoomBy(-1); }); + top->addLayout(title); + m_preview->setObjectName(QStringLiteral("exportPreview")); + m_spinner->setObjectName(QStringLiteral("exportSpinner")); + m_spinner->setRange(0, 0); + m_spinner->setTextVisible(false); + m_spinner->setGeometry(264, 149, 32, 32); + top->addWidget(m_preview); + column->addLayout(top); + + auto *grid = new QGridLayout; + grid->setHorizontalSpacing(12); + grid->setVerticalSpacing(12); + // Swift's segmented picker: touching buttons, one checked. + auto *formats = new QHBoxLayout; + formats->setSpacing(0); + for (const ExportFormat each : allExportFormats) { + auto *button = new QToolButton(this); + button->setText(rawValue(each)); + button->setObjectName(QStringLiteral("exportFormat") + rawValue(each)); + button->setCheckable(true); + button->setChecked(each == format); + m_formats->addButton(button, int(each)); + formats->addWidget(button); + } + formats->addStretch(1); + connect(m_formats, &QButtonGroup::idClicked, this, [this](int id) { + m_settings.format = ExportFormat(id); + request(); + }); + grid->addWidget(new QLabel(QStringLiteral("Format"), this), 0, 0); + grid->addLayout(formats, 0, 1); + auto *size = new QHBoxLayout; + size->setSpacing(6); + m_width->setObjectName(QStringLiteral("exportWidth")); + m_width->setToolTip(QStringLiteral("Width in pixels; the height follows, keeping the proportions")); + m_height->setObjectName(QStringLiteral("exportHeight")); + m_height->setToolTip(QStringLiteral("Height in pixels; the width follows, keeping the proportions")); + for (PickerField *field : {m_width, m_height}) { + field->setFixedWidth(64); + field->setAlignment(Qt::AlignRight); + } + size->addWidget(m_width); + size->addWidget(text(QStringLiteral("×"), 13, QFont::Normal, QPalette::PlaceholderText, this)); + size->addWidget(m_height); + size->addWidget(text(QStringLiteral("px"), 13, QFont::Normal, QPalette::PlaceholderText, this)); + m_scale->setObjectName(QStringLiteral("exportScale")); + m_scale->setToolTip(QStringLiteral("Scale the export: the document stays as it is")); + m_scale->setPopupMode(QToolButton::InstantPopup); + auto *scales = new QMenu(m_scale); + for (const int percent : {25, 50, 75, 100, 200}) + connect(scales->addAction(QStringLiteral("%1%").arg(percent)), &QAction::triggered, this, [this, percent] { + setScale(percent / 100.0); + // The sheet's own change replaces any typing. + showSize(); + synchronize(); + }); + m_scale->setMenu(scales); + size->addWidget(m_scale); + size->addStretch(1); + grid->addWidget(new QLabel(QStringLiteral("Size"), this), 1, 0); + grid->addLayout(size, 1, 1); + // Swift's slider steps by hundredths. + auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this); + qualityLabel->setBuddy(m_quality); + m_quality->setObjectName(QStringLiteral("exportQuality")); + m_quality->setRange(0, 100); + m_quality->setFixedWidth(300); + m_quality->setValue(int(std::lround(m_settings.options.quality * 100))); + connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) { + m_settings.options.quality = hundredths / 100.0; + request(); + }); + m_percent->setObjectName(QStringLiteral("exportPercent")); + m_percent->setFixedWidth(45); + m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + auto *quality = new QWidget(this); + auto *qualityRow = new QHBoxLayout(quality); + qualityRow->setContentsMargins(0, 0, 0, 0); + qualityRow->addWidget(m_quality); + qualityRow->addWidget(m_percent); + qualityRow->addStretch(1); + grid->addWidget(qualityLabel, 2, 0); + grid->addWidget(quality, 2, 1); + m_jpegRow = {qualityLabel, quality}; + auto *pageLabel = new QLabel(QStringLiteral("Page"), this); + m_page->setObjectName(QStringLiteral("exportPage")); + grid->addWidget(pageLabel, 3, 0); + grid->addWidget(m_page, 3, 1); + m_pdfRow = {pageLabel, m_page}; + // JPEG and a page both need a colour beneath. + auto *backgroundLabel = new QLabel(QStringLiteral("Background"), this); + m_matteSwatch->setObjectName(QStringLiteral("exportBackground")); + m_matteSwatch->setToolTip(QStringLiteral("Color that fills transparent areas")); + grid->addWidget(backgroundLabel, 4, 0); + grid->addWidget(m_matteSwatch, 4, 1, Qt::AlignLeft); + m_backgroundRow = {backgroundLabel, m_matteSwatch}; + column->addLayout(grid); + + auto *buttons = new QHBoxLayout; + buttons->setSpacing(12); + m_note->setObjectName(QStringLiteral("exportNote")); + buttons->addWidget(m_note); + buttons->addStretch(1); + m_failure->setObjectName(QStringLiteral("exportError")); + m_bytes->setObjectName(QStringLiteral("exportBytes")); + m_updating->setObjectName(QStringLiteral("exportUpdating")); + buttons->addWidget(m_failure); + buttons->addWidget(m_bytes); + buttons->addWidget(m_updating); + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("exportCancel")); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + m_finish(std::nullopt); + }); + m_export->setObjectName(QStringLiteral("exportExport")); + m_export->setDefault(true); + // Swift's configuredNativeShortcut: Return and Escape, as remapped. + NativeShortcut::bind(*this, m_export, cancel); + // Export rests until a result is ready. + connect(m_export, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + if (m_settings.format == ExportFormat::jpeg) + QSettings().setValue(qualityKey, m_settings.options.quality); + m_finish(Chosen{m_result.value().data, m_result.value().settings.format}); + }); + buttons->addWidget(cancel); + buttons->addWidget(m_export); + column->addLayout(buttons); + + m_wait->setObjectName(QStringLiteral("exportWait")); + m_wait->setSingleShot(true); + m_wait->setInterval(200); + m_wait->setTimerType(Qt::PreciseTimer); + connect(m_wait, &QTimer::timeout, this, &ExportAsSheet::encode); + bindZoomKeys(); + connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &ExportAsSheet::bindZoomKeys); + request(); +} + +// A window-modal sheet blocks the menus: it takes their keys. +void ExportAsSheet::bindZoomKeys() +{ + const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1), + QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)}; + const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); }, + [this] { zoomBy(1); }, [this] { zoomBy(-1); }}; + for (size_t index = 0; index < m_zoomKeys.size(); ++index) { + if (!m_zoomKeys[index]) { + m_zoomKeys[index] = new QShortcut(this); + connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]); + } + m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index])); + } + synchronize(); +} + +// SwiftUI cancels the task when the sheet goes. +ExportAsSheet::~ExportAsSheet() +{ + m_cancelled->store(true); + releaseFocus(*this); + DialogColorSwatch::closePicker(m_session); +} + +// Swift's task(id:): the last request stops; this waits 200 ms. +void ExportAsSheet::request() +{ + if (m_cancelled) + m_cancelled->store(true); + m_cancelled = std::make_shared<std::atomic_bool>(false); + m_error.reset(); + m_wait->start(); + synchronize(); +} + +void ExportAsSheet::encode() +{ + const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled; + const Settings requested = m_settings; + auto *watcher = new QFutureWatcher<Made>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] { + watcher->deleteLater(); + // Swift's check after the await: a newer request supersedes. + if (cancelled->load()) + return; + Made made = watcher->future().takeResult(); + if (made.data) + m_result = Encoded{requested, std::move(*made.data), std::move(made.preview)}; + else + m_error = made.failure.value(); + synchronize(); + }); + watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Made { + try { + const ExportRaster sized = ImageExporter::resized(raster, requested.width, requested.height); + const JPEGOptions &options = requested.options; + const QColor background = QColor::fromRgbF(float(options.red), float(options.green), float(options.blue)); + switch (requested.format) { + case ExportFormat::jpeg: { + JPEGResult encoded = ImageExporter::jpeg(sized, options, [cancelled] { return cancelled->load(); }); + return {std::move(encoded.data), std::move(encoded.preview), std::nullopt}; + } + case ExportFormat::png: + return {ImageExporter::pngData(sized), sized.image, std::nullopt}; + case ExportFormat::pdf: + return {ImageExporter::pdfData(sized, background), ImageExporter::flattened(sized, background), std::nullopt}; + } + throw std::logic_error("unknown export format"); + } catch (const CancellationError &) { + return {}; + } catch (const ExportError &error) { + return {std::nullopt, QImage(), QString::fromUtf8(error.what())}; + } + })); +} + +void ExportAsSheet::setMatte(const PaletteColor &matte) +{ + JPEGOptions next = m_settings.options; + next.red = matte.red; + next.green = matte.green; + next.blue = matte.blue; + // Equal settings ask nothing, as Swift's task id. + if (next == m_settings.options) + return; + m_settings.options = next; + request(); +} + +void ExportAsSheet::setScale(double scale) +{ + const double width = m_raster.image.width(), height = m_raster.image.height(); + const double largest = std::min(DocumentLimits::maxSide / std::max(width, height), std::sqrt(double(DocumentLimits::maxSurfacePixels) / (width * height))); + const double clamped = std::min(largest, std::max(0.01, std::isfinite(scale) ? scale : 1.0)); + const Settings before = m_settings; + m_settings.width = std::max(1, int(std::lround(width * clamped))); + m_settings.height = std::max(1, int(std::lround(height * clamped))); + if (m_settings != before) + request(); +} + +void ExportAsSheet::showSize() +{ + m_width->setText(QString::number(m_settings.width)); + m_height->setText(QString::number(m_settings.height)); +} + +double ExportAsSheet::shownZoom() const +{ + return m_preview->zoom().value_or(JPEGPreview::fitZoom(m_settings.width, m_settings.height, QSizeF(JPEGPreview::frame), devicePixelRatioF())); +} + +void ExportAsSheet::zoomBy(int direction) +{ + if (const std::optional<double> next = JPEGPreview::step(shownZoom(), direction)) + m_preview->setZoom(next); +} + +void ExportAsSheet::changeEvent(QEvent *event) +{ + QWidget::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + synchronize(); +} + +void ExportAsSheet::synchronize() +{ + const bool ready = m_result && m_result->settings == m_settings && !m_error; + const bool updating = !ready && !m_error; + m_preview->setImage(m_result ? m_result->preview : QImage(), m_result ? QSize(m_result->settings.width, m_result->settings.height) : QSize(), updating); + m_spinner->setVisible(updating); + // A field keeps its typing while it holds the keys. + if (!m_width->hasFocus()) + m_width->setText(QString::number(m_settings.width)); + if (!m_height->hasFocus()) + m_height->setText(QString::number(m_settings.height)); + m_scale->setText(QStringLiteral("%1%").arg(std::lround(double(m_settings.width) / m_raster.image.width() * 100))); + const ExportFormat format = m_settings.format; + for (QWidget *each : m_jpegRow) + each->setVisible(format == ExportFormat::jpeg); + for (QWidget *each : m_pdfRow) + each->setVisible(format == ExportFormat::pdf); + for (QWidget *each : m_backgroundRow) + each->setVisible(format != ExportFormat::png); + m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_settings.options.quality * 100))); + // The page: the export's pixels at the document's resolution. + const double dpi = m_raster.resolution > 0 ? m_raster.resolution : 72; + m_page->setText(QStringLiteral("%1 × %2 in at %3 DPI").arg(inches(m_settings.width / dpi), inches(m_settings.height / dpi)).arg(std::lround(dpi))); + m_note->setText(format == ExportFormat::png ? QStringLiteral("Transparency kept · sRGB") : QStringLiteral("sRGB")); + m_failure->setVisible(m_error.has_value()); + m_failure->setText(m_error.value_or(QString())); + m_bytes->setVisible(ready); + m_bytes->setText(ready ? ByteCounts::file(m_result->data.size()) : QString()); + m_updating->setVisible(updating); + m_export->setEnabled(ready); + // The zoom buttons name their keys and the zoom. + const double zoom = shownZoom(); + const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100)); + const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); }; + m_fit->setEnabled(m_preview->zoom().has_value()); + m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0))); + m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value()); + m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the export is one pixel of the screen").arg(key(2), percent)); + m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value()); + m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent)); + const QColor ink = palette().color(QPalette::ButtonText); + m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF())); + m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF())); +} diff --git a/src/UI/JPEGExportSheet.h b/src/UI/ExportAsSheet.h similarity index 60% rename from src/UI/JPEGExportSheet.h rename to src/UI/ExportAsSheet.h index f7762d6..050aa8e 100644 --- a/src/UI/JPEGExportSheet.h +++ b/src/UI/ExportAsSheet.h @@ -1,6 +1,7 @@ #pragma once #include "Document/ColorPalette.h" #include "IO/ImageExporter.h" +#include "IO/ProjectController.h" #include <QAbstractScrollArea> #include <QWidget> #include <array> @@ -8,9 +9,13 @@ #include <functional> #include <memory> #include <optional> +#include <vector> +class QButtonGroup; class QLabel; class QProgressBar; +class QToolButton; +class PickerField; class QShortcut; class QPushButton; class QSlider; @@ -18,7 +23,7 @@ class QTimer; class DialogColorSwatch; class EditorSession; -// Swift's JPEGPreview: the encoded JPEG, fitted or zoomed, dragged. +// Swift's JPEGPreview: the export, fitted or zoomed, dragged. class JPEGPreview : public QAbstractScrollArea { Q_OBJECT public: @@ -29,9 +34,9 @@ public: // The next step past `zoom`: 1 in, −1 out. static std::optional<double> step(double zoom, int direction); + JPEGPreview(std::function<void()> zoomChanged, QWidget *parent); // `pixels`: the export's size, which the image may undercut. - JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent); - void setImage(const QImage &image, bool updating); + void setImage(const QImage &image, QSize pixels, bool updating); // None fits; 1 is a screen pixel a pixel. std::optional<double> zoom() const { return m_zoom; } void setZoom(std::optional<double> zoom); @@ -47,11 +52,12 @@ protected: void mouseDoubleClickEvent(QMouseEvent *event) override; private: + static QPixmap checkerboard(double ratio); QSizeF shownSize(double zoom) const; void updateRanges(); void showCursor(bool grabbing); - const QSize m_pixels; + QSize m_pixels; // Each change of zoom, for the sheet's buttons. const std::function<void()> m_zoomChanged; QImage m_image; @@ -63,46 +69,80 @@ private: std::optional<std::pair<QPoint, QPoint>> m_drag; }; -// Swift's JPEGExportSheet: quality, matte and the encoded preview. -class JPEGExportSheet : public QWidget { +// Swift's ExportAsSheet: PNG, JPEG or PDF, sized and previewed. +class ExportAsSheet : public QWidget { Q_OBJECT public: - // The last export's quality, where the next one starts. + // The last export's JPEG quality, where the next one starts. static const QString qualityKey; + // What Export… hands over: the bytes and their format. + struct Chosen { + QByteArray data; + ExportFormat format; + }; + // What is written; each change makes a new preview. + struct Settings { + ExportFormat format; + JPEGOptions options; + int width; + int height; + bool operator==(const Settings &) const = default; + }; - JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr); - ~JPEGExportSheet() override; + ExportAsSheet(ExportRaster raster, EditorSession &session, ExportFormat format, std::function<void(std::optional<Chosen>)> finish, + QWidget *parent = nullptr); + ~ExportAsSheet() override; + + const Settings &settings() const { return m_settings; } protected: // The zoom glyphs follow the theme. void changeEvent(QEvent *event) override; private: + struct Encoded { + Settings settings; + QByteArray data; + QImage preview; + }; void request(); void encode(); void setMatte(const PaletteColor &matte); + // Both sides follow one, the proportions kept, within the limits. + void setScale(double scale); + // Both fields show the size, typing or not. + void showSize(); + double shownZoom() const; + void zoomBy(int direction); void synchronize(); // The View menu's zoom keys, as remapped, zoom the preview. void bindZoomKeys(); const ExportRaster m_raster; EditorSession &m_session; - const std::function<void(std::optional<QByteArray>)> m_finish; - JPEGOptions m_options; - std::optional<JPEGResult> m_result; - std::optional<JPEGOptions> m_readyOptions; + const std::function<void(std::optional<Chosen>)> m_finish; + Settings m_settings; + std::optional<Encoded> m_result; std::optional<QString> m_error; // The newest request's flag, Swift's task cancellation. std::shared_ptr<std::atomic_bool> m_cancelled; QTimer *const m_wait; JPEGPreview *const m_preview; QProgressBar *const m_spinner; + QButtonGroup *const m_formats; + PickerField *const m_width; + PickerField *const m_height; + QToolButton *const m_scale; QSlider *const m_quality; QLabel *const m_percent; + QLabel *const m_page; DialogColorSwatch *const m_matteSwatch; + // Rows shown by format: Quality for JPEG, Page for PDF. + std::vector<QWidget *> m_jpegRow, m_pdfRow, m_backgroundRow; + QLabel *const m_note; QLabel *const m_failure; QLabel *const m_bytes; - QLabel *const m_note; + QLabel *const m_updating; QPushButton *const m_export; QPushButton *const m_fit; QPushButton *const m_zoomIn; diff --git a/src/UI/JPEGExportSheet.cpp b/src/UI/JPEGExportSheet.cpp deleted file mode 100644 index 77a9d43..0000000 --- a/src/UI/JPEGExportSheet.cpp +++ /dev/null @@ -1,282 +0,0 @@ -#include "UI/JPEGExportSheet.h" -#include "UI/KeyboardShortcuts.h" -#include "UI/ByteCounts.h" -#include "UI/ColorPickerSheet+Dialog.h" -#include "UI/LayerIcons.h" -#include <QFutureWatcher> -#include <QHBoxLayout> -#include <QLabel> -#include <QPainter> -#include <QProgressBar> -#include <QPushButton> -#include <QSettings> -#include <QShortcut> -#include <QSlider> -#include <QTimer> -#include <QVBoxLayout> -#include <QtConcurrent> -#include <algorithm> -#include <cmath> - -const QString JPEGExportSheet::qualityKey = QStringLiteral("jpegExportQuality"); - -namespace { -QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) -{ - auto *label = new QLabel(words, parent); - label->setTextFormat(Qt::PlainText); - QFont font = label->font(); - font.setPixelSize(pixels); - font.setWeight(weight); - label->setFont(font); - label->setForegroundRole(role); - return label; -} - -QString grouped(qint64 value) -{ - return QLocale(QLocale::English, QLocale::UnitedStates).toString(value); -} - -// Swift reads a finite number alone, clamped to 0–1. -double startQuality() -{ - bool number = false; - const double saved = QSettings().value(JPEGExportSheet::qualityKey).toDouble(&number); - return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality; -} - -struct Encoded { - std::optional<JPEGResult> result; - std::optional<QString> failure; -}; -} - -JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent) - : QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new JPEGPreview(m_raster.image.size(), [this] { synchronize(); }, this)), - m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)), - m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new DialogColorSwatch( - QStringLiteral("JPEG Background"), [this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, - [this](const PaletteColor &matte) { setMatte(matte); }, session, this)), - m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), - m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)), - m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)), m_zoomOut(new QPushButton(this)) -{ - m_options.quality = startQuality(); - auto *column = new QVBoxLayout(this); - column->setContentsMargins(24, 24, 24, 24); - column->setSpacing(16); - // The title row sits 8 above; it inherits 8. - auto *top = new QVBoxLayout; - top->setSpacing(8); - auto *title = new QHBoxLayout; - title->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this)); - title->addStretch(1); - m_fit->setObjectName(QStringLiteral("jpegFit")); - m_zoomIn->setObjectName(QStringLiteral("jpegZoomIn")); - m_zoomIn->setAccessibleName(QStringLiteral("Zoom in")); - m_zoomOut->setObjectName(QStringLiteral("jpegZoomOut")); - m_zoomOut->setAccessibleName(QStringLiteral("Zoom out")); - for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) { - button->setAutoDefault(false); - title->addWidget(button); - } - connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); }); - connect(m_zoomIn, &QPushButton::clicked, this, [this] { m_preview->zoomBy(1); }); - connect(m_zoomOut, &QPushButton::clicked, this, [this] { m_preview->zoomBy(-1); }); - top->addLayout(title); - m_preview->setObjectName(QStringLiteral("jpegPreview")); - m_spinner->setObjectName(QStringLiteral("jpegSpinner")); - m_spinner->setRange(0, 0); - m_spinner->setTextVisible(false); - m_spinner->setGeometry(264, 149, 32, 32); - top->addWidget(m_preview); - column->addLayout(top); - - auto *quality = new QHBoxLayout; - auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this); - qualityLabel->setBuddy(m_quality); - // Swift's slider steps by hundredths. - m_quality->setObjectName(QStringLiteral("jpegQuality")); - m_quality->setRange(0, 100); - m_quality->setValue(int(std::lround(m_options.quality * 100))); - connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) { - m_options.quality = hundredths / 100.0; - request(); - }); - m_percent->setObjectName(QStringLiteral("jpegPercent")); - m_percent->setFixedWidth(45); - m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - quality->addWidget(qualityLabel); - quality->addWidget(m_quality, 1); - quality->addWidget(m_percent); - column->addLayout(quality); - - // Its label and the app's picker on a swatch. - auto *matte = new QHBoxLayout; - matte->setSpacing(8); - matte->addWidget(new QLabel(QStringLiteral("Background for transparency"), this)); - m_matteSwatch->setObjectName(QStringLiteral("jpegMatte")); - m_matteSwatch->setToolTip(QStringLiteral("Color that fills transparent areas")); - matte->addWidget(m_matteSwatch); - matte->addStretch(1); - column->addLayout(matte); - - // The size, the result and the buttons share one row. - auto *buttons = new QHBoxLayout; - buttons->setSpacing(12); - QLabel *size = text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal, - QPalette::PlaceholderText, this); - size->setObjectName(QStringLiteral("jpegSize")); - buttons->addWidget(size); - buttons->addStretch(1); - m_failure->setObjectName(QStringLiteral("jpegError")); - m_bytes->setObjectName(QStringLiteral("jpegBytes")); - m_note->setObjectName(QStringLiteral("jpegNote")); - m_note->setText(QStringLiteral("Updating…")); - buttons->addWidget(m_failure); - buttons->addWidget(m_bytes); - buttons->addWidget(m_note); - auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); - cancel->setObjectName(QStringLiteral("jpegCancel")); - cancel->setAutoDefault(false); - connect(cancel, &QPushButton::clicked, this, [this] { - DialogColorSwatch::closePicker(m_session); - m_finish(std::nullopt); - }); - m_export->setObjectName(QStringLiteral("jpegExport")); - m_export->setDefault(true); - // Swift's configuredNativeShortcut: Return and Escape, as remapped. - NativeShortcut::bind(*this, m_export, cancel); - // Export rests until a result is ready. - connect(m_export, &QPushButton::clicked, this, [this] { - DialogColorSwatch::closePicker(m_session); - QSettings().setValue(qualityKey, m_options.quality); - m_finish(m_result.value().data); - }); - buttons->addWidget(cancel); - buttons->addWidget(m_export); - column->addLayout(buttons); - - m_wait->setObjectName(QStringLiteral("jpegWait")); - m_wait->setSingleShot(true); - m_wait->setInterval(200); - m_wait->setTimerType(Qt::PreciseTimer); - connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode); - bindZoomKeys(); - connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &JPEGExportSheet::bindZoomKeys); - request(); -} - -// A window-modal sheet blocks the menus: it takes their keys. -void JPEGExportSheet::bindZoomKeys() -{ - const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1), - QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)}; - const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); }, - [this] { m_preview->zoomBy(1); }, [this] { m_preview->zoomBy(-1); }}; - for (size_t index = 0; index < m_zoomKeys.size(); ++index) { - if (!m_zoomKeys[index]) { - m_zoomKeys[index] = new QShortcut(this); - connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]); - } - m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index])); - } - synchronize(); -} - -// SwiftUI cancels the task when the sheet goes. -JPEGExportSheet::~JPEGExportSheet() -{ - m_cancelled->store(true); - DialogColorSwatch::closePicker(m_session); -} - -// Swift's task(id:): the last request stops; this waits 200 ms. -void JPEGExportSheet::request() -{ - if (m_cancelled) - m_cancelled->store(true); - m_cancelled = std::make_shared<std::atomic_bool>(false); - m_error.reset(); - m_wait->start(); - synchronize(); -} - -void JPEGExportSheet::encode() -{ - const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled; - const JPEGOptions requested = m_options; - auto *watcher = new QFutureWatcher<Encoded>(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] { - watcher->deleteLater(); - // Swift's check after the await: a newer request supersedes. - if (cancelled->load()) - return; - Encoded encoded = watcher->future().takeResult(); - if (encoded.result) { - m_result = std::move(encoded.result); - m_readyOptions = requested; - } else { - m_error = encoded.failure.value(); - } - synchronize(); - }); - watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Encoded { - try { - return {ImageExporter::jpeg(raster, requested, [cancelled] { return cancelled->load(); }), std::nullopt}; - } catch (const CancellationError &) { - return {}; - } catch (const ExportError &error) { - return {std::nullopt, QString::fromUtf8(error.what())}; - } - })); -} - -void JPEGExportSheet::setMatte(const PaletteColor &matte) -{ - JPEGOptions next = m_options; - next.red = matte.red; - next.green = matte.green; - next.blue = matte.blue; - // Equal options ask nothing, as Swift's task id. - if (next == m_options) - return; - m_options = next; - request(); -} - -void JPEGExportSheet::changeEvent(QEvent *event) -{ - QWidget::changeEvent(event); - if (event->type() == QEvent::PaletteChange) - synchronize(); -} - -void JPEGExportSheet::synchronize() -{ - const bool ready = m_result && m_readyOptions == m_options; - const bool updating = m_readyOptions != m_options && !m_error; - m_preview->setImage(m_result ? m_result->preview : QImage(), updating); - m_spinner->setVisible(updating); - m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100))); - m_failure->setVisible(m_error.has_value()); - m_failure->setText(m_error.value_or(QString())); - m_bytes->setVisible(ready && !m_error); - m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString()); - m_note->setVisible(!ready && !m_error); - m_export->setEnabled(ready && !m_error); - // The zoom buttons name their keys and the zoom. - const double zoom = m_preview->shownZoom(); - const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100)); - const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); }; - m_fit->setEnabled(m_preview->zoom().has_value()); - m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0))); - m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value()); - m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas").arg(key(2), percent)); - m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value()); - m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent)); - const QColor ink = palette().color(QPalette::ButtonText); - m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF())); - m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF())); -} diff --git a/src/UI/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp index 1a8b3e5..bd15f6c 100644 --- a/src/UI/KeyboardShortcuts.cpp +++ b/src/UI/KeyboardShortcuts.cpp @@ -124,7 +124,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all() std::vector<ShortcutDefinition> result{ entry("Undo", "z", 1, true), entry("Redo", "z", 9, true), entry("New Canvas", "n", 1, true), entry("Open Project", "o", 1, true), entry("Save", "s", 1, true), entry("Save As", "s", 9, true), - entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Close Project", "w", 1, true), + entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Export As", "w", 11, true), entry("Close Project", "w", 1, true), entry("Fit Canvas", "0", 1, true), entry("Search Commands", "f", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true), entry("Zoom Out", "-", 1, true), entry("Show Transform Controls", "h", 1, true), entry("Cut", "x", 1, true), entry("Copy", "c", 1, true), entry("Copy Merged", "c", 9, true), entry("Paste", "v", 1, true), diff --git a/tests/AcceptanceFinishTests.cpp b/tests/AcceptanceFinishTests.cpp index d2d884a..23d9d90 100644 --- a/tests/AcceptanceFinishTests.cpp +++ b/tests/AcceptanceFinishTests.cpp @@ -1,6 +1,6 @@ #include "AcceptanceFixtures.h" #include "DialogDesk.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include <QComboBox> #include <map> #include <QLineEdit> @@ -164,11 +164,11 @@ void AcceptanceFinishTests::jpegPreviewAndQuitMidEdit() photo(app); // Export JPEG: View keys zoom to 100% and back. bool done = false; - app.workspace.current().controller.exportJPEG([&done] { done = true; }); - QTRY_VERIFY(app.window.findChild<JPEGExportSheet *>()); + app.workspace.current().controller.exportAs(ExportFormat::jpeg, [&done] { done = true; }); + QTRY_VERIFY(app.window.findChild<ExportAsSheet *>()); auto *preview = app.window.findChild<JPEGPreview *>(); QVERIFY(preview && !preview->zoom()); - JPEGExportSheet *sheet = app.window.findChild<JPEGExportSheet *>(); + ExportAsSheet *sheet = app.window.findChild<ExportAsSheet *>(); QTest::keyClick(sheet, Qt::Key_1, Qt::ControlModifier); QCOMPARE(preview->zoom(), std::optional<double>(1)); QTest::keyClick(sheet, Qt::Key_Equal, Qt::ControlModifier); diff --git a/tests/AcceptanceProjectTests.cpp b/tests/AcceptanceProjectTests.cpp index ea343d8..e75b68a 100644 --- a/tests/AcceptanceProjectTests.cpp +++ b/tests/AcceptanceProjectTests.cpp @@ -88,10 +88,10 @@ void AcceptanceProjectTests::cropSizesAndExports() QCOMPARE(QImage(app.path("scene.png")).size(), QSize(600, 300)); desk.replies = {app.path("scene.jpg")}; done = false; - controller.exportJPEG([&done] { done = true; }); + controller.exportAs(ExportFormat::jpeg, [&done] { done = true; }); QTRY_VERIFY(app.window.findChild<QDialog *>()); app.shot(QStringLiteral("11-export-jpeg")); - QPushButton *exported = app.window.findChild<QDialog *>()->findChild<QPushButton *>(QStringLiteral("jpegExport")); + QPushButton *exported = app.window.findChild<QDialog *>()->findChild<QPushButton *>(QStringLiteral("exportExport")); QVERIFY(exported); QTRY_VERIFY_WITH_TIMEOUT(exported->isEnabled(), 30000); exported->click(); diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index f7fe59b..93b241d 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -27,7 +27,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() Bar bar; const std::pair<const char *, const char *> keys[] = { {"newCanvas", "Ctrl+N"}, {"openProject", "Ctrl+O"}, {"importImages", ""}, {"save", "Ctrl+S"}, {"saveAs", "Ctrl+Shift+S"}, - {"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"}, + {"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"}, {"exportAs", "Ctrl+Alt+Shift+W"}, {"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"}, {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"gridSettings", ""}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"}, @@ -49,7 +49,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() for (const QMenu *menu : menus) titles << menu->title(); QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"})); - QCOMPARE(menus[0]->actions().size(), 12); + QCOMPARE(menus[0]->actions().size(), 13); QCOMPARE(menus[1]->actions().size(), 13); QCOMPARE(menus[2]->actions().size(), 19); QCOMPARE(menus[3]->actions().size(), 11); diff --git a/tests/ExportAsSheetTests.cpp b/tests/ExportAsSheetTests.cpp new file mode 100644 index 0000000..52e8865 --- /dev/null +++ b/tests/ExportAsSheetTests.cpp @@ -0,0 +1,386 @@ +#include "UI/ByteCounts.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ExportAsSheet.h" +#include "UI/ColorPickerSheet+Dialog.h" +#include <QHBoxLayout> +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QToolButton> +#include <QProgressBar> +#include <QPushButton> +#include <QSettings> +#include <QSlider> +#include <QStandardPaths> +#include <QTimer> +#include <QtTest> + +// Swift's ExportAsSheet: the preview waits, encodes aside, then exports. +class ExportAsSheetTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void theSheetShowsTheRasterAndWaitsForItsPreview(); + void aChangeSupersedesThePreview(); + void aSupersededEncodingNeverLands(); + void theMatteComesFromTheAppsPicker(); + void exportKeepsTheQualityAndCancelDoesNot(); + void theSavedQualityIsReadAsSwiftReadsIt(); + void anEncodingThatFailsIsShown(); + void eachFormatShowsItsRowsAndWritesItsBytes(); + void theSizeKeepsTheProportionsWithinTheLimits(); + void aClearPNGShowsTheCheckerboard(); +}; + +namespace { +// Clear on the left, opaque red on the right. +ExportRaster halfRed(int width = 64, int height = 32) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + for (int y = 0; y < height; ++y) + for (int x = width / 2; x < width; ++x) + image.setPixel(x, y, qRgba(255, 0, 0, 255)); + return {image, 144}; +} + +struct Sheet { + std::optional<std::optional<ExportAsSheet::Chosen>> answer; + EditorSession session; + ExportAsSheet sheet; + explicit Sheet(ExportRaster raster = halfRed(), ExportFormat format = ExportFormat::jpeg) + : sheet(std::move(raster), session, format, [this](std::optional<ExportAsSheet::Chosen> chosen) { answer = std::move(chosen); }) + { + sheet.show(); + } + template <typename Widget> Widget &find(const char *name) + { + Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; + } + bool ready() { return find<QPushButton>("exportExport").isEnabled(); } + QString note() { return find<QLabel>("exportUpdating").text(); } + QColor previewAt(QPoint point) { return find<QWidget>("exportPreview").grab().toImage().pixelColor(point); } +}; + +struct PaintCount : QObject { + int count = 0; + explicit PaintCount(QWidget &widget) { widget.installEventFilter(this); } + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +QStringList labels(const QWidget &widget) +{ + QStringList texts; + for (const QLabel *label : widget.findChildren<QLabel *>()) + texts << label->text(); + return texts; +} +} + +void ExportAsSheetTests::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + QSettings().clear(); +} + +void ExportAsSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() +{ + Sheet shown(halfRed(1200, 800)); + const QStringList texts = labels(shown.sheet); + QVERIFY(texts.contains("Export As") && texts.contains("Format") && texts.contains("Size") && texts.contains("Quality")); + QVERIFY(texts.contains("Background") && texts.contains("sRGB")); + QCOMPARE(shown.find<QLabel>("exportPercent").text(), QString("85%")); + QCOMPARE(shown.find<QSlider>("exportQuality").value(), 85); + QCOMPARE(shown.find<QWidget>("exportPreview").size(), QSize(560, 330)); + // Swift's task sleeps 200 ms before it encodes. + auto &wait = shown.find<QTimer>("exportWait"); + QVERIFY(wait.isActive() && wait.isSingleShot()); + QCOMPARE(wait.interval(), 200); + QCOMPARE(wait.timerType(), Qt::PreciseTimer); + QVERIFY(!shown.ready()); + QCOMPARE(shown.note(), QString("Updating…")); + QVERIFY(shown.find<QProgressBar>("exportSpinner").isVisible()); + QVERIFY(!shown.find<QLabel>("exportBytes").isVisible() && !shown.find<QLabel>("exportError").isVisible()); + // The material plate behind the spinner, over the dark gray. + QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); + const QColor window = shown.sheet.palette().color(QPalette::Window), plate = shown.previewAt({252, 165}); + QVERIFY(qAbs(plate.red() - qRound(window.red() * 0.85 + 31 * 0.15)) <= 1); + QVERIFY(qAbs(plate.blue() - qRound(window.blue() * 0.85 + 31 * 0.15)) <= 1); + QTRY_VERIFY(shown.ready()); + const JPEGResult expected = ImageExporter::jpeg(halfRed(1200, 800), {}); + QCOMPARE(shown.find<QLabel>("exportBytes").text(), ByteCounts::file(expected.data.size())); + QVERIFY(shown.find<QLabel>("exportBytes").isVisible() && !shown.find<QLabel>("exportUpdating").isVisible()); + QVERIFY(!shown.find<QProgressBar>("exportSpinner").isVisible()); + // Fitted to 495 × 330 and centred: white, then red. + QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); + QCOMPARE(shown.previewAt({10, 165}), QColor(31, 31, 31)); + QVERIFY(qAbs(shown.previewAt({100, 165}).red() - 255) < 4 && qAbs(shown.previewAt({100, 165}).blue() - 255) < 4); + const QColor red = shown.previewAt({460, 165}); + QVERIFY(red.red() > 240 && red.green() < 16 && red.blue() < 16); +} + +void ExportAsSheetTests::aChangeSupersedesThePreview() +{ + Sheet shown; + QTRY_VERIFY(shown.ready()); + auto &quality = shown.find<QSlider>("exportQuality"); + // Each change waits anew; the stale preview stays beneath. + quality.setValue(10); + QCOMPARE(shown.find<QLabel>("exportPercent").text(), QString("10%")); + QVERIFY(!shown.ready()); + QCOMPARE(shown.note(), QString("Updating…")); + QVERIFY(shown.find<QTimer>("exportWait").isActive()); + QVERIFY(shown.find<QProgressBar>("exportSpinner").isVisible()); + quality.setValue(100); + QTRY_VERIFY(shown.ready()); + QCOMPARE(shown.find<QLabel>("exportBytes").text(), ByteCounts::file(ImageExporter::jpeg(halfRed(), {.quality = 1}).data.size())); + shown.find<QPushButton>("exportExport").click(); + QCOMPARE(shown.answer.value().value().data, ImageExporter::jpeg(halfRed(), {.quality = 1}).data); + QCOMPARE(shown.answer.value().value().format, ExportFormat::jpeg); +} + +// Slow to encode: the change comes while it runs. +void ExportAsSheetTests::aSupersededEncodingNeverLands() +{ + QImage noisy(6000, 4000, QImage::Format_RGBA8888_Premultiplied); + quint32 seed = 1; + for (int y = 0; y < noisy.height(); ++y) { + auto *row = reinterpret_cast<quint32 *>(noisy.scanLine(y)); + for (int x = 0; x < noisy.width(); ++x) + row[x] = (seed = seed * 1664525u + 1013904223u) | 0xff000000u; + } + Sheet shown({noisy, 72}); + QTRY_VERIFY(!shown.find<QTimer>("exportWait").isActive()); + shown.find<QSlider>("exportQuality").setValue(50); + // Only the newest request's preview may ever show. + bool stale = false; + while (!shown.ready()) { + stale = stale || shown.previewAt({100, 165}) != QColor(31, 31, 31); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + QVERIFY(!stale); + QCOMPARE(shown.find<QLabel>("exportPercent").text(), QString("50%")); +} + +void ExportAsSheetTests::theMatteComesFromTheAppsPicker() +{ + QSettings().remove(ExportAsSheet::qualityKey); + Sheet shown; + QTRY_VERIFY(shown.ready()); + auto &matte = shown.find<DialogColorSwatch>("exportBackground"); + QVERIFY(matte.accessibleName() == QString("Export Background") && matte.toolTip() == QString("Color that fills transparent areas")); + matte.click(); + QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Export Background)")); + QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); + // The same colour asks for nothing. + shown.session.closeColorPicker(true); + QVERIFY(shown.ready() && !shown.find<QTimer>("exportWait").isActive()); + // The working colour reaches the preview; Cancel takes it back. + matte.click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); + QVERIFY(!shown.ready()); + QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(0, 0, 255)); + shown.session.closeColorPicker(false); + QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(Qt::white)); + QTRY_VERIFY(shown.ready()); + // Export closes an open picker, keeping its colour. + matte.click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("exportExport").click(); + QVERIFY(!shown.session.colorPicker()); + const QImage written = QImage::fromData(shown.answer.value().value().data, "jpeg"); + const QColor left = written.pixelColor(8, 16), right = written.pixelColor(56, 16); + QVERIFY(left.blue() > 240 && left.red() < 16 && left.green() < 16); + QVERIFY(right.red() > 240 && right.blue() < 16); + QCOMPARE(shown.answer.value().value().data, ImageExporter::jpeg(halfRed(), {.red = 0, .green = 0, .blue = 1}).data); + // Cancel and the sheet going close it too. + Sheet cancelled; + cancelled.find<DialogColorSwatch>("exportBackground").click(); + cancelled.find<QPushButton>("exportCancel").click(); + QVERIFY(!cancelled.session.colorPicker() && !cancelled.answer.value()); + EditorSession session; + auto gone = std::make_unique<ExportAsSheet>(halfRed(), session, ExportFormat::jpeg, [](std::optional<ExportAsSheet::Chosen>) {}); + gone->findChild<DialogColorSwatch *>()->click(); + gone.reset(); + QVERIFY(!session.colorPicker()); +} + +void ExportAsSheetTests::exportKeepsTheQualityAndCancelDoesNot() +{ + QSettings().remove(ExportAsSheet::qualityKey); + { + Sheet shown; + shown.find<QSlider>("exportQuality").setValue(40); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("exportCancel").click(); + QVERIFY(!shown.answer.value().has_value()); + QVERIFY(!QSettings().contains(ExportAsSheet::qualityKey)); + } + { + Sheet shown; + QCOMPARE(shown.find<QSlider>("exportQuality").value(), 85); + shown.find<QSlider>("exportQuality").setValue(40); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("exportExport").click(); + QCOMPARE(QSettings().value(ExportAsSheet::qualityKey).toDouble(), 0.4); + } + Sheet next; + QCOMPARE(next.find<QSlider>("exportQuality").value(), 40); + QCOMPARE(next.find<QLabel>("exportPercent").text(), QString("40%")); + // Return reaches Export once it is ready. + QTRY_VERIFY(next.ready()); + QVERIFY(next.find<QPushButton>("exportExport").isDefault()); + QCOMPARE(next.find<QPushButton>("exportExport").text(), QString("Export…")); +} + +void ExportAsSheetTests::theSavedQualityIsReadAsSwiftReadsIt() +{ + const auto started = [](const QVariant &saved) { + QSettings().setValue(ExportAsSheet::qualityKey, saved); + Sheet shown; + return shown.find<QLabel>("exportPercent").text(); + }; + QCOMPARE(started(0.333), QString("33%")); + QCOMPARE(started(0.875), QString("88%")); + QCOMPARE(started(7), QString("100%")); + QCOMPARE(started(-2), QString("0%")); + QCOMPARE(started(QStringLiteral("high")), QString("85%")); + QCOMPARE(started(std::nan("")), QString("85%")); + QCOMPARE(started(qInf()), QString("85%")); + QSettings().remove(ExportAsSheet::qualityKey); +} + +void ExportAsSheetTests::anEncodingThatFailsIsShown() +{ + // JPEG holds at most 65,500 pixels a side. + Sheet shown(halfRed(70'000, 1)); + auto &error = shown.find<QLabel>("exportError"); + QTRY_VERIFY(error.isVisible()); + QCOMPARE(error.text(), QString::fromUtf8(ExportError(ExportError::Kind::encode).what())); + QCOMPARE(error.foregroundRole(), QPalette::BrightText); + QVERIFY(!shown.ready() && !shown.find<QLabel>("exportUpdating").isVisible() && !shown.find<QProgressBar>("exportSpinner").isVisible()); + // A change clears the error while it waits. + shown.find<QSlider>("exportQuality").setValue(50); + QVERIFY(!error.isVisible()); + QCOMPARE(shown.note(), QString("Updating…")); + QTRY_VERIFY(error.isVisible()); +} + +void ExportAsSheetTests::eachFormatShowsItsRowsAndWritesItsBytes() +{ + const ExportRaster raster = halfRed(); + const auto rows = [](Sheet &shown) { + return std::array<bool, 3>{shown.find<QSlider>("exportQuality").isVisible(), shown.find<QLabel>("exportPage").isVisible(), + shown.find<DialogColorSwatch>("exportBackground").isVisible()}; + }; + QSettings().remove(ExportAsSheet::qualityKey); + { + Sheet shown(raster, ExportFormat::png); + QVERIFY(shown.find<QAbstractButton>("exportFormatPNG").isChecked()); + QCOMPARE(rows(shown), (std::array<bool, 3>{false, false, false})); + QCOMPARE(shown.find<QLabel>("exportNote").text(), QString("Transparency kept · sRGB")); + QTRY_VERIFY(shown.ready()); + QCOMPARE(shown.find<QLabel>("exportBytes").text(), ByteCounts::file(ImageExporter::pngData(raster).size())); + shown.find<QPushButton>("exportExport").click(); + QCOMPARE(shown.answer.value().value().data, ImageExporter::pngData(raster)); + QCOMPARE(shown.answer.value().value().format, ExportFormat::png); + // Only a JPEG keeps its quality for next time. + QVERIFY(!QSettings().contains(ExportAsSheet::qualityKey)); + } + Sheet shown(raster, ExportFormat::png); + QTRY_VERIFY(shown.ready()); + // Another format waits for its own result. + shown.find<QAbstractButton>("exportFormatPDF").click(); + QVERIFY(!shown.ready() && shown.find<QAbstractButton>("exportFormatPDF").isChecked() && !shown.find<QAbstractButton>("exportFormatPNG").isChecked()); + QCOMPARE(rows(shown), (std::array<bool, 3>{false, true, true})); + QCOMPARE(shown.find<QLabel>("exportNote").text(), QString("sRGB")); + // 64 by 32 at 144 per inch. + QCOMPARE(shown.find<QLabel>("exportPage").text(), QString("0.44 × 0.22 in at 144 DPI")); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("exportExport").click(); + QCOMPARE(shown.answer.value().value().data, ImageExporter::pdfData(raster, QColor(Qt::white))); + QCOMPARE(shown.answer.value().value().format, ExportFormat::pdf); + // The PDF preview lies over its page's white. + QCOMPARE(shown.previewAt({100, 165}), QColor(Qt::white)); + shown.find<QAbstractButton>("exportFormatJPEG").click(); + QCOMPARE(rows(shown), (std::array<bool, 3>{true, false, true})); +} + +void ExportAsSheetTests::theSizeKeepsTheProportionsWithinTheLimits() +{ + const ExportRaster raster = halfRed(); + Sheet shown(raster, ExportFormat::png); + auto &width = shown.find<QLineEdit>("exportWidth"), &height = shown.find<QLineEdit>("exportHeight"); + auto &scale = shown.find<QToolButton>("exportScale"); + QVERIFY(width.text() == "64" && height.text() == "32" && scale.text() == "100%"); + QCOMPARE(width.toolTip(), QString("Width in pixels; the height follows, keeping the proportions")); + const auto type = [](QLineEdit &field, const QString &text) { + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + QTest::keyClick(&field, Qt::Key_Return); + }; + type(width, "32"); + QVERIFY(height.text() == "16" && scale.text() == "50%"); + QCOMPARE(shown.sheet.settings(), (ExportAsSheet::Settings{ExportFormat::png, shown.sheet.settings().options, 32, 16})); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("exportExport").click(); + QCOMPARE(shown.answer.value().value().data, ImageExporter::pngData(ImageExporter::resized(raster, 32, 16))); + type(height, "64"); + QVERIFY(width.text() == "128" && scale.text() == "200%"); + // Other text goes back; a side holds a pixel. + type(width, "wide"); + QCOMPARE(width.text(), QString("128")); + type(width, "0"); + QVERIFY(width.text() == "1" && height.text() == "1" && scale.text() == "2%"); + // The surface's limit caps a growth: 312.5 times here. + type(width, "100000"); + QVERIFY(width.text() == "20000" && height.text() == "10000"); + // The menu's steps. + QStringList steps; + for (QAction *each : scale.menu()->actions()) + steps << each->text(); + QCOMPARE(steps, (QStringList{"25%", "50%", "75%", "100%", "200%"})); + scale.menu()->actions().at(0)->trigger(); + QVERIFY(width.text() == "16" && height.text() == "8" && scale.text() == "25%"); + // The same size asks nothing new, as Swift's task id. + QTRY_VERIFY(shown.ready()); + scale.menu()->actions().at(0)->trigger(); + QVERIFY(!shown.find<QTimer>("exportWait").isActive()); + QCOMPARE(scale.toolTip(), QString("Scale the export: the document stays as it is")); + // A hundredth at least: 1120 wide stops at 11. + Sheet large(halfRed(1120, 660), ExportFormat::png); + auto &wide = large.find<QLineEdit>("exportWidth"); + auto &in = large.find<QPushButton>("exportZoomIn"); + QVERIFY(in.isEnabled() && in.toolTip().contains("now 50%")); + type(wide, "-5"); + QVERIFY(wide.text() == "11" && large.find<QLineEdit>("exportHeight").text() == "7"); + // Fit follows the export's size: 11 by 7, 47 times. + QVERIFY(!in.isEnabled() && in.toolTip().contains("now 4714%")); +} + +void ExportAsSheetTests::aClearPNGShowsTheCheckerboard() +{ + // 330 square fits the frame's height, one pixel a point. + QImage clear(330, 330, QImage::Format_RGBA8888_Premultiplied); + clear.fill(Qt::transparent); + Sheet shown({clear, 72}, ExportFormat::png); + QTRY_VERIFY(shown.ready()); + // The image starts at x 115: grays 0.35, 0.30. + QCOMPARE(shown.previewAt({116, 1}).rgb(), QColor::fromRgbF(0.35f, 0.35f, 0.35f).rgb()); + QCOMPARE(shown.previewAt({126, 1}).rgb(), QColor::fromRgbF(0.30f, 0.30f, 0.30f).rgb()); + QCOMPARE(shown.previewAt({126, 11}).rgb(), QColor::fromRgbF(0.35f, 0.35f, 0.35f).rgb()); + // Outside the image, the frame's own gray. + QCOMPARE(shown.previewAt({100, 165}), QColor(31, 31, 31)); +} + +QTEST_MAIN(ExportAsSheetTests) +#include "ExportAsSheetTests.moc" diff --git a/tests/ExportPDFTests.cpp b/tests/ExportPDFTests.cpp new file mode 100644 index 0000000..ed12927 --- /dev/null +++ b/tests/ExportPDFTests.cpp @@ -0,0 +1,133 @@ +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include <QtEndian> +#include <QtTest> + +// Swift 1.4.8's PDF and Export As's resize, in the exporter. +namespace { +// Swift's fixture: 6 by 6, red left, clear right. +ProjectSnapshot snapshot(double resolution) +{ + QImage image = BrushRaster::context(2, 2, false); + image.setPixel(0, 0, qRgba(255, 0, 0, 255)); + image.setPixel(0, 1, qRgba(255, 0, 0, 255)); + const QUuid id = QUuid::createUuid(); + const LayerTransform transform{.origin = {1, 1}, .size = {4, 4}, .sampling = LayerSampling::nearest}; + const ProjectLayerRecord record{.id = id, .name = "Red", .isVisible = true, .transform = transform, .imageFile = id.toString(QUuid::WithoutBraces) + ".png"}; + ProjectSnapshot made{.manifest = {.documentID = QUuid::createUuid(), .width = 6, .height = 6, .activeLayerID = id, .layers = {record}}, + .images = {{id, ImportedImage(image, image, "Red")}}}; + made.manifest.resolution = resolution; + return made; +} + +// Object `number`'s text, found through the cross-reference table. +QByteArray object(const QByteArray &pdf, int number) +{ + const qsizetype table = pdf.lastIndexOf("startxref\n"); + const qsizetype xref = pdf.mid(table + 10).split('\n').value(0).toLongLong(); + const QList<QByteArray> lines = pdf.mid(xref).split('\n'); + const qsizetype offset = lines.value(2 + number).left(10).toLongLong(); + const QByteArray start = QByteArray::number(number) + " 0 obj\n"; + if (pdf.mid(offset, start.size()) != start) + throw std::runtime_error("cross-reference points elsewhere"); + return pdf.mid(offset, pdf.indexOf("\nendobj\n", offset) - offset); +} + +// A stream's bytes, inflated; its /Length must be its size. +QByteArray streamOf(const QByteArray &text) +{ + const qsizetype begin = text.indexOf(">>\nstream\n") + 10, end = text.lastIndexOf("\nendstream"); + const QByteArray body = text.mid(begin, end - begin); + const QByteArray dictionary = text.left(begin); + const qsizetype length = dictionary.mid(dictionary.indexOf("/Length ") + 8).split(' ').value(0).toLongLong(); + if (length != body.size()) + throw std::runtime_error("stream length is wrong"); + if (!dictionary.contains("/FlateDecode")) + return body; + QByteArray sized(4, 0); + qToBigEndian(quint32(1 << 20), sized.data()); + return qUncompress(sized + body); +} +} + +class ExportPDFTests : public QObject { + Q_OBJECT +private slots: + void pdfIsOnePageAtThePrintedSizeWithLosslessPixels(); + void aBackgroundFillsThePageUnderThePicture(); + void anOversizedCanvasIsRefused(); + void resizingKeepsTheResolutionWithinTheLimits(); + void flatteningLaysThePictureOverItsBackground(); +}; + +void ExportPDFTests::pdfIsOnePageAtThePrintedSizeWithLosslessPixels() +{ + const QByteArray pdf = ImageExporter::pdfData(snapshot(150)); + QVERIFY(pdf.startsWith("%PDF-1.4\n") && pdf.endsWith("%%EOF\n")); + QCOMPARE(pdf.count("/Type /Page "), qsizetype(1)); + // 6 pixels at 150 per inch: 0.04 in, 2.88 points. + QVERIFY(object(pdf, 3).contains("/MediaBox [0 0 2.8800 2.8800]")); + QVERIFY(pdf.contains("/FlateDecode") && !pdf.contains("/DCTDecode")); + // The pixels as they were: red, then clear beside it. + const QByteArray colour = streamOf(object(pdf, 5)), alpha = streamOf(object(pdf, 6)); + QVERIFY(object(pdf, 5).contains("/Width 6 /Height 6") && object(pdf, 5).contains("/SMask 6 0 R")); + QCOMPARE(colour.size(), qsizetype(6 * 6 * 3)); + QCOMPARE(alpha.size(), qsizetype(6 * 6)); + const auto at = [&](int x, int y) { return std::array<int, 4>{uchar(colour[(y * 6 + x) * 3]), uchar(colour[(y * 6 + x) * 3 + 1]), uchar(colour[(y * 6 + x) * 3 + 2]), uchar(alpha[y * 6 + x])}; }; + QCOMPARE(at(1, 1), (std::array<int, 4>{255, 0, 0, 255})); + QCOMPARE(at(4, 1)[3], 0); + QCOMPARE(at(0, 0)[3], 0); + // The page draws the image over it, nothing beneath. + QCOMPARE(streamOf(object(pdf, 4)), QByteArray("q\n2.8800 0 0 2.8800 0 0 cm /Im0 Do\nQ")); + // A soft edge goes straight: colour whole, alpha apart. + QImage soft = BrushRaster::context(1, 1, false); + soft.setPixelColor(0, 0, QColor(255, 0, 0, 128)); + const QByteArray edge = ImageExporter::pdfData(ExportRaster{soft, 72}); + QCOMPARE(streamOf(object(edge, 5)), QByteArray("\xff\x00\x00", 3)); + QCOMPARE(streamOf(object(edge, 6)), QByteArray("\x80", 1)); + // At 72 per inch a pixel is a point. + QVERIFY(object(ImageExporter::pdfData(snapshot(72)), 3).contains("/MediaBox [0 0 6.0000 6.0000]")); + QVERIFY(object(ImageExporter::pdfData(snapshot(0)), 3).contains("/MediaBox [0 0 6.0000 6.0000]")); +} + +void ExportPDFTests::aBackgroundFillsThePageUnderThePicture() +{ + const ExportRaster raster = ImageExporter::render(snapshot(300)); + const QByteArray pdf = ImageExporter::pdfData(raster, QColor(255, 128, 0)); + QCOMPARE(streamOf(object(pdf, 4)), QByteArray("q\n1.0000 0.5020 0.0000 rg 0 0 1.4400 1.4400 re f\n1.4400 0 0 1.4400 0 0 cm /Im0 Do\nQ")); +} + +void ExportPDFTests::anOversizedCanvasIsRefused() +{ + const ProjectSnapshot huge{.manifest = {.documentID = QUuid::createUuid(), .width = 30'000, .height = 30'000, .activeLayerID = std::nullopt, .layers = {}}, .images = {}}; + QVERIFY_THROWS_EXCEPTION(ExportError, ImageExporter::pdfData(huge)); +} + +void ExportPDFTests::resizingKeepsTheResolutionWithinTheLimits() +{ + QImage pair = BrushRaster::context(2, 1, false); + pair.setPixelColor(0, 0, Qt::black); + pair.setPixelColor(1, 0, Qt::white); + const ExportRaster raster{pair, 300}; + // The same size is the same raster. + QCOMPARE(ImageExporter::resized(raster, 2, 1).image.cacheKey(), pair.cacheKey()); + const ExportRaster half = ImageExporter::resized(raster, 1, 1); + QVERIFY(half.image.size() == QSize(1, 1) && half.resolution == 300); + QVERIFY(std::abs(half.image.pixelColor(0, 0).red() - 128) <= 1); + QCOMPARE(ImageExporter::resized(raster, 8, 3).image.size(), QSize(8, 3)); + for (const QSize wrong : {QSize(0, 1), QSize(1, 0), QSize(30'001, 1), QSize(1, 30'001), QSize(20'000, 20'000)}) + QVERIFY_THROWS_EXCEPTION(ExportError, ImageExporter::resized(raster, wrong.width(), wrong.height())); +} + +void ExportPDFTests::flatteningLaysThePictureOverItsBackground() +{ + QImage half = BrushRaster::context(1, 1, false); + half.setPixelColor(0, 0, QColor(255, 0, 0, 128)); + const QImage flat = ImageExporter::flattened({half, 72}, QColor(0, 0, 255)); + QVERIFY(!flat.hasAlphaChannel()); + const QColor mixed = flat.pixelColor(0, 0); + QVERIFY2(std::abs(mixed.red() - 128) <= 1 && mixed.green() == 0 && std::abs(mixed.blue() - 127) <= 1, qPrintable(mixed.name())); +} + +QTEST_MAIN(ExportPDFTests) +#include "ExportPDFTests.moc" diff --git a/tests/HighDpiCanvasTests.cpp b/tests/HighDpiCanvasTests.cpp index a1d2d7e..1781b7c 100644 --- a/tests/HighDpiCanvasTests.cpp +++ b/tests/HighDpiCanvasTests.cpp @@ -2,7 +2,7 @@ #include "Rendering/AdjustmentSurface.h" #include "Rendering/EditorCanvas.h" #include "RenderFixtures.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include "UI/LevelsSheet.h" #include <QScrollBar> #include <QtTest> @@ -178,8 +178,8 @@ void HighDpiCanvasTests::theJPEGPreviewCountsDevicePixels() for (int y = 0; y < 800; ++y) for (int x = 0; x < 1200; ++x) image.setPixelColor(x, y, QColor((x * 7 + y) % 256, (x + y * 3) % 256, (x * y) % 256)); - JPEGPreview preview(QSize(1200, 800), [] {}, nullptr); - preview.setImage(image, false); + JPEGPreview preview([] {}, nullptr); + preview.setImage(image, QSize(1200, 800), false); preview.show(); QVERIFY(QTest::qWaitForWindowExposed(&preview)); // 600 by 400 points: Fit 0.825, averaged per device pixel. diff --git a/tests/JPEGExportSheetTests.cpp b/tests/JPEGExportSheetTests.cpp deleted file mode 100644 index 30922fe..0000000 --- a/tests/JPEGExportSheetTests.cpp +++ /dev/null @@ -1,273 +0,0 @@ -#include "UI/ByteCounts.h" -#include "UI/ColorPaletteControls.h" -#include "UI/JPEGExportSheet.h" -#include "UI/ColorPickerSheet+Dialog.h" -#include <QHBoxLayout> -#include <QLabel> -#include <QProgressBar> -#include <QPushButton> -#include <QSettings> -#include <QSlider> -#include <QStandardPaths> -#include <QTimer> -#include <QtTest> - -// Swift's JPEGExportSheet: the preview waits, encodes aside, then exports. -class JPEGExportSheetTests : public QObject { - Q_OBJECT -private slots: - void initTestCase(); - void theSheetShowsTheRasterAndWaitsForItsPreview(); - void aChangeSupersedesThePreview(); - void aSupersededEncodingNeverLands(); - void theMatteComesFromTheAppsPicker(); - void exportKeepsTheQualityAndCancelDoesNot(); - void theSavedQualityIsReadAsSwiftReadsIt(); - void anEncodingThatFailsIsShown(); -}; - -namespace { -// Clear on the left, opaque red on the right. -ExportRaster halfRed(int width = 64, int height = 32) -{ - QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); - image.fill(Qt::transparent); - for (int y = 0; y < height; ++y) - for (int x = width / 2; x < width; ++x) - image.setPixel(x, y, qRgba(255, 0, 0, 255)); - return {image, 144}; -} - -struct Sheet { - std::optional<std::optional<QByteArray>> answer; - EditorSession session; - JPEGExportSheet sheet; - explicit Sheet(ExportRaster raster = halfRed()) - : sheet(std::move(raster), session, [this](std::optional<QByteArray> data) { answer = std::move(data); }) - { - sheet.show(); - } - template <typename Widget> Widget &find(const char *name) - { - Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name)); - if (!found) - throw std::runtime_error(std::string("no widget named ") + name); - return *found; - } - bool ready() { return find<QPushButton>("jpegExport").isEnabled(); } - QString note() { return find<QLabel>("jpegNote").text(); } - QColor previewAt(QPoint point) { return find<QWidget>("jpegPreview").grab().toImage().pixelColor(point); } -}; - -struct PaintCount : QObject { - int count = 0; - explicit PaintCount(QWidget &widget) { widget.installEventFilter(this); } - bool eventFilter(QObject *, QEvent *event) override - { - count += event->type() == QEvent::Paint; - return false; - } -}; - -QStringList labels(const QWidget &widget) -{ - QStringList texts; - for (const QLabel *label : widget.findChildren<QLabel *>()) - texts << label->text(); - return texts; -} -} - -void JPEGExportSheetTests::initTestCase() -{ - QStandardPaths::setTestModeEnabled(true); - QSettings().clear(); -} - -void JPEGExportSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() -{ - Sheet shown(halfRed(1200, 800)); - const QStringList texts = labels(shown.sheet); - QVERIFY(texts.contains("Export JPEG")); - QVERIFY(texts.contains("1,200 × 800 px · sRGB")); - QVERIFY(texts.contains("Background for transparency")); - QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("85%")); - QCOMPARE(shown.find<QSlider>("jpegQuality").value(), 85); - QCOMPARE(shown.find<QWidget>("jpegPreview").size(), QSize(560, 330)); - // Swift's task sleeps 200 ms before it encodes. - auto &wait = shown.find<QTimer>("jpegWait"); - QVERIFY(wait.isActive() && wait.isSingleShot()); - QCOMPARE(wait.interval(), 200); - QCOMPARE(wait.timerType(), Qt::PreciseTimer); - QVERIFY(!shown.ready()); - QCOMPARE(shown.note(), QString("Updating…")); - QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); - QVERIFY(!shown.find<QLabel>("jpegBytes").isVisible() && !shown.find<QLabel>("jpegError").isVisible()); - // The material plate behind the spinner, over the dark gray. - QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); - const QColor window = shown.sheet.palette().color(QPalette::Window), plate = shown.previewAt({252, 165}); - QVERIFY(qAbs(plate.red() - qRound(window.red() * 0.85 + 31 * 0.15)) <= 1); - QVERIFY(qAbs(plate.blue() - qRound(window.blue() * 0.85 + 31 * 0.15)) <= 1); - QTRY_VERIFY(shown.ready()); - const JPEGResult expected = ImageExporter::jpeg(halfRed(1200, 800), {}); - QCOMPARE(shown.find<QLabel>("jpegBytes").text(), ByteCounts::file(expected.data.size())); - QVERIFY(shown.find<QLabel>("jpegBytes").isVisible() && !shown.find<QLabel>("jpegNote").isVisible()); - QVERIFY(!shown.find<QProgressBar>("jpegSpinner").isVisible()); - // Fitted to 495 × 330 and centred: white, then red. - QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); - QCOMPARE(shown.previewAt({10, 165}), QColor(31, 31, 31)); - QVERIFY(qAbs(shown.previewAt({100, 165}).red() - 255) < 4 && qAbs(shown.previewAt({100, 165}).blue() - 255) < 4); - const QColor red = shown.previewAt({460, 165}); - QVERIFY(red.red() > 240 && red.green() < 16 && red.blue() < 16); -} - -void JPEGExportSheetTests::aChangeSupersedesThePreview() -{ - Sheet shown; - QTRY_VERIFY(shown.ready()); - auto &quality = shown.find<QSlider>("jpegQuality"); - // Each change waits anew; the stale preview stays beneath. - quality.setValue(10); - QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("10%")); - QVERIFY(!shown.ready()); - QCOMPARE(shown.note(), QString("Updating…")); - QVERIFY(shown.find<QTimer>("jpegWait").isActive()); - QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); - quality.setValue(100); - QTRY_VERIFY(shown.ready()); - QCOMPARE(shown.find<QLabel>("jpegBytes").text(), ByteCounts::file(ImageExporter::jpeg(halfRed(), {.quality = 1}).data.size())); - shown.find<QPushButton>("jpegExport").click(); - QCOMPARE(shown.answer.value().value(), ImageExporter::jpeg(halfRed(), {.quality = 1}).data); -} - -// Slow to encode: the change comes while it runs. -void JPEGExportSheetTests::aSupersededEncodingNeverLands() -{ - QImage noisy(6000, 4000, QImage::Format_RGBA8888_Premultiplied); - quint32 seed = 1; - for (int y = 0; y < noisy.height(); ++y) { - auto *row = reinterpret_cast<quint32 *>(noisy.scanLine(y)); - for (int x = 0; x < noisy.width(); ++x) - row[x] = (seed = seed * 1664525u + 1013904223u) | 0xff000000u; - } - Sheet shown({noisy, 72}); - QTRY_VERIFY(!shown.find<QTimer>("jpegWait").isActive()); - shown.find<QSlider>("jpegQuality").setValue(50); - // Only the newest request's preview may ever show. - bool stale = false; - while (!shown.ready()) { - stale = stale || shown.previewAt({100, 165}) != QColor(31, 31, 31); - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); - } - QVERIFY(!stale); - QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("50%")); -} - -void JPEGExportSheetTests::theMatteComesFromTheAppsPicker() -{ - QSettings().remove(JPEGExportSheet::qualityKey); - Sheet shown; - QTRY_VERIFY(shown.ready()); - auto &matte = shown.find<DialogColorSwatch>("jpegMatte"); - QVERIFY(matte.accessibleName() == QString("JPEG Background") && matte.toolTip() == QString("Color that fills transparent areas")); - QCOMPARE(qobject_cast<QHBoxLayout *>(shown.sheet.layout()->itemAt(2)->layout())->spacing(), 8); - matte.click(); - QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (JPEG Background)")); - QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); - // The same colour asks for nothing. - shown.session.closeColorPicker(true); - QVERIFY(shown.ready() && !shown.find<QTimer>("jpegWait").isActive()); - // The working colour reaches the preview; Cancel takes it back. - matte.click(); - shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); - QVERIFY(!shown.ready()); - QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(0, 0, 255)); - shown.session.closeColorPicker(false); - QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(Qt::white)); - QTRY_VERIFY(shown.ready()); - // Export closes an open picker, keeping its colour. - matte.click(); - shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); - QTRY_VERIFY(shown.ready()); - shown.find<QPushButton>("jpegExport").click(); - QVERIFY(!shown.session.colorPicker()); - const QImage written = QImage::fromData(shown.answer.value().value(), "jpeg"); - const QColor left = written.pixelColor(8, 16), right = written.pixelColor(56, 16); - QVERIFY(left.blue() > 240 && left.red() < 16 && left.green() < 16); - QVERIFY(right.red() > 240 && right.blue() < 16); - QCOMPARE(shown.answer.value().value(), ImageExporter::jpeg(halfRed(), {.red = 0, .green = 0, .blue = 1}).data); - // Cancel and the sheet going close it too. - Sheet cancelled; - cancelled.find<DialogColorSwatch>("jpegMatte").click(); - cancelled.find<QPushButton>("jpegCancel").click(); - QVERIFY(!cancelled.session.colorPicker() && !cancelled.answer.value()); - EditorSession session; - auto gone = std::make_unique<JPEGExportSheet>(halfRed(), session, [](std::optional<QByteArray>) {}); - gone->findChild<DialogColorSwatch *>()->click(); - gone.reset(); - QVERIFY(!session.colorPicker()); -} - -void JPEGExportSheetTests::exportKeepsTheQualityAndCancelDoesNot() -{ - QSettings().remove(JPEGExportSheet::qualityKey); - { - Sheet shown; - shown.find<QSlider>("jpegQuality").setValue(40); - QTRY_VERIFY(shown.ready()); - shown.find<QPushButton>("jpegCancel").click(); - QVERIFY(!shown.answer.value().has_value()); - QVERIFY(!QSettings().contains(JPEGExportSheet::qualityKey)); - } - { - Sheet shown; - QCOMPARE(shown.find<QSlider>("jpegQuality").value(), 85); - shown.find<QSlider>("jpegQuality").setValue(40); - QTRY_VERIFY(shown.ready()); - shown.find<QPushButton>("jpegExport").click(); - QCOMPARE(QSettings().value(JPEGExportSheet::qualityKey).toDouble(), 0.4); - } - Sheet next; - QCOMPARE(next.find<QSlider>("jpegQuality").value(), 40); - QCOMPARE(next.find<QLabel>("jpegPercent").text(), QString("40%")); - // Return reaches Export once it is ready. - QTRY_VERIFY(next.ready()); - QVERIFY(next.find<QPushButton>("jpegExport").isDefault()); - QCOMPARE(next.find<QPushButton>("jpegExport").text(), QString("Export…")); -} - -void JPEGExportSheetTests::theSavedQualityIsReadAsSwiftReadsIt() -{ - const auto started = [](const QVariant &saved) { - QSettings().setValue(JPEGExportSheet::qualityKey, saved); - Sheet shown; - return shown.find<QLabel>("jpegPercent").text(); - }; - QCOMPARE(started(0.333), QString("33%")); - QCOMPARE(started(0.875), QString("88%")); - QCOMPARE(started(7), QString("100%")); - QCOMPARE(started(-2), QString("0%")); - QCOMPARE(started(QStringLiteral("high")), QString("85%")); - QCOMPARE(started(std::nan("")), QString("85%")); - QCOMPARE(started(qInf()), QString("85%")); - QSettings().remove(JPEGExportSheet::qualityKey); -} - -void JPEGExportSheetTests::anEncodingThatFailsIsShown() -{ - // JPEG holds at most 65,500 pixels a side. - Sheet shown(halfRed(70'000, 1)); - auto &error = shown.find<QLabel>("jpegError"); - QTRY_VERIFY(error.isVisible()); - QCOMPARE(error.text(), QString::fromUtf8(ExportError(ExportError::Kind::encode).what())); - QCOMPARE(error.foregroundRole(), QPalette::BrightText); - QVERIFY(!shown.ready() && !shown.find<QLabel>("jpegNote").isVisible() && !shown.find<QProgressBar>("jpegSpinner").isVisible()); - // A change clears the error while it waits. - shown.find<QSlider>("jpegQuality").setValue(50); - QVERIFY(!error.isVisible()); - QCOMPARE(shown.note(), QString("Updating…")); - QTRY_VERIFY(error.isVisible()); -} - -QTEST_MAIN(JPEGExportSheetTests) -#include "JPEGExportSheetTests.moc" diff --git a/tests/JPEGPreviewTests.cpp b/tests/JPEGPreviewTests.cpp index 714c9bf..8f2b1ab 100644 --- a/tests/JPEGPreviewTests.cpp +++ b/tests/JPEGPreviewTests.cpp @@ -1,4 +1,4 @@ -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include "Document/EditorSession.h" #include "UI/KeyboardShortcuts.h" #include <QHBoxLayout> @@ -64,8 +64,10 @@ struct Paints : QObject { }; struct Shown { + // The export's size, the preview's image at most that. + QSize pixels; JPEGPreview preview; - explicit Shown(QSize pixels, std::function<void()> zoomed = [] {}) : preview(pixels, std::move(zoomed), nullptr) + explicit Shown(QSize size, std::function<void()> zoomed = [] {}) : pixels(size), preview(std::move(zoomed), nullptr) { preview.show(); if (!QTest::qWaitForWindowExposed(&preview)) @@ -110,14 +112,14 @@ void JPEGPreviewTests::itDrawsFittedAndBelowOneThroughAnAverage() QCOMPARE(drawn(preview).pixelColor(280, 165), QColor(31, 31, 31)); Paints paints(preview.viewport()); const QImage first = noise(1100, 660, 1); - preview.setImage(first, false); + preview.setImage(first, shown.pixels, false); QTRY_VERIFY(paints.count > 0); // Fitted, 550 by 330 in the middle, averaged down. QVERIFY(shows(preview, QRect(5, 0, 550, 330), first.scaled(550, 330, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); QCOMPARE(drawn(preview).pixelColor(4, 165), QColor(31, 31, 31)); // A new image of the same size is averaged anew. const QImage second = noise(1100, 660, 2); - preview.setImage(second, false); + preview.setImage(second, shown.pixels, false); QVERIFY(shows(preview, QRect(5, 0, 550, 330), second.scaled(550, 330, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); // A quarter, then Fit again: each size averaged for itself. QCoreApplication::processEvents(); @@ -136,7 +138,7 @@ void JPEGPreviewTests::fromOneUpEachPixelShowsAsItIs() Shown shown(QSize(1100, 660)); JPEGPreview &preview = shown.preview; const QImage image = noise(1100, 660, 3); - preview.setImage(image, false); + preview.setImage(image, shown.pixels, false); shown.zoom(2); // Bars come once laid out; ranges follow the view. QScrollBar &across = *preview.horizontalScrollBar(), &down = *preview.verticalScrollBar(); @@ -149,12 +151,12 @@ void JPEGPreviewTests::fromOneUpEachPixelShowsAsItIs() down.setValue(60); QVERIFY(shows(preview, QRect(0, 0, 200, 100), image.copy(50, 30, 100, 50).scaled(200, 100))); // A smaller decode still spans the export's pixels. - preview.setImage(image.scaled(550, 330), false); + preview.setImage(image.scaled(550, 330), shown.pixels, false); shown.zoom(1); QCOMPARE(down.maximum(), 660 - preview.viewport()->height()); // A size between pixels reaches its last one. Shown odd(QSize(1201, 1201)); - odd.preview.setImage(noise(1201, 1201, 4), false); + odd.preview.setImage(noise(1201, 1201, 4), odd.pixels, false); odd.zoom(0.5); QCOMPARE(odd.preview.horizontalScrollBar()->maximum(), int(std::ceil(600.5 - odd.preview.viewport()->width()))); } @@ -162,7 +164,7 @@ void JPEGPreviewTests::fromOneUpEachPixelShowsAsItIs() void JPEGPreviewTests::theUpdatingPlateSitsInTheMiddle() { Shown shown(QSize(1100, 660)); - shown.preview.setImage(QImage(), true); + shown.preview.setImage(QImage(), shown.pixels, true); const QImage image = drawn(shown.preview); const QColor window = shown.preview.palette().color(QPalette::Window); const int plate = qRound(window.red() * 0.85 + 31 * 0.15); @@ -181,7 +183,7 @@ void JPEGPreviewTests::zoomKeepsTheMiddleAndDragsMoveIt() int heard = 0; Shown shown(QSize(1200, 800), [&heard] { ++heard; }); JPEGPreview &preview = shown.preview; - preview.setImage(noise(1200, 800, 5), false); + preview.setImage(noise(1200, 800, 5), shown.pixels, false); // From Fit, the middle of the image. Paints paints(preview.viewport()); preview.setZoom(1); @@ -204,6 +206,7 @@ void JPEGPreviewTests::zoomKeepsTheMiddleAndDragsMoveIt() QVERIFY(across.value() == 20 && down.value() == 35); // A half-pixel landing rounds, as Swift scrolls to it. Shown odd(QSize(1201, 801)); + odd.preview.setImage(QImage(), odd.pixels, true); odd.preview.setZoom(1); QVERIFY(odd.preview.horizontalScrollBar()->value() == 321 && odd.preview.verticalScrollBar()->value() == 236); shown.zoom(2); @@ -244,26 +247,26 @@ void JPEGPreviewTests::zoomKeepsTheMiddleAndDragsMoveIt() void JPEGPreviewTests::theSheetsButtonsAndKeysZoomIt() { - QSettings().remove(JPEGExportSheet::qualityKey); + QSettings().remove(ExportAsSheet::qualityKey); EditorSession session; QImage red(1200, 800, QImage::Format_RGBA8888_Premultiplied); red.fill(Qt::red); - JPEGExportSheet sheet({red, 72}, session, [](std::optional<QByteArray>) {}); + ExportAsSheet sheet({red, 72}, session, ExportFormat::jpeg, [](std::optional<ExportAsSheet::Chosen>) {}); sheet.show(); QVERIFY(QTest::qWaitForWindowActive(&sheet)); auto &preview = *sheet.findChild<JPEGPreview *>(); - auto &fit = *sheet.findChild<QPushButton *>(QStringLiteral("jpegFit")); - auto &in = *sheet.findChild<QPushButton *>(QStringLiteral("jpegZoomIn")); - auto &out = *sheet.findChild<QPushButton *>(QStringLiteral("jpegZoomOut")); + auto &fit = *sheet.findChild<QPushButton *>(QStringLiteral("exportFit")); + auto &in = *sheet.findChild<QPushButton *>(QStringLiteral("exportZoomIn")); + auto &out = *sheet.findChild<QPushButton *>(QStringLiteral("exportZoomOut")); // Title row 8 above the preview; bottom row 12 apart. auto *top = qobject_cast<QVBoxLayout *>(sheet.layout()->itemAt(0)->layout()); auto *title = qobject_cast<QHBoxLayout *>(top->itemAt(0)->layout()); QVERIFY(top->spacing() == 8 && title->spacing() == 8 && title->itemAt(2)->widget() == &fit && title->itemAt(4)->widget() == &out); - auto *bottom = qobject_cast<QHBoxLayout *>(sheet.layout()->itemAt(3)->layout()); - QVERIFY(bottom->spacing() == 12 && bottom->itemAt(0)->widget()->objectName() == QString("jpegSize") && bottom->itemAt(1)->spacerItem()); + auto *bottom = qobject_cast<QHBoxLayout *>(sheet.layout()->itemAt(2)->layout()); + QVERIFY(bottom->spacing() == 12 && bottom->itemAt(0)->widget()->objectName() == QString("exportNote") && bottom->itemAt(1)->spacerItem()); QVERIFY(!fit.isEnabled() && in.isEnabled() && out.isEnabled() && in.accessibleName() == QString("Zoom in")); QCOMPARE(fit.toolTip(), QString("Show the whole image (Ctrl+0)")); - QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 41%. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas")); + QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 41%. At 100% each pixel of the export is one pixel of the screen")); QCOMPARE(out.toolTip(), QString("Zoom out (Ctrl+-), now 41%")); // The magnifiers in the button ink, redrawn with the theme. const QImage before = in.icon().pixmap(16).toImage(); @@ -273,7 +276,7 @@ void JPEGPreviewTests::theSheetsButtonsAndKeysZoomIt() QVERIFY(in.icon().pixmap(16).toImage() != before && out.icon().pixmap(16).toImage() != in.icon().pixmap(16).toImage()); out.click(); QVERIFY(preview.zoom() == std::optional(0.25) && !out.isEnabled() && fit.isEnabled()); - QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 25%. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas")); + QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 25%. At 100% each pixel of the export is one pixel of the screen")); in.click(); QCOMPARE(preview.zoom(), std::optional(0.5)); fit.click(); diff --git a/tests/KeyboardShortcutsTests.cpp b/tests/KeyboardShortcutsTests.cpp index f46ae1b..b8bf040 100644 --- a/tests/KeyboardShortcutsTests.cpp +++ b/tests/KeyboardShortcutsTests.cpp @@ -93,7 +93,7 @@ void KeyboardShortcutsTests::theListIsSwiftsWithoutHide() { const std::vector<ShortcutDefinition> &all = ShortcutDefinition::all(); // Swift's 113 with Ungroup Layers, less Hide Compositor. - QCOMPARE(int(all.size()), 115); + QCOMPARE(int(all.size()), 116); QSet<QString> ids; for (const ShortcutDefinition &definition : all) ids.insert(definition.id()); diff --git a/tests/ProjectExportFailureTests.cpp b/tests/ProjectExportFailureTests.cpp index 38efd9c..06be911 100644 --- a/tests/ProjectExportFailureTests.cpp +++ b/tests/ProjectExportFailureTests.cpp @@ -2,7 +2,7 @@ #include "DialogDesk.h" #include "IO/ImageExporter.h" #include "IO/ProjectController.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include <QApplication> #include <QSemaphore> #include <QTemporaryDir> @@ -105,12 +105,12 @@ void ProjectExportFailureTests::aSnapshotThatFindsNoMemoryIsExplained() malloc_trim(0); const AddressSpaceLimit limit(512ll * 1024); if (jpeg) - desk.controller.exportJPEG([&desk] { desk.done = true; }); + desk.controller.exportAs(ExportFormat::jpeg, [&desk] { desk.done = true; }); else desk.controller.exportPNG([&desk] { desk.done = true; }); } QTRY_VERIFY(desk.done); - QCOMPARE(desk.alerts.seen, desk.explained(jpeg ? QStringLiteral("Couldn’t export JPEG") : QStringLiteral("Couldn’t export PNG"))); + QCOMPARE(desk.alerts.seen, desk.explained(jpeg ? QStringLiteral("Couldn’t export") : QStringLiteral("Couldn’t export PNG"))); QVERIFY(!desk.session.isProjectBusy()); } } @@ -127,7 +127,7 @@ void ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained() release.acquire(); }); entered.acquire(); - desk.controller.exportJPEG([&desk] { desk.done = true; }); + desk.controller.exportAs(ExportFormat::jpeg, [&desk] { desk.done = true; }); { malloc_trim(0); const AddressSpaceLimit limit(512ll * 1024); @@ -135,7 +135,7 @@ void ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained() pool->waitForDone(); } QTRY_VERIFY(desk.done); - QCOMPARE(desk.alerts.seen, desk.explained(QStringLiteral("Couldn’t export JPEG"))); + QCOMPARE(desk.alerts.seen, desk.explained(QStringLiteral("Couldn’t export"))); QVERIFY(!desk.session.isProjectBusy()); } @@ -200,11 +200,11 @@ void ProjectExportFailureTests::theJPEGRenderTakesTheSnapshotWithoutACopy() { malloc_trim(0); const AddressSpaceLimit limit(5120ll * 1024); - desk.controller.exportJPEG([&desk] { desk.done = true; }); + desk.controller.exportAs(ExportFormat::jpeg, [&desk] { desk.done = true; }); } release.release(); - QTRY_VERIFY(desk.window.findChild<JPEGExportSheet *>()); - QTest::keyClick(desk.window.findChild<JPEGExportSheet *>(), Qt::Key_Escape); + QTRY_VERIFY(desk.window.findChild<ExportAsSheet *>()); + QTest::keyClick(desk.window.findChild<ExportAsSheet *>(), Qt::Key_Escape); QTRY_VERIFY(desk.done); QVERIFY(desk.alerts.seen.isEmpty() && !desk.session.isProjectBusy()); } diff --git a/tests/ProjectExportTests.cpp b/tests/ProjectExportTests.cpp index 7335c33..825e69b 100644 --- a/tests/ProjectExportTests.cpp +++ b/tests/ProjectExportTests.cpp @@ -2,8 +2,9 @@ #include "MenuFixtures.h" #include "IO/ImageExporter.h" #include "IO/ProjectController.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include <QDialog> +#include <QLineEdit> #include <QPushButton> #include <QSettings> #include <QSlider> @@ -22,6 +23,7 @@ private slots: void aJPEGPassesThroughItsSheet(); void aJPEGNeedsAWindowAndADocument(); void theFileMenuOffersBothExports(); + void exportAsWritesEachFormatAndRemembersTheLast(); }; namespace { @@ -49,9 +51,9 @@ struct Desk { } static QString path(const QString &name) { return QDir::current().absoluteFilePath(name); } // The shown sheet; a closed one may await deletion. - JPEGExportSheet *sheet() + ExportAsSheet *sheet() { - for (JPEGExportSheet *shown : window.findChildren<JPEGExportSheet *>()) + for (ExportAsSheet *shown : window.findChildren<ExportAsSheet *>()) if (shown->isVisible()) return shown; return nullptr; @@ -141,20 +143,20 @@ void ProjectExportTests::aCancelledExportWritesNothing() QCOMPARE(QDir::current().entryList(QDir::Files), QStringList()); // A JPEG cancelled at its sheet opens no panel. desk.controller.window = &desk.window; - desk.controller.exportJPEG(desk.finished()); + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); QTRY_VERIFY(desk.sheet()); QTest::keyClick(desk.sheet(), Qt::Key_Escape); QTRY_VERIFY(desk.done); QCOMPARE(desk.dialogs.seen.size(), 1); QVERIFY(!desk.session.isProjectBusy()); - QVERIFY(!QSettings().contains(JPEGExportSheet::qualityKey)); + QVERIFY(!QSettings().contains(ExportAsSheet::qualityKey)); // Cancelled at its panel, nothing is written. desk.dialogs.replies = {"<cancel>"}; - desk.controller.exportJPEG(desk.finished()); - QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("jpegExport")->isEnabled()); - desk.sheet()->findChild<QPushButton *>("jpegExport")->click(); + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); + QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("exportExport")->isEnabled()); + desk.sheet()->findChild<QPushButton *>("exportExport")->click(); QTRY_VERIFY(desk.done); - QCOMPARE(desk.dialogs.seen.value(1), QString("panel|Export JPEG|save|file|Untitled.jpg|jpeg|busy 1")); + QCOMPARE(desk.dialogs.seen.value(1), QString("panel|Export JPEG|save|file|Untitled.jpeg|jpeg|busy 1")); QCOMPARE(QDir::current().entryList(QDir::Files), QStringList()); QVERIFY(!desk.session.isProjectBusy()); } @@ -178,33 +180,33 @@ void ProjectExportTests::aFailedWriteIsExplained() void ProjectExportTests::aJPEGPassesThroughItsSheet() { Desk desk; - QSettings().remove(JPEGExportSheet::qualityKey); + QSettings().remove(ExportAsSheet::qualityKey); desk.dialogs.replies = {Desk::path("photo")}; desk.dialogs.note = panelFilters; - desk.controller.exportJPEG(desk.finished()); + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); QVERIFY(desk.session.isProjectBusy()); // The render runs aside; then the sheet holds the window. QTRY_VERIFY(desk.sheet()); auto *dialog = qobject_cast<QDialog *>(desk.sheet()->parentWidget()); QVERIFY(dialog && dialog->isVisible() && dialog->windowModality() == Qt::WindowModal); - QCOMPARE(dialog->windowTitle(), QString("Export JPEG")); + QCOMPARE(dialog->windowTitle(), QString("Export As")); QCOMPARE(dialog->minimumSize(), dialog->maximumSize()); - auto *exporting = desk.sheet()->findChild<QPushButton *>("jpegExport"); - desk.sheet()->findChild<QSlider *>("jpegQuality")->setValue(50); + auto *exporting = desk.sheet()->findChild<QPushButton *>("exportExport"); + desk.sheet()->findChild<QSlider *>("exportQuality")->setValue(50); QTRY_VERIFY(exporting->isEnabled()); exporting->click(); QTRY_VERIFY(desk.done); - QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export JPEG|save|file|Untitled.jpg|jpeg|JPEG image (*.jpeg *.jpg *.jpe)"}); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export JPEG|save|file|Untitled.jpeg|jpeg|JPEG image (*.jpeg *.jpg *.jpe)"}); const ExportRaster raster = ImageExporter::render(desk.session.projectSnapshot().value()); QCOMPARE(contents(Desk::path("photo.jpeg")), ImageExporter::jpeg(raster, {.quality = 0.5}).data); - QCOMPARE(QSettings().value(JPEGExportSheet::qualityKey).toDouble(), 0.5); + QCOMPARE(QSettings().value(ExportAsSheet::qualityKey).toDouble(), 0.5); QVERIFY(!desk.session.isProjectBusy()); // Every JPEG suffix is one; another gains the preferred. for (const QString &name : {QStringLiteral("a.jpg"), QStringLiteral("b.JPE"), QStringLiteral("c.png")}) { desk.dialogs.replies = {Desk::path(name)}; - desk.controller.exportJPEG(desk.finished()); - QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("jpegExport")->isEnabled()); - desk.sheet()->findChild<QPushButton *>("jpegExport")->click(); + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); + QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("exportExport")->isEnabled()); + desk.sheet()->findChild<QPushButton *>("exportExport")->click(); QTRY_VERIFY(desk.done); } QVERIFY(QFileInfo::exists(Desk::path("a.jpg")) && QFileInfo::exists(Desk::path("b.JPE")) && QFileInfo::exists(Desk::path("c.png.jpeg"))); @@ -214,14 +216,14 @@ void ProjectExportTests::aJPEGNeedsAWindowAndADocument() { Desk desk; desk.controller.window = nullptr; - desk.controller.exportJPEG(desk.finished()); + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); QVERIFY(!desk.session.isProjectBusy()); QTRY_VERIFY(desk.done); EditorSession empty; ProjectController idle(empty); idle.window = &desk.window; bool refused = false; - idle.exportJPEG([&refused] { refused = true; }); + idle.exportAs(ExportFormat::jpeg, [&refused] { refused = true; }); idle.exportPNG([&refused] { refused = true; }); QTRY_VERIFY(refused); QVERIFY(desk.dialogs.seen.isEmpty() && !empty.isProjectBusy()); @@ -239,11 +241,14 @@ void ProjectExportTests::theFileMenuOffersBothExports() { QTemporaryDir folder; Bar bar; - QVERIFY(!bar.action("exportPNG").isEnabled() && !bar.action("exportJPEG").isEnabled()); + const auto all = [&](bool enabled) { + return bar.action("exportPNG").isEnabled() == enabled && bar.action("exportJPEG").isEnabled() == enabled && bar.action("exportAs").isEnabled() == enabled; + }; + QVERIFY(all(false)); bar.session().createDocument(8, 8); - QVERIFY(bar.action("exportPNG").isEnabled() && bar.action("exportJPEG").isEnabled()); + QVERIFY(all(true)); bar.session().setIsProjectBusy(true); - QVERIFY(!bar.action("exportPNG").isEnabled() && !bar.action("exportJPEG").isEnabled()); + QVERIFY(all(false)); bar.session().setIsProjectBusy(false); DialogDesk desk; desk.replies = {folder.filePath("Shot.png")}; @@ -251,21 +256,73 @@ void ProjectExportTests::theFileMenuOffersBothExports() QTRY_VERIFY(QFileInfo::exists(folder.filePath("Shot.png")) && !bar.session().isProjectBusy()); bar.window.show(); bar.action("exportJPEG").trigger(); - QTRY_VERIFY(bar.window.findChild<JPEGExportSheet *>()); + QTRY_VERIFY(bar.window.findChild<ExportAsSheet *>()); // The menus' zoom keys reach the preview, not the canvas. - JPEGExportSheet &sheet = *bar.window.findChild<JPEGExportSheet *>(); + ExportAsSheet &sheet = *bar.window.findChild<ExportAsSheet *>(); QTRY_COMPARE(QApplication::activeWindow(), sheet.window()); const double zoom = bar.session().viewport.zoom(); QTest::keyClick(&sheet, Qt::Key_1, Qt::ControlModifier); QCOMPARE(sheet.findChild<JPEGPreview *>()->zoom(), std::optional(1.0)); QCOMPARE(bar.session().viewport.zoom(), zoom); // In the dialog only Export takes Return. - for (const char *name : {"jpegFit", "jpegZoomIn", "jpegZoomOut"}) + for (const char *name : {"exportFit", "exportZoomIn", "exportZoomOut"}) QVERIFY(!sheet.findChild<QPushButton *>(QString::fromLatin1(name))->autoDefault()); QTest::keyClick(&sheet, Qt::Key_Escape); QTRY_VERIFY(!bar.session().isProjectBusy()); QCOMPARE(desk.seen.size(), 1); } +void ProjectExportTests::exportAsWritesEachFormatAndRemembersTheLast() +{ + Desk desk; + const auto checked = [&desk] { + for (const ExportFormat format : allExportFormats) + if (desk.sheet()->findChild<QAbstractButton *>(QStringLiteral("exportFormat") + rawValue(format))->isChecked()) + return format; + throw std::runtime_error("no format checked"); + }; + // First, PNG; a PDF written then starts the next. + desk.dialogs.replies = {Desk::path("page")}; + desk.dialogs.note = panelFilters; + desk.controller.exportAs(std::nullopt, desk.finished()); + QTRY_VERIFY(desk.sheet()); + QCOMPARE(checked(), ExportFormat::png); + desk.sheet()->findChild<QAbstractButton *>("exportFormatPDF")->click(); + auto *exporting = desk.sheet()->findChild<QPushButton *>("exportExport"); + QTRY_VERIFY(exporting->isEnabled()); + exporting->click(); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export PDF|save|file|Untitled.pdf|pdf|PDF document (*.pdf)"}); + const ExportRaster raster = ImageExporter::render(desk.session.projectSnapshot().value()); + QCOMPARE(contents(Desk::path("page.pdf")), ImageExporter::pdfData(raster, QColor(Qt::white))); + QVERIFY(!desk.session.isProjectBusy()); + desk.controller.exportAs(std::nullopt, desk.finished()); + QTRY_VERIFY(desk.sheet()); + QCOMPARE(checked(), ExportFormat::pdf); + QTest::keyClick(desk.sheet(), Qt::Key_Escape); + QTRY_VERIFY(desk.done); + // Export JPEG starts on JPEG, whatever came last. + desk.controller.exportAs(ExportFormat::jpeg, desk.finished()); + QTRY_VERIFY(desk.sheet()); + QCOMPARE(checked(), ExportFormat::jpeg); + QTest::keyClick(desk.sheet(), Qt::Key_Escape); + QTRY_VERIFY(desk.done); + // A PNG through Export As, scaled to half. + desk.dialogs.seen.clear(); + desk.dialogs.replies = {Desk::path("half")}; + desk.controller.exportAs(ExportFormat::png, desk.finished()); + QTRY_VERIFY(desk.sheet()); + auto *width = desk.sheet()->findChild<QLineEdit *>("exportWidth"); + width->setFocus(); + width->selectAll(); + QTest::keyClicks(width, "20"); + QTest::keyClick(width, Qt::Key_Return); + QTRY_VERIFY(desk.sheet()->findChild<QPushButton *>("exportExport")->isEnabled()); + desk.sheet()->findChild<QPushButton *>("exportExport")->click(); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export PNG|save|file|Untitled.png|png|PNG image (*.png)"}); + QCOMPARE(contents(Desk::path("half.png")), ImageExporter::pngData(ImageExporter::resized(raster, 20, 10))); +} + QTEST_MAIN(ProjectExportTests) #include "ProjectExportTests.moc" diff --git a/tests/RemappedKeysTests.cpp b/tests/RemappedKeysTests.cpp index 06a574d..cd09f97 100644 --- a/tests/RemappedKeysTests.cpp +++ b/tests/RemappedKeysTests.cpp @@ -7,7 +7,7 @@ #include "UI/FilterSheet.h" #include "UI/HueSaturationSheet.h" #include "UI/ImageSizeSheet.h" -#include "UI/JPEGExportSheet.h" +#include "UI/ExportAsSheet.h" #include "UI/KeyboardShortcuts.h" #include "UI/LevelsSheet.h" #include "UI/NewCanvasSheet.h" @@ -231,9 +231,9 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys() QCOMPARE(keys(GridSettingsSheet(session, LayoutGrid(), GridAppearance(), [](const GridSettingsSheet::Settings &) {}, [](std::optional<GridSettingsSheet::Settings>) {})), both); QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both); QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both); - // Export JPEG also takes the View menu's four zoom keys. + // Export As also takes the View menu's four zoom keys. const QSet<QString> zooms{"Ctrl+K", "Ctrl+Q", "Ctrl+0", "Ctrl+1", "Ctrl+=", "Ctrl+-"}; - QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, session, [](std::optional<QByteArray>) {})), zooms); + QCOMPARE(keys(ExportAsSheet(ExportRaster{red, 72}, session, ExportFormat::jpeg, [](std::optional<ExportAsSheet::Chosen>) {})), zooms); QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both); QCOMPARE(keys(NewCanvasSheet(session, [](int, int, double, std::optional<QColor>) {}, [] {})), QSet<QString>{"Ctrl+K"}); QCOMPARE(keys(TransformInspector(session)), both); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 86dfffd..6edf6da 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -120,6 +120,7 @@ compositor_test(EditorSessionSignalTests) compositor_test(EditorSessionTests) compositor_test(LayerDuplicateTests) compositor_test(ExportTests) +compositor_test(ExportPDFTests) compositor_test(FolderMaskClipTests) compositor_test(EditorCanvasTests) compositor_test(GroupSessionTests) @@ -371,7 +372,7 @@ compositor_test(CanvasSizeSheetTests) compositor_test(ImageSizeSheetTests) compositor_test(ProjectResizeTests) compositor_test(ImageTrimTests) -compositor_test(JPEGExportSheetTests) +compositor_test(ExportAsSheetTests) compositor_test(ProjectExportTests) compositor_test(ProjectExportFailureTests) compositor_test(ByteCountsTests)