diff --git a/AGENTS.md b/AGENTS.md
index 2959af4..45ac767 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -154,6 +154,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`.
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "
. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least).
- Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through paste, commit, preedit, cut, undo, redo, a replacement by itself, paste and commit around a preedit, a commit over a selection and undo after a preedit, a coloured tail through redo, undone deletions, a commit with a preedit and a paste around one, a preedit over the first letter updated, moved and committed, the first letter's colour at the start for the swatch and the caret, the caret's colour, Swift's swatch keeping the selection, each run drawn without a fringe, the overlay drawing no glyphs, selected or not, with the keys or without), coloured rows in `TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted`, the store's version 10 rows in `ProjectManifestTests`, and version 10 across the store's tests.
+- Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests.
- Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer).
- 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.
- 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.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5f0c992..abaf565 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -90,6 +90,7 @@ add_library(compositor_core STATIC
src/Document/SelectionEdits.cpp
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
+ src/Document/TypeTool+Runs.cpp
src/Document/SmudgeLiquify.cpp
src/UI/BlendModePicker.cpp
src/UI/BrushControls.cpp
diff --git a/TASKS.md b/TASKS.md
index 541f1a7..e15904a 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -408,13 +408,15 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
## 13 Compositor 1.4.5 (the user's order, 2026-09-30: port through v1.4.5, release OmaPhoto 1.4.5; issue #9 gets its workaround documented)
-Reviews for this section run on codex `gpt-6.1-sol` at `max` (agent `codex-sol`, a split pane to the right). Each item names its upstream commits; release, feed and Xcode commits (`ab99b60`, `c28f827`, `4f11bb5`, `0c56c86`, `5809fe9`, `8294fc4`, `9e2894e`, `e2440b4`, `2fbf14f`, `de442f2`, `0d9986f`, `3b50e5d`, `ed9c280`, `0787b60`, `557d2f0`, `3a2798b`, `ec02559`, `2187cbf`, `086f163`, `250578e`, `38ec6f7`), review notes (`9593bf5`, `bc29006`'s note), the 1.3.3 feed (`aba3077`), README's line about the author's own history (`3b3f6ca`) and merges, trials included (`7b22492`, `9d3d1b7`, `359956b`, `5adf0b1`, `0a74187`, `4211695`, `e359885`, `7dec592`, `c8295cc`: none resolves a conflict, `git show --cc` is empty) carry nothing to port.
+Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) through 13.2's second round; from 13.2's third they run on Claude Code, Fable 5.1 at high effort (agent `fable-reviewer`, a split pane to the right; the user's order, 2026-09-30: codex was too slow). Each item names its upstream commits; release, feed and Xcode commits (`ab99b60`, `c28f827`, `4f11bb5`, `0c56c86`, `5809fe9`, `8294fc4`, `9e2894e`, `e2440b4`, `2fbf14f`, `de442f2`, `0d9986f`, `3b50e5d`, `ed9c280`, `0787b60`, `557d2f0`, `3a2798b`, `ec02559`, `2187cbf`, `086f163`, `250578e`, `38ec6f7`), review notes (`9593bf5`, `bc29006`'s note), the 1.3.3 feed (`aba3077`), README's line about the author's own history (`3b3f6ca`) and merges, trials included (`7b22492`, `9d3d1b7`, `359956b`, `5adf0b1`, `0a74187`, `4211695`, `e359885`, `7dec592`, `c8295cc`: none resolves a conflict, `git show --cc` is empty) carry nothing to port.
- [x] 13.0 Survey v1.3.3..v1.4.5 (94 commits) and split them into the items below; the two window-teardown crashes found in 11.5 are fixed on `main` (e94fd73) and ship with 1.4.5.
- 13.0 codex review (gpt-6.1-sol, max): round 1 FAIL (the Metal commits' shared CPU contracts unnamed, ordinary paint at the layer's resolution missing from 13.11, Camera Raw's graph gestures missing from 13.18); round 2 FAIL (more contracts from its inventory, the empty layer's document resolution); round 3 PASS.
- [x] 13.1 Issue #9: custom cursors garbled on NVIDIA + Hyprland at a fractional scale (the driver's hardware cursor plane, hyprwm/Hyprland#16413). README's Install section names the workaround, `cursor { no_hardware_cursors = true }`; the issue gets the answer. No app-side fallback (the user's decision).
- 13.1 review (codex-sol): round 1 FAIL (the config path: Hyprland 0.56 and Omarchy load `hyprland.lua`, the `.conf` block is the older form; an unproven reason for theme cursors; the cursor list not marked as examples); round 2 PASS. The reply posted on the issue.
-- [ ] 13.2 Version 1.3.4, part one: a font change applies to the selected letters, a mixed selection takes the face the menu shows, the menu says (Multiple) (`0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test), with the manifest and both format documents.
+- [x] 13.2 Version 1.3.4, part one: a font change applies to the selected letters, a mixed selection takes the face the menu shows, the menu says (Multiple) (`0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test), with the manifest and both format documents.
+- 13.2 work: `LayerTextFontRun` and `fontRuns` with Swift's model (`fontNameAt` for `fontName(at:)`), the run members moved to `TypeTool+Runs.cpp`, each run's face as a layout format, format version 11, (Multiple) in the Type bar's menu; a fix beyond Swift keeps a folded base face. Tests: `TextFontRunsTests`, `TextFontControlsTests`.
+- 13.2 review: round 1 (codex) FAIL: the folded face's parity unstated, and test gaps (overflow, start-of-text inheritance, newlines, an independent bold reference, editor geometry); round 2 (codex) FAIL: test gaps (neighbouring faces, size and tracking in layout, colour with faces, a missing location); round 3 (Fable 5.1) FAIL: test gaps (a negative location, a run starting at the span's end, fractional numbers, an unlisted face's own name). All fixed, each mutant caught; closed after the third round.
- [ ] 13.3 Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet); Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling).
- [ ] 13.4 Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); Export JPEG's zoomable preview and tidier layout (`ac6f309`); `908bed9` and `158cd53` judged (Swift concurrency and warnings).
- [ ] 13.5 Part two: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`); View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`).
diff --git a/docs/project-format.md b/docs/project-format.md
index 46290f7..87cc00e 100644
--- a/docs/project-format.md
+++ b/docs/project-format.md
@@ -1,10 +1,10 @@
-# OmaPhoto project format, versions 1–10
+# OmaPhoto project format, versions 1–11
OmaPhoto reads and writes [Compositor](https://github.com/robbietilton/Compositor)'s project format; this page follows Compositor's own description.
A `.comp` project is a folder (a document package on macOS) containing `manifest.json` and an `images/` directory of `.png` assets.
-The manifest identifies `com.compositor.project`, version `10` for new saves (versions `1`–`9` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
+The manifest identifies `com.compositor.project`, version `11` for new saves (versions `1`–`10` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
Embedded PNGs preserve source pixels and transparency; transforms remain separate. Projects survive moving or deleting imported source photos. Saving writes a hidden sibling folder and syncs it. A first save renames it into place; a later save swaps it with the old project atomically (`renameat2` with `RENAME_EXCHANGE`). Where the file system knows no exchange, the old project is moved aside and the new one moved in, two steps with a moment between them and a warning in the log, and the old copy is kept until the new one is synced. Unsupported versions, invalid metadata, missing assets, unsafe paths, and oversized data are rejected before replacing the live document.
@@ -36,6 +36,8 @@ Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian
Version 10 lets a text layer color some of its letters differently: optional `colorRuns` in its `text` metadata (see Editable text). Files declaring 1–9 cannot contain it.
+Version 11 lets those letters use different faces too: optional `fontRuns` in the same metadata. Files declaring 1–10 cannot contain it. `colorRuns` stays valid from version 10.
+
### Additive layer fields
Later fields are optional and not gated on the version, so older readers ignore them and keep the pixels or the linked mask as they were:
@@ -45,7 +47,7 @@ Later fields are optional and not gated on the version, so older readers ignore
### Editable text
-Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). Runs are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color.
+Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face.
### Layer effects
diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md
index 877ece7..57527ce 100644
--- a/docs/writing-comp-files.md
+++ b/docs/writing-comp-files.md
@@ -28,7 +28,7 @@ A minimal manifest with one full-canvas image layer:
```json
{
"format": "com.compositor.project",
- "version": 10,
+ "version": 11,
"colorSpace": "sRGB",
"documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
"width": 1920,
diff --git a/src/Document/TypeTool+Runs.cpp b/src/Document/TypeTool+Runs.cpp
new file mode 100644
index 0000000..ced8e84
--- /dev/null
+++ b/src/Document/TypeTool+Runs.cpp
@@ -0,0 +1,210 @@
+#include "Document/ColorPalette.h"
+#include "Document/TypeTool.h"
+#include
+#include
+#include
+
+// LayerTextStyle's letters in colours and faces of their own.
+bool LayerTextStyle::colorRunsAreValid() const
+{
+ if (!colorRuns)
+ return true;
+ qint64 end = 0;
+ for (const LayerTextColorRun &run : *colorRuns) {
+ // Each after the last, not empty, its end within Int.
+ if (run.location < end || run.length <= 0 || run.location > std::numeric_limits::max() - run.length)
+ return false;
+ for (const double channel : {run.red, run.green, run.blue}) {
+ if (!(channel >= 0 && channel <= 1))
+ return false;
+ }
+ end = run.location + run.length;
+ }
+ return !colorRuns->empty() && end <= content.size();
+}
+
+namespace {
+// Swift's guard: a name, 200 characters at most, no newline.
+bool usableFontName(const QString &name)
+{
+ if (name.isEmpty())
+ return false;
+ for (const QChar unit : name) {
+ if (unit == u'\n' || unit == u'\r' || unit == u'\v' || unit == u'\f' || unit == QChar(0x85) || unit == QChar::LineSeparator || unit == QChar::ParagraphSeparator)
+ return false;
+ }
+ QTextBoundaryFinder characters(QTextBoundaryFinder::Grapheme, name);
+ int count = 0;
+ while (characters.toNextBoundary() >= 0)
+ ++count;
+ return count <= 200;
+}
+}
+
+bool LayerTextStyle::fontRunsAreValid() const
+{
+ if (!fontRuns)
+ return true;
+ qint64 end = 0;
+ for (const LayerTextFontRun &run : *fontRuns) {
+ if (run.location < end || run.length <= 0 || run.location > std::numeric_limits::max() - run.length || !usableFontName(run.fontName))
+ return false;
+ end = run.location + run.length;
+ }
+ return !fontRuns->empty() && end <= content.size();
+}
+
+PaletteColor LayerTextStyle::color(qint64 index) const
+{
+ for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
+ if (run.location <= index && index < run.location + run.length)
+ return {run.red, run.green, run.blue};
+ }
+ return {red, green, blue};
+}
+
+void LayerTextStyle::setColor(const PaletteColor &color, TextSpan span)
+{
+ const qint64 count = content.size();
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ if (start == end || (start == 0 && end == count)) {
+ red = color.red;
+ green = color.green;
+ blue = color.blue;
+ colorRuns = std::nullopt;
+ return;
+ }
+ std::vector colors = unitColors();
+ std::fill(colors.begin() + start, colors.begin() + end, color);
+ setUnitColors(colors);
+}
+
+QString LayerTextStyle::fontNameAt(qint64 index) const
+{
+ for (const LayerTextFontRun &run : fontRuns.value_or(std::vector())) {
+ if (run.location <= index && index < run.location + run.length)
+ return run.fontName;
+ }
+ return fontName;
+}
+
+std::optional LayerTextStyle::uniformFontName(TextSpan span) const
+{
+ const qint64 count = content.size();
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ if (end <= start)
+ return std::nullopt;
+ const QString face = fontNameAt(start);
+ qint64 index = start;
+ for (const LayerTextFontRun &run : fontRuns.value_or(std::vector())) {
+ if (run.location >= end || run.location + run.length <= index)
+ continue;
+ // A gap in the base face, or another run face.
+ if ((run.location > index && fontName != face) || run.fontName != face)
+ return std::nullopt;
+ index = std::min(end, std::max(index, run.location + run.length));
+ }
+ if (index < end && fontName != face)
+ return std::nullopt;
+ return face;
+}
+
+void LayerTextStyle::setFont(const QString &name, TextSpan span)
+{
+ if (!usableFontName(name))
+ return;
+ const qint64 count = content.size();
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ if (start == end || (start == 0 && end == count)) {
+ fontName = name;
+ fontRuns = std::nullopt;
+ return;
+ }
+ std::vector fonts = unitFonts();
+ std::fill(fonts.begin() + start, fonts.begin() + end, name);
+ setUnitFonts(fonts);
+}
+
+void LayerTextStyle::replaceCharacters(TextSpan span, qint64 length)
+{
+ const qint64 count = content.size();
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ // New letters take the colour and face before them.
+ if (colorRuns) {
+ std::vector colors = unitColors();
+ const PaletteColor inherited = start > 0 ? colors[size_t(start - 1)] : end > start ? colors[size_t(start)] : !colors.empty() ? colors.front() : PaletteColor{red, green, blue};
+ colors.erase(colors.begin() + start, colors.begin() + end);
+ colors.insert(colors.begin() + start, size_t(std::max(0, length)), inherited);
+ setUnitColors(colors);
+ }
+ if (fontRuns) {
+ std::vector fonts = unitFonts();
+ const QString inherited = start > 0 ? fonts[size_t(start - 1)] : end > start ? fonts[size_t(start)] : !fonts.empty() ? fonts.front() : fontName;
+ fonts.erase(fonts.begin() + start, fonts.begin() + end);
+ fonts.insert(fonts.begin() + start, size_t(std::max(0, length)), inherited);
+ setUnitFonts(fonts);
+ }
+}
+
+std::vector LayerTextStyle::unitColors() const
+{
+ std::vector colors(size_t(content.size()), PaletteColor{red, green, blue});
+ for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
+ const qint64 from = std::max(0, run.location), to = std::min(qint64(colors.size()), run.location + run.length);
+ for (qint64 index = from; index < to; ++index)
+ colors[size_t(index)] = {run.red, run.green, run.blue};
+ }
+ return colors;
+}
+
+void LayerTextStyle::setUnitColors(const std::vector &colors)
+{
+ const PaletteColor base{red, green, blue};
+ std::vector runs;
+ for (size_t index = 0; index < colors.size(); ++index) {
+ const PaletteColor &color = colors[index];
+ if (color == base)
+ continue;
+ if (!runs.empty() && size_t(runs.back().location + runs.back().length) == index
+ && PaletteColor{runs.back().red, runs.back().green, runs.back().blue} == color)
+ runs.back().length += 1;
+ else
+ runs.push_back({qint64(index), 1, color.red, color.green, color.blue});
+ }
+ colorRuns = runs.empty() ? std::nullopt : std::optional(runs);
+}
+
+std::vector LayerTextStyle::unitFonts() const
+{
+ std::vector fonts(size_t(content.size()), fontName);
+ for (const LayerTextFontRun &run : fontRuns.value_or(std::vector())) {
+ const qint64 from = std::max(0, run.location), to = std::min(qint64(fonts.size()), run.location + run.length);
+ for (qint64 index = from; index < to; ++index)
+ fonts[size_t(index)] = run.fontName;
+ }
+ return fonts;
+}
+
+void LayerTextStyle::setUnitFonts(const std::vector &fonts)
+{
+ // One face throughout becomes the text's own.
+ if (!fonts.empty() && std::all_of(fonts.begin(), fonts.end(), [&](const QString &name) { return name == fonts.front(); })) {
+ fontName = fonts.front();
+ fontRuns = std::nullopt;
+ return;
+ }
+ std::vector runs;
+ for (size_t index = 0; index < fonts.size(); ++index) {
+ if (fonts[index] == fontName)
+ continue;
+ if (!runs.empty() && size_t(runs.back().location + runs.back().length) == index && runs.back().fontName == fonts[index])
+ runs.back().length += 1;
+ else
+ runs.push_back({qint64(index), 1, fonts[index]});
+ }
+ fontRuns = runs.empty() ? std::nullopt : std::optional(runs);
+}
diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp
index 766002f..f39543f 100644
--- a/src/Document/TypeTool.cpp
+++ b/src/Document/TypeTool.cpp
@@ -46,94 +46,7 @@ bool LayerTextStyle::isValid() const
// Ranges refuse NaN and infinities: Swift's isFinite adds nothing.
const auto within = [](double value, double low, double high) { return value >= low && value <= high; };
return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1)
- && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000) && colorRunsAreValid();
-}
-
-bool LayerTextStyle::colorRunsAreValid() const
-{
- if (!colorRuns)
- return true;
- qint64 end = 0;
- for (const LayerTextColorRun &run : *colorRuns) {
- // Each after the last, not empty, its end within Int.
- if (run.location < end || run.length <= 0 || run.location > std::numeric_limits::max() - run.length)
- return false;
- for (const double channel : {run.red, run.green, run.blue}) {
- if (!(channel >= 0 && channel <= 1))
- return false;
- }
- end = run.location + run.length;
- }
- return !colorRuns->empty() && end <= content.size();
-}
-
-PaletteColor LayerTextStyle::color(qint64 index) const
-{
- for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
- if (run.location <= index && index < run.location + run.length)
- return {run.red, run.green, run.blue};
- }
- return {red, green, blue};
-}
-
-void LayerTextStyle::setColor(const PaletteColor &color, TextSpan span)
-{
- const qint64 count = content.size();
- const qint64 start = std::clamp(span.location, 0, count);
- const qint64 end = std::max(start, std::min(span.location + span.length, count));
- if (start == end || (start == 0 && end == count)) {
- red = color.red;
- green = color.green;
- blue = color.blue;
- colorRuns = std::nullopt;
- return;
- }
- std::vector colors = unitColors();
- std::fill(colors.begin() + start, colors.begin() + end, color);
- setUnitColors(colors);
-}
-
-void LayerTextStyle::replaceCharacters(TextSpan span, qint64 length)
-{
- if (!colorRuns)
- return;
- std::vector colors = unitColors();
- const qint64 count = qint64(colors.size());
- const qint64 start = std::clamp(span.location, 0, count);
- const qint64 end = std::max(start, std::min(span.location + span.length, count));
- // New letters take the colour before them, as typing does.
- const PaletteColor inherited = start > 0 ? colors[size_t(start - 1)] : end > start ? colors[size_t(start)] : !colors.empty() ? colors.front() : PaletteColor{red, green, blue};
- colors.erase(colors.begin() + start, colors.begin() + end);
- colors.insert(colors.begin() + start, size_t(std::max(0, length)), inherited);
- setUnitColors(colors);
-}
-
-std::vector LayerTextStyle::unitColors() const
-{
- std::vector colors(size_t(content.size()), PaletteColor{red, green, blue});
- for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
- const qint64 from = std::max(0, run.location), to = std::min(qint64(colors.size()), run.location + run.length);
- for (qint64 index = from; index < to; ++index)
- colors[size_t(index)] = {run.red, run.green, run.blue};
- }
- return colors;
-}
-
-void LayerTextStyle::setUnitColors(const std::vector &colors)
-{
- const PaletteColor base{red, green, blue};
- std::vector runs;
- for (size_t index = 0; index < colors.size(); ++index) {
- const PaletteColor &color = colors[index];
- if (color == base)
- continue;
- if (!runs.empty() && size_t(runs.back().location + runs.back().length) == index
- && PaletteColor{runs.back().red, runs.back().green, runs.back().blue} == color)
- runs.back().length += 1;
- else
- runs.push_back({qint64(index), 1, color.red, color.green, color.blue});
- }
- colorRuns = runs.empty() ? std::nullopt : std::optional(runs);
+ && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000) && colorRunsAreValid() && fontRunsAreValid();
}
std::optional LayerText::loaded(const std::optional &style, const std::optional &image)
@@ -255,8 +168,9 @@ bool EditorSession::applyText(TextDraft draft)
addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style);
}
m_textDefaults = draft.style;
- // The next text starts in one colour.
+ // The next text starts in one colour and face.
m_textDefaults.colorRuns = std::nullopt;
+ m_textDefaults.fontRuns = std::nullopt;
requestCanvasFocus();
return succeed();
} catch (const ProjectError &error) {
diff --git a/src/Document/TypeTool.h b/src/Document/TypeTool.h
index 4967e92..ab04135 100644
--- a/src/Document/TypeTool.h
+++ b/src/Document/TypeTool.h
@@ -32,6 +32,14 @@ struct LayerTextColorRun {
friend bool operator==(const LayerTextColorRun &, const LayerTextColorRun &) = default;
};
+// Letters in a face of their own, as Swift's LayerTextFontRun.
+struct LayerTextFontRun {
+ qint64 location = 0;
+ qint64 length = 0;
+ QString fontName;
+ friend bool operator==(const LayerTextFontRun &, const LayerTextFontRun &) = default;
+};
+
// What a text layer says and how, in layer pixels.
struct LayerTextStyle {
QString content = QStringLiteral("Text");
@@ -49,6 +57,8 @@ struct LayerTextStyle {
std::optional boxSize = std::nullopt;
// Sorted, apart, within the content; none is one colour.
std::optional> colorRuns = std::nullopt;
+ // The same rules; none is one face.
+ std::optional> fontRuns = std::nullopt;
// The gap between the text and its box, either kind.
static constexpr double padding = 12;
double autoLeading() const { return fontSize * 1.2; }
@@ -59,14 +69,23 @@ struct LayerTextStyle {
PaletteColor color(qint64 index) const;
// Paints `span`; empty or whole, it recolours all the text.
void setColor(const PaletteColor &color, TextSpan span);
- // Moves the colours as `span` becomes `length` units.
+ // The face of the UTF-16 unit at `index`.
+ QString fontNameAt(qint64 index) const;
+ // The one face over `span`; none when empty or mixed.
+ std::optional uniformFontName(TextSpan span) const;
+ // Sets `span`'s face; empty or whole, all the text's.
+ void setFont(const QString &name, TextSpan span);
+ // Moves colours and faces as `span` becomes `length` units.
void replaceCharacters(TextSpan span, qint64 length);
friend bool operator==(const LayerTextStyle &, const LayerTextStyle &) = default;
private:
bool colorRunsAreValid() const;
+ bool fontRunsAreValid() const;
std::vector unitColors() const;
void setUnitColors(const std::vector &colors);
+ std::vector unitFonts() const;
+ void setUnitFonts(const std::vector &fonts);
};
// A text layer: its style and the image it drew.
@@ -85,7 +104,7 @@ struct TextDraft {
QPointF origin;
std::optional transform = std::nullopt;
LayerTextStyle style;
- // What the editor selects; colour applies to it alone.
+ // What the editor selects; colour and face apply to it.
TextSpan selection;
friend bool operator==(const TextDraft &, const TextDraft &) = default;
};
diff --git a/src/IO/ProjectStore+Manifest.cpp b/src/IO/ProjectStore+Manifest.cpp
index 1b77a2b..91e4eab 100644
--- a/src/IO/ProjectStore+Manifest.cpp
+++ b/src/IO/ProjectStore+Manifest.cpp
@@ -120,6 +120,22 @@ std::vector colorRuns(const QJsonValue &value)
return runs;
}
+LayerTextFontRun fontRun(const QJsonValue &value)
+{
+ const QJsonObject object = value.toObject();
+ return {integer(object.value("location")), integer(object.value("length")), string(object.value("fontName"))};
+}
+
+std::vector fontRuns(const QJsonValue &value)
+{
+ if (!value.isArray())
+ refuse();
+ std::vector runs;
+ for (const QJsonValue &each : value.toArray())
+ runs.push_back(fontRun(each));
+ return runs;
+}
+
// Synthesized Codable: all required but the box and runs.
LayerTextStyle textStyle(const QJsonValue &value)
{
@@ -130,7 +146,8 @@ LayerTextStyle textStyle(const QJsonValue &value)
return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")),
.red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")),
.alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")),
- .boxSize = optional(object, "boxSize", size), .colorRuns = optional(object, "colorRuns", colorRuns)};
+ .boxSize = optional(object, "boxSize", size), .colorRuns = optional(object, "colorRuns", colorRuns),
+ .fontRuns = optional(object, "fontRuns", fontRuns)};
}
CanvasGuide guide(const QJsonValue &value)
@@ -200,6 +217,12 @@ QJsonObject encoded(const LayerTextStyle &text)
runs.append(QJsonObject{{"location", run.location}, {"length", run.length}, {"red", run.red}, {"green", run.green}, {"blue", run.blue}});
object.insert("colorRuns", runs);
}
+ if (text.fontRuns) {
+ QJsonArray runs;
+ for (const LayerTextFontRun &run : *text.fontRuns)
+ runs.append(QJsonObject{{"location", run.location}, {"length", run.length}, {"fontName", run.fontName}});
+ object.insert("fontRuns", runs);
+ }
return object;
}
diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp
index dbead78..2fb358c 100644
--- a/src/IO/ProjectStore.cpp
+++ b/src/IO/ProjectStore.cpp
@@ -73,8 +73,9 @@ void validate(const ProjectManifest &manifest)
if (manifest.width < 1 || manifest.width > DocumentLimits::maxSide || manifest.height < 1 || manifest.height > DocumentLimits::maxSide || manifest.layers.size() > 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const ProjectLayerRecord &layer : manifest.layers) {
- // Text needs a valid style, pixels; runs from 10.
- if (layer.text && (!layer.text->isValid() || !layer.imageFile || (layer.text->colorRuns && manifest.version < 10)))
+ // Valid text with pixels; colours from 10, faces from 11.
+ if (layer.text && (!layer.text->isValid() || !layer.imageFile || (layer.text->colorRuns && manifest.version < 10)
+ || (layer.text->fontRuns && manifest.version < 11)))
throw ProjectError(ProjectError::Kind::invalid);
// Version 7's adjustments hold settings, never pixels or children.
if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid()))
diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h
index 6ce85d7..d7dd8ec 100644
--- a/src/IO/ProjectStore.h
+++ b/src/IO/ProjectStore.h
@@ -51,7 +51,7 @@ QString uuidString(const QUuid &id);
struct ProjectManifest {
// Swift's current and supported: what saves write, what loads accept.
- static constexpr qint64 current = 10;
+ static constexpr qint64 current = 11;
static constexpr std::pair supported{1, current};
static bool supports(qint64 version) { return version >= supported.first && version <= supported.second; }
QString format = QStringLiteral("com.compositor.project");
diff --git a/src/Rendering/TextLayout.cpp b/src/Rendering/TextLayout.cpp
index 658264a..6e0fd3d 100644
--- a/src/Rendering/TextLayout.cpp
+++ b/src/Rendering/TextLayout.cpp
@@ -42,14 +42,19 @@ const std::map> &faces()
}
QFont TextLayout::font(const LayerTextStyle &style)
+{
+ return font(style, style.fontName);
+}
+
+QFont TextLayout::font(const LayerTextStyle &style, const QString &face)
{
QFont font;
- if (faces().contains(style.fontName)) {
- font.setFamily(faces().at(style.fontName).first);
- font.setStyleName(faces().at(style.fontName).second);
+ if (faces().contains(face)) {
+ font.setFamily(faces().at(face).first);
+ font.setStyleName(faces().at(face).second);
} else {
// Not installed: Qt's match, where macOS takes its own.
- font.setFamily(style.fontName);
+ font.setFamily(face);
}
// A point is a pixel here; Qt's faces come whole.
font.setPixelSize(qRound(style.fontSize));
@@ -93,8 +98,16 @@ TextLines::TextLines(const LayerTextStyle &style, QSizeF container)
const QString paragraph = style.content.mid(range.start, range.end - range.start);
auto layout = std::make_unique(paragraph, font);
layout->setTextOption(option);
- // Run letters draw once in their colour, as CoreText's.
+ // Run letters draw once in their face and colour.
QList runs;
+ for (const LayerTextFontRun &run : style.fontRuns.value_or(std::vector())) {
+ const qint64 start = std::max(run.location, range.start), end = std::min(run.location + run.length, range.end);
+ if (start >= end)
+ continue;
+ QTextCharFormat format;
+ format.setFont(TextLayout::font(style, run.fontName));
+ runs << QTextLayout::FormatRange{int(start - range.start), int(end - start), format};
+ }
for (const LayerTextColorRun &run : style.colorRuns.value_or(std::vector())) {
const qint64 start = std::max(run.location, range.start), end = std::min(run.location + run.length, range.end);
if (start >= end)
diff --git a/src/Rendering/TextLayout.h b/src/Rendering/TextLayout.h
index a64618f..dff5dbd 100644
--- a/src/Rendering/TextLayout.h
+++ b/src/Rendering/TextLayout.h
@@ -13,6 +13,8 @@ class QPainter;
namespace TextLayout {
// The face a PostScript name names, or Qt's match.
QFont font(const LayerTextStyle &style);
+// The style's size and spacing in another face.
+QFont font(const LayerTextStyle &style, const QString &face);
// Every installed face's PostScript name, sorted.
QStringList availableFonts();
}
diff --git a/src/UI/TypeControls.cpp b/src/UI/TypeControls.cpp
index 4cd7007..28733da 100644
--- a/src/UI/TypeControls.cpp
+++ b/src/UI/TypeControls.cpp
@@ -96,10 +96,37 @@ TypeFontPicker::TypeFontPicker(QWidget *parent) : QComboBox(parent)
setAccessibleName(QStringLiteral("Font"));
}
+namespace {
+const QString multiple = QStringLiteral("(Multiple)");
+}
+
+bool TypeFontPicker::isMultiple(int index) const
+{
+ return itemData(index).toString() == multiple;
+}
+
+void TypeFontPicker::showMultiple()
+{
+ if (!isMultiple(0))
+ insertItem(0, multiple, multiple);
+ setCurrentIndex(0);
+}
+
+void TypeFontPicker::hideMultiple()
+{
+ if (isMultiple(0))
+ removeItem(0);
+}
+
void TypeFontPicker::sync(const QString &name)
{
if (view()->isVisible())
return;
+ if (name.isEmpty()) {
+ showMultiple();
+ return;
+ }
+ hideMultiple();
if (findText(name) < 0)
addItem(name);
setCurrentIndex(findText(name));
@@ -109,15 +136,18 @@ void TypeFontPicker::showPopup()
{
if (!m_loaded) {
// Every installed face, and the style's own though missing.
- const QString selected = currentText();
+ const QString selected = isMultiple(currentIndex()) ? QString() : currentText();
QStringList names = TextLayout::availableFonts();
- if (!names.contains(selected))
+ if (!selected.isEmpty() && !names.contains(selected))
names << selected;
names.sort();
const QSignalBlocker quiet(this);
clear();
addItems(names);
- setCurrentIndex(findText(selected));
+ if (selected.isEmpty())
+ showMultiple();
+ else
+ setCurrentIndex(findText(selected));
m_loaded = true;
}
QComboBox::showPopup();
@@ -193,10 +223,11 @@ TypeControls::TypeControls(EditorSession &session, QWidget *parent)
m_font->setToolTip(QStringLiteral("Font face, including bold and italic variants"));
connect(m_font, &QComboBox::activated, this, [this](int index) {
const QString name = m_font->itemText(index);
- // The face already shown changes nothing, as Swift's choose.
- if (name == m_session.currentTextStyle().fontName)
+ // (Multiple), or the face already shown, changes nothing.
+ if (m_font->isMultiple(index) || name == shownFont())
return;
- m_session.changeTextStyle([&name](LayerTextStyle &style) { style.fontName = name; });
+ const TextSpan selection = m_session.textDraft() ? m_session.textDraft().value().selection : TextSpan();
+ m_session.changeTextStyle([&name, selection](LayerTextStyle &style) { style.setFont(name, selection); });
});
m_size->setObjectName(QStringLiteral("typeSize"));
m_size->setFixedWidth(52);
@@ -287,6 +318,18 @@ QToolButton *TypeControls::alignment(TextAlignment value)
return button;
}
+QString TypeControls::shownFont() const
+{
+ const std::optional &draft = m_session.textDraft();
+ if (!draft)
+ return m_session.currentTextStyle().fontName;
+ const TextSpan selection = draft->selection;
+ if (selection.length == 0)
+ return draft->style.fontNameAt(std::max(0, selection.location - 1));
+ // Several faces: (Multiple), so any face chosen applies.
+ return draft->style.uniformFontName(selection).value_or(QString());
+}
+
void TypeControls::synchronize()
{
// Swift's onChange: open text previews the picker's colour.
@@ -296,7 +339,7 @@ void TypeControls::synchronize()
m_session.previewTextColor();
}
const LayerTextStyle style = m_session.currentTextStyle();
- m_font->sync(style.fontName);
+ m_font->sync(shownFont());
for (size_t index = 0; index < m_alignments.size(); ++index)
m_alignments[index]->setChecked(style.alignment == allTextAlignments[index]);
for (TextStyleField *field : {m_size, m_tracking, m_leading})
diff --git a/src/UI/TypeControls.h b/src/UI/TypeControls.h
index c02c0f3..d9ee16a 100644
--- a/src/UI/TypeControls.h
+++ b/src/UI/TypeControls.h
@@ -40,15 +40,19 @@ class TypeFontPicker : public QComboBox {
Q_OBJECT
public:
explicit TypeFontPicker(QWidget *parent = nullptr);
- // Shows `name`; an open menu is left alone.
+ // Shows `name`, or (Multiple) when empty; an open menu stays.
void sync(const QString &name);
void showPopup() override;
+ // Swift's isMultiple: the top item that is no font.
+ bool isMultiple(int index) const;
protected:
// A long name is cut short, never widening the control.
void paintEvent(QPaintEvent *event) override;
private:
+ void showMultiple();
+ void hideMultiple();
bool m_loaded = false;
};
@@ -65,6 +69,8 @@ private:
QToolButton *alignment(TextAlignment value);
void applyGlyphs();
void synchronize();
+ // Swift's binding: the caret's face, the selection's, or none.
+ QString shownFont() const;
EditorSession &m_session;
TypeFontPicker *const m_font;
diff --git a/tests/FolderOpacityTests.cpp b/tests/FolderOpacityTests.cpp
index 9581598..df94181 100644
--- a/tests/FolderOpacityTests.cpp
+++ b/tests/FolderOpacityTests.cpp
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
const QString path = root.filePath("Dimmed.comp");
ProjectStore::save(session.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(10));
+ QCOMPARE(loaded.manifest.version, qint64(11));
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
if (record.isGroup == true)
QCOMPARE(record.opacity, std::optional(0.5));
diff --git a/tests/GroupSessionTests.cpp b/tests/GroupSessionTests.cpp
index b7494a0..91455ea 100644
--- a/tests/GroupSessionTests.cpp
+++ b/tests/GroupSessionTests.cpp
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
session.addBlankLayer();
const QUuid child = session.activeLayerID().value();
const ProjectSnapshot snapshot = session.projectSnapshot().value();
- QCOMPARE(snapshot.manifest.version, qint64(10));
+ QCOMPARE(snapshot.manifest.version, qint64(11));
const auto record = [](const ProjectSnapshot &from, QUuid id) {
for (const ProjectLayerRecord &layer : from.manifest.layers) {
if (layer.id == id)
diff --git a/tests/GroupTests.cpp b/tests/GroupTests.cpp
index ed0cb12..56abe7e 100644
--- a/tests/GroupTests.cpp
+++ b/tests/GroupTests.cpp
@@ -259,10 +259,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
QCOMPARE(invalid.version, std::nullopt);
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
- const ProjectError version = ProjectError::unsupportedVersion(11);
+ const ProjectError version = ProjectError::unsupportedVersion(12);
QCOMPARE(version.kind, ProjectError::Kind::version);
- QCOMPARE(version.version, std::optional(11));
- QCOMPARE(QString(version.what()), QString("This project uses format version 11. This app supports versions 1–10."));
+ QCOMPARE(version.version, std::optional(12));
+ QCOMPARE(QString(version.what()), QString("This project uses format version 12. This app supports versions 1–11."));
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
const ProjectError missingImage(ProjectError::Kind::missingImage);
diff --git a/tests/GuideTests.cpp b/tests/GuideTests.cpp
index a03e49b..4841f61 100644
--- a/tests/GuideTests.cpp
+++ b/tests/GuideTests.cpp
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
session.addGuide(first);
session.addGuide(second);
ProjectSnapshot snapshot = session.projectSnapshot().value();
- QCOMPARE(snapshot.manifest.version, qint64(10));
+ QCOMPARE(snapshot.manifest.version, qint64(11));
QCOMPARE(snapshot.manifest.guides.value(), (std::vector{first, second}));
// Swift's keys: an array of id, axis and position.
const QByteArray json = snapshot.manifest.encoded();
diff --git a/tests/LayerAppearanceTests.cpp b/tests/LayerAppearanceTests.cpp
index 208802b..a6793ac 100644
--- a/tests/LayerAppearanceTests.cpp
+++ b/tests/LayerAppearanceTests.cpp
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
const QString path = folder.filePath("Appearance.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(10));
+ QCOMPARE(loaded.manifest.version, qint64(11));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
diff --git a/tests/LayerMaskSessionTests.cpp b/tests/LayerMaskSessionTests.cpp
index f50a2bd..01655f8 100644
--- a/tests/LayerMaskSessionTests.cpp
+++ b/tests/LayerMaskSessionTests.cpp
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
ProjectStore::save(session->projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(10));
+ QCOMPARE(loaded.manifest.version, qint64(11));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QVERIFY(loaded.manifest.version == 10 && loaded.masks.count(folder) == 1);
+ QVERIFY(loaded.manifest.version == 11 && loaded.masks.count(folder) == 1);
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList{255, 0, 128, 255}));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
diff --git a/tests/ProjectManifestTests.cpp b/tests/ProjectManifestTests.cpp
index b6d9095..b969209 100644
--- a/tests/ProjectManifestTests.cpp
+++ b/tests/ProjectManifestTests.cpp
@@ -313,7 +313,7 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
for (int version = 1; version <= 9; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
- QTest::newRow("version 11") << "" << 11 << std::optional(ProjectError::Kind::version);
+ QTest::newRow("version 12") << "" << 12 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
@@ -369,6 +369,8 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::newRow("letters in colours before version 10") << "textRuns" << 9 << invalid;
QTest::newRow("letters in colours at version 10") << "textRuns" << 10 << fine;
QTest::newRow("one-colour text before version 10") << "text" << 9 << fine;
+ QTest::newRow("letters in faces before version 11") << "textFonts" << 10 << invalid;
+ QTest::newRow("letters in faces at version 11") << "textFonts" << 11 << fine;
}
void ProjectManifestTests::validationFollowsTheFormatsVersions()
@@ -458,11 +460,13 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions()
image.imageFile = "image.png";
else if (change == "imageLower")
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
- else if (change == "text" || change == "textRuns") {
+ else if (change == "text" || change == "textRuns" || change == "textFonts") {
LayerTextStyle style;
style.content = QStringLiteral("Ab");
if (change == "textRuns")
style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
+ if (change == "textFonts")
+ style.fontRuns = std::vector{LayerTextFontRun{1, 1, QStringLiteral("DejaVuSansMono")}};
image.text = style;
} else if (change == "active")
manifest.activeLayerID = QUuid::createUuid();
diff --git a/tests/ProjectSessionTests.cpp b/tests/ProjectSessionTests.cpp
index 39be94f..a38d2f1 100644
--- a/tests/ProjectSessionTests.cpp
+++ b/tests/ProjectSessionTests.cpp
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
- QCOMPARE(again.manifest.version, qint64(10));
+ QCOMPARE(again.manifest.version, qint64(11));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
diff --git a/tests/ProjectTests.cpp b/tests/ProjectTests.cpp
index de3ee98..11f01d3 100644
--- a/tests/ProjectTests.cpp
+++ b/tests/ProjectTests.cpp
@@ -53,10 +53,10 @@ private slots:
void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts()
{
QVERIFY(ProjectManifest::supports(ProjectManifest::current));
- QVERIFY((ProjectManifest::supported == std::pair(1, 10)));
+ QVERIFY((ProjectManifest::supported == std::pair(1, 11)));
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
- QCOMPARE(QString(ProjectError::unsupportedVersion(11).what()), QString("This project uses format version 11. This app supports versions 1–10."));
+ QCOMPARE(QString(ProjectError::unsupportedVersion(12).what()), QString("This project uses format version 12. This app supports versions 1–11."));
}
void ProjectTests::projectRoundTripSurvivesPackageMove()
@@ -201,14 +201,14 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
ProjectStore::save(snapshot, path);
const QByteArray good = contents(metadata);
// Each edit below replaces this text; it must be there.
- QVERIFY(good.contains("\"version\": 10"));
+ QVERIFY(good.contains("\"version\": 11"));
const auto loading = [&](const QByteArray &manifest) {
overwrite(metadata, manifest);
return projectError([&] { ProjectStore::load(path); });
};
// A version out of range is named whatever else fails.
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
- for (const qint64 strange : {qint64(42), qint64(11), qint64(0)}) {
+ for (const qint64 strange : {qint64(42), qint64(12), qint64(0)}) {
try {
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
ProjectStore::load(path);
@@ -223,12 +223,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
// Another format is invalid whatever version it claims.
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 0")), std::optional(ProjectError::Kind::version));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 0")), std::optional(ProjectError::Kind::version));
// Swift's Int holds 64 bits, each one: no double between.
const qint64 most = std::numeric_limits::max(), least = std::numeric_limits::min();
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
try {
- overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": " + QByteArray::number(wild)));
+ overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": " + QByteArray::number(wild)));
ProjectStore::load(path);
QFAIL("a wild version opened");
} catch (const ProjectError &error) {
@@ -238,19 +238,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
}
}
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
// Declared: Qt rounds a token just past Int64's minimum.
try {
- overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": -9223372036854775809"));
+ overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": -9223372036854775809"));
ProjectStore::load(path);
QFAIL("a version below every integer opened");
} catch (const ProjectError &error) {
QCOMPARE(error.version, std::optional(least));
}
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index 2219ce3..53b7e21 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -173,6 +173,8 @@ compositor_test(TextColorRunsTests)
compositor_test(TextColorModelTests)
compositor_test(TextColorEditorTests)
compositor_test(TextEditingGatesTests)
+compositor_test(TextFontRunsTests)
+compositor_test(TextFontControlsTests)
compositor_test(DitherTests)
compositor_test(DitherSheetTests)
compositor_test(DitherKernelTests)
diff --git a/tests/TextColorModelTests.cpp b/tests/TextColorModelTests.cpp
index 9d24a97..e78ef86 100644
--- a/tests/TextColorModelTests.cpp
+++ b/tests/TextColorModelTests.cpp
@@ -44,6 +44,10 @@ void TextColorModelTests::colorAppliesToSelectionAndFollowsEdits()
{
LayerTextStyle style;
style.content = QStringLiteral("Hello world");
+ // The whole text takes every channel of its colour.
+ style.setColor(PaletteColor{0.25, 0.5, 0.75}, {0, 0});
+ QVERIFY(style.red == 0.25 && style.green == 0.5 && style.blue == 0.75 && !style.colorRuns);
+ style.setColor(PaletteColor::black(), {0, 11});
style.setColor(red, {6, 5});
QCOMPARE(style.colorRuns.value(), (std::vector{run(6, 5, red)}));
QVERIFY(style.color(5) == PaletteColor::black() && style.color(6) == red);
diff --git a/tests/TextColorRunsTests.cpp b/tests/TextColorRunsTests.cpp
index ba02d2d..1cc9dfb 100644
--- a/tests/TextColorRunsTests.cpp
+++ b/tests/TextColorRunsTests.cpp
@@ -135,7 +135,7 @@ void TextColorRunsTests::selectedColorPaintsOnlyThoseLettersAndSurvivesReopening
const auto pixels = redAndDark(session->activeLayer().value().asset.value().image());
QVERIFY2(pixels.first > 50 && pixels.second > 50, qPrintable(QStringLiteral("%1 %2").arg(pixels.first).arg(pixels.second)));
const ProjectSnapshot snapshot = session->projectSnapshot().value();
- QCOMPARE(snapshot.manifest.version, qint64(10));
+ QCOMPARE(snapshot.manifest.version, qint64(11));
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("TextColors.comp"));
ProjectStore::save(snapshot, path);
diff --git a/tests/TextFontControlsTests.cpp b/tests/TextFontControlsTests.cpp
new file mode 100644
index 0000000..e999a8c
--- /dev/null
+++ b/tests/TextFontControlsTests.cpp
@@ -0,0 +1,193 @@
+#include "InlineTextDrawFixtures.h"
+#include "UI/TypeControls.h"
+#include
+#include
+#include "Rendering/TextLayout.h"
+
+// Swift 1.3.4's font menu: the selection's face, or (Multiple).
+namespace {
+const QString sans = QStringLiteral("DejaVuSans");
+const QString mono = QStringLiteral("DejaVuSansMono");
+
+// Typed text with a menu beside the canvas.
+struct Typed : DrawnText {
+ TypeControls *const bar = new TypeControls(session, &window);
+ TypeFontPicker &menu() const { return *bar->findChild(QStringLiteral("typeFont")); }
+ Typed()
+ {
+ bar->setGeometry(0, 300, 400, 40);
+ bar->show();
+ session.changeTextStyle([](LayerTextStyle &style) { style.fontName = sans; });
+ canvas->setFocus();
+ beginTextAt(session, QPointF(20, 30));
+ QTest::keyClicks(canvas, QStringLiteral("Hello"));
+ }
+ const LayerTextStyle &style() const { return session.textDraft().value().style; }
+ // The menu chooses a face, as a click does.
+ void choose(const QString &face)
+ {
+ if (menu().findText(face) < 0) {
+ menu().showPopup();
+ menu().hidePopup();
+ }
+ emit menu().activated(menu().findText(face));
+ }
+};
+}
+
+class TextFontControlsTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theMenuShowsTheCaretsFaceOrMultiple();
+ void aChosenFaceTakesTheSelectionTheMenuKept();
+ void typingAndUndoKeepTheFaces();
+ void theLoadedMenuKeepsMultipleOnTop();
+ void deletingToOneFaceKeepsItsLetters();
+ void theEditorPlacesTheCaretByTheFaces();
+};
+
+void TextFontControlsTests::theMenuShowsTheCaretsFaceOrMultiple()
+{
+ Typed typed;
+ typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
+ // The caret shows the letter before it.
+ QTest::keyClick(typed.canvas, Qt::Key_Home);
+ QTest::keyClick(typed.canvas, Qt::Key_Right);
+ QCOMPARE(typed.menu().currentText(), mono);
+ QTest::keyClick(typed.canvas, Qt::Key_Right);
+ QCOMPARE(typed.menu().currentText(), mono);
+ QTest::keyClick(typed.canvas, Qt::Key_Right);
+ QCOMPARE(typed.menu().currentText(), sans);
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ QCOMPARE(typed.menu().currentText(), sans);
+ // At the start, the first letter.
+ QTest::keyClick(typed.canvas, Qt::Key_Home);
+ QCOMPARE(typed.menu().currentText(), mono);
+ // Across both faces: (Multiple), on top, no font.
+ QTest::keyClick(typed.canvas, Qt::Key_Right);
+ QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ QCOMPARE(typed.menu().currentIndex(), 0);
+ QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
+ QVERIFY(typed.menu().isMultiple(0));
+ // Still mixed: the item stays one.
+ QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
+ QVERIFY(!typed.menu().isMultiple(1));
+ // One face again: the item goes.
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QCOMPARE(typed.menu().currentText(), sans);
+ QVERIFY(!typed.menu().isMultiple(0) && typed.menu().findText(QStringLiteral("(Multiple)")) < 0);
+}
+
+void TextFontControlsTests::aChosenFaceTakesTheSelectionTheMenuKept()
+{
+ Typed typed;
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ // The menu takes the keys; the letters stay selected.
+ typed.menu().setFocus(Qt::MouseFocusReason);
+ QTRY_VERIFY(!typed.canvas->hasFocus());
+ QCOMPARE(typed.session.textDraft().value().selection, (TextSpan{3, 2}));
+ QVERIFY(typed.editor().anchor() == 5 && typed.editor().caretPosition() == 3);
+ typed.choose(mono);
+ QCOMPARE(typed.style().fontRuns.value(), (std::vector{{3, 2, mono}}));
+ QCOMPARE(typed.style().fontName, sans);
+ // The face shown, or (Multiple), changes nothing.
+ QSignalSpy changed(&typed.session, &EditorSession::changed);
+ typed.choose(mono);
+ QCOMPARE(int(changed.count()), 0);
+ typed.canvas->setFocus();
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
+ const int heard = int(changed.count());
+ emit typed.menu().activated(0);
+ QCOMPARE(int(changed.count()), heard);
+ // From (Multiple), any face takes the whole selection.
+ typed.choose(sans);
+ QVERIFY(!typed.style().fontRuns && typed.style().fontName == sans);
+ // A caret alone sets the whole text's face.
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ typed.choose(mono);
+ QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
+}
+
+void TextFontControlsTests::typingAndUndoKeepTheFaces()
+{
+ Typed typed;
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ typed.choose(mono);
+ // Typed after mono, mono; after sans, sans.
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ QTest::keyClicks(typed.canvas, QStringLiteral("!"));
+ QCOMPARE(typed.style().fontRuns.value(), (std::vector{{3, 3, mono}}));
+ QTest::keyClick(typed.canvas, Qt::Key_Home);
+ QTest::keyClick(typed.canvas, Qt::Key_Right);
+ QTest::keyClicks(typed.canvas, QStringLiteral("x"));
+ QCOMPARE(typed.style().content, QStringLiteral("Hxello!"));
+ QCOMPARE(typed.style().fontRuns.value(), (std::vector{{4, 3, mono}}));
+ // Undo takes the typing back, the faces with it.
+ QTest::keyClick(typed.canvas, Qt::Key_Z, Qt::ControlModifier);
+ QCOMPARE(typed.style().content, QStringLiteral("Hello!"));
+ QCOMPARE(typed.style().fontRuns.value(), (std::vector{{3, 3, mono}}));
+ // Applied, the layer keeps them.
+ QVERIFY(typed.session.finishText());
+ QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontRuns.value(), (std::vector{{3, 3, mono}}));
+}
+
+void TextFontControlsTests::theLoadedMenuKeepsMultipleOnTop()
+{
+ Typed typed;
+ typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
+ QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
+ QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
+ typed.menu().showPopup();
+ QVERIFY(typed.menu().isMultiple(0) && typed.menu().currentIndex() == 0);
+ QVERIFY(typed.menu().findText(mono) > 0 && typed.menu().findText(sans) > 0);
+ typed.menu().hidePopup();
+ // A single face after loading drops the item again.
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ QTRY_COMPARE(typed.menu().currentText(), sans);
+ QVERIFY(!typed.menu().isMultiple(0));
+}
+
+// A fix beyond Swift, whose draft drops the folded face.
+void TextFontControlsTests::deletingToOneFaceKeepsItsLetters()
+{
+ Typed typed;
+ QTest::keyClick(typed.canvas, Qt::Key_Home);
+ QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ typed.choose(mono);
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ for (int letter = 0; letter < 3; ++letter)
+ QTest::keyClick(typed.canvas, Qt::Key_Backspace);
+ QCOMPARE(typed.style().content, QStringLiteral("He"));
+ QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
+ QVERIFY(typed.session.finishText());
+ QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontName, mono);
+}
+
+// A click lands by the faces the letters show.
+void TextFontControlsTests::theEditorPlacesTheCaretByTheFaces()
+{
+ Typed typed;
+ QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
+ QTest::keyClicks(typed.canvas, QStringLiteral("iiii"));
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ typed.choose(mono);
+ const double narrow = QFontMetricsF(TextLayout::font(typed.style(), sans)).horizontalAdvance(QLatin1Char('i'));
+ const double wide = QFontMetricsF(TextLayout::font(typed.style(), mono)).horizontalAdvance(QLatin1Char('i'));
+ QVERIFY(wide > 2 * narrow);
+ // A fifth into the last letter: before it, in mono.
+ const double x = LayerTextStyle::padding + 2 * narrow + wide + wide / 5;
+ const QPointF at = typed.editor().textTransform().map(QPointF(x, LayerTextStyle::padding + typed.style().lineHeight() / 2));
+ typed.click(at);
+ QCOMPARE(typed.editor().caretPosition(), 3);
+}
+
+QTEST_MAIN(TextFontControlsTests)
+#include "TextFontControlsTests.moc"
diff --git a/tests/TextFontRunsTests.cpp b/tests/TextFontRunsTests.cpp
new file mode 100644
index 0000000..c349f3f
--- /dev/null
+++ b/tests/TextFontRunsTests.cpp
@@ -0,0 +1,290 @@
+#include "Document/EditorSession.h"
+#include "IO/ProjectStore.h"
+#include "Rendering/TextLayout.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Swift 1.3.4: a font change applies to the selected letters.
+namespace {
+const QString sans = QStringLiteral("DejaVuSans");
+const QString mono = QStringLiteral("DejaVuSansMono");
+
+std::unique_ptr makeSession()
+{
+ auto session = std::make_unique();
+ session->createDocument(800, 600, true);
+ session->selectTool(NavigationTool::type);
+ return session;
+}
+
+LayerTextStyle hello()
+{
+ LayerTextStyle style;
+ style.content = QStringLiteral("Hello");
+ style.fontName = sans;
+ return style;
+}
+
+// A saved text layer whose first two letters are mono.
+struct SavedFonts {
+ QTemporaryDir root;
+ QString path = root.filePath(QStringLiteral("Fonts.comp"));
+ SavedFonts()
+ {
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ TextDraft draft = session->textDraft().value();
+ draft.style.content = QStringLiteral("Hello");
+ draft.style.fontName = sans;
+ draft.selection = {0, 2};
+ session->setTextDraft(draft);
+ session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
+ if (!session->finishText())
+ throw std::runtime_error("the text was refused");
+ ProjectStore::save(session->projectSnapshot().value(), path);
+ }
+ // The manifest with its text's runs and version rewritten.
+ std::optional load(qint64 version, const QJsonValue &fontRuns) const
+ {
+ QFile file(path + QStringLiteral("/manifest.json"));
+ if (!file.open(QIODevice::ReadOnly))
+ throw std::runtime_error("no manifest");
+ QJsonObject manifest = QJsonDocument::fromJson(file.readAll()).object();
+ file.close();
+ manifest["version"] = version;
+ QJsonArray layers = manifest["layers"].toArray();
+ const qsizetype index = layers.size() - 1;
+ QJsonObject layer = layers[index].toObject();
+ if (!layer.contains("text"))
+ throw std::runtime_error("the top layer holds no text");
+ QJsonObject text = layer["text"].toObject();
+ text["fontRuns"] = fontRuns;
+ layer["text"] = text;
+ layers[index] = layer;
+ manifest["layers"] = layers;
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(QJsonDocument(manifest).toJson()) < 0)
+ throw std::runtime_error("the manifest could not be written");
+ file.close();
+ try {
+ ProjectStore::load(path);
+ return std::nullopt;
+ } catch (const ProjectError &error) {
+ return error.kind;
+ }
+ }
+};
+
+QJsonObject run(qint64 location, qint64 length, const QString &face)
+{
+ return {{"location", location}, {"length", length}, {"fontName", face}};
+}
+}
+
+class TextFontRunsTests : public QObject {
+ Q_OBJECT
+private slots:
+ void fontAppliesToTheSelectionOnly();
+ void selectedFontSurvivesReopening();
+ void runsNeedVersionElevenAndAValidFace();
+ void eachRunLaysOutInItsFace();
+ void newTextStartsInOneFace();
+};
+
+// Swift's test, with faces of different widths kept.
+void TextFontRunsTests::fontAppliesToTheSelectionOnly()
+{
+ LayerTextStyle style = hello();
+ style.setFont(mono, {0, 2});
+ QCOMPARE(style.fontName, sans);
+ QCOMPARE(style.fontRuns.value(), (std::vector{{0, 2, mono}}));
+ QVERIFY(style.fontNameAt(0) == mono && style.fontNameAt(2) == sans);
+ const double mixed = EditorSession::textBoxSize(style).width();
+ style.setFont(mono, {0, 0});
+ QVERIFY(!style.fontRuns && style.fontName == mono);
+ QVERIFY(EditorSession::textBoxSize(style).width() > mixed);
+ style.setFont(sans, {1, 3});
+ style.replaceCharacters({5, 0}, 1);
+ style.content += QStringLiteral("!");
+ QVERIFY(style.isValid() && style.fontNameAt(5) == mono);
+ QCOMPARE(style.uniformFontName({0, 2}), std::optional());
+ QCOMPARE(style.uniformFontName({1, 3}), std::optional(sans));
+ QCOMPARE(style.uniformFontName({4, 2}), std::optional(mono));
+ QCOMPARE(style.uniformFontName({2, 0}), std::optional());
+ style.setFont(mono, {0, 6});
+ QVERIFY(!style.fontRuns && style.fontName == mono);
+ style.setFont(sans, {0, 2});
+ QCOMPARE(style.fontRuns.value(), (std::vector{{0, 2, sans}}));
+ // A name that is no face changes nothing.
+ style.setFont(QString(), {2, 1});
+ style.setFont(QStringLiteral("Bad\nName"), {2, 1});
+ QCOMPARE(style.fontRuns.value(), (std::vector{{0, 2, sans}}));
+ // Same-face runs apart stay apart; a base gap mixes.
+ LayerTextStyle apart = hello();
+ apart.setFont(mono, {0, 1});
+ apart.setFont(mono, {3, 1});
+ QCOMPARE(apart.fontRuns.value(), (std::vector{{0, 1, mono}, {3, 1, mono}}));
+ QCOMPARE(apart.uniformFontName({0, 4}), std::optional());
+ QCOMPARE(apart.uniformFontName({3, 1}), std::optional(mono));
+ // All letters in the run's face: the text's face.
+ LayerTextStyle kept = hello();
+ kept.setFont(mono, {0, 2});
+ kept.replaceCharacters({2, 3}, 0);
+ kept.content.truncate(2);
+ QVERIFY(!kept.fontRuns && kept.fontName == mono);
+ // Neighbouring runs in two faces stay two.
+ const QString bold = QStringLiteral("DejaVuSans-Bold");
+ LayerTextStyle three = hello();
+ three.setFont(mono, {0, 1});
+ three.setFont(bold, {1, 1});
+ QCOMPARE(three.fontRuns.value(), (std::vector{{0, 1, mono}, {1, 1, bold}}));
+ QVERIFY(three.fontNameAt(0) == mono && three.fontNameAt(1) == bold && three.fontNameAt(2) == sans);
+ // A run starting at the span's end stays out.
+ QCOMPARE(three.uniformFontName({0, 1}), std::optional(mono));
+ QCOMPARE(three.uniformFontName({2, 3}), std::optional(sans));
+ // A span past the end stops at it.
+ three.setFont(mono, {3, 99});
+ QCOMPARE(three.fontRuns.value(), (std::vector{{0, 1, mono}, {1, 1, bold}, {3, 2, mono}}));
+ QCOMPARE(three.uniformFontName({3, 99}), std::optional(mono));
+ // Inserted at the start, letters take the first letter's face.
+ LayerTextStyle first = hello();
+ first.setFont(mono, {0, 1});
+ first.replaceCharacters({0, 0}, 2);
+ first.content.prepend(QStringLiteral("xy"));
+ QCOMPARE(first.fontRuns.value(), (std::vector{{0, 3, mono}}));
+ first.replaceCharacters({0, 1}, 1);
+ QCOMPARE(first.fontRuns.value(), (std::vector{{0, 3, mono}}));
+ // Deleting a run's letters drops the run.
+ style.replaceCharacters({0, 2}, 0);
+ style.content.remove(0, 2);
+ QVERIFY(!style.fontRuns && style.fontName == mono);
+}
+
+void TextFontRunsTests::selectedFontSurvivesReopening()
+{
+ const SavedFonts saved;
+ EditorSession reopened;
+ reopened.installProject(ProjectStore::load(saved.path), saved.path);
+ QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontRuns.value(), (std::vector{{0, 2, mono}}));
+ QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontName, sans);
+ QCOMPARE(ProjectStore::load(saved.path).manifest.version, qint64(11));
+}
+
+void TextFontRunsTests::runsNeedVersionElevenAndAValidFace()
+{
+ const SavedFonts saved;
+ QCOMPARE(saved.load(11, QJsonArray{run(0, 2, mono)}), std::optional());
+ // Version 10 cannot hold faces.
+ QCOMPARE(saved.load(10, QJsonArray{run(0, 2, mono)}), std::optional(ProjectError::Kind::invalid));
+ // Overlapping, empty, past the end, nameless, newline, long, none.
+ for (const QJsonArray &runs : {QJsonArray{run(0, 2, mono), run(1, 2, mono)}, QJsonArray{run(0, 0, mono)}, QJsonArray{run(4, 2, mono)},
+ QJsonArray{run(0, 2, QString())}, QJsonArray{run(0, 2, QStringLiteral("A
B"))},
+ QJsonArray{run(0, 2, QString(201, u'x'))}, QJsonArray{}})
+ QCOMPARE(saved.load(11, runs), std::optional(ProjectError::Kind::invalid));
+ // Two hundred characters, a decomposed letter counted once, pass.
+ QString name;
+ for (int index = 0; index < 200; ++index)
+ name += QStringLiteral("é");
+ QCOMPARE(saved.load(11, QJsonArray{run(0, 2, name)}), std::optional());
+ // A negative start, a fractional place or length.
+ QCOMPARE(saved.load(11, QJsonArray{run(-1, 2, mono)}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0.5}, {"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2.5}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
+ // Every newline character, and a run past Int's end.
+ for (const QChar newline : {QChar(u'\n'), QChar(u'\r'), QChar(u'\v'), QChar(u'\f'), QChar(0x85), QChar(QChar::LineSeparator), QChar(QChar::ParagraphSeparator)})
+ QCOMPARE(saved.load(11, QJsonArray{run(0, 2, QStringLiteral("A") + newline + QStringLiteral("B"))}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonArray{run(std::numeric_limits::max(), 1, mono)}), std::optional(ProjectError::Kind::invalid));
+ // A run missing a key, or no array, fails.
+ QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2}}}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(saved.load(11, QJsonValue(3)), std::optional(ProjectError::Kind::invalid));
+}
+
+void TextFontRunsTests::eachRunLaysOutInItsFace()
+{
+ LayerTextStyle style = hello();
+ style.content = QStringLiteral("iiii\niiii");
+ const double plain = TextLines(style, {10'000, 10'000}).usedSize().width();
+ // A run on the second paragraph widens that line alone.
+ style.setFont(mono, {5, 4});
+ const double wider = TextLines(style, {10'000, 10'000}).usedSize().width();
+ QCOMPARE(wider, 4 * QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i')));
+ QVERIFY(wider > plain);
+ // A face named by PostScript keeps its style: bold.
+ const QString bold = QStringLiteral("DejaVuSans-Bold");
+ LayerTextStyle heavy = hello();
+ heavy.content = QStringLiteral("iiiii");
+ heavy.setFont(bold, {0, 4});
+ QCOMPARE(QFontInfo(TextLayout::font(heavy, bold)).styleName(), QStringLiteral("Bold"));
+ // An independent reference: DejaVu Sans Bold at 72 pixels.
+ QFont reference(QStringLiteral("DejaVu Sans"));
+ reference.setStyleName(QStringLiteral("Bold"));
+ reference.setPixelSize(72);
+ reference.setKerning(false);
+ reference.setHintingPreference(QFont::PreferNoHinting);
+ QFont regular = reference;
+ regular.setStyleName(QStringLiteral("Book"));
+ QCOMPARE(TextLines(heavy, {10'000, 10'000}).usedSize().width(),
+ 4 * QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) + QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
+ QVERIFY(QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) > QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
+ // A run across a break covers both lines.
+ style.fontRuns = std::nullopt;
+ style.setFont(mono, {2, 5});
+ const TextLines across(style, {10'000, 10'000});
+ const double narrow = QFontMetricsF(TextLayout::font(style, sans)).horizontalAdvance(QLatin1Char('i'));
+ const double wide = QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i'));
+ QCOMPARE(across.caret(4).value().left(), 2 * narrow + 2 * wide);
+ QCOMPARE(across.caret(9).value().left(), 2 * wide + 2 * narrow);
+ // At another size and tracking, against fonts built here.
+ LayerTextStyle sized = hello();
+ sized.content = QStringLiteral("iiiii");
+ sized.fontSize = 40;
+ sized.tracking = 3;
+ sized.setFont(mono, {1, 2});
+ const auto made = [](const QString &family) {
+ QFont font(family);
+ font.setPixelSize(40);
+ font.setKerning(false);
+ font.setLetterSpacing(QFont::AbsoluteSpacing, 3);
+ font.setHintingPreference(QFont::PreferNoHinting);
+ return QFontMetricsF(font).horizontalAdvance(QStringLiteral("i"));
+ };
+ const double expected = 3 * made(QStringLiteral("DejaVu Sans")) + 2 * made(QStringLiteral("DejaVu Sans Mono"));
+ QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
+ // Colouring the run's letters keeps their face.
+ sized.setColor(PaletteColor{1, 0, 0}, {1, 2});
+ QVERIFY(sized.colorRuns && sized.fontRuns);
+ QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
+ // The face keeps the style's size and spacing.
+ style.tracking = 3;
+ QCOMPARE(TextLayout::font(style, mono).letterSpacing(), 3.0);
+ QCOMPARE(TextLayout::font(style, mono).pixelSize(), 72);
+ QCOMPARE(TextLayout::font(style, mono).family(), QStringLiteral("DejaVu Sans Mono"));
+ // An unlisted face asks Qt by its own name.
+ QCOMPARE(TextLayout::font(style, QStringLiteral("Zapfino")).family(), QStringLiteral("Zapfino"));
+}
+
+void TextFontRunsTests::newTextStartsInOneFace()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ TextDraft draft = session->textDraft().value();
+ draft.style.content = QStringLiteral("Hello");
+ draft.style.fontName = sans;
+ session->setTextDraft(draft);
+ session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
+ QVERIFY(session->finishText());
+ session->beginText(QPointF(300, 300), true);
+ QVERIFY(!session->textDraft().value().style.fontRuns);
+ QCOMPARE(session->textDraft().value().style.fontName, sans);
+}
+
+QTEST_MAIN(TextFontRunsTests)
+#include "TextFontRunsTests.moc"