diff --git a/AGENTS.md b/AGENTS.md
index 1f905b6..44a554e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -135,6 +135,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
- Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`.
- Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`.
+- Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index aaae12d..dba21e1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -161,6 +161,9 @@ add_library(compositor_core STATIC
src/IO/PSD/PSDReader.cpp
src/IO/PSD/PSDReader+Records.cpp
src/IO/PSD/PSDReader+Adjustments.cpp
+ src/IO/PSD/PSDText.cpp
+ src/IO/PSD/PSDText+Descriptor.cpp
+ src/IO/PSD/PSDText+Engine.cpp
src/IO/PSD/PSDTypes.cpp
src/IO/PSD/PSDVector.cpp
src/IO/ProjectStore.cpp
diff --git a/TASKS.md b/TASKS.md
index 957a910..f12be79 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -380,7 +380,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags).
- [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS.
- [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed.
-- [ ] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`).
+- [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed.
- [ ] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`).
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
diff --git a/src/IO/PSD/PSDDocumentBuilder.cpp b/src/IO/PSD/PSDDocumentBuilder.cpp
index 273451b..80754a8 100644
--- a/src/IO/PSD/PSDDocumentBuilder.cpp
+++ b/src/IO/PSD/PSDDocumentBuilder.cpp
@@ -8,11 +8,30 @@ ImportedImage imported(const QImage &image, const QString &name)
return ImportedImage(image, PixelAdjust::thumbnail(image), name);
}
-std::vector notes(const PSDRecord &record)
+// Swift's `try?`: text that cannot be drawn stays pixels.
+std::optional rendered(const PSDRecord &record)
+{
+ if (!record.text)
+ return std::nullopt;
+ try {
+ return PSDText::render(*record.text);
+ } catch (const std::runtime_error &) {
+ return std::nullopt;
+ }
+}
+
+std::vector notes(const PSDRecord &record, bool typed)
{
std::vector result;
- if (record.kind == PSDLayerKind::text)
- result.push_back(QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped."));
+ if (record.kind == PSDLayerKind::text) {
+ if (typed) {
+ result.insert(result.end(), record.text->notes.begin(), record.text->notes.end());
+ if (const std::optional missing = PSDText::missingFontNote(record.text->style.fontName))
+ result.push_back(*missing);
+ } else {
+ result.push_back(PSDText::rasterizedNote);
+ }
+ }
if (record.kind == PSDLayerKind::smartObject)
result.push_back(QStringLiteral("The smart object was rasterized. Linked contents can’t be edited."));
if (record.kind == PSDLayerKind::effects)
@@ -41,10 +60,14 @@ std::vector notes(const PSDRecord &record)
return result;
}
-ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map &assets)
+ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map &assets, const std::optional &typed)
{
ImageLayer layer(record.name, canvas);
- if (record.image && !record.isGroup && !record.adjustment) {
+ if (typed && !record.adjustment) {
+ layer = ImageLayer(imported(typed->image, record.name), QPointF());
+ layer.transform = typed->transform;
+ layer.text = LayerText{record.text->style, layer.asset->identity()};
+ } else if (record.image && !record.isGroup && !record.adjustment) {
const ImportedImage asset = assets.contains(record.id) ? assets.at(record.id) : imported(*record.image, record.name);
const QSizeF size = record.bounds.width() > 0 && record.bounds.height() > 0 ? record.bounds.size() : QSizeF(record.image->size());
layer = ImageLayer(asset, record.bounds.topLeft());
@@ -83,11 +106,12 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
if (record.croppedToCanvas)
conversions.push_back(PSDConversion{.layerName = record.name,
.message = QStringLiteral("Cropped to the canvas so the file fits in memory. Pixels outside the canvas weren't imported.")});
- for (const QString ¬e : notes(record))
+ const std::optional typed = rendered(record);
+ for (const QString ¬e : notes(record, typed.has_value()))
conversions.push_back(PSDConversion{.layerName = record.name, .message = note});
if (record.kind == PSDLayerKind::adjustment && !record.adjustment)
continue;
- ImageLayer layer = layerFor(record, canvas, assets);
+ ImageLayer layer = layerFor(record, canvas, assets, typed);
if (record.mask) {
try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
diff --git a/src/IO/PSD/PSDReader.cpp b/src/IO/PSD/PSDReader.cpp
index 9eb5e98..bdec74d 100644
--- a/src/IO/PSD/PSDReader.cpp
+++ b/src/IO/PSD/PSDReader.cpp
@@ -61,7 +61,9 @@ std::vector assemble(const std::vector &raw, QSizeF canvas,
record.opacity = hasEffects && layer.fill != 255 ? layer.opacity / 255.0 : (layer.opacity / 255.0) * (layer.fill / 255.0);
record.bounds = isGroup ? QRectF(QPointF(0, 0), canvas) : QRectF(layer.left, layer.top, std::max(0, layer.right - layer.left), std::max(0, layer.bottom - layer.top));
record.image = isGroup ? std::nullopt : layer.image;
- std::optional live = isGroup ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining);
+ if (record.kind == PSDLayerKind::text)
+ record.text = PSDText::parse(layer.extra);
+ std::optional live = isGroup || record.text ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining);
if (live) {
record.image = live->image;
record.bounds = live->bounds;
@@ -69,7 +71,7 @@ std::vector assemble(const std::vector &raw, QSizeF canvas,
record.shapeNotes = live->notes;
record.kind = PSDLayerKind::vector;
remaining = std::max(0, remaining - qint64(live->image.width()) * live->image.height());
- } else if (!record.image && !isGroup) {
+ } else if (!record.text && !record.image && !isGroup) {
if (const std::optional raster = PSDVector::raster(layer.extra, canvas, remaining)) {
record.image = raster->image;
record.bounds = raster->bounds;
diff --git a/src/IO/PSD/PSDText+Descriptor.cpp b/src/IO/PSD/PSDText+Descriptor.cpp
new file mode 100644
index 0000000..4f6a2ed
--- /dev/null
+++ b/src/IO/PSD/PSDText+Descriptor.cpp
@@ -0,0 +1,264 @@
+#include "IO/PSD/PSDText+Descriptor.h"
+#include
+#include
+#include
+#include
+
+namespace PSDDescriptor {
+namespace {
+// Nesting past this reads as broken, where Swift's stack overflows.
+constexpr int maxDepth = 512;
+
+// Swift's ASCII decoding: a high byte fails.
+std::optional ascii(const QByteArray &raw)
+{
+ for (const char byte : raw)
+ if (uchar(byte) >= 0x80)
+ return std::nullopt;
+ return QString::fromLatin1(raw);
+}
+
+template
+const T *find(const Items &items, const QString &key)
+{
+ return items.contains(key) ? std::get_if(&items.at(key).value) : nullptr;
+}
+
+// Swift's `??`: the key present decides, then its type.
+std::optional side(const Items &items, const QString &name)
+{
+ const double *found = find(items, items.contains(name) ? name : name.trimmed());
+ return found ? std::optional(*found) : std::nullopt;
+}
+}
+
+std::optional string(const Items &items, const QString &key)
+{
+ const QString *found = find(items, key);
+ return found ? std::optional(*found) : std::nullopt;
+}
+
+std::optional enumeration(const Items &items, const QString &key)
+{
+ const Enumeration *found = find(items, key);
+ return found ? std::optional(found->name) : std::nullopt;
+}
+
+std::optional data(const Items &items, const QString &key)
+{
+ const QByteArray *found = find(items, key);
+ return found ? std::optional(*found) : std::nullopt;
+}
+
+QString utf16(const QByteArray &raw)
+{
+ QString text = QStringDecoder(QStringConverter::Utf16BE, QStringConverter::Flag::ConvertInitialBom).decode(raw);
+ for (qsizetype index = 0; index < text.size(); ++index) {
+ if (text[index].isHighSurrogate() && index + 1 < text.size() && text[index + 1].isLowSurrogate())
+ ++index;
+ else if (text[index].isSurrogate())
+ text[index] = QChar::ReplacementCharacter;
+ }
+ return text;
+}
+
+std::optional rect(const Items &items, const QString &key)
+{
+ const Items *box = find(items, key);
+ if (!box)
+ return std::nullopt;
+ const std::optional left = side(*box, QStringLiteral("Left")), top = side(*box, QStringLiteral("Top ")),
+ right = side(*box, QStringLiteral("Rght")), bottom = side(*box, QStringLiteral("Btom"));
+ if (!left || !top || !right || !bottom)
+ return std::nullopt;
+ if (!std::isfinite(*left) || !std::isfinite(*top) || !std::isfinite(*right) || !std::isfinite(*bottom))
+ return std::nullopt;
+ return QRectF(QPointF(*left, *top), QPointF(*right, *bottom)).normalized();
+}
+
+std::optional Reader::descriptor(bool versioned)
+{
+ if (versioned && u32() != 16u)
+ return std::nullopt;
+ if (!unicode() || !identifier())
+ return std::nullopt;
+ const std::optional count = u32();
+ if (!count || *count > 10'000)
+ return std::nullopt;
+ Items result;
+ for (quint32 index = 0; index < *count; ++index) {
+ const std::optional key = identifier();
+ const std::optional type = key ? fourCC() : std::nullopt;
+ std::optional item = type ? value(*type) : std::nullopt;
+ if (!item)
+ return std::nullopt;
+ result.insert_or_assign(*key, std::move(*item));
+ }
+ return result;
+}
+
+std::optional Reader::value(const QString &type)
+{
+ if (depth >= maxDepth)
+ return std::nullopt;
+ ++depth;
+ std::optional result = read(type);
+ --depth;
+ return result;
+}
+
+std::optional Reader::read(const QString &type)
+{
+ if (type == QLatin1String("doub")) {
+ const std::optional number = f64();
+ return number ? std::optional(Value{*number}) : std::nullopt;
+ }
+ if (type == QLatin1String("UntF")) {
+ const std::optional number = fourCC() ? f64() : std::nullopt;
+ return number ? std::optional(Value{*number}) : std::nullopt;
+ }
+ if (type == QLatin1String("long")) {
+ const std::optional number = i32();
+ return number ? std::optional(Value{double(*number)}) : std::nullopt;
+ }
+ if (type == QLatin1String("comp")) {
+ const std::optional raw = bytes(8);
+ return raw ? std::optional(Value{double(qFromBigEndian(raw->constData()))}) : std::nullopt;
+ }
+ if (type == QLatin1String("bool"))
+ return u8() ? std::optional(Value{0.0}) : std::nullopt;
+ if (type == QLatin1String("TEXT")) {
+ const std::optional text = unicode();
+ return text ? std::optional(Value{*text}) : std::nullopt;
+ }
+ if (type == QLatin1String("enum")) {
+ const std::optional name = identifier() ? identifier() : std::nullopt;
+ return name ? std::optional(Value{Enumeration{*name}}) : std::nullopt;
+ }
+ if (type == QLatin1String("tdta")) {
+ const std::optional length = u32();
+ const std::optional raw = length && *length <= 8'000'000 ? bytes(*length) : std::nullopt;
+ return raw ? std::optional(Value{*raw}) : std::nullopt;
+ }
+ if (type == QLatin1String("Objc") || type == QLatin1String("GlbO")) {
+ std::optional nested = descriptor(false);
+ return nested ? std::optional(Value{std::move(*nested)}) : std::nullopt;
+ }
+ if (type == QLatin1String("VlLs")) {
+ const std::optional count = u32();
+ if (!count || *count > 10'000)
+ return std::nullopt;
+ std::vector list;
+ for (quint32 index = 0; index < *count; ++index) {
+ const std::optional itemType = fourCC();
+ std::optional item = itemType ? value(*itemType) : std::nullopt;
+ if (!item)
+ return std::nullopt;
+ list.push_back(std::move(*item));
+ }
+ return Value{std::move(list)};
+ }
+ if (type == QLatin1String("alis")) {
+ const std::optional length = u32();
+ return length && *length <= 8'000'000 && bytes(*length) ? std::optional(Value{0.0}) : std::nullopt;
+ }
+ if (type == QLatin1String("obj "))
+ return reference() ? std::optional(Value{0.0}) : std::nullopt;
+ if (type == QLatin1String("type") || type == QLatin1String("GlbC"))
+ return unicode() && identifier() ? std::optional(Value{0.0}) : std::nullopt;
+ return std::nullopt;
+}
+
+bool Reader::reference()
+{
+ const std::optional count = u32();
+ if (!count || *count > 10'000)
+ return false;
+ for (quint32 index = 0; index < *count; ++index) {
+ const std::optional form = fourCC();
+ if (!form)
+ return false;
+ bool read = false;
+ if (*form == QLatin1String("prop"))
+ read = unicode() && identifier() && identifier();
+ else if (*form == QLatin1String("Clss"))
+ read = unicode() && identifier();
+ else if (*form == QLatin1String("Enmr"))
+ read = unicode() && identifier() && identifier() && identifier();
+ else if (*form == QLatin1String("rele"))
+ read = unicode() && identifier() && i32();
+ else if (*form == QLatin1String("Idnt") || *form == QLatin1String("indx"))
+ read = bool(i32());
+ else if (*form == QLatin1String("name"))
+ read = bool(unicode());
+ if (!read)
+ return false;
+ }
+ return true;
+}
+
+std::optional Reader::unicode()
+{
+ const std::optional count = u32();
+ const std::optional raw = count && *count <= 1'000'000 ? bytes(qint64(*count) * 2) : std::nullopt;
+ if (!raw)
+ return std::nullopt;
+ return utf16(*raw);
+}
+
+std::optional Reader::identifier()
+{
+ const std::optional length = u32();
+ if (!length)
+ return std::nullopt;
+ if (*length == 0)
+ return fourCC();
+ const std::optional raw = *length <= 10'000 ? bytes(*length) : std::nullopt;
+ return raw ? ascii(*raw) : std::nullopt;
+}
+
+std::optional Reader::fourCC()
+{
+ const std::optional raw = bytes(4);
+ return raw ? ascii(*raw) : std::nullopt;
+}
+
+std::optional Reader::bytes(qint64 count)
+{
+ if (count < 0 || count > remaining())
+ return std::nullopt;
+ const QByteArray slice = data.mid(offset, count);
+ offset += count;
+ return slice;
+}
+
+std::optional Reader::u8()
+{
+ const std::optional raw = bytes(1);
+ return raw ? std::optional(uchar(raw->at(0))) : std::nullopt;
+}
+
+std::optional Reader::u16()
+{
+ const std::optional raw = bytes(2);
+ return raw ? std::optional(qFromBigEndian(raw->constData())) : std::nullopt;
+}
+
+std::optional Reader::u32()
+{
+ const std::optional raw = bytes(4);
+ return raw ? std::optional(qFromBigEndian(raw->constData())) : std::nullopt;
+}
+
+std::optional Reader::i32()
+{
+ const std::optional value = u32();
+ return value ? std::optional(qint32(*value)) : std::nullopt;
+}
+
+std::optional Reader::f64()
+{
+ const std::optional raw = bytes(8);
+ return raw ? std::optional(std::bit_cast(qFromBigEndian(raw->constData()))) : std::nullopt;
+}
+}
diff --git a/src/IO/PSD/PSDText+Descriptor.h b/src/IO/PSD/PSDText+Descriptor.h
new file mode 100644
index 0000000..b077863
--- /dev/null
+++ b/src/IO/PSD/PSDText+Descriptor.h
@@ -0,0 +1,53 @@
+#pragma once
+#include
+#include
+#include
+#include