Photoshop PSD import (Compositor 1.2.0, part one)

This commit is contained in:
雷电芽衣 committed 2026-09-27 04:41:08 -04:00
1 parent 3d56688ce4
commit 2cd0d1b86d
41 files changed
+3912 -31

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDVector.h"
#include "PSDFixture.h"
#include "PSDVectorBuilders.h"
#include "PSDVectorFixtures.h"
#include <QtTest>
// Swift's vector tests: shapes live, masks rasterized, sizes refused.
using namespace PSDVectorBuilders;
namespace {
std::vector<QPointF> box()
{
return {QPointF(120, 30), QPointF(120, 80), QPointF(20, 80), QPointF(20, 30)};
}
std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const ImageImportError &error) {
return error.kind;
}
return std::nullopt;
}
PSDRecord shapeRecord(const QString &name, const PSDVector::Live &live)
{
PSDRecord record = PSDFixture::record(name, live.image, live.bounds);
record.kind = PSDLayerKind::vector;
record.shape = live.style;
record.shapeNotes = live.notes;
return record;
}
}
class PSDVectorTests : public QObject {
Q_OBJECT
private slots:
void vectorMaskIsRasterizedWithFillAndStroke();
void photoshopShapeExtrasRasterizeInPlace();
void fillEllipseImportsAsALiveShape();
void strokedRectangleImportsAsALiveShapeAndReportsTheStroke();
void fourSharpCornersInferARectangleWithoutOrigination();
void hugeOriginationSizeIsRejectedWithoutTrapping();
void nonFiniteOriginationSizeIsIgnored();
void hugeStrokeWidthIsRejectedWithoutTrapping();
void sidesAndBudgetsRefuseAtTheirEdges();
void knotCountsAndCurvesDecideTheShape();
void subpathsCloseOpenAndStandApart();
void fillsWindAntialiasAndMitersKeepTheirTips();
};
void PSDVectorTests::vectorMaskIsRasterizedWithFillAndStroke()
{
const QSizeF canvas(200, 200);
PSDVector::Extra extra{{"vmsk", vectorMask(canvas, box())},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
const PSDVector::Raster raster = PSDVector::raster(extra, canvas).value();
// Fractions of 2^24 fall short: the box floors outward.
QCOMPARE(raster.bounds, QRectF(19, 29, 101, 51));
QCOMPARE(raster.image.size(), QSize(101, 51));
QCOMPARE(raster.image.pixel(50, 25), qRgb(0, 110, 255));
extra["vstk"] = strokeStyle(true, true, 10, 255, 255, 0);
extra["SoCo"] = colorDescriptor(0, 0, 0);
const PSDVector::Raster stroked = PSDVector::raster(extra, canvas).value();
// Half the width and a pixel more on each side.
QCOMPARE(stroked.bounds, QRectF(13, 23, 113, 63));
QCOMPARE(stroked.image.pixel(56, 31), qRgb(0, 0, 0));
QCOMPARE(stroked.image.pixel(56, 7), qRgb(255, 255, 0));
// A miter keeps the square corner; a bevel would not.
QCOMPARE(stroked.image.pixel(2, 2), qRgb(255, 255, 0));
}
void PSDVectorTests::photoshopShapeExtrasRasterizeInPlace()
{
const PSDVector::Raster circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value();
QVERIFY(std::abs(circle.bounds.center().x() - 618) < 8);
QVERIFY(std::abs(circle.bounds.center().y() - 677) < 8);
QVERIFY(std::abs(circle.bounds.width() - 328) < 12);
QVERIFY(std::abs(circle.bounds.height() - 328) < 12);
const PSDVector::Raster rectangle = PSDVector::raster(PSDVectorFixtures::rectangle(), PSDVectorFixtures::canvas).value();
QVERIFY(rectangle.bounds.width() > 640);
QVERIFY(rectangle.bounds.height() > 170);
QVERIFY(std::abs(rectangle.bounds.center().x() - 1268) < 20);
QVERIFY(std::abs(rectangle.bounds.center().y() - 244) < 20);
}
void PSDVectorTests::fillEllipseImportsAsALiveShape()
{
PSDVector::Extra extra = PSDVectorFixtures::circle();
extra["vogk"] = originationData(5, QRectF(454, 513, 328, 328));
const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QVERIFY(live.style.kind == ShapeKind::ellipse);
QVERIFY(std::abs(live.style.green - 110 / 255.0) < 0.01);
QVERIFY(std::abs(live.style.blue - 1) < 0.01);
QVERIFY(live.notes.empty());
QCOMPARE(live.bounds, QRectF(454, 513, 328, 328));
QCOMPARE(live.image.size(), QSize(328, 328));
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("cercle-bleu", live)}});
QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::ellipse);
QVERIFY(imported.conversions.empty());
QCOMPARE(imported.layers[0].liveShape().value().image, imported.layers[0].asset.value().identity());
}
void PSDVectorTests::strokedRectangleImportsAsALiveShapeAndReportsTheStroke()
{
PSDVector::Extra extra = PSDVectorFixtures::rectangle();
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {0, 0, 0, 0});
const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QVERIFY(live.style.kind == ShapeKind::rectangle);
QCOMPARE(live.style.cornerRadius, 0.0);
QCOMPARE(live.notes, std::vector<QString>{"The Photoshop stroke isn’t supported on shape layers and was omitted."});
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("rectangle-contour-jaune", live)}});
QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::rectangle);
QCOMPARE(imported.conversions.size(), size_t(1));
QCOMPARE(imported.conversions[0].layerName, QString("rectangle-contour-jaune"));
QVERIFY(imported.conversions[0].message.contains("stroke"));
// Radii within half a pixel round by the largest.
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.4, 12, 12});
QCOMPARE(PSDVector::live(extra, PSDVectorFixtures::canvas).value().style.cornerRadius, 12.4);
// Wider apart they refuse; the four sharp anchors decide instead.
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.6, 12, 12});
const PSDVector::Live sharp = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QCOMPARE(sharp.style.cornerRadius, 0.0);
QVERIFY(sharp.bounds != QRectF(945, 153, 646, 182));
}
void PSDVectorTests::fourSharpCornersInferARectangleWithoutOrigination()
{
const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
const PSDVector::Live live = PSDVector::live(extra, QSizeF(200, 200)).value();
QVERIFY(live.style.kind == ShapeKind::rectangle);
QVERIFY(live.notes.empty());
QCOMPARE(live.bounds, QRectF(19, 29, 101, 51));
}
void PSDVectorTests::hugeOriginationSizeIsRejectedWithoutTrapping()
{
const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(0, 0, 1e20, 1e20))},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
QCOMPARE(refusal([&] { PSDVector::live(extra, PSDVectorFixtures::canvas); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::nonFiniteOriginationSizeIsIgnored()
{
const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(10, 10, qInf(), 100))},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
QVERIFY(!PSDVector::live(extra, PSDVectorFixtures::canvas));
}
void PSDVectorTests::hugeStrokeWidthIsRejectedWithoutTrapping()
{
const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())},
{"SoCo", colorDescriptor(0, 0, 0)},
{"vstk", strokeStyle(true, true, 1e20, 255, 255, 0)}};
QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::sidesAndBudgetsRefuseAtTheirEdges()
{
const auto filled = [](QSizeF canvas, const std::vector<QPointF> &corners) {
return PSDVector::Extra{{"vmsk", vectorMask(canvas, corners)}, {"SoCo", colorDescriptor(0, 0, 0)}};
};
// A side past 30,000 pixels, however few pixels in all.
const QSizeF wide(40'000, 10);
const PSDVector::Extra long_ = filled(wide, {QPointF(0, 0), QPointF(30'001, 0), QPointF(30'001, 1), QPointF(0, 1)});
QCOMPARE(refusal([&] { PSDVector::raster(long_, wide); }), std::optional(ImageImportError::Kind::tooLarge));
// The budget holds at its edge: 101 by 51 pixels.
const PSDVector::Extra extra = filled(QSizeF(200, 200), box());
QCOMPARE(PSDVector::raster(extra, QSizeF(200, 200), 5151).value().image.size(), QSize(101, 51));
QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200), 5150); }), std::optional(ImageImportError::Kind::tooLarge));
// A lone anchor still spends one pixel of the budget.
const PSDVector::Extra dot = filled(QSizeF(200, 200), {QPointF(10.5, 10.5)});
QCOMPARE(PSDVector::raster(dot, QSizeF(200, 200), 1).value().image.size(), QSize(1, 1));
QCOMPARE(refusal([&] { PSDVector::raster(dot, QSizeF(200, 200), 0); }), std::optional(ImageImportError::Kind::tooLarge));
// A stroke narrower than nothing is refused.
const PSDVector::Extra negative{{"vmsk", vectorMask(QSizeF(200, 200), box())}, {"vstk", strokeStyle(false, true, -5, 255, 0, 0)}};
QCOMPARE(refusal([&] { PSDVector::raster(negative, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::knotCountsAndCurvesDecideTheShape()
{
const QSizeF canvas(200, 200);
// A knot past the declared count stays out.
std::vector<QPointF> five = box();
five.push_back(QPointF(190, 190));
const PSDVector::Extra counted{{"vmsk", vectorMask(canvas, {Subpath{five, true, 4}})}, {"SoCo", colorDescriptor(0, 110, 255)}};
QCOMPARE(PSDVector::live(counted, canvas).value().bounds, QRectF(19, 29, 101, 51));
QCOMPARE(PSDVector::raster(counted, canvas).value().bounds, QRectF(19, 29, 101, 51));
// Handles off their anchors make curves, which infer no rectangle.
const PSDVector::Extra curved{{"vmsk", vectorMask(canvas, {Subpath{box(), true, std::nullopt, QPointF(5, 0)}})}, {"SoCo", colorDescriptor(0, 110, 255)}};
QVERIFY(!PSDVector::live(curved, canvas));
QVERIFY(PSDVector::raster(curved, canvas).value().bounds.width() > 101);
}
void PSDVectorTests::subpathsCloseOpenAndStandApart()
{
const QSizeF canvas(200, 200);
const std::vector<QPointF> far{QPointF(190, 150), QPointF(190, 190), QPointF(150, 190), QPointF(150, 150)};
const auto stroked = [&](const std::vector<Subpath> &subpaths) {
return PSDVector::raster({{"vmsk", vectorMask(canvas, subpaths)}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value();
};
const auto ink = [](const PSDVector::Raster &raster, QPointF at) { return qAlpha(raster.image.pixel((at - raster.bounds.topLeft()).toPoint())); };
// A closed subpath strokes its closing edge; open ones not.
const PSDVector::Raster both = stroked({Subpath{box()}, Subpath{far}});
QCOMPARE(ink(both, QPointF(70, 30)), 255);
QCOMPARE(ink(both, QPointF(170, 150)), 255);
const PSDVector::Raster open = stroked({Subpath{box(), false}, Subpath{far}});
QCOMPARE(ink(open, QPointF(70, 30)), 0);
QCOMPARE(ink(open, QPointF(120, 55)), 255);
// Each subpath begins anew: nothing joins them.
QCOMPARE(ink(both, QPointF(105, 90)), 0);
}
void PSDVectorTests::fillsWindAntialiasAndMitersKeepTheirTips()
{
const QSizeF canvas(200, 200);
// Two squares turning the same way: their overlap stays filled.
const std::vector<QPointF> first{QPointF(20, 20), QPointF(80, 20), QPointF(80, 80), QPointF(20, 80)};
const std::vector<QPointF> second{QPointF(50, 50), QPointF(110, 50), QPointF(110, 110), QPointF(50, 110)};
const PSDVector::Raster overlap =
PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{first}, Subpath{second}})}, {"SoCo", colorDescriptor(0, 0, 0)}}, canvas).value();
QCOMPARE(qAlpha(overlap.image.pixel((QPointF(65, 65) - overlap.bounds.topLeft()).toPoint())), 255);
// A circle's rim takes partial coverage.
const QImage circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value().image;
int partial = 0;
for (int y = 0; y < circle.height(); ++y)
for (int x = 0; x < circle.width(); ++x)
partial += qAlpha(circle.pixel(x, y)) > 0 && qAlpha(circle.pixel(x, y)) < 255;
QVERIFY2(partial > 200, qPrintable(QString::number(partial)));
// A 30° apex keeps its miter at limit 10.
const double reach = 100 * std::tan(15 * M_PI / 180);
const std::vector<QPointF> apex{QPointF(100, 20), QPointF(100 + reach, 120), QPointF(100 - reach, 120)};
const std::vector<QPointF> spot{QPointF(100, 0), QPointF(101, 0), QPointF(101, 1), QPointF(100, 1)};
const PSDVector::Raster tip =
PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{apex}, Subpath{spot}})}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value();
// The tip's edge crosses this pixel; a bevel clears it.
QVERIFY(qAlpha(tip.image.pixel((QPointF(100, 14) - tip.bounds.topLeft()).toPoint())) > 100);
}
QTEST_GUILESS_MAIN(PSDVectorTests)
#include "PSDVectorTests.moc"