Photoshop's remaining blend modes in its groups (12.7a); v1.2.2 kernels for 12.7b

This commit is contained in:
雷电芽衣 committed 2026-09-27 12:50:33 -04:00
1 parent 29eab10edc
commit 7d47cc367a
26 files changed
+348 -56

No files matched your search

+4
View File
@@ -186,6 +186,10 @@ void AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage()
s.updateAdjustment(id, value);
s.setLayerBlendMode(LayerBlendMode::multiply);
QVERIFY((rendered(s) == std::vector<int>{0, 0, 0, 255, 0, 0, 0, 128, 0, 0, 0, 32, 0, 0, 0, 0}));
// Green over red adds to yellow; Swift adjusts as Normal.
s.setLayerBlendMode(LayerBlendMode::linearDodge);
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 255, 0, 255}));
QCOMPARE(rendered(s)[7], 128);
s.setLayerBlendMode(LayerBlendMode::normal);
QImage gray(1, 1, QImage::Format_Grayscale8);
gray.fill(128);
+23 -14
View File
@@ -26,13 +26,16 @@ void BlendModePickerTests::theMenuListsEveryModeAndFollowsTheActiveLayer()
{
EditorSession session;
BlendModePicker picker(session);
QCOMPARE(picker.count(), int(allLayerBlendModes.size()));
// Photoshop's six groups, a separator between each.
QCOMPARE(picker.count(), int(allLayerBlendModes.size()) + 5);
QStringList titles;
for (int index = 0; index < picker.count(); ++index)
titles << picker.itemText(index);
QCOMPARE(titles.first(), QString("Normal"));
QCOMPARE(titles.last(), QString("Luminosity"));
QCOMPARE(titles[1], QString("Multiply"));
QCOMPARE(titles, (QStringList{"Normal", "", "Darken", "Multiply", "Color Burn", "Linear Burn", "", "Lighten", "Screen", "Color Dodge",
"Linear Dodge (Add)", "", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light",
"Hard Mix", "", "Difference", "Exclusion", "Subtract", "Divide", "", "Hue", "Saturation", "Color", "Luminosity"}));
for (const int separator : {1, 6, 11, 19, 24})
QCOMPARE(picker.itemData(separator, Qt::AccessibleDescriptionRole).toString(), QString("separator"));
QVERIFY(!picker.isEnabled());
session.createDocument(4, 4);
session.insert(white());
@@ -63,20 +66,20 @@ void BlendModePickerTests::hoveringTriesAModeOnAndChoosingKeepsIt()
picker.showPopup();
QTRY_VERIFY(picker.view()->isVisible());
// Hovering previews on the layer the menu opened for.
emit picker.highlighted(1);
emit picker.highlighted(3);
QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::multiply);
QCOMPARE(session.blendPreview().value().layerID, layer);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal);
emit picker.highlighted(2);
emit picker.highlighted(8);
QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::screen);
// The session's news leaves an open menu alone.
picker.view()->setCurrentIndex(picker.model()->index(2, 0));
picker.view()->setCurrentIndex(picker.model()->index(8, 0));
session.setLayerOpacity(0.5);
QVERIFY(session.blendPreview().has_value());
QCOMPARE(picker.view()->currentIndex().row(), 2);
QCOMPARE(picker.view()->currentIndex().row(), 8);
// A click closes the menu, then chooses; preview clears after.
picker.hidePopup();
emit picker.activated(2);
emit picker.activated(8);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen);
QCOMPARE(picker.currentText(), QString("Screen"));
QCOMPARE(session.history.undoName(), QString("Layer Blend Mode"));
@@ -85,8 +88,14 @@ void BlendModePickerTests::hoveringTriesAModeOnAndChoosingKeepsIt()
// Keys on the closed menu choose for the active layer.
picker.setFocus();
QTest::keyClick(&picker, Qt::Key_Down);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
QCOMPARE(picker.currentText(), QString("Color Dodge"));
// Keys step over the lines between groups.
QTest::keyClick(&picker, Qt::Key_Down);
QTest::keyClick(&picker, Qt::Key_Down);
QCOMPARE(picker.currentText(), QString("Overlay"));
session.undo();
session.undo();
// Closed, the menu follows the session again.
session.undo();
QCOMPARE(picker.currentText(), QString("Screen"));
@@ -104,19 +113,19 @@ void BlendModePickerTests::aMenuClosedWithoutAChoiceLeavesTheLayerAlone()
picker.show();
QVERIFY(QTest::qWaitForWindowExposed(&picker));
picker.showPopup();
emit picker.highlighted(4);
emit picker.highlighted(3);
picker.hidePopup();
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal);
QTRY_VERIFY(!session.blendPreview().has_value());
QCOMPARE(picker.currentText(), QString("Normal"));
// Another layer active at the choice: nothing changes.
picker.showPopup();
emit picker.highlighted(4);
emit picker.highlighted(3);
session.selectLayer(session.document().value().layers.front().id);
// Open, the menu keeps its text; closed, the active layer.
QCOMPARE(picker.currentText(), QString("Normal"));
picker.hidePopup();
emit picker.activated(4);
emit picker.activated(3);
QCOMPARE(session.document().value().layers.front().blendMode, LayerBlendMode::multiply);
QCOMPARE(session.document().value().layers.back().blendMode, LayerBlendMode::normal);
QCOMPARE(session.history.undoName(), QString("Import Image"));
@@ -131,7 +140,7 @@ void BlendModePickerTests::aMenuClosedWithoutAChoiceLeavesTheLayerAlone()
session.selectLayer(session.document().value().layers.front().id);
picker.setFocus();
QTest::keyClick(&picker, Qt::Key_Down);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorBurn);
}
void BlendModePickerTests::theMaskButtonAddsAWhiteMaskOnce()
+22 -1
View File
@@ -80,6 +80,20 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source)
case LayerBlendMode::difference: result[channel] = std::abs(b - s); break;
case LayerBlendMode::colorDodge: result[channel] = b == 0 ? 0 : s >= 1 ? 1 : std::min(1.0, b / (1 - s)); break;
case LayerBlendMode::colorBurn: result[channel] = b >= 1 ? 1 : s <= 0 ? 0 : 1 - std::min(1.0, (1 - b) / s); break;
// Photoshop's separable modes that QPainter lacks.
case LayerBlendMode::hardLight: result[channel] = hardLight(b, s); break;
case LayerBlendMode::exclusion: result[channel] = b + s - 2 * b * s; break;
case LayerBlendMode::linearBurn: result[channel] = std::max(0.0, b + s - 1); break;
case LayerBlendMode::linearDodge: result[channel] = std::min(1.0, b + s); break;
case LayerBlendMode::vividLight:
result[channel] = s <= 0.5 ? (b >= 1 ? 1 : s <= 0 ? 0 : 1 - std::min(1.0, (1 - b) / (2 * s)))
: (b <= 0 ? 0 : s >= 1 ? 1 : std::min(1.0, b / (2 - 2 * s)));
break;
case LayerBlendMode::linearLight: result[channel] = std::clamp(b + 2 * s - 1, 0.0, 1.0); break;
case LayerBlendMode::pinLight: result[channel] = s <= 0.5 ? std::min(b, 2 * s) : std::max(b, 2 * s - 1); break;
case LayerBlendMode::hardMix: result[channel] = b + s >= 1 ? 1 : 0; break;
case LayerBlendMode::subtract: result[channel] = std::max(0.0, b - s); break;
case LayerBlendMode::divide: result[channel] = s <= 0 ? 1 : std::min(1.0, b / s); break;
default: break;
}
}
@@ -140,7 +154,10 @@ void BlendModeTests::everyBlendModeMatchesThePdfFormulas_data()
{"overlay", LayerBlendMode::overlay}, {"softLight", LayerBlendMode::softLight}, {"darken", LayerBlendMode::darken}, {"lighten", LayerBlendMode::lighten},
{"difference", LayerBlendMode::difference}, {"colorDodge", LayerBlendMode::colorDodge},
{"colorBurn", LayerBlendMode::colorBurn}, {"hue", LayerBlendMode::hue}, {"saturation", LayerBlendMode::saturation},
{"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}};
{"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}, {"hardLight", LayerBlendMode::hardLight},
{"exclusion", LayerBlendMode::exclusion}, {"linearBurn", LayerBlendMode::linearBurn}, {"linearDodge", LayerBlendMode::linearDodge},
{"vividLight", LayerBlendMode::vividLight}, {"linearLight", LayerBlendMode::linearLight}, {"pinLight", LayerBlendMode::pinLight},
{"hardMix", LayerBlendMode::hardMix}, {"subtract", LayerBlendMode::subtract}, {"divide", LayerBlendMode::divide}};
for (const auto &[name, mode] : modes)
QTest::newRow(name) << mode;
}
@@ -224,6 +241,10 @@ void BlendModeTests::hslBlendChecksItsInputs()
QVERIFY(HslBlend::handles(LayerBlendMode::hue) && HslBlend::handles(LayerBlendMode::saturation));
QVERIFY(HslBlend::handles(LayerBlendMode::color) && HslBlend::handles(LayerBlendMode::luminosity));
QVERIFY(!HslBlend::handles(LayerBlendMode::normal) && !HslBlend::handles(LayerBlendMode::colorBurn));
QVERIFY(!HslBlend::handles(LayerBlendMode::hardLight) && !HslBlend::handles(LayerBlendMode::exclusion));
for (const LayerBlendMode mode : {LayerBlendMode::linearBurn, LayerBlendMode::linearDodge, LayerBlendMode::vividLight, LayerBlendMode::linearLight,
LayerBlendMode::pinLight, LayerBlendMode::hardMix, LayerBlendMode::subtract, LayerBlendMode::divide})
QVERIFY(HslBlend::handles(mode));
const QImage large(8192, 8192, QImage::Format_RGBA8888_Premultiplied), other(8192, 8192, QImage::Format_RGBA8888_Premultiplied);
std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024);
+2 -1
View File
@@ -29,8 +29,9 @@ void BlendShortcutTests::shiftPlusAndMinusStepTheActiveLayersBlendModeWhereverFo
session.selectTool(NavigationTool::brush);
canvas->setFocus();
QTRY_VERIFY(canvas->hasFocus());
// Photoshop's order: the first darkening mode follows Normal.
QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier);
QCOMPARE(mode(), LayerBlendMode::multiply);
QCOMPARE(mode(), LayerBlendMode::darken);
// From the list, back past Normal to the last mode.
session.selectTool(NavigationTool::lasso);
list->setFocus();
+19
View File
@@ -75,6 +75,7 @@ class CanvasDisplayTests : public QObject {
private slots:
void theBackgroundCheckerboardShadowAndBorderSurroundTheDocument();
void layersCompositeAsTheExportDoes();
void photoshopsOwnModesShowAsTheExportDoes();
void blendModesMeetNoCheckerboardButTranslucencyDoes();
void thePixelGridAppearsFromEightHundredPercent();
void fromTwoHundredPercentPixelsAreHardEdged();
@@ -190,6 +191,24 @@ void CanvasDisplayTests::layersCompositeAsTheExportDoes()
session.cancelTransform();
}
void CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes()
{
// Blended by hand on the canvas's surface, as exported.
for (const LayerBlendMode mode : {LayerBlendMode::linearDodge, LayerBlendMode::subtract, LayerBlendMode::hardMix, LayerBlendMode::hardLight}) {
Scene scene(20, 10, 60, 40);
scene.session.insert(filled(20, 10, qRgba(90, 140, 200, 255), "Ground"), QPointF(10, 5));
scene.session.insert(filled(10, 6, qRgba(120, 60, 160, 255), "Top"), QPointF(8, 5));
scene.session.setLayerBlendMode(mode);
scene.session.zoom(1);
const QImage exported = ImageExporter::render(scene.session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
const QImage shot = scene.shot();
const QPointF origin = scene.origin();
for (const QPoint at : {QPoint(5, 4), QPoint(16, 4)})
QCOMPARE(shot.pixel(int(origin.x()) + at.x(), int(origin.y()) + at.y()), exported.pixel(at));
QVERIFY(exported.pixel(5, 4) != qRgb(120, 60, 160) && exported.pixel(5, 4) != qRgb(90, 140, 200));
}
}
void CanvasDisplayTests::blendModesMeetNoCheckerboardButTranslucencyDoes()
{
Scene multiplied(20, 10, 60, 40);
+7
View File
@@ -99,6 +99,13 @@ void ClippingStackTests::aStackBlendsOnceWithItsBasesMode()
QCOMPARE(drawn.pixel(0, 0), qRgba(100, 50, 25, 255));
QCOMPARE(drawn.pixel(1, 1), qRgba(200, 100, 50, 255));
QCOMPARE(drawn.pixel(1, 0), qRgba(150, 75, 37, 255));
// A hand-blended mode too, where Swift's group falls to Normal.
scene.layers[base].blendMode = LayerBlendMode::linearBurn;
const QImage burnt = scene.render({background, base, top});
QCOMPARE(burnt.pixel(0, 0), qRgba(73, 0, 0, 255));
QCOMPARE(burnt.pixel(1, 1), qRgba(200, 100, 50, 255));
QCOMPARE(qRed(burnt.pixel(1, 0)), 136);
QCOMPARE(qGreen(burnt.pixel(1, 0)), 50);
}
void ClippingStackTests::laterChildrenCoverEarlierOnes()
+1 -1
View File
@@ -319,7 +319,7 @@ void EditorCanvasTests::shiftPlusAndMinusCycleTheBlendModeAcrossTheWindow()
const auto mode = [&] { return session.activeLayer().value().blendMode; };
QCOMPARE(mode(), LayerBlendMode::normal);
QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier);
QCOMPARE(mode(), LayerBlendMode::multiply);
QCOMPARE(mode(), LayerBlendMode::darken);
QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier);
QCOMPARE(mode(), LayerBlendMode::normal);
QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier);
+2 -1
View File
@@ -144,7 +144,8 @@ void GradientCanvasTests::escapeCancelsAndReturnApplies()
shown.drag(QPointF(50, 150), QPointF(350, 150));
QTest::keyClick(shown.canvas, Qt::Key_Return);
QTRY_COMPARE(shown.session.history.undoCount(), count + 1);
QVERIFY(!shown.session.gradientEdit());
// The edit goes a turn after its step lands.
QTRY_VERIFY(!shown.session.gradientEdit());
shown.drag(QPointF(50, 150), QPointF(350, 150));
QTest::keyClick(shown.canvas, Qt::Key_Enter);
QTRY_COMPARE(shown.session.history.undoCount(), count + 2);
+5 -4
View File
@@ -287,8 +287,9 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
const QUuid below = session.activeLayerID().value();
session.insert(asset(0.8));
const int count = session.history.undoCount();
// Photoshop's order: the first darkening mode follows Normal.
session.cycleBlendMode(true);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::multiply);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::darken);
// The mode is the active layer's alone.
QCOMPARE(layerWith(session, below).blendMode, LayerBlendMode::normal);
session.cycleBlendMode(false);
@@ -302,7 +303,7 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
session.setLayerBlendMode(LayerBlendMode::screen);
session.previewBlendMode(LayerBlendMode::hue, top);
session.cycleBlendMode(true);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
QVERIFY(!session.blendPreview().has_value());
// A refused cycle leaves a preview where it was.
session.previewBlendMode(LayerBlendMode::hue, top);
@@ -311,8 +312,8 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps()
session.cycleBlendMode(true);
QCOMPARE(session.blendPreview(), std::optional(EditorSession::BlendPreview{top, LayerBlendMode::hue}));
QCOMPARE(int(changes.count()), 0);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay);
QCOMPARE(int(allLayerBlendModes.size()), 14);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::colorDodge);
QCOMPARE(int(allLayerBlendModes.size()), 24);
for (size_t index = 0; index < allLayerBlendModes.size(); ++index)
QCOMPARE(int(allLayerBlendModes[index]), int(index));
}
+5 -5
View File
@@ -106,17 +106,17 @@ void PSDImportTests::importCreatesDocumentAndExistingCanvasGetsAGroup()
void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
const QUrl url = photoshop(folder, "Dissolved.psd", {layer("Dissolved", QRectF(0, 0, 2, 2), "diss")}, QSize(2, 2));
EditorSession session;
bool done = false;
session.importImages({url}, std::nullopt, [&done] { done = true; });
QVERIFY(session.showsConversionSheet() && session.conversionRequest().value().isReading);
QTRY_VERIFY(!session.conversionRequest().value().isReading);
const PSDConversionRequest &request = session.conversionRequest().value();
QCOMPARE(request.title, QString("Open “Vivid.psd”?"));
QCOMPARE(request.title, QString("Open “Dissolved.psd”?"));
QCOMPARE(request.confirmTitle, QString("Import"));
QCOMPARE(request.conversions.size(), size_t(1));
QCOMPARE(request.conversions[0].layerName, QString("Vivid"));
QCOMPARE(request.conversions[0].layerName, QString("Dissolved"));
QVERIFY(session.isImporting() && !done);
session.finishConversion(false);
QVERIFY(!session.showsConversionSheet() && !session.conversionRequest());
@@ -127,14 +127,14 @@ void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged()
void PSDImportTests::aConfirmedConversionImportsAndCancelWhileReadingStops()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
const QUrl url = photoshop(folder, "Dissolved.psd", {layer("Dissolved", QRectF(0, 0, 2, 2), "diss")}, QSize(2, 2));
EditorSession session;
bool done = false;
session.importImages({url}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.conversionRequest() && !session.conversionRequest().value().isReading);
session.finishConversion(true);
QTRY_VERIFY(done);
QCOMPARE(names(session), QStringList{"Vivid"});
QCOMPARE(names(session), QStringList{"Dissolved"});
QVERIFY(session.document().value().layers[0].blendMode == LayerBlendMode::normal);
// Cancel while reading: nothing lands, nothing is said.
EditorSession stopped;
+16 -6
View File
@@ -103,7 +103,7 @@ private slots:
void unusedSpotChannelsAreSkippedBeforeDecode();
void unsupportedCompressionOnColorChannelsIsStillRejected();
void matchesRequiresPhotoshopMagic();
void unknownBlendProducesConversionReport();
void unsupportedBlendProducesConversionReport();
void softLightImportsWithoutConversion();
void folderOpacityImportsOntoTheFolder();
void unsupportedHeadersAreRejected();
@@ -239,16 +239,26 @@ void PSDRoundTripTests::matchesRequiresPhotoshopMagic()
QVERIFY(PSDReader::matches(QByteArray("8BPS")) && !PSDReader::matches(QByteArray("8BP")));
}
void PSDRoundTripTests::unknownBlendProducesConversionReport()
void PSDRoundTripTests::unsupportedBlendProducesConversionReport()
{
// Dissolve scatters pixels by opacity: no twin, so Normal.
const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0);
PSDRecord layer = PSDFixture::record("Vivid", fill, QRectF(0, 0, 2, 2));
layer.blendKey = "vLit";
PSDRecord layer = PSDFixture::record("Dissolved", fill, QRectF(0, 0, 2, 2));
layer.blendKey = "diss";
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, fill)));
QCOMPARE(imported.conversions.size(), size_t(1));
QCOMPARE(imported.conversions[0].layerName, QString("Vivid"));
QCOMPARE(imported.conversions[0].message, QString("Blend mode “vLit” isn’t supported and will be applied as Normal."));
QCOMPARE(imported.conversions[0].layerName, QString("Dissolved"));
QCOMPARE(imported.conversions[0].message, QString("Blend mode “diss” isn’t supported and will be applied as Normal."));
QVERIFY(imported.layers[0].blendMode == LayerBlendMode::normal);
for (const char *key : {"dkCl", "lgCl"})
QVERIFY2(!fromPSD(key), key);
const std::vector<std::pair<const char *, LayerBlendMode>> keys{
{"lbrn", LayerBlendMode::linearBurn}, {"lddg", LayerBlendMode::linearDodge}, {"hLit", LayerBlendMode::hardLight},
{"vLit", LayerBlendMode::vividLight}, {"lLit", LayerBlendMode::linearLight}, {"pLit", LayerBlendMode::pinLight},
{"hMix", LayerBlendMode::hardMix}, {"smud", LayerBlendMode::exclusion}, {"fsub", LayerBlendMode::subtract},
{"fdiv", LayerBlendMode::divide}};
for (const auto &[key, mode] : keys)
QCOMPARE(fromPSD(key), std::optional(mode));
}
void PSDRoundTripTests::softLightImportsWithoutConversion()
+3 -2
View File
@@ -202,8 +202,9 @@ void ProjectManifestTests::encodingWritesSwiftsKeysAndLeavesAbsentOnesOut()
void ProjectManifestTests::everyRawValueRoundTrips()
{
const QStringList blends{"Normal", "Multiply", "Screen", "Overlay", "Soft Light", "Darken", "Lighten", "Difference", "Color Dodge", "Color Burn",
"Hue", "Saturation", "Color", "Luminosity"};
const QStringList blends{"Normal", "Darken", "Multiply", "Color Burn", "Linear Burn", "Lighten", "Screen", "Color Dodge",
"Linear Dodge (Add)", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light", "Hard Mix",
"Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity"};
for (int index = 0; index < blends.size(); ++index) {
QCOMPARE(rawValue(LayerBlendMode(index)), blends[index]);
QCOMPARE(layerBlendMode(blends[index]), std::optional(LayerBlendMode(index)));