From 8fbb104c485611e8eaa3c7805c5635d878b5d80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 29 Sep 2026 10:55:10 -0400 Subject: [PATCH] Clicked text on its baseline, live text on the Move tool's double click, SVG import (12.11c) --- AGENTS.md | 1 + CMakeLists.txt | 4 +- Dockerfile.dev | 2 +- TASKS.md | 2 +- flake.nix | 2 +- packaging/arch/PKGBUILD | 2 +- ...io.github.ZacharyZhang_NY.OmaPhoto.desktop | 2 +- scripts/distro/arch/Dockerfile | 2 +- scripts/distro/fedora/Dockerfile | 2 +- src/ContentView+Panels.cpp | 2 +- src/Document/EditorSession+Imports.cpp | 10 +- src/Document/TypeTool.cpp | 7 +- src/IO/ImageImporter.cpp | 31 +++ src/IO/ImageImporter.h | 4 + src/Rendering/EditorCanvas+Navigation.cpp | 12 +- src/Rendering/EditorCanvas+Transform.cpp | 22 ++ src/Rendering/EditorCanvas.h | 3 + src/Rendering/InlineTextEditor.cpp | 8 +- src/Rendering/InlineTextEditor.h | 6 +- src/UI/NativeLayerList+Cell.cpp | 2 +- tests/ContentViewTests.cpp | 2 +- tests/DesktopIntegrationTests.cpp | 8 +- tests/InlineTextDrawTests.cpp | 20 +- tests/InlineTextFixtures.h | 9 +- tests/InlineTextPointerTests.cpp | 6 +- tests/InlineTextPreeditTests.cpp | 12 +- tests/LiveTextDoubleClickTests.cpp | 188 ++++++++++++++++++ tests/PickingTests.cpp | 2 +- tests/SVGImportTests.cpp | 174 ++++++++++++++++ tests/SessionFixtures.h | 9 + tests/Tests.cmake | 6 + tests/TypeControlsTests.cpp | 2 +- tests/TypeSessionTests.cpp | 4 +- tests/TypeToolTests.cpp | 51 ++++- 34 files changed, 572 insertions(+), 47 deletions(-) create mode 100644 tests/LiveTextDoubleClickTests.cpp create mode 100644 tests/SVGImportTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 44a554e..423d6aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,7 @@ Platform adaptations, each forced by a missing Qt analog: - Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`. - Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`. - Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks. +- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index dba21e1..f928b47 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ endif() option(OMAPHOTO_WERROR "Treat warnings as errors" OFF) -find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test) +find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Svg Test) find_package(libheif REQUIRED) find_package(Fontconfig REQUIRED) find_package(onnxruntime REQUIRED) @@ -222,7 +222,7 @@ set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_O # The per-pixel loops vectorise only at -O3. set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3) target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS}) -target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW) +target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW) target_compile_options(compositor_core PUBLIC -Wall -Wextra $<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>) # Empty optionals and bad indexes abort, not pass by luck. diff --git a/Dockerfile.dev b/Dockerfile.dev index c7e4c81..7d117b0 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,7 +2,7 @@ ARG BASE=ubuntu:24.04 FROM $BASE ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ - g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \ + g++ cmake ninja-build qt6-base-dev qt6-svg-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \ qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \ ca-certificates curl dpkg-dev file fakeroot \ && rm -rf /var/lib/apt/lists/* diff --git a/TASKS.md b/TASKS.md index f12be79..1739ed1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -381,7 +381,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS. - [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed. - [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed. -- [ ] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). +- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed. - [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. - [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. - [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. diff --git a/flake.nix b/flake.nix index 92e2597..9d814fb 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ filter = path: type: path != "${toString self}/build"; }; nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ]; - buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ]; + buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ]; cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ]; ninjaFlags = [ "omaphoto" ]; # Staged: profiles build MIME caches outside the store. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 9e62a0a..d53e5cc 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -6,7 +6,7 @@ pkgdesc='Layered image editing for Linux, a port of Compositor' arch=('x86_64') url='https://github.com/ZacharyZhang-NY/OmaPhoto' license=('MIT') -depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info') +depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info') makedepends=('cmake' 'ninja') # ONNX Runtime ships inside: Arch's changes its symbols each release. source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx' diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop index 04a7186..c0087f4 100644 --- a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop +++ b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop @@ -7,5 +7,5 @@ Exec=omaphoto %F Icon=io.github.ZacharyZhang_NY.OmaPhoto Terminal=false Categories=Graphics;2DGraphics;RasterGraphics; -MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw; +MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/svg+xml;image/svg+xml-compressed;image/x-dcraw; StartupWMClass=OmaPhoto diff --git a/scripts/distro/arch/Dockerfile b/scripts/distro/arch/Dockerfile index a339757..2921a3a 100644 --- a/scripts/distro/arch/Dockerfile +++ b/scripts/distro/arch/Dockerfile @@ -1,6 +1,6 @@ # Arch and Omarchy: Qt and GCC as shipped. FROM archlinux:latest -RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 libraw \ +RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-svg qt6-imageformats libheif libde265 libraw \ fontconfig ttf-dejavu curl \ && pacman -Scc --noconfirm # The ONNX Runtime the packages carry: Microsoft's own build. diff --git a/scripts/distro/fedora/Dockerfile b/scripts/distro/fedora/Dockerfile index 50f6b16..be7c35f 100644 --- a/scripts/distro/fedora/Dockerfile +++ b/scripts/distro/fedora/Dockerfile @@ -1,7 +1,7 @@ # Fedora 43, the oldest still supported; HEVC from RPM Fusion. FROM fedora:43 RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \ - && dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \ + && dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtsvg-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \ fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \ && dnf clean all # The ONNX Runtime the packages carry: Microsoft's own build. diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index 7beafb7..f147467 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -87,7 +87,7 @@ void ContentView::showImporter() m_importer = new QFileDialog(this); m_importer->setAttribute(Qt::WA_DeleteOnClose); m_importer->setFileMode(QFileDialog::ExistingFiles); - m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb %1)").arg(RawImporter::globs().join(' '))); + m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb *.svg *.svgz %1)").arg(RawImporter::globs().join(' '))); connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) { // Ours no longer: the flag must not reject it again. if (m_importer != panel) diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp index 3219bdf..164c589 100644 --- a/src/Document/EditorSession+Imports.cpp +++ b/src/Document/EditorSession+Imports.cpp @@ -105,6 +105,11 @@ void EditorSession::decodeNext() decodeRaw(url.toLocalFile(), remainingPixels()); return; } + // An SVG goes by its suffix, before Photoshop's bytes. + if (url.isLocalFile() && ImageImporter::isSVG(url.toLocalFile())) { + decode(url, false); + return; + } if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) { beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import")); m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = remainingPixels()]() -> PhotoshopRead { @@ -143,10 +148,13 @@ qint64 EditorSession::remainingPixels() const // `flattened`: a layerless Photoshop file, read as its merged image. void EditorSession::decode(const QUrl &url, bool flattened) { - m_decoder.setFuture(QtConcurrent::run([url, flattened, remaining = remainingPixels()]() -> Decoded { + const std::optional<QSizeF> fitting = m_document ? std::optional(m_document->size()) : std::nullopt; + m_decoder.setFuture(QtConcurrent::run([url, flattened, fitting, remaining = remainingPixels()]() -> Decoded { try { if (!url.isLocalFile()) throw ImageImportError(ImageImportError::Kind::unsupported); + if (ImageImporter::isSVG(url.toLocalFile())) + return {ImageImporter::decodeSVG(url.toLocalFile(), fitting, remaining), QString()}; return {ImageImporter::decode(url.toLocalFile(), remaining, flattened), QString()}; } catch (const ImageImportError &error) { return {std::nullopt, url.fileName() + ": " + error.what()}; diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp index 84d3dc5..977a9d4 100644 --- a/src/Document/TypeTool.cpp +++ b/src/Document/TypeTool.cpp @@ -97,8 +97,11 @@ void EditorSession::beginText(QPointF point, bool newLayer) style.boxSize = std::nullopt; } m_tool = NavigationTool::type; + // A click puts new text's first baseline on the pointer. + const double padding = LayerTextStyle::padding; + const QPointF origin = target ? target->origin() : point - QPointF(padding, padding + TextLines(style, QSizeF(100'000, 100'000)).baseline(0)); setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt, - .origin = target ? target->origin() : point, + .origin = origin, .transform = target ? std::optional(target->transform) : std::nullopt, .style = style}); } @@ -202,6 +205,8 @@ void EditorSession::beginText(const QRectF &rect) beginText(rect.topLeft(), true); if (m_textDraft) { TextDraft draft = *m_textDraft; + // A dragged box is exactly where it was drawn. + draft.origin = rect.topLeft(); draft.style.boxSize = style.boxSize; setTextDraft(draft); } diff --git a/src/IO/ImageImporter.cpp b/src/IO/ImageImporter.cpp index f9e5c03..061591a 100644 --- a/src/IO/ImageImporter.cpp +++ b/src/IO/ImageImporter.cpp @@ -1,5 +1,7 @@ #include "IO/ImageImporter.h" +#include "Document/BrushStroke.h" #include "Document/DocumentLimits.h" +#include "Document/PixelAdjust.h" #include "IO/PSD/PSDDocumentBuilder.h" #include "IO/PSD/PSDReader.h" #include "Logging.h" @@ -8,6 +10,8 @@ #include <QFile> #include <QFileInfo> #include <QImageReader> +#include <QPainter> +#include <QSvgRenderer> #include <QTransform> #include <cmath> #include <libheif/heif.h> @@ -265,3 +269,30 @@ std::map<QUuid, ImportedImage> ImageImporter::photoshopAssets(const PSDDocument { return PSDDocumentBuilder::assets(document); } + +bool ImageImporter::isSVG(const QString &path) +{ + const QString suffix = QFileInfo(path).suffix(); + return suffix.compare(QLatin1String("svg"), Qt::CaseInsensitive) == 0 || suffix.compare(QLatin1String("svgz"), Qt::CaseInsensitive) == 0; +} + +ImportedImage ImageImporter::decodeSVG(const QString &path, std::optional<QSizeF> fitting, qint64 remainingPixels) +{ + QSvgRenderer svg(path); + const QSizeF size = svg.defaultSize(); + // A drawing Qt cannot read measures nothing. + if (size.width() <= 0 || size.height() <= 0) + throw ImageImportError(ImageImportError::Kind::unreadable); + const double scale = fitting ? std::min(fitting->width() / size.width(), fitting->height() / size.height()) : 1; + const double width = std::max(1.0, std::round(size.width() * scale)), height = std::max(1.0, std::round(size.height() * scale)); + if (!(width <= DocumentLimits::maxSide && height <= DocumentLimits::maxSide && width * height <= double(remainingPixels))) + throw ImageImportError(ImageImportError::Kind::tooLarge); + QImage image = BrushRaster::context(int(width), int(height), false); + QPainter painter(&image); + svg.render(&painter, QRectF(0, 0, width, height)); + painter.end(); + image.setColorSpace(QColorSpace::SRgb); + const QString name = QFileInfo(path).completeBaseName(); + qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height(); + return ImportedImage(image, PixelAdjust::thumbnail(image), name.isEmpty() ? QFileInfo(path).fileName() : name); +} diff --git a/src/IO/ImageImporter.h b/src/IO/ImageImporter.h index d4be63e..5a379f4 100644 --- a/src/IO/ImageImporter.h +++ b/src/IO/ImageImporter.h @@ -52,4 +52,8 @@ void liftAllocationLimit(); ImportedImage decode(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget(), bool flattenedPhotoshop = false); PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document); +// Swift's `UTType.svg` by suffix: `svg` and `svgz`. +bool isSVG(const QString &path); +// Drawn once: fitted to the canvas, else its declared size. +ImportedImage decodeSVG(const QString &path, std::optional<QSizeF> fitting, qint64 remainingPixels = DocumentLimits::documentPixelBudget()); } diff --git a/src/Rendering/EditorCanvas+Navigation.cpp b/src/Rendering/EditorCanvas+Navigation.cpp index de74eab..d808efb 100644 --- a/src/Rendering/EditorCanvas+Navigation.cpp +++ b/src/Rendering/EditorCanvas+Navigation.cpp @@ -113,6 +113,12 @@ void CanvasView::wheelEvent(QWheelEvent *event) } void CanvasView::mousePressEvent(QMouseEvent *event) +{ + press(event, 1); +} + +// Swift's mouseDown with its click count. +void CanvasView::press(QMouseEvent *event, int clicks) { // The middle button pans in any tool, beside the left. if (event->button() == Qt::MiddleButton && m_session.document()) { @@ -156,6 +162,8 @@ void CanvasView::mousePressEvent(QMouseEvent *event) m_lastDragPoint = point; updateCursor(); } else if (m_session.tool() == NavigationTool::move) { + if (clicks >= 2 && beginLiveTextEdit(point)) + return; if (!beginGuideDrag(point)) beginTransformDrag(point, event->modifiers()); } else if (isSelectionTool(m_session.tool())) { @@ -199,8 +207,8 @@ void CanvasView::mouseDoubleClickEvent(QMouseEvent *event) updateCursor(); return; } - // Else, picking included, it is a press, as Swift's mouseDown. - QWidget::mouseDoubleClickEvent(event); + // Else, picking included, it is Swift's second mouseDown. + press(event, 2); } void CanvasView::mouseMoveEvent(QMouseEvent *event) diff --git a/src/Rendering/EditorCanvas+Transform.cpp b/src/Rendering/EditorCanvas+Transform.cpp index 6e99288..6200dd0 100644 --- a/src/Rendering/EditorCanvas+Transform.cpp +++ b/src/Rendering/EditorCanvas+Transform.cpp @@ -308,3 +308,25 @@ void CanvasView::cancelTransformDrag() m_dragCursor.reset(); updateCursor(); } + +bool CanvasView::beginLiveTextEdit(QPointF point) +{ + if (!m_session.document() || !m_session.canEditLayers()) + return false; + const CanvasDocument &document = *m_session.document(); + const QPointF pixel = m_session.viewport.documentPoint(point, document.size()); + const QSet<QUuid> visible = document.effectiveVisibleIDs(); + std::optional<QUuid> found; + for (auto layer = document.layers.rbegin(); !found && layer != document.layers.rend(); ++layer) { + if (visible.contains(layer->id) && layer->liveText() && layer->transform.contains(pixel)) + found = layer->id; + } + if (!found) + return false; + // Ends an opacity drag and a blend preview, as Swift's. + m_session.commitTransform(); + m_session.selectLayer(*found); + m_session.editActiveText(); + synchronizeInlineText(); + return true; +} diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index 6432376..603bb4b 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -243,6 +243,9 @@ private: bool cropKey(const QKeyEvent &key); // The Move tool's guide drags (EditorCanvas+Guides.cpp). bool beginGuideDrag(QPointF point); + // The Move tool's double click: the topmost live text opens. + bool beginLiveTextEdit(QPointF point); + void press(QMouseEvent *event, int clicks); bool guideMove(QPointF point, Qt::MouseButtons buttons); void endGuideDrag(QPointF point); // Camera Raw's tools on the canvas (EditorCanvas+CameraRaw.cpp). diff --git a/src/Rendering/InlineTextEditor.cpp b/src/Rendering/InlineTextEditor.cpp index 368e0bf..49701b4 100644 --- a/src/Rendering/InlineTextEditor.cpp +++ b/src/Rendering/InlineTextEditor.cpp @@ -10,7 +10,7 @@ #include <QStyleHints> #include <cmath> -InlineTextEditor::InlineTextEditor(EditorSession &session, QWidget &canvas) : m_session(session), m_canvas(canvas) +InlineTextEditor::InlineTextEditor(EditorSession &session, CanvasView &canvas) : m_session(session), m_canvas(canvas) { // NSTextView's insertion point blinks at the system's pace. m_blink.setObjectName(QStringLiteral("caretBlink")); @@ -76,10 +76,14 @@ void InlineTextEditor::synchronize(const TextDraft &draft) // The text view takes the keys, sparing a text field. QMetaObject::invokeMethod( &m_canvas, - [canvas = QPointer<QWidget>(&m_canvas), session = &m_session, id = draft.id] { + [canvas = QPointer<CanvasView>(&m_canvas), session = &m_session, id = draft.id, existing = draft.layerID.has_value()] { if (!canvas || !session->textDraft() || session->textDraft().value().id != id || qobject_cast<QLineEdit *>(QApplication::focusWidget())) return; canvas->setFocus(Qt::OtherFocusReason); + // Existing text: the caret after it, unless clicked in. + InlineTextEditor *editor = canvas->inlineTextEditor(); + if (existing && editor && editor->m_anchor == 0 && editor->m_position == 0) + editor->moveTo(int(session->textDraft().value().style.content.size()), false); }, Qt::QueuedConnection); } diff --git a/src/Rendering/InlineTextEditor.h b/src/Rendering/InlineTextEditor.h index 10a38de..cef712f 100644 --- a/src/Rendering/InlineTextEditor.h +++ b/src/Rendering/InlineTextEditor.h @@ -13,12 +13,12 @@ class EditorSession; class QKeyEvent; class QPainter; -class QWidget; +class CanvasView; // Swift's InlineTextEditor and its text view, painted by the canvas. class InlineTextEditor { public: - InlineTextEditor(EditorSession &session, QWidget &canvas); + InlineTextEditor(EditorSession &session, CanvasView &canvas); // Swift's synchronize: size, placement, content and style. void synchronize(const TextDraft &draft); QUuid draftID() const { return m_draftID; } @@ -132,7 +132,7 @@ private: void updateInputMethod() const; EditorSession &m_session; - QWidget &m_canvas; + CanvasView &m_canvas; QUuid m_draftID; std::optional<LayerTextStyle> m_shownStyle; QSizeF m_logicalSize{360, 160}; diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp index c6fe09a..5f0b95e 100644 --- a/src/UI/NativeLayerList+Cell.cpp +++ b/src/UI/NativeLayerList+Cell.cpp @@ -112,7 +112,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool // A pending rename closes the edits: rows never move. if (!m_renaming) m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name); - m_dimensions->setText(m_editableText ? QStringLiteral("Text") + m_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit") : m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit") : layer.isGroup ? QStringLiteral("Folder") : QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height()))); diff --git a/tests/ContentViewTests.cpp b/tests/ContentViewTests.cpp index 6d7c0f8..590e683 100644 --- a/tests/ContentViewTests.cpp +++ b/tests/ContentViewTests.cpp @@ -323,7 +323,7 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag() QTest::qWait(50); QCOMPARE(int(session.document().value().layers.size()), 2); desk.typeBeforeCancel.clear(); - const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb " + RawImporter::globs().join(" ") + ")"; + const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb *.svg *.svgz " + RawImporter::globs().join(" ") + ")"; QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel})); QCOMPARE(desk.seen.size(), 3); // The flag cleared from elsewhere takes the panel down. diff --git a/tests/DesktopIntegrationTests.cpp b/tests/DesktopIntegrationTests.cpp index edd9aa3..06f1f86 100644 --- a/tests/DesktopIntegrationTests.cpp +++ b/tests/DesktopIntegrationTests.cpp @@ -2,6 +2,7 @@ #include <QProcess> #include <QTemporaryDir> #include <QXmlStreamReader> +#include <QMimeDatabase> #include <QtTest> // The desktop entry, the project type, and an install. @@ -70,7 +71,12 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes() QCOMPARE(keys.value("Terminal"), QString("false")); QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics")); // Swift's two document types: its projects, then four pictures. - QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw;")); + QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/svg+xml;image/svg+xml-compressed;image/x-dcraw;")); + // Each SVG suffix's type as the system names it. + for (const char *name : {"icon.svg", "icon.svgz"}) { + const QString type = QMimeDatabase().mimeTypeForFile(QString::fromLatin1(name), QMimeDatabase::MatchExtension).name(); + QVERIFY2(keys.value("MimeType").split(';').contains(type), qPrintable(type)); + } } void DesktopIntegrationTests::aProjectFolderIsAType() diff --git a/tests/InlineTextDrawTests.cpp b/tests/InlineTextDrawTests.cpp index 833cb2d..1d09daa 100644 --- a/tests/InlineTextDrawTests.cpp +++ b/tests/InlineTextDrawTests.cpp @@ -102,7 +102,7 @@ void InlineTextDrawTests::theCaretShowsWhileTheCanvasHasFocus() field->show(); shown.canvas->setFocus(); QTRY_VERIFY(shown.canvas->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("ab")); const QRectF caret = shown.editor().textTransform().mapRect( QRectF(QPointF(LayerTextStyle::padding, LayerTextStyle::padding), QSizeF(1, shown.session.textDraft().value().style.lineHeight())) @@ -149,7 +149,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText() DrawnText shown; shown.canvas->setFocus(); QTRY_VERIFY(shown.canvas->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("iiii")); QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); // Above the letters, the line's height is highlighted. @@ -204,13 +204,13 @@ void InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField() button->show(); field->setFocus(); QTRY_VERIFY(field->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::qWait(50); QVERIFY(field->hasFocus()); shown.session.cancelText(); button->setFocus(); QTRY_VERIFY(button->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTRY_VERIFY(shown.canvas->hasFocus()); // A focus request leaves open text's keys alone. button->setFocus(); @@ -235,7 +235,7 @@ void InlineTextDrawTests::theDraftTakesItsKeysAheadOfShortcuts() QTRY_VERIFY(shown.canvas->hasFocus()); QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); QCOMPARE(triggered, 1); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("all")); QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); QCOMPARE(triggered, 1); @@ -281,7 +281,7 @@ void InlineTextDrawTests::theEditMenuReachesOpenText() EditorSession &session = bar.session(); session.createDocument(400, 300, true); session.selectTool(NavigationTool::type); - session.beginText(QPointF(20, 30), true); + beginTextAt(session, QPointF(20, 30)); auto *canvas = bar.window.findChild<CanvasView *>(); QTRY_VERIFY(canvas->hasFocus()); QTest::keyClicks(canvas, QStringLiteral("menu")); @@ -316,7 +316,7 @@ void InlineTextDrawTests::editsRepaintTheEditor() field->show(); field->setFocus(); QTRY_VERIFY(field->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::qWait(50); PaintSpy spy(*shown.canvas); QTest::keyClicks(shown.canvas, QStringLiteral("Wide words")); @@ -327,7 +327,7 @@ void InlineTextDrawTests::editsRepaintTheEditor() shown.session.cancelText(); QTRY_VERIFY(spy.painted.contains(stood)); // A paste grows the box past its old margins. - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::qWait(50); QVERIFY(!shown.canvas->findChild<QTimer *>(QStringLiteral("caretBlink"))->isActive()); spy.painted = QRect(); @@ -361,7 +361,7 @@ void InlineTextDrawTests::editsRepaintTheEditor() QTRY_VERIFY(spy.painted.contains(right)); // The caret's line after a final newline falls far below. shown.session.cancelText(); - shown.session.beginText(QPointF(20, 20), true); + beginTextAt(shown.session, QPointF(20, 20)); shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 24; style.leading = 120; @@ -378,7 +378,7 @@ void InlineTextDrawTests::editsRepaintTheEditor() void InlineTextDrawTests::openingTextRepaintsItsWholeLayer() { DrawnText shown; - shown.session.beginText(QPointF(20, 20), true); + beginTextAt(shown.session, QPointF(20, 20)); shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 24; }); QTest::keyClicks(shown.canvas, QStringLiteral("Tall")); QVERIFY(shown.session.finishText()); diff --git a/tests/InlineTextFixtures.h b/tests/InlineTextFixtures.h index 39a0674..195cede 100644 --- a/tests/InlineTextFixtures.h +++ b/tests/InlineTextFixtures.h @@ -1,5 +1,6 @@ #pragma once #include "SelectionCanvasFixtures.h" +#include "Rendering/TextLayout.h" #include <QInputMethodEvent> // Shared by the inline editor's key and input tests. @@ -11,7 +12,7 @@ struct TextCanvas : Canvas { QObject::connect(&session, &EditorSession::changed, canvas, [this] { canvas->synchronizeDisplay(); }); session.addBlankLayer(); session.selectTool(NavigationTool::type); - session.beginText(QPointF(20, 30), true); + beginTextAt(session, QPointF(20, 30)); } InlineTextEditor &editor() const { @@ -20,6 +21,12 @@ struct TextCanvas : Canvas { return *canvas->inlineTextEditor(); } QString content() const { return session.textDraft().value().style.content; } + // Where a click puts new text's corner: baseline at it. + QPointF clickedCorner(QPointF point) const + { + const double padding = LayerTextStyle::padding; + return point - QPointF(padding, padding + TextLines(session.textDraft().value().style, QSizeF(100'000, 100'000)).baseline(0)); + } int caret() const { return editor().caretPosition(); } void key(Qt::Key key, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::keyClick(canvas, key, modifiers); } void type(const QString &text) { QTest::keyClicks(canvas, text); } diff --git a/tests/InlineTextPointerTests.cpp b/tests/InlineTextPointerTests.cpp index 805a996..80fab55 100644 --- a/tests/InlineTextPointerTests.cpp +++ b/tests/InlineTextPointerTests.cpp @@ -47,7 +47,7 @@ void InlineTextPointerTests::aClickOrADragBeginsText() shown.session.cancelText(); // A click is Swift's small box: point text there. shown.click(QPointF(50, 60)); - QCOMPARE(shown.session.textDraft().value().origin, QPointF(50, 60)); + QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(50, 60))); QVERIFY(!shown.session.textDraft().value().style.boxSize); shown.session.cancelText(); // A drag draws a box, shown in the accent meanwhile. @@ -112,7 +112,7 @@ void InlineTextPointerTests::aClickOpensTheTextUnderIt() const size_t count = shown.session.document().value().layers.size(); shown.click(QPointF(300, 250)); QCOMPARE(shown.session.document().value().layers.size(), count + 1); - QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250)); + QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(300, 250))); shown.session.cancelText(); // A click opens text, active or not, the caret there. const QUuid kept = shown.session.document().value().layers.back().id; @@ -287,7 +287,7 @@ void InlineTextPointerTests::aLostReleaseDropsTheBox() shown.press(QPointF(300, 250)); QTRY_VERIFY(press.painted.contains(QPoint(40, 80))); shown.release(QPointF(300, 250)); - QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250)); + QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(300, 250))); QVERIFY(!shown.session.textDraft().value().style.boxSize); shown.session.cancelText(); // So does a lost focus. diff --git a/tests/InlineTextPreeditTests.cpp b/tests/InlineTextPreeditTests.cpp index f67320b..94f2492 100644 --- a/tests/InlineTextPreeditTests.cpp +++ b/tests/InlineTextPreeditTests.cpp @@ -11,7 +11,7 @@ private slots: void InlineTextPreeditTests::markedTextIsUnderlined() { DrawnText shown; - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QInputMethodEvent composing(QStringLiteral("nn"), {}); QApplication::sendEvent(shown.canvas, &composing); // Below the baseline, where no letter of these reaches. @@ -48,7 +48,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit() DrawnText shown; shown.canvas->setFocus(); QTRY_VERIFY(shown.canvas->hasFocus()); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); // Its clause's format replaces the underline; its caret hides. QTextCharFormat clause; clause.setBackground(Qt::yellow); @@ -63,14 +63,14 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit() QCOMPARE(shown.grab().pixelColor(above), QColor(Qt::yellow)); // Formats count from the preedit, wherever it sits. shown.session.cancelText(); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("nn")); QApplication::sendEvent(shown.canvas, &composing); QVERIFY(shown.grab().pixelColor(above) != QColor(Qt::yellow)); const QPoint later = shown.editor().textTransform().map(QPointF(padding + width * 1.5, baseline - metrics.xHeight() - 4)).toPoint(); QCOMPARE(shown.grab().pixelColor(later), QColor(Qt::yellow)); shown.session.cancelText(); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QApplication::sendEvent(shown.canvas, &composing); const QRect band = shown.editor().textTransform().mapRect(QRectF(padding, baseline + 1, width - 4, metrics.descent() - 2)).toAlignedRect(); QVERIFY(!where(shown.grab().copy(band), reddish).isValid()); @@ -85,7 +85,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit() QVERIFY(where(shown.grab().copy(band), reddish).isValid()); // The input method's selection shows beside its preedit. shown.session.cancelText(); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("xx")); QInputMethodEvent selecting(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 2, QVariant())}); QApplication::sendEvent(shown.canvas, &selecting); @@ -95,7 +95,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit() QCOMPARE(shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight)); // Past the preedit, it sits after the preedit's letters. shown.session.cancelText(); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("xx")); QInputMethodEvent ahead(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 2, -2, QVariant())}); QApplication::sendEvent(shown.canvas, &ahead); diff --git a/tests/LiveTextDoubleClickTests.cpp b/tests/LiveTextDoubleClickTests.cpp new file mode 100644 index 0000000..8dee90e --- /dev/null +++ b/tests/LiveTextDoubleClickTests.cpp @@ -0,0 +1,188 @@ +#include "SelectionCanvasFixtures.h" +#include "Rendering/TextLayout.h" +#include <QFontMetricsF> + +// Swift 1.2.10 (a5721b2): the Move tool's double click opens text. +namespace { +// Live text whose box covers `box`, made in one step. +QUuid addText(EditorSession &session, const QString &content, QRectF box) +{ + session.selectTool(NavigationTool::type); + session.beginText(box); + TextDraft draft = session.textDraft().value(); + draft.style.content = content; + if (!session.applyText(draft)) + throw std::runtime_error("the text was refused"); + return session.activeLayerID().value(); +} + +// Opaque red pixels, as a layer's. +QImage redPixels(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(Qt::red); + return image; +} + +// A document point on the canvas's view. +QPointF viewPoint(Canvas &shown, QPointF pixel) +{ + return shown.session.viewport.viewPoint(pixel, shown.documentSize()); +} +} + +class LiveTextDoubleClickTests : public QObject { + Q_OBJECT +private slots: + void theTopmostShownTextOpens(); + void otherPressesKeepTheMoveTool(); + void anOpenDragAndPreviewEndFirst(); + void theCaretWaitsAfterTheText(); + void zoomAndTurnsPlaceThePress(); +}; + +void LiveTextDoubleClickTests::theTopmostShownTextOpens() +{ + // No display sync: the gesture opens the editor itself. + Canvas shown; + const QUuid lower = addText(shown.session, QStringLiteral("Lower"), QRectF(20, 20, 200, 100)); + const QUuid upper = addText(shown.session, QStringLiteral("Upper"), QRectF(100, 60, 200, 100)); + shown.session.addBlankLayer(); + const QUuid blank = shown.session.activeLayerID().value(); + shown.session.selectTool(NavigationTool::move); + // Where both lie, the upper opens, in the Type tool. + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint()); + QCOMPARE(shown.session.tool(), NavigationTool::type); + QCOMPARE(shown.session.activeLayerID(), std::optional(upper)); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(upper)); + QVERIFY(shown.canvas->inlineTextEditor()); + shown.session.cancelText(); + // A hidden text is passed over for the one below. + shown.session.selectTool(NavigationTool::move); + shown.session.toggleLayerVisibility(upper); + shown.session.selectLayer(blank); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint()); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(lower)); + QCOMPARE(shown.session.activeLayerID(), std::optional(lower)); + shown.session.cancelText(); + // A text in a hidden folder is passed over too. + shown.session.selectTool(NavigationTool::move); + shown.session.toggleLayerVisibility(upper); + shown.session.selectLayer(upper); + shown.session.groupSelectedLayers(); + const QUuid folder = shown.session.activeLayerID().value(); + QCOMPARE(layerWith(shown.session, upper).parentID, std::optional(folder)); + shown.session.toggleLayerVisibility(folder); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint()); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(lower)); +} + +void LiveTextDoubleClickTests::anOpenDragAndPreviewEndFirst() +{ + // On the text already active, Swift's commitTransform still cleans up. + Canvas shown; + const QUuid text = addText(shown.session, QStringLiteral("Words"), QRectF(20, 20, 200, 100)); + shown.session.selectTool(NavigationTool::move); + shown.session.beginOpacityEdit(); + shown.session.setLayerOpacity(0.5); + shown.session.previewBlendMode(LayerBlendMode::multiply, text); + QVERIFY(!shown.session.canUndo()); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint()); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text)); + QVERIFY(shown.session.displayedBlendMode(layerWith(shown.session, text)) == LayerBlendMode::normal); + shown.session.cancelText(); + QVERIFY(shown.session.canUndo()); +} + +void LiveTextDoubleClickTests::otherPressesKeepTheMoveTool() +{ + Canvas shown; + QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); }); + const QUuid text = addText(shown.session, QStringLiteral("Words"), QRectF(20, 20, 200, 100)); + shown.session.selectTool(NavigationTool::move); + // One click moves; it opens nothing. + shown.click(viewPoint(shown, QPointF(60, 60))); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.tool(), NavigationTool::move); + // Off the text, a double click is the Move tool's. + shown.session.addPixelLayer(redPixels(60, 40), QPointF(260, 220), QStringLiteral("Red"), QStringLiteral("Add")); + const QUuid red = shown.session.activeLayerID().value(); + shown.session.selectLayer(text); + shown.session.setTransformAutoSelect(true); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(280, 240)).toPoint()); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.tool(), NavigationTool::move); + QCOMPARE(shown.session.activeLayerID(), std::optional(red)); + // While a layer is renamed, layers rest: nothing opens. + shown.session.setRenamingLayerID(red); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint()); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.activeLayerID(), std::optional(red)); + shown.session.setRenamingLayerID(std::nullopt); + // A busy project opens nothing. + shown.session.setIsProjectBusy(true); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint()); + QVERIFY(!shown.session.textDraft()); + shown.session.setIsProjectBusy(false); + // Other tools' double clicks are theirs. + shown.session.selectTool(NavigationTool::marquee); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint()); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.activeLayerID(), std::optional(red)); +} + +void LiveTextDoubleClickTests::theCaretWaitsAfterTheText() +{ + Canvas shown; + QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); }); + addText(shown.session, QStringLiteral("Word"), QRectF(20, 20, 300, 150)); + shown.session.selectTool(NavigationTool::move); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint()); + QTRY_COMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 4); + QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 4); + QTest::keyClicks(shown.canvas, QStringLiteral("X")); + QCOMPARE(shown.session.textDraft().value().style.content, QString("WordX")); + // A caret a click placed stays where it was put. + shown.session.cancelText(); + shown.session.selectTool(NavigationTool::type); + const LayerTextStyle style = layerWith(shown.session, shown.session.activeLayerID().value()).liveText().value().style; + const QFontMetricsF metrics(TextLayout::font(style)); + const double padding = LayerTextStyle::padding; + const QPointF at = viewPoint(shown, QPointF(20 + padding + metrics.horizontalAdvance(QStringLiteral("Wo")), 20 + padding + style.lineHeight() / 2)); + shown.click(at); + QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 2); + QCoreApplication::processEvents(); + QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 2); + QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 2); + // New text keeps its caret, despite opened text's queued step. + shown.session.cancelText(); + shown.session.editActiveText(); + shown.session.cancelText(); + TextDraft fresh{.documentID = shown.session.document().value().id, .layerID = std::nullopt, .origin = QPointF(20, 200), .style = style}; + fresh.style.content = QStringLiteral("New"); + shown.session.setTextDraft(fresh); + QCoreApplication::processEvents(); + QCOMPARE(shown.session.textDraft().value().id, fresh.id); + QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 0); + QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 0); +} + +void LiveTextDoubleClickTests::zoomAndTurnsPlaceThePress() +{ + Canvas shown; + const QUuid text = addText(shown.session, QStringLiteral("Turned"), QRectF(100, 100, 160, 60)); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, text).transform.rotation = 90; }); + shown.session.selectTool(NavigationTool::move); + shown.session.zoom(1.5); + QVERIFY(shown.session.viewport.viewPoint(QPointF(180, 70), shown.documentSize()) != QPointF(180, 70)); + // Outside the turned box, inside the upright one: nothing. + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(110, 130)).toPoint()); + QVERIFY(!shown.session.textDraft()); + shown.release(viewPoint(shown, QPointF(110, 130))); + // Inside the turned box only: the text opens. + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(180, 70)).toPoint()); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text)); +} + +QTEST_MAIN(LiveTextDoubleClickTests) +#include "LiveTextDoubleClickTests.moc" diff --git a/tests/PickingTests.cpp b/tests/PickingTests.cpp index bf5aeaf..4fdf51a 100644 --- a/tests/PickingTests.cpp +++ b/tests/PickingTests.cpp @@ -223,7 +223,7 @@ void PickingTests::theInlineEditorStandsAsideForThePicker() Picking shown; QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); }); shown.tool(NavigationTool::type); - shown.session.beginText(QPointF(20, 30), true); + beginTextAt(shown.session, QPointF(20, 30)); QTest::keyClicks(shown.canvas, QStringLiteral("Hello there")); QTest::keyClick(shown.canvas, Qt::Key_Home); const InlineTextEditor &editor = *shown.canvas->inlineTextEditor(); diff --git a/tests/SVGImportTests.cpp b/tests/SVGImportTests.cpp new file mode 100644 index 0000000..6dc9548 --- /dev/null +++ b/tests/SVGImportTests.cpp @@ -0,0 +1,174 @@ +#include "BudgetFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageImporter.h" +#include "IO/PSD/PSDReader.h" +#include <QColorSpace> +#include <QTemporaryDir> +#include <QtTest> +#include <zlib.h> + +// Swift 1.2.10 (0534e00): SVG drawn once into an image layer. +namespace { +// A red rectangle filling a document of the given size. +QByteArray svg(const QByteArray &width, const QByteArray &height) +{ + return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" + width + "\" height=\"" + height + "\"><rect width=\"100%\" height=\"100%\" fill=\"red\"/></svg>"; +} + +QString written(const QTemporaryDir &folder, const QString &name, const QByteArray &bytes) +{ + QFile file(folder.filePath(name)); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) + throw std::runtime_error("cannot write a fixture"); + return file.fileName(); +} + +// The same bytes in gzip, as `.svgz` holds them. +QByteArray gzipped(const QByteArray &bytes) +{ + z_stream stream{}; + if (deflateInit2(&stream, 9, Z_DEFLATED, 31, 8, Z_DEFAULT_STRATEGY) != Z_OK) + throw std::runtime_error("no zlib"); + QByteArray out(int(deflateBound(&stream, uLong(bytes.size()))), '\0'); + stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(bytes.constData())); + stream.avail_in = uInt(bytes.size()); + stream.next_out = reinterpret_cast<Bytef *>(out.data()); + stream.avail_out = uInt(out.size()); + deflate(&stream, Z_FINISH); + out.resize(qsizetype(stream.total_out)); + deflateEnd(&stream); + return out; +} + +std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run) +{ + try { + run(); + } catch (const ImageImportError &error) { + return error.kind; + } + return std::nullopt; +} + +bool imported(EditorSession &session, const QList<QUrl> &urls) +{ + bool done = false; + session.importImages(urls, std::nullopt, [&] { done = true; }); + return QTest::qWaitFor([&] { return done && !session.isImporting(); }, 10'000); +} +} + +class SVGImportTests : public QObject { + Q_OBJECT +private slots: + void anSVGFitsTheCanvasOrKeepsItsSize(); + void brokenOrOversizedSVGsRefuse(); + void theSessionImportsSVGBySuffix(); +}; + +void SVGImportTests::anSVGFitsTheCanvasOrKeepsItsSize() +{ + const QTemporaryDir folder; + const QString path = written(folder, QStringLiteral("icon.svg"), svg("40", "20")); + const ImportedImage alone = ImageImporter::decodeSVG(path, std::nullopt); + QCOMPARE(alone.size(), QSize(40, 20)); + QCOMPARE(alone.name, QString("icon")); + QCOMPARE(alone.image().pixelColor(20, 10), QColor(255, 0, 0)); + QCOMPARE(alone.image().colorSpace(), QColorSpace(QColorSpace::SRgb)); + QVERIFY(!alone.thumbnail.isNull()); + // Fitted, the smaller side's ratio rules and sides round. + QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(400, 300)).size(), QSize(400, 200)); + QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(10, 10)).size(), QSize(10, 5)); + const QString thin = written(folder, QStringLiteral("thin.svg"), svg("3", "1")); + QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(10, 10)).size(), QSize(10, 3)); + QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(1, 1)).size(), QSize(1, 1)); + QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(5, 5)).size(), QSize(5, 2)); + QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(400, 100)).size(), QSize(200, 100)); + const QString narrow = written(folder, QStringLiteral("narrow.svg"), svg("1", "3")); + QCOMPARE(ImageImporter::decodeSVG(narrow, QSizeF(1, 1)).size(), QSize(1, 1)); + QCOMPARE(ImageImporter::decodeSVG(narrow, QSizeF(5, 5)).size(), QSize(2, 5)); + // Red top left, blue bottom right, clear between: upright. + const QString corners = written(folder, QStringLiteral("corners.svg"), + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"20\"><rect width=\"5\" height=\"10\" fill=\"red\"/>" + "<rect x=\"5\" y=\"10\" width=\"5\" height=\"10\" fill=\"blue\"/></svg>"); + const QImage fitted = ImageImporter::decodeSVG(corners, QSizeF(100, 100)).image(); + QCOMPARE(fitted.size(), QSize(50, 100)); + QCOMPARE(fitted.pixelColor(12, 25), QColor(255, 0, 0)); + QCOMPARE(fitted.pixelColor(37, 75), QColor(0, 0, 255)); + QCOMPARE(fitted.pixelColor(37, 25).alpha(), 0); + QCOMPARE(fitted.pixelColor(12, 75).alpha(), 0); + // A large drawing's thumbnail is 96 on its long side. + const QString large = written(folder, QStringLiteral("large.svg"), svg("200", "400")); + QCOMPARE(ImageImporter::decodeSVG(large, std::nullopt).thumbnail.size(), QSize(48, 96)); + // Compressed and upper-case names read as SVG. + const QString compressed = written(folder, QStringLiteral("icon.SVGZ"), gzipped(svg("40", "20"))); + QVERIFY(ImageImporter::isSVG(compressed)); + QCOMPARE(ImageImporter::decodeSVG(compressed, std::nullopt).size(), QSize(40, 20)); + QVERIFY(ImageImporter::isSVG(QStringLiteral("/a/b.Svg"))); + QVERIFY(!ImageImporter::isSVG(QStringLiteral("/a/b.png"))); + QVERIFY(!ImageImporter::isSVG(QStringLiteral("/a/svg"))); +} + +void SVGImportTests::brokenOrOversizedSVGsRefuse() +{ + const QTemporaryDir folder; + using Kind = ImageImportError::Kind; + const QString broken = written(folder, QStringLiteral("broken.svg"), "<svg"); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(broken, std::nullopt); }), std::optional(Kind::unreadable)); + // An empty drawing with a zero side has no size. + const QString empty = written(folder, QStringLiteral("empty.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"20\"/>"); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(empty, std::nullopt); }), std::optional(Kind::unreadable)); + for (const char *box : {"0 0 0 10", "0 0 10 0"}) { + const QString flat = written(folder, QStringLiteral("flat.svg"), QByteArray("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"") + box + "\"/>"); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(flat, std::nullopt); }), std::optional(Kind::unreadable)); + } + const QString wide = written(folder, QStringLiteral("wide.svg"), svg("30001", "1")); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(wide, std::nullopt); }), std::optional(Kind::tooLarge)); + QCOMPARE(ImageImporter::decodeSVG(wide, QSizeF(30'000, 30'000)).size(), QSize(30'000, 1)); + const QString tall = written(folder, QStringLiteral("tall.svg"), svg("1", "30001")); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(tall, std::nullopt); }), std::optional(Kind::tooLarge)); + QCOMPARE(ImageImporter::decodeSVG(tall, QSizeF(30'000, 30'000)).size(), QSize(1, 30'000)); + // The budget counts the pixels drawn. + const QString icon = written(folder, QStringLiteral("icon.svg"), svg("40", "20")); + QCOMPARE(ImageImporter::decodeSVG(icon, std::nullopt, 800).size(), QSize(40, 20)); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(icon, std::nullopt, 799); }), std::optional(Kind::tooLarge)); + // Fitted, the budget counts the drawn size, not the declared. + QCOMPARE(ImageImporter::decodeSVG(icon, QSizeF(400, 300), 80'000).size(), QSize(400, 200)); + QCOMPARE(refusal([&] { ImageImporter::decodeSVG(icon, QSizeF(400, 300), 79'999); }), std::optional(Kind::tooLarge)); + QCOMPARE(ImageImporter::decodeSVG(icon, QSizeF(10, 10), 50).size(), QSize(10, 5)); +} + +void SVGImportTests::theSessionImportsSVGBySuffix() +{ + const QTemporaryDir folder; + const QString icon = written(folder, QStringLiteral("icon.svg"), svg("40", "20")); + // Without a document, its declared size makes the canvas. + EditorSession session; + QVERIFY(imported(session, {QUrl::fromLocalFile(icon)})); + QCOMPARE(session.document().value().size(), QSizeF(40, 20)); + QCOMPARE(session.activeLayer().value().name, QString("icon")); + // Over a canvas, it is drawn to fit it. + EditorSession canvas; + canvas.createDocument(400, 300, true); + QVERIFY(imported(canvas, {QUrl::fromLocalFile(icon)})); + QCOMPARE(canvas.activeLayer().value().asset.value().size(), QSize(400, 200)); + QCOMPARE(canvas.activeLayer().value().transform.origin, QPointF(0, 50)); + // The suffix rules before Photoshop's bytes. + const QString disguised = written(folder, QStringLiteral("disguised.svg"), QByteArray("8BPS\0\1", 6) + QByteArray(40, '\0')); + QVERIFY(PSDReader::matches(disguised)); + QVERIFY(imported(canvas, {QUrl::fromLocalFile(disguised)})); + QCOMPARE(canvas.importError().value(), QString("disguised.svg: The image could not be read. It may be damaged or unavailable.")); + // The drawing counts against what the canvas already holds. + EditorSession full; + full.createDocument(40, 20, true); + for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 800)) + full.insert(layer); + QVERIFY(imported(full, {QUrl::fromLocalFile(icon)})); + QCOMPARE(full.importError(), std::nullopt); + QCOMPARE(full.activeLayer().value().asset.value().size(), QSize(40, 20)); + QVERIFY(imported(full, {QUrl::fromLocalFile(icon)})); + QVERIFY(full.importError().value().startsWith("icon.svg: This import exceeds")); +} + +QTEST_MAIN(SVGImportTests) +#include "SVGImportTests.moc" diff --git a/tests/SessionFixtures.h b/tests/SessionFixtures.h index d6e5580..7f30e41 100644 --- a/tests/SessionFixtures.h +++ b/tests/SessionFixtures.h @@ -58,3 +58,12 @@ inline ImportedImage coverage() image.scanLine(index / 2)[index % 2] = values[index]; return LayerMask::assetFrom(image); } + +// New text whose box starts at `corner`, unlike a click. +inline void beginTextAt(EditorSession &session, QPointF corner) +{ + session.beginText(corner, true); + TextDraft draft = session.textDraft().value(); + draft.origin = corner; + session.setTextDraft(draft); +} diff --git a/tests/Tests.cmake b/tests/Tests.cmake index f7449a4..6a5bf6c 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -49,6 +49,8 @@ compositor_test(ShapeToolTests) compositor_test(ShapeLayerTests) compositor_test(ShapeCanvasTests) compositor_test(TypeToolTests) +compositor_test(LiveTextDoubleClickTests) +compositor_test(SVGImportTests) compositor_test(TextLayoutTests) compositor_test(TypeSessionTests) compositor_test(TypeStoreTests) @@ -284,3 +286,7 @@ compositor_test(ByteCountsTests) compositor_test(ImageFileDropTests) compositor_test(SliderSnapTests) compositor_test(ProjectResizeFailureTests) + +# The `.svgz` fixture is gzip, written through zlib. +find_package(ZLIB REQUIRED) +target_link_libraries(SVGImportTests PRIVATE ZLIB::ZLIB) diff --git a/tests/TypeControlsTests.cpp b/tests/TypeControlsTests.cpp index a50673d..4d845dd 100644 --- a/tests/TypeControlsTests.cpp +++ b/tests/TypeControlsTests.cpp @@ -405,7 +405,7 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText() QVERIFY(QTest::qWaitForWindowActive(&list)); LayerCell &textRow = *list.cells().at(0), &blankRow = *list.cells().at(1); const auto dimensions = [](LayerCell &row) { return row.findChild<QLabel *>("layerDimensions")->text(); }; - QCOMPARE(dimensions(textRow), QString("Text")); + QCOMPARE(dimensions(textRow), QString("Text · Double-click to edit")); QCOMPARE(dimensions(blankRow), QString("400 × 300 px")); QCOMPARE(textRow.thumbnail().toolTip(), QString("Editable text layer")); QCOMPARE(textRow.thumbnail().accessibleName(), QString("Select text: Row")); diff --git a/tests/TypeSessionTests.cpp b/tests/TypeSessionTests.cpp index d309af5..30e1d8d 100644 --- a/tests/TypeSessionTests.cpp +++ b/tests/TypeSessionTests.cpp @@ -19,7 +19,7 @@ std::unique_ptr<EditorSession> makeSession() QUuid textLayer(EditorSession &session, QPointF at, const QString &content) { session.selectTool(NavigationTool::type); - session.beginText(at, true); + beginTextAt(session, at); TextDraft draft = session.textDraft().value(); draft.style.content = content; if (!session.applyText(draft)) @@ -234,7 +234,7 @@ void TypeSessionTests::aClickFindsTheTextUnderIt() const TextDraft fresh = session->textDraft().value(); QVERIFY(!fresh.layerID && fresh.style.content.isEmpty() && !fresh.style.boxSize); QVERIFY(fresh.style.red == 0.25 && fresh.style.green == 1 && fresh.style.blue == 0.5); - QCOMPARE(fresh.origin, QPointF(700, 300)); + QVERIFY(fresh.origin.x() == 688 && std::abs(fresh.origin.y() + fresh.style.lineHeight() - 288) < fresh.style.fontSize / 3); session->cancelText(); session->beginText(QPointF(NAN, 5)); QVERIFY(!session->textDraft()); diff --git a/tests/TypeToolTests.cpp b/tests/TypeToolTests.cpp index 85e4064..94f55d7 100644 --- a/tests/TypeToolTests.cpp +++ b/tests/TypeToolTests.cpp @@ -2,6 +2,8 @@ #include "SessionFixtures.h" #include "Document/EditorSession.h" #include "IO/ImageExporter.h" +#include "Rendering/TextLayout.h" +#include <QFontMetricsF> #include <QTemporaryDir> // Swift's TypeToolTests: text layers made, edited, saved, clipped. @@ -60,6 +62,7 @@ private slots: void invalidAndStaleDraftsDoNotChangeDocument(); void textColorPickerPreviewsAndRestoresDraft(); void theForegroundPickerPreviewsOpenTextToo(); + void clickedTextSitsOnItsBaseline(); }; void TypeToolTests::createEditCancelAndUndo() @@ -67,6 +70,12 @@ void TypeToolTests::createEditCancelAndUndo() const auto session = makeSession(); const int before = session->history.undoCount(); session->beginText(QPointF(30, 40)); + // The first letter starts at the pointer, on its baseline. + const TextDraft start = session->textDraft().value(); + const double descent = QFontMetricsF(TextLayout::font(start.style)).descent(); + // The laid line's descent, as drawn, within a hundredth. + QCOMPARE(start.origin.x(), 30 - LayerTextStyle::padding); + QVERIFY(std::abs(start.origin.y() - (40 - (LayerTextStyle::padding + start.style.lineHeight() - descent))) < 0.02); type(*session, QStringLiteral("Text")); QCOMPARE(session->document().value().layers.size(), size_t(1)); TextDraft draft = session->textDraft().value(); @@ -74,7 +83,7 @@ void TypeToolTests::createEditCancelAndUndo() draft.style.fontSize = 48; QVERIFY(session->applyText(draft)); QCOMPARE(session->activeLayer().value().liveText().value().style, draft.style); - QCOMPARE(session->activeLayer().value().origin(), QPointF(30, 40)); + QCOMPARE(session->activeLayer().value().origin(), start.origin); QCOMPARE(session->history.undoCount(), before + 1); session->editActiveText(); session->setTextDraft(std::nullopt); @@ -162,6 +171,10 @@ void TypeToolTests::clippingToTextExportsColoredGlyphsOnTransparency() { const auto session = makeSession(); session->beginText(QPointF(0, 0)); + // The box on the canvas's corner, where the fill goes. + TextDraft corner = session->textDraft().value(); + corner.origin = QPointF(0, 0); + session->setTextDraft(corner); type(*session, QStringLiteral("Text")); QVERIFY(session->applyText(session->textDraft().value())); const QUuid source = session->activeLayerID().value(); @@ -190,6 +203,8 @@ void TypeToolTests::paragraphBoxAndToolSwitchCommitEditableText() session->selectTool(NavigationTool::brush); QVERIFY(!session->textDraft() && session->tool() == NavigationTool::brush); QCOMPARE(session->activeLayer().value().size(), QSizeF(200, 120)); + // A dragged box sits exactly where it was drawn. + QCOMPARE(session->activeLayer().value().origin(), QPointF(40, 60)); QCOMPARE(session->activeLayer().value().liveText().value().style.boxSize, std::optional(QSizeF(200, 120))); session->selectTool(NavigationTool::type); session->editActiveText(); @@ -317,5 +332,39 @@ void TypeToolTests::theForegroundPickerPreviewsOpenTextToo() QCOMPARE(colour(), (PaletteColor{1, 0, 0})); } +void TypeToolTests::clickedTextSitsOnItsBaseline() +{ + // Ink ends on the clicked row, from its column. + const auto session = makeSession(); + // A first text sets the face the next click uses. + session->beginText(QPointF(600, 500)); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Face"); + draft.style.fontName = QStringLiteral("DejaVuSans"); + draft.style.fontSize = 40; + QVERIFY(session->applyText(draft)); + session->beginText(QPointF(100, 200)); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("HHH"); + const LayerTextStyle before = draft.style; + QCOMPARE(before.fontName, QString("DejaVuSans")); + QVERIFY(session->applyText(draft)); + const ImageLayer layer = session->activeLayer().value(); + QCOMPARE(layer.liveText().value().style, before); + const QImage image = layer.asset.value().image(); + int bottom = -1, left = image.width(); + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, y)) > 128) { + bottom = y; + left = std::min(left, x); + } + } + } + const QPointF origin = layer.origin(); + QVERIFY2(std::abs(origin.y() + bottom + 1 - 200) < 1, qPrintable(QString::number(origin.y() + bottom + 1))); + QVERIFY2(std::abs(origin.x() + left - 100) < 5, qPrintable(QString::number(origin.x() + left))); +} + QTEST_MAIN(TypeToolTests) #include "TypeToolTests.moc"