Import simple Photoshop text as editable text (12.11b2)
This commit is contained in:
1 parent
a2241601ec
commit
008493b0a3
20 files changed
+2081
-17
No files matched your search
@@ -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 <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
|
||||
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
|
||||
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,11 +8,30 @@ ImportedImage imported(const QImage &image, const QString &name)
|
||||
return ImportedImage(image, PixelAdjust::thumbnail(image), name);
|
||||
}
|
||||
|
||||
std::vector<QString> notes(const PSDRecord &record)
|
||||
// Swift's `try?`: text that cannot be drawn stays pixels.
|
||||
std::optional<PSDText::Rendered> 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<QString> notes(const PSDRecord &record, bool typed)
|
||||
{
|
||||
std::vector<QString> 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<QString> 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<QString> notes(const PSDRecord &record)
|
||||
return result;
|
||||
}
|
||||
|
||||
ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets)
|
||||
ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets, const std::optional<PSDText::Rendered> &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<PSDText::Rendered> 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);
|
||||
|
||||
@@ -61,7 +61,9 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &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<qint64>(0, layer.right - layer.left), std::max<qint64>(0, layer.bottom - layer.top));
|
||||
record.image = isGroup ? std::nullopt : layer.image;
|
||||
std::optional<PSDVector::Live> live = isGroup ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining);
|
||||
if (record.kind == PSDLayerKind::text)
|
||||
record.text = PSDText::parse(layer.extra);
|
||||
std::optional<PSDVector::Live> 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<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
|
||||
record.shapeNotes = live->notes;
|
||||
record.kind = PSDLayerKind::vector;
|
||||
remaining = std::max<qint64>(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<PSDVector::Raster> raster = PSDVector::raster(layer.extra, canvas, remaining)) {
|
||||
record.image = raster->image;
|
||||
record.bounds = raster->bounds;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
#include "IO/PSD/PSDText+Descriptor.h"
|
||||
#include <QStringDecoder>
|
||||
#include <QtEndian>
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
|
||||
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<QString> ascii(const QByteArray &raw)
|
||||
{
|
||||
for (const char byte : raw)
|
||||
if (uchar(byte) >= 0x80)
|
||||
return std::nullopt;
|
||||
return QString::fromLatin1(raw);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
const T *find(const Items &items, const QString &key)
|
||||
{
|
||||
return items.contains(key) ? std::get_if<T>(&items.at(key).value) : nullptr;
|
||||
}
|
||||
|
||||
// Swift's `??`: the key present decides, then its type.
|
||||
std::optional<double> side(const Items &items, const QString &name)
|
||||
{
|
||||
const double *found = find<double>(items, items.contains(name) ? name : name.trimmed());
|
||||
return found ? std::optional(*found) : std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<QString> string(const Items &items, const QString &key)
|
||||
{
|
||||
const QString *found = find<QString>(items, key);
|
||||
return found ? std::optional(*found) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<QString> enumeration(const Items &items, const QString &key)
|
||||
{
|
||||
const Enumeration *found = find<Enumeration>(items, key);
|
||||
return found ? std::optional(found->name) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<QByteArray> data(const Items &items, const QString &key)
|
||||
{
|
||||
const QByteArray *found = find<QByteArray>(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<QRectF> rect(const Items &items, const QString &key)
|
||||
{
|
||||
const Items *box = find<Items>(items, key);
|
||||
if (!box)
|
||||
return std::nullopt;
|
||||
const std::optional<double> 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<Items> Reader::descriptor(bool versioned)
|
||||
{
|
||||
if (versioned && u32() != 16u)
|
||||
return std::nullopt;
|
||||
if (!unicode() || !identifier())
|
||||
return std::nullopt;
|
||||
const std::optional<quint32> count = u32();
|
||||
if (!count || *count > 10'000)
|
||||
return std::nullopt;
|
||||
Items result;
|
||||
for (quint32 index = 0; index < *count; ++index) {
|
||||
const std::optional<QString> key = identifier();
|
||||
const std::optional<QString> type = key ? fourCC() : std::nullopt;
|
||||
std::optional<Value> item = type ? value(*type) : std::nullopt;
|
||||
if (!item)
|
||||
return std::nullopt;
|
||||
result.insert_or_assign(*key, std::move(*item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Value> Reader::value(const QString &type)
|
||||
{
|
||||
if (depth >= maxDepth)
|
||||
return std::nullopt;
|
||||
++depth;
|
||||
std::optional<Value> result = read(type);
|
||||
--depth;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Value> Reader::read(const QString &type)
|
||||
{
|
||||
if (type == QLatin1String("doub")) {
|
||||
const std::optional<double> number = f64();
|
||||
return number ? std::optional(Value{*number}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("UntF")) {
|
||||
const std::optional<double> number = fourCC() ? f64() : std::nullopt;
|
||||
return number ? std::optional(Value{*number}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("long")) {
|
||||
const std::optional<qint32> number = i32();
|
||||
return number ? std::optional(Value{double(*number)}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("comp")) {
|
||||
const std::optional<QByteArray> raw = bytes(8);
|
||||
return raw ? std::optional(Value{double(qFromBigEndian<qint64>(raw->constData()))}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("bool"))
|
||||
return u8() ? std::optional(Value{0.0}) : std::nullopt;
|
||||
if (type == QLatin1String("TEXT")) {
|
||||
const std::optional<QString> text = unicode();
|
||||
return text ? std::optional(Value{*text}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("enum")) {
|
||||
const std::optional<QString> name = identifier() ? identifier() : std::nullopt;
|
||||
return name ? std::optional(Value{Enumeration{*name}}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("tdta")) {
|
||||
const std::optional<quint32> length = u32();
|
||||
const std::optional<QByteArray> 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<Items> nested = descriptor(false);
|
||||
return nested ? std::optional(Value{std::move(*nested)}) : std::nullopt;
|
||||
}
|
||||
if (type == QLatin1String("VlLs")) {
|
||||
const std::optional<quint32> count = u32();
|
||||
if (!count || *count > 10'000)
|
||||
return std::nullopt;
|
||||
std::vector<Value> list;
|
||||
for (quint32 index = 0; index < *count; ++index) {
|
||||
const std::optional<QString> itemType = fourCC();
|
||||
std::optional<Value> 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<quint32> 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<quint32> count = u32();
|
||||
if (!count || *count > 10'000)
|
||||
return false;
|
||||
for (quint32 index = 0; index < *count; ++index) {
|
||||
const std::optional<QString> 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<QString> Reader::unicode()
|
||||
{
|
||||
const std::optional<quint32> count = u32();
|
||||
const std::optional<QByteArray> raw = count && *count <= 1'000'000 ? bytes(qint64(*count) * 2) : std::nullopt;
|
||||
if (!raw)
|
||||
return std::nullopt;
|
||||
return utf16(*raw);
|
||||
}
|
||||
|
||||
std::optional<QString> Reader::identifier()
|
||||
{
|
||||
const std::optional<quint32> length = u32();
|
||||
if (!length)
|
||||
return std::nullopt;
|
||||
if (*length == 0)
|
||||
return fourCC();
|
||||
const std::optional<QByteArray> raw = *length <= 10'000 ? bytes(*length) : std::nullopt;
|
||||
return raw ? ascii(*raw) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<QString> Reader::fourCC()
|
||||
{
|
||||
const std::optional<QByteArray> raw = bytes(4);
|
||||
return raw ? ascii(*raw) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<QByteArray> 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<uchar> Reader::u8()
|
||||
{
|
||||
const std::optional<QByteArray> raw = bytes(1);
|
||||
return raw ? std::optional(uchar(raw->at(0))) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<quint16> Reader::u16()
|
||||
{
|
||||
const std::optional<QByteArray> raw = bytes(2);
|
||||
return raw ? std::optional(qFromBigEndian<quint16>(raw->constData())) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<quint32> Reader::u32()
|
||||
{
|
||||
const std::optional<QByteArray> raw = bytes(4);
|
||||
return raw ? std::optional(qFromBigEndian<quint32>(raw->constData())) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<qint32> Reader::i32()
|
||||
{
|
||||
const std::optional<quint32> value = u32();
|
||||
return value ? std::optional(qint32(*value)) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<double> Reader::f64()
|
||||
{
|
||||
const std::optional<QByteArray> raw = bytes(8);
|
||||
return raw ? std::optional(std::bit_cast<double>(qFromBigEndian<quint64>(raw->constData()))) : std::nullopt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <QRectF>
|
||||
#include <QString>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
// Adobe's action descriptors, as far as a type layer needs.
|
||||
namespace PSDDescriptor {
|
||||
struct Value;
|
||||
using Items = std::map<QString, Value>;
|
||||
|
||||
struct Enumeration {
|
||||
QString name;
|
||||
};
|
||||
|
||||
struct Value {
|
||||
std::variant<QString, double, Enumeration, QByteArray, Items, std::vector<Value>> value;
|
||||
};
|
||||
|
||||
std::optional<QString> string(const Items &items, const QString &key);
|
||||
std::optional<QString> enumeration(const Items &items, const QString &key);
|
||||
std::optional<QByteArray> data(const Items &items, const QString &key);
|
||||
// Standardised, as CGRect's reads are.
|
||||
std::optional<QRectF> rect(const Items &items, const QString &key);
|
||||
// Big-endian UTF-16, a lone surrogate read as U+FFFD.
|
||||
QString utf16(const QByteArray &raw);
|
||||
|
||||
// Big-endian reads over a block; each fails past its end.
|
||||
struct Reader {
|
||||
const QByteArray &data;
|
||||
qsizetype offset = 0;
|
||||
int depth = 0;
|
||||
qsizetype remaining() const { return data.size() - offset; }
|
||||
std::optional<Items> descriptor(bool versioned);
|
||||
std::optional<QByteArray> bytes(qint64 count);
|
||||
std::optional<uchar> u8();
|
||||
std::optional<quint16> u16();
|
||||
std::optional<quint32> u32();
|
||||
std::optional<qint32> i32();
|
||||
std::optional<double> f64();
|
||||
|
||||
private:
|
||||
std::optional<Value> value(const QString &type);
|
||||
std::optional<Value> read(const QString &type);
|
||||
bool reference();
|
||||
std::optional<QString> unicode();
|
||||
std::optional<QString> identifier();
|
||||
std::optional<QString> fourCC();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
#include "IO/PSD/PSDText+Engine.h"
|
||||
#include "IO/PSD/PSDText+Descriptor.h"
|
||||
#include <clocale>
|
||||
#include <cstdlib>
|
||||
#include <locale.h>
|
||||
|
||||
namespace PSDTextEngine {
|
||||
namespace {
|
||||
// Nesting past this reads as broken, where Swift's stack overflows.
|
||||
constexpr int maxDepth = 512;
|
||||
|
||||
bool isDelimiter(uchar byte)
|
||||
{
|
||||
return byte <= 0x20 || byte == '/' || byte == '<' || byte == '>' || byte == '[' || byte == ']' || byte == '(' || byte == ')';
|
||||
}
|
||||
|
||||
bool isDigit(uchar byte)
|
||||
{
|
||||
return byte >= '0' && byte <= '9';
|
||||
}
|
||||
|
||||
std::optional<uchar> hex(uchar byte)
|
||||
{
|
||||
if (isDigit(byte))
|
||||
return uchar(byte - '0');
|
||||
if (byte >= 'a' && byte <= 'f')
|
||||
return uchar(byte - 'a' + 10);
|
||||
if (byte >= 'A' && byte <= 'F')
|
||||
return uchar(byte - 'A' + 10);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Swift's `Double(text)`: whole text, C numbers, overflow infinite.
|
||||
std::optional<double> toDouble(const QByteArray &text)
|
||||
{
|
||||
static const locale_t c = newlocale(LC_ALL_MASK, "C", locale_t(nullptr));
|
||||
char *end = nullptr;
|
||||
const double value = strtod_l(text.constData(), &end, c);
|
||||
if (end != text.constData() + text.size())
|
||||
return std::nullopt;
|
||||
return value;
|
||||
}
|
||||
|
||||
QString decoded(const QByteArray &raw)
|
||||
{
|
||||
if (raw.size() >= 2 && uchar(raw[0]) == 0xFE && uchar(raw[1]) == 0xFF) {
|
||||
// Foundation reads no odd count of UTF-16 bytes.
|
||||
if (raw.size() % 2 == 1)
|
||||
return QString();
|
||||
return PSDDescriptor::utf16(raw.mid(2));
|
||||
}
|
||||
return QString::fromLatin1(raw);
|
||||
}
|
||||
|
||||
struct Cursor {
|
||||
const QByteArray &bytes;
|
||||
qsizetype index = 0;
|
||||
int depth = 0;
|
||||
|
||||
std::optional<uchar> peek(qsizetype ahead = 0) const
|
||||
{
|
||||
const qsizetype at = index + ahead;
|
||||
return at < bytes.size() ? std::optional(uchar(bytes[at])) : std::nullopt;
|
||||
}
|
||||
|
||||
bool take(const char *token)
|
||||
{
|
||||
const QByteArray encoded(token);
|
||||
if (bytes.mid(index, encoded.size()) != encoded)
|
||||
return false;
|
||||
index += encoded.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool takeWord(const char *word)
|
||||
{
|
||||
const QByteArray encoded(word);
|
||||
if (bytes.mid(index, encoded.size()) != encoded)
|
||||
return false;
|
||||
const qsizetype after = index + encoded.size();
|
||||
if (after < bytes.size() && !isDelimiter(uchar(bytes[after])))
|
||||
return false;
|
||||
index = after;
|
||||
return true;
|
||||
}
|
||||
|
||||
void skipWhitespace()
|
||||
{
|
||||
while (peek() && (*peek() <= 0x20 || *peek() == '%')) {
|
||||
if (*peek() == '%') {
|
||||
while (peek() && *peek() != '\n' && *peek() != '\r')
|
||||
++index;
|
||||
} else {
|
||||
++index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swift's ASCII decoding: a high byte leaves the name empty.
|
||||
QString readToken()
|
||||
{
|
||||
const qsizetype start = index;
|
||||
while (peek() && !isDelimiter(*peek()))
|
||||
++index;
|
||||
const QByteArray token = bytes.mid(start, index - start);
|
||||
for (const char byte : token)
|
||||
if (uchar(byte) >= 0x80)
|
||||
return QString();
|
||||
return QString::fromLatin1(token);
|
||||
}
|
||||
|
||||
std::optional<Engine> value()
|
||||
{
|
||||
if (depth >= maxDepth)
|
||||
return std::nullopt;
|
||||
++depth;
|
||||
std::optional<Engine> result = parseValue();
|
||||
--depth;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Engine> parseValue()
|
||||
{
|
||||
skipWhitespace();
|
||||
const std::optional<uchar> byte = peek();
|
||||
if (!byte)
|
||||
return std::nullopt;
|
||||
if (*byte == '<')
|
||||
return peek(1) == uchar('<') ? parseDictionary() : parseHex();
|
||||
if (*byte == '[')
|
||||
return parseArray();
|
||||
if (*byte == '(')
|
||||
return parseString();
|
||||
if (*byte == '/') {
|
||||
++index;
|
||||
return Engine{readToken()};
|
||||
}
|
||||
if (*byte == '-' || *byte == '+' || *byte == '.' || isDigit(*byte)) {
|
||||
const std::optional<double> parsed = parseNumber();
|
||||
return parsed ? std::optional(Engine{*parsed}) : std::nullopt;
|
||||
}
|
||||
if (takeWord("true"))
|
||||
return Engine{true};
|
||||
if (takeWord("false"))
|
||||
return Engine{false};
|
||||
if (takeWord("null"))
|
||||
return Engine{QString()};
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Engine> parseDictionary()
|
||||
{
|
||||
take("<<");
|
||||
Dictionary items;
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
if (!peek() || *peek() == '>')
|
||||
break;
|
||||
if (*peek() != '/')
|
||||
return std::nullopt;
|
||||
++index;
|
||||
const QString key = readToken();
|
||||
std::optional<Engine> item = value();
|
||||
if (!item)
|
||||
return std::nullopt;
|
||||
items.insert_or_assign(key, std::move(*item));
|
||||
}
|
||||
if (!take(">>"))
|
||||
return std::nullopt;
|
||||
return Engine{std::move(items)};
|
||||
}
|
||||
|
||||
std::optional<Engine> parseArray()
|
||||
{
|
||||
take("[");
|
||||
Array items;
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
if (!peek() || *peek() == ']')
|
||||
break;
|
||||
std::optional<Engine> item = value();
|
||||
if (!item)
|
||||
return std::nullopt;
|
||||
items.push_back(std::move(*item));
|
||||
}
|
||||
if (!take("]"))
|
||||
return std::nullopt;
|
||||
return Engine{std::move(items)};
|
||||
}
|
||||
|
||||
void digits()
|
||||
{
|
||||
while (peek() && isDigit(*peek()))
|
||||
++index;
|
||||
}
|
||||
|
||||
std::optional<double> parseNumber()
|
||||
{
|
||||
const qsizetype start = index;
|
||||
if (peek() == uchar('+') || peek() == uchar('-'))
|
||||
++index;
|
||||
digits();
|
||||
if (peek() == uchar('.')) {
|
||||
++index;
|
||||
digits();
|
||||
}
|
||||
if (peek() == uchar('e') || peek() == uchar('E')) {
|
||||
++index;
|
||||
if (peek() == uchar('+') || peek() == uchar('-'))
|
||||
++index;
|
||||
digits();
|
||||
}
|
||||
return toDouble(bytes.mid(start, index - start));
|
||||
}
|
||||
|
||||
std::optional<Engine> parseString()
|
||||
{
|
||||
take("(");
|
||||
QByteArray raw;
|
||||
while (peek()) {
|
||||
const uchar byte = *peek();
|
||||
++index;
|
||||
if (byte == ')')
|
||||
break;
|
||||
if (byte != '\\') {
|
||||
raw.append(char(byte));
|
||||
continue;
|
||||
}
|
||||
const std::optional<uchar> escaped = peek();
|
||||
if (!escaped)
|
||||
return std::nullopt;
|
||||
++index;
|
||||
if (*escaped == 'n') {
|
||||
raw.append('\n');
|
||||
} else if (*escaped == 'r') {
|
||||
raw.append('\r');
|
||||
} else if (*escaped == 't') {
|
||||
raw.append('\t');
|
||||
} else if (*escaped >= '0' && *escaped <= '7') {
|
||||
int octal = *escaped - '0';
|
||||
for (int count = 0; count < 2 && peek() && *peek() >= '0' && *peek() <= '7'; ++count)
|
||||
octal = octal * 8 + (bytes[index++] - '0');
|
||||
raw.append(char(octal & 0xFF));
|
||||
} else if (*escaped != '\n' && *escaped != '\r') {
|
||||
raw.append(char(*escaped));
|
||||
}
|
||||
}
|
||||
return Engine{decoded(raw)};
|
||||
}
|
||||
|
||||
std::optional<Engine> parseHex()
|
||||
{
|
||||
take("<");
|
||||
std::vector<uchar> nibbles;
|
||||
while (peek() && *peek() != '>') {
|
||||
if (const std::optional<uchar> nibble = hex(*peek()))
|
||||
nibbles.push_back(*nibble);
|
||||
++index;
|
||||
}
|
||||
if (!take(">"))
|
||||
return std::nullopt;
|
||||
QByteArray raw;
|
||||
for (size_t at = 0; at + 1 < nibbles.size(); at += 2)
|
||||
raw.append(char(nibbles[at] << 4 | nibbles[at + 1]));
|
||||
return Engine{decoded(raw)};
|
||||
}
|
||||
};
|
||||
|
||||
std::optional<Engine> dictionary(const QByteArray &data, qsizetype start)
|
||||
{
|
||||
Cursor cursor{data, start};
|
||||
std::optional<Engine> value = cursor.value();
|
||||
if (!value || !std::holds_alternative<Dictionary>(value->value))
|
||||
return std::nullopt;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Engine> parse(const QByteArray &data)
|
||||
{
|
||||
if (std::optional<Engine> found = dictionary(data, 0))
|
||||
return found;
|
||||
const qsizetype start = data.indexOf("<<");
|
||||
if (start <= 0)
|
||||
return std::nullopt;
|
||||
return dictionary(data, start);
|
||||
}
|
||||
|
||||
const Engine *walk(const Engine *value, std::initializer_list<const char *> keys)
|
||||
{
|
||||
for (const char *key : keys) {
|
||||
const Dictionary *items = value ? std::get_if<Dictionary>(&value->value) : nullptr;
|
||||
if (!items || !items->contains(QString::fromLatin1(key)))
|
||||
return nullptr;
|
||||
value = &items->at(QString::fromLatin1(key));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<double> number(const Engine *value)
|
||||
{
|
||||
const double *found = value ? std::get_if<double>(&value->value) : nullptr;
|
||||
return found ? std::optional(*found) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<bool> boolean(const Engine *value)
|
||||
{
|
||||
const bool *found = value ? std::get_if<bool>(&value->value) : nullptr;
|
||||
return found ? std::optional(*found) : std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<QString> string(const Engine *value)
|
||||
{
|
||||
const QString *found = value ? std::get_if<QString>(&value->value) : nullptr;
|
||||
return found ? std::optional(*found) : std::nullopt;
|
||||
}
|
||||
|
||||
Array array(const Engine *value)
|
||||
{
|
||||
const Array *found = value ? std::get_if<Array>(&value->value) : nullptr;
|
||||
return found ? *found : Array();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
// Photoshop's text-engine dictionary, a small PostScript subset.
|
||||
namespace PSDTextEngine {
|
||||
struct Engine;
|
||||
using Dictionary = std::map<QString, Engine>;
|
||||
using Array = std::vector<Engine>;
|
||||
|
||||
struct Engine {
|
||||
std::variant<double, bool, QString, Dictionary, Array> value;
|
||||
};
|
||||
|
||||
std::optional<Engine> parse(const QByteArray &data);
|
||||
// Swift's `walk`: nested dictionary keys, else none.
|
||||
const Engine *walk(const Engine *value, std::initializer_list<const char *> keys);
|
||||
std::optional<double> number(const Engine *value);
|
||||
std::optional<bool> boolean(const Engine *value);
|
||||
std::optional<QString> string(const Engine *value);
|
||||
// An array's items; anything else reads empty.
|
||||
Array array(const Engine *value);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#include "IO/PSD/PSDText.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/PSD/PSDText+Descriptor.h"
|
||||
#include "IO/PSD/PSDText+Engine.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "Rendering/TextLayout.h"
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
namespace PSDText {
|
||||
namespace {
|
||||
using namespace PSDTextEngine;
|
||||
|
||||
// Uniform scale, a turn and a vertical flip; no shear.
|
||||
struct Placement {
|
||||
double pixelScale;
|
||||
double rotation;
|
||||
bool flipY;
|
||||
QTransform map;
|
||||
};
|
||||
|
||||
// The matrix alone maps engine sizes to pixels.
|
||||
std::optional<Placement> placement(double xx, double xy, double yx, double yy, double tx, double ty)
|
||||
{
|
||||
const double scaleX = std::hypot(xx, yx);
|
||||
if (!(scaleX > 1e-6))
|
||||
return std::nullopt;
|
||||
const double cosR = xx / scaleX, sinR = yx / scaleX;
|
||||
const double localX = cosR * xy + sinR * yy;
|
||||
const double localY = -sinR * xy + cosR * yy;
|
||||
const double scaleY = std::abs(localY);
|
||||
if (!(scaleY > 1e-6))
|
||||
return std::nullopt;
|
||||
const double largest = std::max(scaleX, scaleY);
|
||||
if (!(std::abs(localX) <= 0.02 * largest && std::abs(scaleX - scaleY) <= 0.02 * largest))
|
||||
return std::nullopt;
|
||||
const double ySign = localY < 0 ? -1.0 : 1.0;
|
||||
// Swift's exx, eyx, exy and eyy, then the offset.
|
||||
const QTransform map(cosR * scaleX, sinR * scaleX, -sinR * scaleX * ySign, cosR * scaleX * ySign, tx, ty);
|
||||
return Placement{scaleX, std::atan2(sinR, cosR) * 180 / std::numbers::pi, localY < 0, map};
|
||||
}
|
||||
|
||||
// Swift's `Int(x.rounded())`, with no trap on a wild value.
|
||||
std::optional<qint64> rounded(double value)
|
||||
{
|
||||
const double whole = std::round(value);
|
||||
if (!(std::abs(whole) < 9e18))
|
||||
return std::nullopt;
|
||||
return qint64(whole);
|
||||
}
|
||||
|
||||
double unit(double value)
|
||||
{
|
||||
return value > 1 ? std::min(255.0, std::max(0.0, value)) / 255 : std::min(1.0, std::max(0.0, value));
|
||||
}
|
||||
|
||||
std::array<double, 3> color(const std::vector<double> &values)
|
||||
{
|
||||
if (values.size() >= 4)
|
||||
return {unit(values[1]), unit(values[2]), unit(values[3])};
|
||||
if (values.size() == 3)
|
||||
return {unit(values[0]), unit(values[1]), unit(values[2])};
|
||||
if (!values.empty())
|
||||
return {unit(values[0]), unit(values[0]), unit(values[0])};
|
||||
return {0, 0, 0};
|
||||
}
|
||||
|
||||
std::vector<double> channels(const Engine *data)
|
||||
{
|
||||
std::vector<double> result;
|
||||
for (const Engine &value : array(walk(data, {"FillColor", "Values"})))
|
||||
if (const std::optional<double> channel = number(&value))
|
||||
result.push_back(*channel);
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Signature {
|
||||
double font = 0, size = 0, tracking = 0, leading = 0;
|
||||
bool autoLeading = true;
|
||||
double horizontalScale = 1, verticalScale = 1;
|
||||
bool bold = false, italic = false;
|
||||
std::array<double, 3> rgb{};
|
||||
friend bool operator==(const Signature &, const Signature &) = default;
|
||||
};
|
||||
|
||||
Signature signature(const Engine &run)
|
||||
{
|
||||
const Engine *sheet = walk(&run, {"StyleSheet", "StyleSheetData"});
|
||||
const Engine *data = sheet ? sheet : &run;
|
||||
return Signature{.font = number(walk(data, {"Font"})).value_or(0),
|
||||
.size = number(walk(data, {"FontSize"})).value_or(0),
|
||||
.tracking = number(walk(data, {"Tracking"})).value_or(0),
|
||||
.leading = number(walk(data, {"Leading"})).value_or(0),
|
||||
.autoLeading = boolean(walk(data, {"AutoLeading"})).value_or(true),
|
||||
.horizontalScale = number(walk(data, {"HorizontalScale"})).value_or(1),
|
||||
.verticalScale = number(walk(data, {"VerticalScale"})).value_or(1),
|
||||
.bold = boolean(walk(data, {"FauxBold"})).value_or(false),
|
||||
.italic = boolean(walk(data, {"FauxItalic"})).value_or(false),
|
||||
.rgb = color(channels(data))};
|
||||
}
|
||||
|
||||
void applyStyle(LayerTextStyle &style, const Engine &engine, double pixelScale, std::vector<QString> ¬es)
|
||||
{
|
||||
const Array runs = array(walk(&engine, {"EngineDict", "StyleRun", "RunArray"}));
|
||||
const Engine &first = runs.empty() ? engine : runs.front();
|
||||
// Swift falls back on the run array, where nothing reads.
|
||||
const Engine *sheet = walk(&first, {"StyleSheet", "StyleSheetData"});
|
||||
const Engine *data = sheet ? sheet : walk(&engine, {"EngineDict", "StyleRun", "RunArray"});
|
||||
if (!data)
|
||||
data = &first;
|
||||
const double points = number(walk(data, {"FontSize"})).value_or(12);
|
||||
if (!(std::isfinite(points) && points > 0))
|
||||
return;
|
||||
style.fontSize = std::min(2000.0, std::max(1.0, points * pixelScale));
|
||||
const Array fonts = array(walk(&engine, {"ResourceDict", "FontSet"}));
|
||||
const std::optional<qint64> index = rounded(number(walk(data, {"Font"})).value_or(0));
|
||||
if (index && *index >= 0 && *index < qint64(fonts.size())) {
|
||||
const QString name = string(walk(&fonts[size_t(*index)], {"Name"})).value_or(QString());
|
||||
if (!name.isEmpty())
|
||||
style.fontName = name;
|
||||
}
|
||||
if (!array(walk(data, {"FillColor", "Values"})).empty()) {
|
||||
const std::array<double, 3> rgb = color(channels(data));
|
||||
style.red = rgb[0];
|
||||
style.green = rgb[1];
|
||||
style.blue = rgb[2];
|
||||
}
|
||||
const double tracking = number(walk(data, {"Tracking"})).value_or(0);
|
||||
if (std::isfinite(tracking))
|
||||
style.tracking = std::min(1000.0, std::max(-100.0, tracking * style.fontSize / 1000));
|
||||
const bool automatic = boolean(walk(data, {"AutoLeading"})).value_or(true);
|
||||
const std::optional<double> leading = number(walk(data, {"Leading"}));
|
||||
if (!automatic && leading && std::isfinite(*leading) && *leading > 0)
|
||||
style.leading = std::min(5000.0, std::max(0.0, *leading * pixelScale));
|
||||
if (boolean(walk(data, {"FauxBold"})) == true || boolean(walk(data, {"FauxItalic"})) == true)
|
||||
notes.push_back(fauxNote);
|
||||
const Signature firstSignature = signature(first);
|
||||
if (runs.size() > 1 && std::any_of(runs.begin() + 1, runs.end(), [&](const Engine &run) { return signature(run) != firstSignature; }))
|
||||
notes.push_back(firstStyleNote);
|
||||
const Array paragraphs = array(walk(&engine, {"EngineDict", "ParagraphRun", "RunArray"}));
|
||||
const Engine ¶graph = paragraphs.empty() ? engine : paragraphs.front();
|
||||
const std::optional<qint64> justification = rounded(number(walk(¶graph, {"ParagraphSheet", "Properties", "Justification"})).value_or(0));
|
||||
if (justification == 1) {
|
||||
style.alignment = TextAlignment::right;
|
||||
} else if (justification == 2) {
|
||||
style.alignment = TextAlignment::center;
|
||||
} else {
|
||||
style.alignment = TextAlignment::left;
|
||||
if (justification != 0)
|
||||
notes.push_back(justifyNote);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<QString> cleaned(std::optional<QString> text)
|
||||
{
|
||||
if (!text)
|
||||
return std::nullopt;
|
||||
while (!text->isEmpty() && (text->front() == QChar(0xFEFF) || text->front() == QChar(0)))
|
||||
text->remove(0, 1);
|
||||
while (!text->isEmpty() && text->back() == QChar(0))
|
||||
text->chop(1);
|
||||
text->replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
|
||||
text->replace(QChar('\r'), QChar('\n'));
|
||||
return text;
|
||||
}
|
||||
|
||||
double horizontalAnchor(const LayerTextStyle &style, double width)
|
||||
{
|
||||
switch (style.alignment) {
|
||||
case TextAlignment::left:
|
||||
return LayerTextStyle::padding;
|
||||
case TextAlignment::center:
|
||||
return width / 2;
|
||||
case TextAlignment::right:
|
||||
return width - LayerTextStyle::padding;
|
||||
}
|
||||
Q_UNREACHABLE();
|
||||
}
|
||||
|
||||
// The first line's baseline, laid as `textImage` lays it.
|
||||
double baseline(const LayerTextStyle &style, QSizeF image)
|
||||
{
|
||||
const double padding = LayerTextStyle::padding;
|
||||
const TextLines lines(style, QSizeF(std::max(1.0, image.width() - 2 * padding), std::max(1.0, image.height() - 2 * padding)));
|
||||
return padding + lines.baseline(0);
|
||||
}
|
||||
|
||||
// As `pixelToDocument`: flip, then a clockwise turn about the centre.
|
||||
LayerTransform layerTransform(QSizeF image, QPointF imageAnchor, QPointF documentAnchor, double rotation, bool flipY)
|
||||
{
|
||||
QPointF local(imageAnchor.x() - image.width() / 2, imageAnchor.y() - image.height() / 2);
|
||||
if (flipY)
|
||||
local.setY(-local.y());
|
||||
const double radians = rotation * std::numbers::pi / 180;
|
||||
const QPointF turned(local.x() * std::cos(radians) - local.y() * std::sin(radians), local.x() * std::sin(radians) + local.y() * std::cos(radians));
|
||||
const QPointF centre = documentAnchor - turned;
|
||||
LayerTransform transform{QPointF(centre.x() - image.width() / 2, centre.y() - image.height() / 2), image};
|
||||
transform.rotation = rotation;
|
||||
transform.flipY = flipY;
|
||||
return transform;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<QString> missingFontNote(const QString &name)
|
||||
{
|
||||
if (TextLayout::availableFonts().contains(name))
|
||||
return std::nullopt;
|
||||
return QStringLiteral("The font “%1” isn’t installed, so the text was drawn with a substitute font.").arg(name);
|
||||
}
|
||||
|
||||
std::optional<Source> parse(const std::map<QString, QByteArray> &extra)
|
||||
{
|
||||
const QString key = extra.contains(QStringLiteral("TySh")) ? QStringLiteral("TySh") : QStringLiteral("tySh");
|
||||
if (!extra.contains(key) || extra.at(key).size() > 8'000'000)
|
||||
return std::nullopt;
|
||||
PSDDescriptor::Reader reader{extra.at(key)};
|
||||
if (reader.u16() != 1u)
|
||||
return std::nullopt;
|
||||
std::array<double, 6> matrix{};
|
||||
for (double &value : matrix) {
|
||||
const std::optional<double> read = reader.f64();
|
||||
if (!read || !std::isfinite(*read))
|
||||
return std::nullopt;
|
||||
value = *read;
|
||||
}
|
||||
if (reader.u16() != 50u)
|
||||
return std::nullopt;
|
||||
const std::optional<PSDDescriptor::Items> text = reader.descriptor(true);
|
||||
if (!text || PSDDescriptor::enumeration(*text, QStringLiteral("Ornt")) == QStringLiteral("Vrtc"))
|
||||
return std::nullopt;
|
||||
const std::optional<Placement> placed = placement(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
|
||||
if (!placed)
|
||||
return std::nullopt;
|
||||
|
||||
std::vector<QString> notes;
|
||||
if (reader.remaining() >= 2 && reader.u16() == 1u) {
|
||||
const std::optional<PSDDescriptor::Items> warp = reader.descriptor(true);
|
||||
const std::optional<QString> style = warp ? PSDDescriptor::enumeration(*warp, QStringLiteral("warpStyle")) : std::nullopt;
|
||||
if (style && *style != QLatin1String("warpNone") && *style != QLatin1String("none"))
|
||||
notes.push_back(warpNote);
|
||||
}
|
||||
|
||||
const std::optional<QByteArray> engineData = PSDDescriptor::data(*text, QStringLiteral("EngineData"));
|
||||
const std::optional<Engine> engine = engineData ? PSDTextEngine::parse(*engineData) : std::nullopt;
|
||||
std::optional<QString> typed = PSDDescriptor::string(*text, QStringLiteral("Txt "));
|
||||
if (!typed)
|
||||
typed = PSDDescriptor::string(*text, QStringLiteral("Txt"));
|
||||
std::optional<QString> content = cleaned(typed);
|
||||
if (!content && engine)
|
||||
content = cleaned(string(walk(&*engine, {"EngineDict", "Editor", "Text"})));
|
||||
if (!content || content->isEmpty() || content->size() > 100'000)
|
||||
return std::nullopt;
|
||||
|
||||
LayerTextStyle style;
|
||||
style.content = *content;
|
||||
if (engine)
|
||||
applyStyle(style, *engine, placed->pixelScale, notes);
|
||||
else
|
||||
style.fontSize = std::min(2000.0, std::max(1.0, 12 * placed->pixelScale));
|
||||
|
||||
QPointF anchor(matrix[4], matrix[5]);
|
||||
bool anchorIsFrame = false;
|
||||
const std::optional<QRectF> bounds = PSDDescriptor::rect(*text, QStringLiteral("bounds"));
|
||||
const std::optional<QRectF> glyphs = PSDDescriptor::rect(*text, QStringLiteral("boundingBox"));
|
||||
if (bounds && glyphs && bounds->width() > glyphs->width() + 4 && bounds->height() > glyphs->height() + 4) {
|
||||
const double pad = LayerTextStyle::padding;
|
||||
style.boxSize = QSizeF(bounds->width() * placed->pixelScale + pad * 2, bounds->height() * placed->pixelScale + pad * 2);
|
||||
anchor = placed->map.map(bounds->topLeft());
|
||||
anchorIsFrame = true;
|
||||
}
|
||||
if (!style.isValid())
|
||||
return std::nullopt;
|
||||
return Source{style, notes, anchor, placed->rotation, placed->flipY, anchorIsFrame};
|
||||
}
|
||||
|
||||
Rendered render(const Source &source)
|
||||
{
|
||||
const QImage image = EditorSession::textImage(source.style);
|
||||
const QSizeF size = image.size();
|
||||
const QPointF anchor = source.anchorIsFrame ? QPointF(LayerTextStyle::padding, LayerTextStyle::padding)
|
||||
: QPointF(horizontalAnchor(source.style, size.width()), baseline(source.style, size));
|
||||
const LayerTransform transform = layerTransform(size, anchor, source.documentAnchor, source.rotation, source.flipY);
|
||||
if (!transform.isValid())
|
||||
throw ProjectError(ProjectError::Kind::tooLarge);
|
||||
return Rendered{image, transform};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include "Document/TypeTool.h"
|
||||
#include <QByteArray>
|
||||
#include <QImage>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
// Photoshop 6 type (`TySh`) read into the text model.
|
||||
namespace PSDText {
|
||||
struct Source {
|
||||
LayerTextStyle style;
|
||||
std::vector<QString> notes;
|
||||
// The document point the image's anchor lands on.
|
||||
QPointF documentAnchor;
|
||||
double rotation = 0;
|
||||
bool flipY = false;
|
||||
// The paragraph frame's corner, else point text's baseline.
|
||||
bool anchorIsFrame = false;
|
||||
};
|
||||
|
||||
struct Rendered {
|
||||
QImage image;
|
||||
LayerTransform transform;
|
||||
};
|
||||
|
||||
inline const QString rasterizedNote = QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped.");
|
||||
inline const QString firstStyleNote = QStringLiteral("Only the first text style was kept.");
|
||||
inline const QString warpNote = QStringLiteral("The Photoshop text warp was omitted.");
|
||||
inline const QString fauxNote = QStringLiteral("Faux bold or faux italic was omitted.");
|
||||
inline const QString justifyNote = QStringLiteral("Full justification was imported as left alignment.");
|
||||
|
||||
std::optional<QString> missingFontNote(const QString &name);
|
||||
std::optional<Source> parse(const std::map<QString, QByteArray> &extra);
|
||||
Rendered render(const Source &source);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession+Model.h"
|
||||
#include "IO/PSD/PSDText.h"
|
||||
#include <QImage>
|
||||
#include <QRectF>
|
||||
#include <QString>
|
||||
@@ -43,6 +44,8 @@ struct PSDRecord {
|
||||
PSDLayerKind kind = PSDLayerKind::raster;
|
||||
std::optional<LayerShapeStyle> shape;
|
||||
std::vector<QString> shapeNotes;
|
||||
// Photoshop type the text model can hold.
|
||||
std::optional<PSDText::Source> text;
|
||||
// Cut to the canvas so the file fits the budget.
|
||||
bool croppedToCanvas = false;
|
||||
std::optional<LayerBlendMode> blendMode() const;
|
||||
|
||||
@@ -220,6 +220,13 @@ std::optional<QRectF> TextLines::caret(int position) const
|
||||
return QRectF(x, *line * m_lineHeight, 1, m_lineHeight);
|
||||
}
|
||||
|
||||
double TextLines::baseline(int line) const
|
||||
{
|
||||
const Line &laid = m_lineList[size_t(line)];
|
||||
const QTextLine text = m_paragraphs[laid.paragraph]->lineAt(laid.index);
|
||||
return text.position().y() + text.ascent();
|
||||
}
|
||||
|
||||
int TextLines::lineStart(int line) const
|
||||
{
|
||||
const Line &laid = m_lineList[size_t(line)];
|
||||
|
||||
@@ -43,6 +43,8 @@ public:
|
||||
std::optional<int> lineOf(int position) const;
|
||||
// The caret before a position: a unit wide.
|
||||
std::optional<QRectF> caret(int position) const;
|
||||
// A laid line's baseline from the container's top.
|
||||
double baseline(int line) const;
|
||||
// A line's first position; its last before any wrap.
|
||||
int lineStart(int line) const;
|
||||
int lineEnd(int line) const;
|
||||
|
||||
@@ -91,7 +91,7 @@ void PSBImportTests::PSBLargeAdditionalInfoBlockDoesNotHideUnicodeName()
|
||||
const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0);
|
||||
const PSDRecord layer = PSDFixture::record(QStringLiteral("Café layer"), image, QRectF(0, 0, 2, 2));
|
||||
const QByteArray data =
|
||||
PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, image, true, PSDFixture::AdditionalLayerInfo{"LMsk", QByteArray("\1\2\3", 3)});
|
||||
PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, image, true, {PSDFixture::AdditionalLayerInfo{"LMsk", QByteArray("\1\2\3", 3)}});
|
||||
QCOMPARE(PSDReader::read(data).layers.at(0).name, QStringLiteral("Café layer"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#pragma once
|
||||
#include "PSDFixture.h"
|
||||
#include <bit>
|
||||
|
||||
// Swift's `PSDFixture.tySh`: a Photoshop 6 type block.
|
||||
namespace PSDFixture {
|
||||
struct TypeBlock {
|
||||
QString text;
|
||||
QString font = QStringLiteral("Helvetica");
|
||||
double fontSize = 24;
|
||||
double red = 0, green = 0, blue = 0;
|
||||
QByteArray justification = "0";
|
||||
QByteArray fontIndex = "0";
|
||||
// Without it the words come from the engine alone.
|
||||
bool typed = true;
|
||||
// Swift's other forms: bare runs, no engine at all.
|
||||
bool sheet = true;
|
||||
bool withEngine = true;
|
||||
QByteArray tracking = "0";
|
||||
std::optional<double> leading;
|
||||
bool fauxBold = false, fauxItalic = false, vertical = false, warp = false;
|
||||
std::optional<double> secondSize, secondLeading, secondHorizontalScale, secondVerticalScale;
|
||||
// Keys the second run's sheet repeats, later winning.
|
||||
QByteArray secondOverride;
|
||||
// The fill's values as written, else ARGB from the channels.
|
||||
std::optional<QByteArray> values;
|
||||
// The engine's own words, else `text`.
|
||||
std::optional<QString> engineText;
|
||||
// A second face in the set; the words' own key.
|
||||
std::optional<QString> secondFont;
|
||||
QByteArray wordsKey = "Txt ";
|
||||
double tx = 40, ty = 50;
|
||||
double xx = 1, xy = 0, yx = 0, yy = 1;
|
||||
std::optional<std::array<double, 4>> bounds, glyphBounds;
|
||||
};
|
||||
|
||||
inline void f64(Buffer &buffer, double value)
|
||||
{
|
||||
const quint64 bits = std::bit_cast<quint64>(value);
|
||||
buffer.u32(quint32(bits >> 32));
|
||||
buffer.u32(quint32(bits));
|
||||
}
|
||||
|
||||
// A four-letter key takes a zero length.
|
||||
inline void id(Buffer &buffer, const QByteArray &value)
|
||||
{
|
||||
buffer.u32(value.size() == 4 ? 0 : quint32(value.size()));
|
||||
buffer.bytes(value);
|
||||
}
|
||||
|
||||
inline void descriptor(Buffer &buffer, const QByteArray &classID, const std::vector<std::pair<QByteArray, QByteArray>> &items)
|
||||
{
|
||||
buffer.u32(16);
|
||||
buffer.u32(0);
|
||||
id(buffer, classID);
|
||||
buffer.u32(quint32(items.size()));
|
||||
for (const auto &[key, value] : items) {
|
||||
id(buffer, key);
|
||||
buffer.bytes(value);
|
||||
}
|
||||
}
|
||||
|
||||
inline QByteArray textItem(const QString &text)
|
||||
{
|
||||
Buffer item;
|
||||
item.string("TEXT");
|
||||
item.u32(quint32(text.size()));
|
||||
for (const QChar unit : text)
|
||||
item.u16(unit.unicode());
|
||||
return item.data;
|
||||
}
|
||||
|
||||
inline QByteArray enumItem(const QByteArray &type, const QByteArray &value)
|
||||
{
|
||||
Buffer item;
|
||||
item.string("enum");
|
||||
id(item, type);
|
||||
id(item, value);
|
||||
return item.data;
|
||||
}
|
||||
|
||||
inline QByteArray rawItem(const QByteArray &payload)
|
||||
{
|
||||
Buffer item;
|
||||
item.string("tdta");
|
||||
item.u32(quint32(payload.size()));
|
||||
item.bytes(payload);
|
||||
return item.data;
|
||||
}
|
||||
|
||||
inline QByteArray rectItem(const std::array<double, 4> &box)
|
||||
{
|
||||
Buffer item;
|
||||
item.string("Objc");
|
||||
item.u32(0);
|
||||
id(item, "Rctn");
|
||||
item.u32(4);
|
||||
const std::array<QByteArray, 4> keys{"Left", "Top ", "Rght", "Btom"};
|
||||
for (size_t index = 0; index < keys.size(); ++index) {
|
||||
id(item, keys[index]);
|
||||
item.string("UntF");
|
||||
item.string("#Pnt");
|
||||
f64(item, box[index]);
|
||||
}
|
||||
return item.data;
|
||||
}
|
||||
|
||||
// Swift's `parenthesized`: backslashes before the three specials.
|
||||
inline QByteArray parenthesized(const QString &text)
|
||||
{
|
||||
QByteArray encoded("(");
|
||||
for (const char byte : text.toUtf8()) {
|
||||
if (byte == '\\' || byte == '(' || byte == ')')
|
||||
encoded.append('\\');
|
||||
encoded.append(byte);
|
||||
}
|
||||
return encoded + ')';
|
||||
}
|
||||
|
||||
inline QByteArray number(double value)
|
||||
{
|
||||
return QByteArray::number(value, 'g', 17);
|
||||
}
|
||||
|
||||
inline QByteArray flag(bool value)
|
||||
{
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
inline QByteArray run(const TypeBlock &block, double size, std::optional<double> leading, double horizontal, double vertical,
|
||||
const QByteArray &override = {})
|
||||
{
|
||||
const QByteArray open = block.sheet ? "<<\n/StyleSheet\n<<\n/StyleSheetData\n<<\n" : "<<\n";
|
||||
const QByteArray close = block.sheet ? " ]\n>>\n>>\n>>\n>>" : " ]\n>>\n>>";
|
||||
return open + "/Font " + block.fontIndex + "\n/FontSize " + number(size) + "\n/FauxBold " + flag(block.fauxBold)
|
||||
+ "\n/FauxItalic " + flag(block.fauxItalic) + "\n/AutoLeading " + flag(!leading) + "\n/Leading " + number(leading.value_or(size * 1.2))
|
||||
+ "\n/Tracking " + block.tracking + "\n/HorizontalScale " + number(horizontal) + "\n/VerticalScale " + number(vertical)
|
||||
+ "\n/FillColor\n<<\n/Type 1\n/Values [ "
|
||||
+ block.values.value_or("1.0 " + number(block.red) + " " + number(block.green) + " " + number(block.blue))
|
||||
+ " ]\n>>\n" + override + close.mid(6);
|
||||
}
|
||||
|
||||
inline QByteArray engine(const TypeBlock &block)
|
||||
{
|
||||
QByteArray runs = run(block, block.fontSize, block.leading, 1, 1);
|
||||
if (block.secondSize || block.secondLeading || block.secondHorizontalScale || block.secondVerticalScale || !block.secondOverride.isEmpty())
|
||||
runs += "\n" + run(block, block.secondSize.value_or(block.fontSize), block.secondLeading ? block.secondLeading : block.leading,
|
||||
block.secondHorizontalScale.value_or(1), block.secondVerticalScale.value_or(1), block.secondOverride);
|
||||
return "<<\n/EngineDict\n<<\n/Editor\n<<\n/Text " + parenthesized(block.engineText.value_or(block.text)) + "\n>>\n/ParagraphRun\n<<\n/RunArray\n[\n<<\n"
|
||||
+ "/ParagraphSheet\n<<\n/Properties\n<<\n/Justification " + block.justification + "\n>>\n>>\n>>\n]\n>>\n"
|
||||
+ "/StyleRun\n<<\n/RunArray\n[\n" + runs + "\n]\n>>\n>>\n/ResourceDict\n<<\n/FontSet\n[\n<<\n/Name " + parenthesized(block.font)
|
||||
+ "\n>>\n" + (block.secondFont ? "<<\n/Name " + parenthesized(*block.secondFont) + "\n>>\n" : QByteArray()) + "]\n>>\n>>";
|
||||
}
|
||||
|
||||
inline QByteArray tySh(const TypeBlock &block)
|
||||
{
|
||||
Buffer data;
|
||||
data.u16(1);
|
||||
for (const double value : {block.xx, block.xy, block.yx, block.yy, block.tx, block.ty})
|
||||
f64(data, value);
|
||||
data.u16(50);
|
||||
std::vector<std::pair<QByteArray, QByteArray>> items;
|
||||
if (block.typed)
|
||||
items.emplace_back(block.wordsKey, textItem(block.text));
|
||||
items.emplace_back("Ornt", enumItem("Ornt", block.vertical ? "Vrtc" : "Hrzn"));
|
||||
if (block.bounds)
|
||||
items.emplace_back("bounds", rectItem(*block.bounds));
|
||||
if (block.glyphBounds)
|
||||
items.emplace_back("boundingBox", rectItem(*block.glyphBounds));
|
||||
if (block.withEngine)
|
||||
items.emplace_back("EngineData", rawItem(engine(block)));
|
||||
descriptor(data, "TxLr", items);
|
||||
data.u16(1);
|
||||
descriptor(data, "warp", {{"warpStyle", enumItem("warpStyle", block.warp ? "warpArc" : "warpNone")}});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
inline std::map<QString, QByteArray> typeExtra(const TypeBlock &block)
|
||||
{
|
||||
return {{QStringLiteral("TySh"), tySh(block)}};
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -203,7 +203,7 @@ inline QByteArray luni(const QString &name)
|
||||
return data.data;
|
||||
}
|
||||
|
||||
inline QByteArray extraData(const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info)
|
||||
inline QByteArray extraData(const Prepared &item, bool large, const std::vector<AdditionalLayerInfo> &info)
|
||||
{
|
||||
Buffer extra;
|
||||
if (item.record.mask && item.maskRight > item.maskLeft && item.maskBottom > item.maskTop) {
|
||||
@@ -223,8 +223,8 @@ inline QByteArray extraData(const Prepared &item, bool large, const std::optiona
|
||||
extra.u8(uchar(pascal.size()));
|
||||
extra.bytes(pascal);
|
||||
extra.bytes(QByteArray((4 - ((1 + pascal.size()) % 4)) % 4, '\0'));
|
||||
if (info)
|
||||
additional(extra, info->key, info->payload, large);
|
||||
for (const AdditionalLayerInfo &block : info)
|
||||
additional(extra, block.key, block.payload, large);
|
||||
additional(extra, "luni", luni(item.record.name), large);
|
||||
if (item.record.isGroup || item.isDivider) {
|
||||
Buffer payload;
|
||||
@@ -236,7 +236,7 @@ inline QByteArray extraData(const Prepared &item, bool large, const std::optiona
|
||||
return extra.data;
|
||||
}
|
||||
|
||||
inline void writeRecord(Buffer &buffer, const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info)
|
||||
inline void writeRecord(Buffer &buffer, const Prepared &item, bool large, const std::vector<AdditionalLayerInfo> &info)
|
||||
{
|
||||
buffer.i32(item.top);
|
||||
buffer.i32(item.left);
|
||||
@@ -274,7 +274,7 @@ inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent,
|
||||
}
|
||||
}
|
||||
|
||||
inline QByteArray layerSection(const PSDDocument &document, bool large, const std::optional<AdditionalLayerInfo> &info)
|
||||
inline QByteArray layerSection(const PSDDocument &document, bool large, const std::vector<AdditionalLayerInfo> &info)
|
||||
{
|
||||
std::vector<Prepared> prepared;
|
||||
emitLayers(document, std::nullopt, prepared, large);
|
||||
@@ -316,7 +316,7 @@ inline QByteArray resolutionResource(double resolution)
|
||||
return resource.data;
|
||||
}
|
||||
|
||||
inline QByteArray data(const PSDDocument &document, const QImage &composite, bool large = false, const std::optional<AdditionalLayerInfo> &info = std::nullopt)
|
||||
inline QByteArray data(const PSDDocument &document, const QImage &composite, bool large = false, const std::vector<AdditionalLayerInfo> &info = {})
|
||||
{
|
||||
Buffer file;
|
||||
file.string("8BPS");
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
#include "IO/PSD/PSDText.h"
|
||||
#include "IO/PSD/PSDText+Descriptor.h"
|
||||
#include "IO/PSD/PSDText+Engine.h"
|
||||
#include "PSDFixture+Text.h"
|
||||
#include <QtTest>
|
||||
|
||||
// The type block's two readers: engine dictionaries and descriptors.
|
||||
namespace {
|
||||
// A descriptor of these items, read back.
|
||||
std::optional<PSDDescriptor::Items> described(const std::vector<std::pair<QByteArray, QByteArray>> &items, quint32 version = 16)
|
||||
{
|
||||
PSDFixture::Buffer data;
|
||||
PSDFixture::descriptor(data, "Test", items);
|
||||
data.data.replace(0, 4, QByteArray("\0\0\0", 3) + char(version));
|
||||
PSDDescriptor::Reader reader{data.data};
|
||||
return reader.descriptor(true);
|
||||
}
|
||||
|
||||
QByteArray unicode(const QByteArray &type, const QString &text)
|
||||
{
|
||||
return type + PSDFixture::textItem(text).mid(4);
|
||||
}
|
||||
|
||||
std::optional<PSDTextEngine::Engine> engine(const char *text)
|
||||
{
|
||||
return PSDTextEngine::parse(QByteArray(text));
|
||||
}
|
||||
}
|
||||
|
||||
class PSDTextReaderTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theEngineReadsItsPostScriptForms();
|
||||
void deepNestingReadsAsBrokenWithoutACrash();
|
||||
void theDescriptorReadsEveryType();
|
||||
void theDescriptorReadsNestingAndNames();
|
||||
void loneSurrogatesReadAsReplacements();
|
||||
void listsAndReferencesStopAtTenThousand();
|
||||
void coloursReadInEachForm();
|
||||
void theSourcesKeepSwiftsPrecedence();
|
||||
void theStyleRoundsAndScalesAsSwift();
|
||||
void versionsAndLimitsHoldAtTheirEdges();
|
||||
};
|
||||
|
||||
void PSDTextReaderTests::theEngineReadsItsPostScriptForms()
|
||||
{
|
||||
using namespace PSDTextEngine;
|
||||
const Engine read = engine("junk << /A <FEFF00480069> /B (a\\101\\n\\(\\\nz) /C [1 -2.5 .5 +3 1e2 1.] % note\n"
|
||||
" /D true /E null /F /Name /G <41 4 2> /A2 << /K false >> >>")
|
||||
.value();
|
||||
QCOMPARE(string(walk(&read, {"A"})).value(), QString("Hi"));
|
||||
QCOMPARE(string(walk(&read, {"B"})).value(), QString("aA\n(z"));
|
||||
const Array numbers = array(walk(&read, {"C"}));
|
||||
QCOMPARE(numbers.size(), size_t(6));
|
||||
const std::vector<double> expected{1, -2.5, 0.5, 3, 100, 1};
|
||||
for (size_t index = 0; index < expected.size(); ++index)
|
||||
QCOMPARE(number(&numbers[index]).value(), expected[index]);
|
||||
QCOMPARE(boolean(walk(&read, {"D"})).value(), true);
|
||||
QCOMPARE(string(walk(&read, {"E"})).value(), QString());
|
||||
QCOMPARE(string(walk(&read, {"F"})).value(), QString("Name"));
|
||||
QCOMPARE(string(walk(&read, {"G"})).value(), QString("AB"));
|
||||
QCOMPARE(boolean(walk(&read, {"A2", "K"})).value(), false);
|
||||
QVERIFY(!walk(&read, {"A", "B"}));
|
||||
// Overflow reads as infinity, as Swift's `Double` reads it.
|
||||
const Engine huge = engine("<< /X 1e999 >>").value();
|
||||
QVERIFY(std::isinf(number(walk(&huge, {"X"})).value()));
|
||||
// Broken forms fail the whole dictionary.
|
||||
for (const char *broken : {"<< /X - >>", "<< /X 1e >>", "<< /X [1 >>", "<< X 1 >>", "<< /X (a\\", "[1 2]", "<< /X 1", "<< /X <41 >>"})
|
||||
QVERIFY2(!engine(broken), broken);
|
||||
// Three octal digits; `)` ends names; words stand alone.
|
||||
const Engine octal = engine("<< /X (\\1011) >>").value();
|
||||
QCOMPARE(string(walk(&octal, {"X"})).value(), QString("A1"));
|
||||
QVERIFY(!engine("<< /A [/B)] >>"));
|
||||
QVERIFY(!engine("<< /X [true5] >>"));
|
||||
// A name with a high byte reads empty.
|
||||
const Engine high = engine("<< /\xC3\xA9 1 >>").value();
|
||||
QCOMPARE(number(walk(&high, {""})).value(), 1.0);
|
||||
// An odd UTF-16 string reads empty.
|
||||
const Engine odd = engine("<< /X <FEFF0048 00> >>").value();
|
||||
QCOMPARE(string(walk(&odd, {"X"})).value(), QString());
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::deepNestingReadsAsBrokenWithoutACrash()
|
||||
{
|
||||
const QByteArray deep = "<< /X " + QByteArray(200'000, '[') + QByteArray(200'000, ']') + " >>";
|
||||
QVERIFY(!PSDTextEngine::parse(deep));
|
||||
const QByteArray fits = "<< /X " + QByteArray(500, '[') + QByteArray(500, ']') + " >>";
|
||||
QVERIFY(PSDTextEngine::parse(fits));
|
||||
// A descriptor of lists within lists, far past the cap.
|
||||
PSDFixture::Buffer nested;
|
||||
nested.u32(16);
|
||||
nested.u32(0);
|
||||
PSDFixture::id(nested, "TxLr");
|
||||
nested.u32(1);
|
||||
PSDFixture::id(nested, "Deep");
|
||||
for (int depth = 0; depth < 100'000; ++depth) {
|
||||
nested.string("VlLs");
|
||||
nested.u32(1);
|
||||
}
|
||||
nested.string("long");
|
||||
nested.i32(1);
|
||||
PSDDescriptor::Reader reader{nested.data};
|
||||
QVERIFY(!reader.descriptor(true));
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::theDescriptorReadsEveryType()
|
||||
{
|
||||
using PSDFixture::Buffer;
|
||||
Buffer comp, flag, alias, reference, global, type;
|
||||
comp.string("comp");
|
||||
comp.u64(quint64(-2));
|
||||
flag.string("bool");
|
||||
flag.u8(1);
|
||||
alias.string("alis");
|
||||
alias.u32(3);
|
||||
alias.string("abc");
|
||||
reference.string("obj ");
|
||||
reference.u32(7);
|
||||
for (const char *form : {"prop", "Clss", "Enmr", "rele", "Idnt", "indx", "name"}) {
|
||||
reference.string(form);
|
||||
const QByteArray name = PSDFixture::textItem(QStringLiteral("n")).mid(4);
|
||||
const std::map<QByteArray, int> identifiers{{"prop", 2}, {"Clss", 1}, {"Enmr", 3}, {"rele", 1}};
|
||||
if (QByteArray(form) != "Idnt" && QByteArray(form) != "indx")
|
||||
reference.bytes(name);
|
||||
for (int count = 0; count < (identifiers.contains(form) ? identifiers.at(form) : 0); ++count)
|
||||
PSDFixture::id(reference, "Idnt");
|
||||
if (QByteArray(form) == "rele" || QByteArray(form) == "Idnt" || QByteArray(form) == "indx")
|
||||
reference.i32(4);
|
||||
}
|
||||
global.bytes(unicode("GlbC", QStringLiteral("g")));
|
||||
PSDFixture::id(global, "Clss");
|
||||
type.bytes(unicode("type", QStringLiteral("t")));
|
||||
PSDFixture::id(type, "Clss");
|
||||
Buffer box;
|
||||
box.string("Objc");
|
||||
box.u32(0);
|
||||
PSDFixture::id(box, "Rctn");
|
||||
box.u32(4);
|
||||
for (const auto &[key, value] : std::vector<std::pair<QByteArray, double>>{{"Left", 1}, {"Top", 2}, {"Rght", 5}, {"Btom", 9}}) {
|
||||
PSDFixture::id(box, key);
|
||||
box.string("doub");
|
||||
PSDFixture::f64(box, value);
|
||||
}
|
||||
const PSDDescriptor::Items items = described({{"C", comp.data}, {"B", flag.data}, {"A", alias.data}, {"R", reference.data}, {"G", global.data},
|
||||
{"T", type.data}, {"box", box.data}, {"Txt ", PSDFixture::textItem(QStringLiteral("after"))}})
|
||||
.value();
|
||||
QCOMPARE(std::get<double>(items.at("C").value), -2.0);
|
||||
QCOMPARE(std::get<double>(items.at("B").value), 0.0);
|
||||
QCOMPARE(PSDDescriptor::string(items, QStringLiteral("Txt ")).value(), QString("after"));
|
||||
// `Top` without its space still reads, as Swift trims.
|
||||
QCOMPARE(PSDDescriptor::rect(items, QStringLiteral("box")).value(), QRectF(1, 2, 4, 7));
|
||||
// An infinite side is no rectangle.
|
||||
Buffer infinite = box;
|
||||
infinite.data.chop(8);
|
||||
PSDFixture::f64(infinite, std::numeric_limits<double>::infinity());
|
||||
QVERIFY(!PSDDescriptor::rect(described({{"box", infinite.data}}).value(), QStringLiteral("box")));
|
||||
// Refusals: a version, a high byte, and each cap.
|
||||
QVERIFY(!described({{"Txt ", PSDFixture::textItem(QStringLiteral("x"))}}, 15));
|
||||
QVERIFY(!described({{"K\x80", PSDFixture::textItem(QStringLiteral("x"))}}));
|
||||
Buffer data;
|
||||
data.string("tdta");
|
||||
data.u32(8'000'001);
|
||||
data.bytes(QByteArray(8'000'001, 'x'));
|
||||
QVERIFY(!described({{"D", data.data}}));
|
||||
Buffer longAlias;
|
||||
longAlias.string("alis");
|
||||
longAlias.u32(8'000'001);
|
||||
longAlias.bytes(QByteArray(8'000'001, 'x'));
|
||||
QVERIFY(!described({{"A", longAlias.data}}));
|
||||
Buffer words;
|
||||
words.string("TEXT");
|
||||
words.u32(1'000'001);
|
||||
words.bytes(QByteArray(2'000'002, 'x'));
|
||||
QVERIFY(!described({{"W", words.data}}));
|
||||
std::vector<std::pair<QByteArray, QByteArray>> many;
|
||||
for (int index = 0; index <= 10'000; ++index)
|
||||
many.emplace_back(QByteArray::number(index), QByteArray("long\0\0\0\1", 8));
|
||||
QVERIFY(!described(many));
|
||||
many.pop_back();
|
||||
QCOMPARE(described(many).value().size(), size_t(10'000));
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::theDescriptorReadsNestingAndNames()
|
||||
{
|
||||
using PSDFixture::Buffer;
|
||||
Buffer global, list;
|
||||
global.string("GlbO");
|
||||
global.u32(0);
|
||||
PSDFixture::id(global, "Innr");
|
||||
global.u32(1);
|
||||
PSDFixture::id(global, "N");
|
||||
global.bytes(QByteArray("long\0\0\0\7", 8));
|
||||
list.string("VlLs");
|
||||
list.u32(2);
|
||||
list.bytes(QByteArray("long\0\0\0\1", 8));
|
||||
list.bytes(QByteArray("long\0\0\0\2", 8));
|
||||
const PSDDescriptor::Items items = described({{"G", global.data}, {"L", list.data}}).value();
|
||||
QCOMPARE(std::get<double>(std::get<PSDDescriptor::Items>(items.at("G").value).at("N").value), 7.0);
|
||||
const auto &values = std::get<std::vector<PSDDescriptor::Value>>(items.at("L").value);
|
||||
QCOMPARE(values.size(), size_t(2));
|
||||
QCOMPARE(std::get<double>(values[1].value), 2.0);
|
||||
// A key of 10,000 bytes reads; one more refuses.
|
||||
QVERIFY(described({{QByteArray(10'000, 'k'), QByteArray("long\0\0\0\1", 8)}}));
|
||||
QVERIFY(!described({{QByteArray(10'001, 'k'), QByteArray("long\0\0\0\1", 8)}}));
|
||||
// The exact side's key decides, number or not.
|
||||
Buffer box;
|
||||
box.string("Objc");
|
||||
box.u32(0);
|
||||
PSDFixture::id(box, "Rctn");
|
||||
box.u32(5);
|
||||
for (const auto &[key, value] : std::vector<std::pair<QByteArray, double>>{{"Left", 0}, {"Top", 0}, {"Rght", 200}, {"Btom", 80}}) {
|
||||
PSDFixture::id(box, key);
|
||||
box.string("doub");
|
||||
PSDFixture::f64(box, value);
|
||||
}
|
||||
PSDFixture::id(box, "Top ");
|
||||
box.bytes(PSDFixture::textItem(QStringLiteral("bad")));
|
||||
QVERIFY(!PSDDescriptor::rect(described({{"box", box.data}}).value(), QStringLiteral("box")));
|
||||
// Lowercase hex reads as uppercase.
|
||||
const PSDTextEngine::Engine lower = engine("<< /X <feff006a> >>").value();
|
||||
QCOMPARE(PSDTextEngine::string(PSDTextEngine::walk(&lower, {"X"})).value(), QString("j"));
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::loneSurrogatesReadAsReplacements()
|
||||
{
|
||||
const QString replaced = QString(QChar::ReplacementCharacter);
|
||||
const std::vector<std::pair<QByteArray, QString>> cases{{"0048D800", "H" + replaced},
|
||||
{"D800", replaced},
|
||||
{"DC00", replaced},
|
||||
{"0048D8000049", "H" + replaced + "I"},
|
||||
{"D800DC00", QString::fromUtf16(u"\xD800\xDC00")}};
|
||||
for (const auto &[units, expected] : cases) {
|
||||
const PSDTextEngine::Engine read = engine(("<< /X <FEFF" + units + "> >>").constData()).value();
|
||||
QCOMPARE(PSDTextEngine::string(PSDTextEngine::walk(&read, {"X"})).value(), expected);
|
||||
PSDFixture::Buffer text;
|
||||
text.string("TEXT");
|
||||
text.u32(quint32(units.size() / 4));
|
||||
text.bytes(QByteArray::fromHex(units));
|
||||
QCOMPARE(PSDDescriptor::string(described({{"Txt ", text.data}}).value(), QStringLiteral("Txt ")).value(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::listsAndReferencesStopAtTenThousand()
|
||||
{
|
||||
for (const quint32 count : {10'000u, 10'001u}) {
|
||||
PSDFixture::Buffer list, reference;
|
||||
list.string("VlLs");
|
||||
list.u32(count);
|
||||
reference.string("obj ");
|
||||
reference.u32(count);
|
||||
for (quint32 index = 0; index < count; ++index) {
|
||||
list.bytes(QByteArray("long\0\0\0\1", 8));
|
||||
reference.string("Idnt");
|
||||
reference.i32(1);
|
||||
}
|
||||
QCOMPARE(described({{"L", list.data}}).has_value(), count == 10'000u);
|
||||
QCOMPARE(described({{"R", reference.data}}).has_value(), count == 10'000u);
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::coloursReadInEachForm()
|
||||
{
|
||||
using PSDFixture::TypeBlock;
|
||||
const auto colour = [](const QByteArray &values) {
|
||||
TypeBlock block;
|
||||
block.text = QStringLiteral("Hello");
|
||||
block.values = values;
|
||||
const LayerTextStyle style = PSDText::parse(PSDFixture::typeExtra(block)).value().style;
|
||||
return std::array{style.red, style.green, style.blue};
|
||||
};
|
||||
QCOMPARE(colour("1.0 0 0 1"), (std::array{0.0, 0.0, 1.0}));
|
||||
QCOMPARE(colour("0.2 0.4 0.6"), (std::array{0.2, 0.4, 0.6}));
|
||||
QCOMPARE(colour("0.6"), (std::array{0.6, 0.6, 0.6}));
|
||||
// A red or green difference alone is a second style.
|
||||
for (const char *values : {"[ 1.0 1 0 0 ]", "[ 1.0 0 1 0 ]"}) {
|
||||
TypeBlock block;
|
||||
block.text = QStringLiteral("Hello");
|
||||
block.secondOverride = QByteArray("/FillColor << /Values ") + values + " >>\n";
|
||||
QCOMPARE(PSDText::parse(PSDFixture::typeExtra(block)).value().notes, std::vector{PSDText::firstStyleNote});
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::theSourcesKeepSwiftsPrecedence()
|
||||
{
|
||||
using PSDFixture::TypeBlock;
|
||||
// Present but empty words refuse; absent ones read the engine.
|
||||
TypeBlock empty;
|
||||
empty.engineText = QStringLiteral("Fallback");
|
||||
QVERIFY(!PSDText::parse(PSDFixture::typeExtra(empty)));
|
||||
empty.typed = false;
|
||||
QCOMPARE(PSDText::parse(PSDFixture::typeExtra(empty)).value().style.content, QString("Fallback"));
|
||||
// `TySh` wins over `tySh`, broken or not.
|
||||
TypeBlock upper, lower;
|
||||
upper.text = QStringLiteral("Upper");
|
||||
lower.text = QStringLiteral("Lower");
|
||||
QVERIFY(!PSDText::parse({{QStringLiteral("TySh"), QByteArray("\0\1", 2)}, {QStringLiteral("tySh"), PSDFixture::tySh(lower)}}));
|
||||
const PSDText::Source both = PSDText::parse({{QStringLiteral("TySh"), PSDFixture::tySh(upper)}, {QStringLiteral("tySh"), PSDFixture::tySh(lower)}}).value();
|
||||
QCOMPARE(both.style.content, QString("Upper"));
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::theStyleRoundsAndScalesAsSwift()
|
||||
{
|
||||
using PSDFixture::TypeBlock;
|
||||
const auto parsed = [](const TypeBlock &block) { return PSDText::parse(PSDFixture::typeExtra(block)).value(); };
|
||||
// Manual leading scales with the matrix, as the size does.
|
||||
TypeBlock scaled;
|
||||
scaled.text = QStringLiteral("H\nH");
|
||||
scaled.leading = 30;
|
||||
scaled.xx = scaled.yy = 2;
|
||||
QCOMPARE(parsed(scaled).style.fontSize, 48.0);
|
||||
QCOMPARE(parsed(scaled).style.leading, 60.0);
|
||||
// The font index and justification round half away from zero.
|
||||
TypeBlock faces;
|
||||
faces.text = QStringLiteral("Hello");
|
||||
faces.font = QStringLiteral("DejaVuSans");
|
||||
faces.secondFont = QStringLiteral("DejaVuSerif");
|
||||
const std::vector<std::pair<QByteArray, QString>> indices{{"1", "DejaVuSerif"}, {"0.6", "DejaVuSerif"}, {"0.4", "DejaVuSans"}, {"-0.6", "Helvetica"}};
|
||||
for (const auto &[index, name] : indices) {
|
||||
faces.fontIndex = index;
|
||||
QCOMPARE(parsed(faces).style.fontName, name);
|
||||
}
|
||||
faces.justification = "1.6";
|
||||
QVERIFY(parsed(faces).style.alignment == TextAlignment::center);
|
||||
QVERIFY(parsed(faces).notes.empty());
|
||||
faces.justification = "2.5";
|
||||
QVERIFY(parsed(faces).style.alignment == TextAlignment::left);
|
||||
QCOMPARE(parsed(faces).notes, std::vector{PSDText::justifyNote});
|
||||
faces.justification = "0.4";
|
||||
QVERIFY(parsed(faces).notes.empty());
|
||||
// `Txt` without its space holds the words over the engine's.
|
||||
TypeBlock alias;
|
||||
alias.text = QStringLiteral("Alias words");
|
||||
alias.wordsKey = "Txt";
|
||||
alias.engineText = QStringLiteral("Fallback");
|
||||
QCOMPARE(parsed(alias).style.content, QString("Alias words"));
|
||||
alias.withEngine = false;
|
||||
QCOMPARE(parsed(alias).style.content, QString("Alias words"));
|
||||
// A negative exponent keeps the engine and its style.
|
||||
TypeBlock tracked;
|
||||
tracked.text = QStringLiteral("Hello");
|
||||
tracked.tracking = "1e-1";
|
||||
tracked.red = 1;
|
||||
QCOMPARE(parsed(tracked).style.tracking, 0.0024);
|
||||
QCOMPARE(parsed(tracked).style.red, 1.0);
|
||||
const PSDTextEngine::Engine small = engine("<< /X -2.5E-2 >>").value();
|
||||
QCOMPARE(PSDTextEngine::number(PSDTextEngine::walk(&small, {"X"})).value(), -0.025);
|
||||
}
|
||||
|
||||
void PSDTextReaderTests::versionsAndLimitsHoldAtTheirEdges()
|
||||
{
|
||||
using PSDFixture::TypeBlock;
|
||||
TypeBlock block;
|
||||
block.text = QStringLiteral("Hello");
|
||||
const QByteArray whole = PSDFixture::tySh(block);
|
||||
QVERIFY(PSDText::parse({{QStringLiteral("TySh"), whole}}));
|
||||
// Object version 1 and text version 50, whole blocks otherwise.
|
||||
QByteArray object = whole, text = whole;
|
||||
object[1] = 2;
|
||||
text[51] = 51;
|
||||
QVERIFY(!PSDText::parse({{QStringLiteral("TySh"), object}}));
|
||||
QVERIFY(!PSDText::parse({{QStringLiteral("TySh"), text}}));
|
||||
// 100,000 UTF-16 units at most.
|
||||
block.text = QString(100'000, u'x');
|
||||
QCOMPARE(PSDText::parse(PSDFixture::typeExtra(block)).value().style.content.size(), qsizetype(100'000));
|
||||
block.text += u'x';
|
||||
QVERIFY(!PSDText::parse(PSDFixture::typeExtra(block)));
|
||||
// 511 lists round a number read; 512 refuse.
|
||||
for (const int depth : {511, 512}) {
|
||||
PSDFixture::Buffer lists;
|
||||
for (int level = 0; level < depth; ++level) {
|
||||
lists.string("VlLs");
|
||||
lists.u32(1);
|
||||
}
|
||||
lists.bytes(QByteArray("long\0\0\0\1", 8));
|
||||
QCOMPARE(described({{"Deep", lists.data}}).has_value(), depth == 511);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PSDTextReaderTests)
|
||||
#include "PSDTextReaderTests.moc"
|
||||
@@ -0,0 +1,468 @@
|
||||
#include "IO/PSD/PSDDocumentBuilder.h"
|
||||
#include "IO/PSD/PSDReader.h"
|
||||
#include "IO/PSD/PSDVector.h"
|
||||
#include "PSDFixture+Text.h"
|
||||
#include "PSDVectorBuilders.h"
|
||||
#include "PSDVectorFixtures.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's PSDRoundTripTests, the text half, and the twin's own.
|
||||
namespace {
|
||||
using PSDFixture::TypeBlock;
|
||||
|
||||
std::optional<PSDText::Source> parsed(const TypeBlock &block)
|
||||
{
|
||||
return PSDText::parse(PSDFixture::typeExtra(block));
|
||||
}
|
||||
|
||||
TypeBlock hello()
|
||||
{
|
||||
TypeBlock block;
|
||||
block.text = QStringLiteral("Hello");
|
||||
return block;
|
||||
}
|
||||
|
||||
bool notes(const std::vector<QString> &list, const QString ¬e)
|
||||
{
|
||||
return std::find(list.begin(), list.end(), note) != list.end();
|
||||
}
|
||||
|
||||
bool reported(const PSDImport &imported, const QString &message)
|
||||
{
|
||||
return std::any_of(imported.conversions.begin(), imported.conversions.end(), [&](const PSDConversion &item) { return item.message == message; });
|
||||
}
|
||||
|
||||
// One layer carrying the block, read back through a file.
|
||||
PSDImport importedFile(const QString &name, const QImage &pixels, int side, const TypeBlock &block)
|
||||
{
|
||||
const PSDRecord record = PSDFixture::record(name, pixels, QRectF(QPointF(), pixels.size()));
|
||||
const QByteArray file = PSDFixture::data(PSDDocument{side, side, 72, {record}}, PSDFixture::colorImage(side, side, 1, 1, 1), false,
|
||||
{PSDFixture::AdditionalLayerInfo{"TySh", PSDFixture::tySh(block)}});
|
||||
return PSDDocumentBuilder::makeImport(PSDReader::read(file));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PSDTextTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void photoshopPointTextImportsAsEditableText();
|
||||
void photoshopTextSizeUsesMatrixScaleNotDocumentResolution();
|
||||
void photoshopTextKeepsTheFirstStyleAndReportsTheRest();
|
||||
void photoshopTextReportsLeadingOnlyStyleDifferences();
|
||||
void photoshopParagraphTextKeepsItsBox();
|
||||
void oversizedPhotoshopParagraphFrameStaysPixels();
|
||||
void warpedPhotoshopTextStaysEditableAndSaysSo();
|
||||
void verticalOrBrokenPhotoshopTextStaysPixels();
|
||||
void missingPhotoshopFontIsReportedButStaysEditable();
|
||||
void rotatedPhotoshopTextKeepsItsAngle();
|
||||
void wildNumbersNeverTrap();
|
||||
void theWordsAreCleanedAndMayComeFromTheEngine();
|
||||
void framesReadStandardisedAndPlaced();
|
||||
void pointTextSitsOnItsBaseline();
|
||||
void textThatCannotBeDrawnStaysPixels();
|
||||
void theStyleReadsAsSwiftReadsIt();
|
||||
void typeWithAShapeStaysType();
|
||||
};
|
||||
|
||||
void PSDTextTests::photoshopPointTextImportsAsEditableText()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QCOMPARE(source.style.content, QString("Hello"));
|
||||
QCOMPARE(source.style.fontName, QString("Helvetica"));
|
||||
QCOMPARE(source.style.fontSize, 24.0);
|
||||
QVERIFY(source.style.red == 0 && source.style.green == 0 && source.style.blue == 0);
|
||||
QVERIFY(source.style.alignment == TextAlignment::left);
|
||||
QVERIFY(source.notes.empty());
|
||||
|
||||
const PSDRecord record = PSDFixture::record(QStringLiteral("Greeting"), PSDFixture::colorImage(8, 8, 0, 0, 0), QRectF(1, 2, 8, 8));
|
||||
const PSDDocument document = PSDReader::read(PSDFixture::data(PSDDocument{32, 32, 72, {record}}, PSDFixture::colorImage(32, 32, 1, 1, 1), false,
|
||||
{PSDFixture::AdditionalLayerInfo{"TySh", PSDFixture::tySh(block)}}));
|
||||
QVERIFY(document.layers.at(0).kind == PSDLayerKind::text);
|
||||
QCOMPARE(document.layers.at(0).text.value().style.content, QString("Hello"));
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
|
||||
const ImageLayer &layer = imported.layers.at(0);
|
||||
const LayerText live = layer.liveText().value();
|
||||
QCOMPARE(live.style.content, QString("Hello"));
|
||||
QCOMPARE(live.style.fontName, QString("Helvetica"));
|
||||
QCOMPARE(live.style.fontSize, 24.0);
|
||||
QVERIFY(layer.asset.value().identity() == live.image);
|
||||
QVERIFY(!reported(imported, PSDText::rasterizedNote));
|
||||
QVERIFY(std::abs(layer.transform.origin.x() - 40) < 80);
|
||||
QVERIFY(std::abs(layer.transform.origin.y() - 50) < 80);
|
||||
// The rendered words replace Photoshop's cached pixels.
|
||||
const PSDText::Rendered rendered = PSDText::render(document.layers.at(0).text.value());
|
||||
QCOMPARE(layer.transform, rendered.transform);
|
||||
QCOMPARE(layer.asset.value().image(), rendered.image);
|
||||
QVERIFY(rendered.image.size() != QSize(8, 8));
|
||||
}
|
||||
|
||||
void PSDTextTests::photoshopTextSizeUsesMatrixScaleNotDocumentResolution()
|
||||
{
|
||||
// Only the matrix scales the engine's size.
|
||||
QCOMPARE(parsed(hello()).value().style.fontSize, 24.0);
|
||||
TypeBlock doubled = hello();
|
||||
doubled.xx = doubled.yy = 2;
|
||||
QCOMPARE(parsed(doubled).value().style.fontSize, 48.0);
|
||||
doubled.fontSize = 25;
|
||||
QCOMPARE(parsed(doubled).value().style.fontSize, 50.0);
|
||||
}
|
||||
|
||||
void PSDTextTests::photoshopTextKeepsTheFirstStyleAndReportsTheRest()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.red = 1;
|
||||
block.justification = "2";
|
||||
block.tracking = "1000";
|
||||
block.leading = 30;
|
||||
block.secondSize = 48;
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QCOMPARE(source.style.content, QString("Hello"));
|
||||
QVERIFY(source.style.alignment == TextAlignment::center);
|
||||
QCOMPARE(source.style.tracking, 24.0);
|
||||
QCOMPARE(source.style.leading, 30.0);
|
||||
QCOMPARE(source.style.red, 1.0);
|
||||
QCOMPARE(source.style.fontSize, 24.0);
|
||||
QVERIFY(notes(source.notes, PSDText::firstStyleNote));
|
||||
}
|
||||
|
||||
void PSDTextTests::photoshopTextReportsLeadingOnlyStyleDifferences()
|
||||
{
|
||||
TypeBlock byLeading = hello();
|
||||
byLeading.leading = 30;
|
||||
byLeading.secondLeading = 48;
|
||||
const PSDText::Source leading = parsed(byLeading).value();
|
||||
QCOMPARE(leading.style.leading, 30.0);
|
||||
QVERIFY(notes(leading.notes, PSDText::firstStyleNote));
|
||||
TypeBlock byScale = hello();
|
||||
byScale.secondHorizontalScale = 1.2;
|
||||
QVERIFY(notes(parsed(byScale).value().notes, PSDText::firstStyleNote));
|
||||
// The same second run says nothing.
|
||||
TypeBlock same = hello();
|
||||
same.secondSize = 24;
|
||||
QVERIFY(parsed(same).value().notes.empty());
|
||||
// Each other field of the signature counts alone.
|
||||
for (const char *field : {"/Font 1\n", "/Tracking 5\n", "/AutoLeading false\n", "/VerticalScale 1.2\n", "/FauxBold true\n",
|
||||
"/FauxItalic true\n", "/FillColor << /Values [ 1.0 0 0 1 ] >>\n"}) {
|
||||
TypeBlock second = hello();
|
||||
second.secondOverride = field;
|
||||
QVERIFY2(parsed(second).value().notes == std::vector{PSDText::firstStyleNote}, field);
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextTests::photoshopParagraphTextKeepsItsBox()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.tx = 10;
|
||||
block.ty = 30;
|
||||
block.bounds = std::array{0.0, 0.0, 200.0, 80.0};
|
||||
block.glyphBounds = std::array{0.0, -10.0, 40.0, 10.0};
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QVERIFY(source.anchorIsFrame);
|
||||
QCOMPARE(source.style.boxSize.value(), QSizeF(224, 104));
|
||||
QCOMPARE(source.documentAnchor, QPointF(10, 30));
|
||||
// A frame barely wider than its letters stays point text.
|
||||
block.bounds = std::array{0.0, 0.0, 44.0, 80.0};
|
||||
QVERIFY(!parsed(block).value().anchorIsFrame);
|
||||
QVERIFY(!parsed(block).value().style.boxSize);
|
||||
}
|
||||
|
||||
void PSDTextTests::oversizedPhotoshopParagraphFrameStaysPixels()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.bounds = std::array{0.0, 0.0, 40'000.0, 100.0};
|
||||
block.glyphBounds = std::array{0.0, 0.0, 40.0, 10.0};
|
||||
QVERIFY(!parsed(block));
|
||||
const PSDImport imported = importedFile(QStringLiteral("Billboard"), PSDFixture::colorImage(4, 4, 0, 1, 0), 16, block);
|
||||
const ImageLayer &layer = imported.layers.at(0);
|
||||
QVERIFY(!layer.liveText());
|
||||
QCOMPARE(layer.asset.value().size(), QSize(4, 4));
|
||||
QVERIFY(reported(imported, PSDText::rasterizedNote));
|
||||
}
|
||||
|
||||
void PSDTextTests::warpedPhotoshopTextStaysEditableAndSaysSo()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.fauxBold = true;
|
||||
block.warp = true;
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QCOMPARE(source.style.content, QString("Hello"));
|
||||
QVERIFY(notes(source.notes, PSDText::warpNote));
|
||||
QVERIFY(notes(source.notes, PSDText::fauxNote));
|
||||
// The builder reports the parser's notes, in order.
|
||||
const PSDImport imported = importedFile(QStringLiteral("Arc"), PSDFixture::colorImage(4, 4, 0, 0, 0), 16, block);
|
||||
QCOMPARE(imported.conversions.at(0).message, PSDText::warpNote);
|
||||
QCOMPARE(imported.conversions.at(1).message, PSDText::fauxNote);
|
||||
TypeBlock italic = hello();
|
||||
italic.fauxItalic = true;
|
||||
QCOMPARE(parsed(italic).value().notes, std::vector{PSDText::fauxNote});
|
||||
}
|
||||
|
||||
void PSDTextTests::verticalOrBrokenPhotoshopTextStaysPixels()
|
||||
{
|
||||
TypeBlock vertical = hello();
|
||||
vertical.vertical = true;
|
||||
QVERIFY(!parsed(vertical));
|
||||
QVERIFY(!PSDText::parse({{QStringLiteral("TySh"), QByteArray("\0\1", 2)}}));
|
||||
TypeBlock uneven = hello();
|
||||
uneven.xx = 2;
|
||||
QVERIFY(!parsed(uneven));
|
||||
TypeBlock sheared = hello();
|
||||
sheared.xy = 0.5;
|
||||
QVERIFY(!parsed(sheared));
|
||||
|
||||
const PSDImport imported = importedFile(QStringLiteral("Sideways"), PSDFixture::colorImage(4, 4, 0, 0, 1), 16, vertical);
|
||||
const ImageLayer &layer = imported.layers.at(0);
|
||||
QVERIFY(!layer.liveText());
|
||||
QCOMPARE(layer.asset.value().size(), QSize(4, 4));
|
||||
QVERIFY(reported(imported, PSDText::rasterizedNote));
|
||||
}
|
||||
|
||||
void PSDTextTests::missingPhotoshopFontIsReportedButStaysEditable()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.font = QStringLiteral("DefinitelyMissingFontXYZ");
|
||||
PSDRecord record = PSDFixture::record(QStringLiteral("Missing"), PSDFixture::colorImage(2, 2, 0, 0, 0));
|
||||
record.kind = PSDLayerKind::text;
|
||||
record.text = parsed(block).value();
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{64, 64, 72, {record}});
|
||||
QCOMPARE(imported.layers.at(0).liveText().value().style.fontName, QString("DefinitelyMissingFontXYZ"));
|
||||
const QString note = PSDText::missingFontNote(QStringLiteral("DefinitelyMissingFontXYZ")).value();
|
||||
QCOMPARE(note, QString("The font “DefinitelyMissingFontXYZ” isn’t installed, so the text was drawn with a substitute font."));
|
||||
QVERIFY(reported(imported, note));
|
||||
// An installed face by its PostScript name says nothing.
|
||||
QVERIFY(!PSDText::missingFontNote(QStringLiteral("DejaVuSans")));
|
||||
}
|
||||
|
||||
void PSDTextTests::rotatedPhotoshopTextKeepsItsAngle()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.xx = 0;
|
||||
block.xy = -1;
|
||||
block.yx = 1;
|
||||
block.yy = 0;
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QVERIFY(std::abs(source.rotation - 90) < 0.01);
|
||||
QVERIFY(!source.flipY);
|
||||
block.xy = 1;
|
||||
QVERIFY(parsed(block).value().flipY);
|
||||
// Two per cent of shear or unevenness passes, no more.
|
||||
const std::vector<std::pair<std::array<double, 4>, bool>> limits{{{1, 0.019, 0, 1}, true}, {{1, 0.021, 0, 1}, false},
|
||||
{{1, 0, 0, 1.019}, true}, {{1, 0, 0, 1.021}, false},
|
||||
{{1.019, 0, 0, 1}, true}, {{1.021, 0, 0, 1}, false}};
|
||||
for (const auto &[matrix, passes] : limits) {
|
||||
TypeBlock near = hello();
|
||||
near.xx = matrix[0];
|
||||
near.xy = matrix[1];
|
||||
near.yx = matrix[2];
|
||||
near.yy = matrix[3];
|
||||
QCOMPARE(parsed(near).has_value(), passes);
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextTests::wildNumbersNeverTrap()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.fontIndex = "1e300";
|
||||
block.justification = "1e300";
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
QCOMPARE(source.style.fontName, QString("Helvetica"));
|
||||
QVERIFY(source.style.alignment == TextAlignment::left);
|
||||
QCOMPARE(source.notes, std::vector{PSDText::justifyNote});
|
||||
// Full justification, 3, is left with the note.
|
||||
block.justification = "3";
|
||||
QCOMPARE(parsed(block).value().notes, std::vector{PSDText::justifyNote});
|
||||
block.justification = "1";
|
||||
QVERIFY(parsed(block).value().style.alignment == TextAlignment::right);
|
||||
block.fontIndex = "1";
|
||||
QCOMPARE(parsed(block).value().style.fontName, QString("Helvetica"));
|
||||
}
|
||||
|
||||
void PSDTextTests::theWordsAreCleanedAndMayComeFromTheEngine()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.text = QStringLiteral("") + QChar(0) + QStringLiteral("A\r\nB\rC") + QChar(0);
|
||||
QCOMPARE(parsed(block).value().style.content, QString("A\nB\nC"));
|
||||
block.text = QStringLiteral("H") + QChar(0xD800);
|
||||
QCOMPARE(parsed(block).value().style.content, QStringLiteral("H") + QChar::ReplacementCharacter);
|
||||
// Without a `Txt ` item the engine's words count.
|
||||
block.text = QStringLiteral("From the engine");
|
||||
block.typed = false;
|
||||
QCOMPARE(parsed(block).value().style.content, QString("From the engine"));
|
||||
// Empty words stay pixels, never falling back.
|
||||
block.typed = true;
|
||||
block.text = QString();
|
||||
QVERIFY(!parsed(block));
|
||||
}
|
||||
|
||||
void PSDTextTests::framesReadStandardisedAndPlaced()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.tx = 10;
|
||||
block.ty = 30;
|
||||
block.bounds = std::array{200.0, 80.0, 0.0, 0.0};
|
||||
block.glyphBounds = std::array{40.0, 10.0, 0.0, -10.0};
|
||||
const PSDText::Source flipped = parsed(block).value();
|
||||
QCOMPARE(flipped.style.boxSize.value(), QSizeF(224, 104));
|
||||
QCOMPARE(flipped.documentAnchor, QPointF(10, 30));
|
||||
// Turned a quarter, the frame's corner goes through the matrix.
|
||||
block.xx = 0;
|
||||
block.xy = -1;
|
||||
block.yx = 1;
|
||||
block.yy = 0;
|
||||
block.bounds = std::array{5.0, 7.0, 205.0, 87.0};
|
||||
QCOMPARE(parsed(block).value().documentAnchor, QPointF(3, 35));
|
||||
// Scaled twice, the frame doubles.
|
||||
block.xx = block.yy = 2;
|
||||
block.xy = block.yx = 0;
|
||||
QCOMPARE(parsed(block).value().style.boxSize.value(), QSizeF(424, 184));
|
||||
QCOMPARE(parsed(block).value().documentAnchor, QPointF(20, 44));
|
||||
// Rendered, the frame's padded corner lands on its anchor.
|
||||
for (const auto &matrix : std::vector<std::array<double, 4>>{{1, 0, 0, 1}, {0, -1, 1, 0}, {0, 1, 1, 0}}) {
|
||||
block.xx = matrix[0];
|
||||
block.xy = matrix[1];
|
||||
block.yx = matrix[2];
|
||||
block.yy = matrix[3];
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
const PSDText::Rendered placed = PSDText::render(source);
|
||||
QCOMPARE(placed.image.size(), source.style.boxSize.value().toSize());
|
||||
const double pad = LayerTextStyle::padding;
|
||||
const QPointF corner = BrushRaster::pixelToDocument(placed.transform, placed.image.width(), placed.image.height()).map(QPointF(pad, pad));
|
||||
QVERIFY2(std::abs(corner.x() - source.documentAnchor.x()) < 1e-6 && std::abs(corner.y() - source.documentAnchor.y()) < 1e-6,
|
||||
qPrintable(QString("%1 %2").arg(corner.x()).arg(corner.y())));
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextTests::pointTextSitsOnItsBaseline()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.text = QStringLiteral("HHH");
|
||||
block.font = QStringLiteral("DejaVuSans");
|
||||
block.fontSize = 40;
|
||||
const PSDText::Source source = parsed(block).value();
|
||||
const PSDText::Rendered rendered = PSDText::render(source);
|
||||
QCOMPARE(rendered.transform.size, QSizeF(rendered.image.size()));
|
||||
QCOMPARE(rendered.transform.origin.x(), 40 - LayerTextStyle::padding);
|
||||
// The letters' last inked row ends on the document's baseline.
|
||||
int bottom = -1;
|
||||
for (int y = 0; y < rendered.image.height(); ++y)
|
||||
for (int x = 0; x < rendered.image.width(); ++x)
|
||||
if (qAlpha(rendered.image.pixel(x, y)) > 128)
|
||||
bottom = y;
|
||||
const double baseline = 50 - rendered.transform.origin.y();
|
||||
QVERIFY(baseline > LayerTextStyle::padding + 20 && baseline < rendered.image.height() - LayerTextStyle::padding);
|
||||
QVERIFY2(std::abs(bottom + 1 - baseline) < 1, qPrintable(QString("%1 %2").arg(bottom).arg(baseline)));
|
||||
// Turned and flipped, the anchor still lands on its point.
|
||||
for (const bool right : {false, true}) {
|
||||
TypeBlock turned = block;
|
||||
turned.xx = 0;
|
||||
turned.xy = 1;
|
||||
turned.yx = 1;
|
||||
turned.yy = 0;
|
||||
turned.justification = right ? "1" : "2";
|
||||
const PSDText::Source twisted = parsed(turned).value();
|
||||
QVERIFY(twisted.flipY);
|
||||
const PSDText::Rendered placed = PSDText::render(twisted);
|
||||
const double x = right ? placed.image.width() - LayerTextStyle::padding : placed.image.width() / 2.0;
|
||||
const QPointF lands = BrushRaster::pixelToDocument(placed.transform, placed.image.width(), placed.image.height())
|
||||
.map(QPointF(x, baseline));
|
||||
QVERIFY2(std::abs(lands.x() - 40) < 1e-6 && std::abs(lands.y() - 50) < 1e-6, qPrintable(QString("%1 %2").arg(lands.x()).arg(lands.y())));
|
||||
}
|
||||
}
|
||||
|
||||
void PSDTextTests::textThatCannotBeDrawnStaysPixels()
|
||||
{
|
||||
TypeBlock block = hello();
|
||||
block.tx = 1e300;
|
||||
const PSDText::Source far = parsed(block).value();
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, PSDText::render(far));
|
||||
const PSDImport imported = importedFile(QStringLiteral("Far"), PSDFixture::colorImage(4, 4, 1, 0, 0), 16, block);
|
||||
const ImageLayer &layer = imported.layers.at(0);
|
||||
QVERIFY(!layer.liveText());
|
||||
QCOMPARE(layer.asset.value().size(), QSize(4, 4));
|
||||
QCOMPARE(layer.transform.origin, QPointF(0, 0));
|
||||
QVERIFY(reported(imported, PSDText::rasterizedNote));
|
||||
}
|
||||
|
||||
void PSDTextTests::theStyleReadsAsSwiftReadsIt()
|
||||
{
|
||||
// Runs without a style sheet read nothing, as Swift's fallback.
|
||||
TypeBlock bare = hello();
|
||||
bare.sheet = false;
|
||||
bare.fontSize = 30;
|
||||
bare.red = 1;
|
||||
QCOMPARE(parsed(bare).value().style.fontSize, 12.0);
|
||||
QCOMPARE(parsed(bare).value().style.red, 0.0);
|
||||
// A size that is no size leaves every default.
|
||||
TypeBlock zero = hello();
|
||||
zero.fontSize = 0;
|
||||
zero.red = 1;
|
||||
QCOMPARE(parsed(zero).value().style.fontSize, 72.0);
|
||||
QCOMPARE(parsed(zero).value().style.red, 0.0);
|
||||
TypeBlock nameless = hello();
|
||||
nameless.font = QString();
|
||||
QCOMPARE(parsed(nameless).value().style.fontName, QString("Helvetica"));
|
||||
TypeBlock infinite = hello();
|
||||
infinite.tracking = "1e999";
|
||||
QCOMPARE(parsed(infinite).value().style.tracking, 0.0);
|
||||
// Automatic leading ignores the stored number.
|
||||
QCOMPARE(parsed(hello()).value().style.leading, 0.0);
|
||||
// Channels past one count in bytes.
|
||||
TypeBlock bytes = hello();
|
||||
bytes.red = 255;
|
||||
bytes.green = 51;
|
||||
const LayerTextStyle coloured = parsed(bytes).value().style;
|
||||
QCOMPARE(coloured.red, 1.0);
|
||||
QCOMPARE(coloured.green, 0.2);
|
||||
// Without an engine the size is twelve, scaled.
|
||||
TypeBlock plain = hello();
|
||||
plain.withEngine = false;
|
||||
plain.xx = plain.yy = 2;
|
||||
QCOMPARE(parsed(plain).value().style.fontSize, 24.0);
|
||||
QCOMPARE(parsed(plain).value().style.content, QString("Hello"));
|
||||
// A frame too short for its letters stays point text.
|
||||
TypeBlock shallow = hello();
|
||||
shallow.bounds = std::array{0.0, 0.0, 200.0, 22.0};
|
||||
shallow.glyphBounds = std::array{0.0, -10.0, 40.0, 10.0};
|
||||
QVERIFY(!parsed(shallow).value().anchorIsFrame);
|
||||
// Flipped, the frame's corner flips through the matrix.
|
||||
TypeBlock flipped = hello();
|
||||
flipped.tx = 10;
|
||||
flipped.ty = 30;
|
||||
flipped.yy = -1;
|
||||
flipped.bounds = std::array{5.0, 7.0, 205.0, 87.0};
|
||||
flipped.glyphBounds = std::array{0.0, -10.0, 40.0, 10.0};
|
||||
const PSDText::Source mirrored = parsed(flipped).value();
|
||||
QVERIFY(mirrored.flipY);
|
||||
QCOMPARE(mirrored.documentAnchor, QPointF(15, 23));
|
||||
// Eight million bytes at most, padding after the warp included.
|
||||
const QByteArray block = PSDFixture::tySh(hello());
|
||||
QVERIFY(PSDText::parse({{QStringLiteral("TySh"), block + QByteArray(8'000'000 - block.size(), '\0')}}));
|
||||
QVERIFY(!PSDText::parse({{QStringLiteral("TySh"), block + QByteArray(8'000'001 - block.size(), '\0')}}));
|
||||
QVERIFY(PSDText::parse({{QStringLiteral("tySh"), block}}));
|
||||
}
|
||||
|
||||
void PSDTextTests::typeWithAShapeStaysType()
|
||||
{
|
||||
// Swift reads the type first: no shape, no mask.
|
||||
std::vector<PSDFixture::AdditionalLayerInfo> extras{{"TySh", PSDFixture::tySh(hello())}};
|
||||
PSDVector::Extra shape = PSDVectorFixtures::rectangle();
|
||||
shape["vogk"] = PSDVectorBuilders::originationData(2, QRectF(945, 153, 646, 182), {0, 0, 0, 0});
|
||||
QVERIFY(PSDVector::live(shape, PSDVectorFixtures::canvas));
|
||||
for (const char *key : {"vmsk", "SoCo", "vstk", "vogk"})
|
||||
extras.push_back({key, shape.at(QString::fromLatin1(key))});
|
||||
for (const bool pixels : {true, false}) {
|
||||
const PSDRecord record = pixels ? PSDFixture::record(QStringLiteral("Both"), PSDFixture::colorImage(4, 4, 0, 0, 0), QRectF(0, 0, 4, 4))
|
||||
: PSDFixture::record(QStringLiteral("Both"));
|
||||
const PSDDocument document = PSDReader::read(PSDFixture::data(PSDDocument{1920, 1080, 72, {record}}, PSDFixture::colorImage(2, 2, 1, 1, 1), false, extras));
|
||||
const PSDRecord &read = document.layers.at(0);
|
||||
QVERIFY(read.kind == PSDLayerKind::text);
|
||||
QVERIFY(!read.shape);
|
||||
QCOMPARE(read.image.has_value(), pixels);
|
||||
QVERIFY(PSDDocumentBuilder::makeImport(document).layers.at(0).liveText());
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(PSDTextTests)
|
||||
#include "PSDTextTests.moc"
|
||||
@@ -207,6 +207,8 @@ compositor_test(RemoveBackgroundTests)
|
||||
compositor_test(SelectSubjectTests)
|
||||
compositor_test(ObjectSelectionTests)
|
||||
compositor_test(PSDRoundTripTests)
|
||||
compositor_test(PSDTextTests)
|
||||
compositor_test(PSDTextReaderTests)
|
||||
compositor_test(PSBImportTests)
|
||||
compositor_test(CropToCanvasImportTests)
|
||||
compositor_test(PSDVectorTests)
|
||||
|
||||
Reference in new issue
Block a user