Import simple Photoshop text as editable text (12.11b2)

This commit is contained in:
雷电芽衣 committed 2026-09-29 09:04:50 -04:00
1 parent a2241601ec
commit 008493b0a3
20 files changed
+2081 -17

No files matched your search

+468
View File
@@ -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 &note)
{
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"