The document's own pixel budget, marching ants at screen resolution (12.11a)
This commit is contained in:
1 parent
ffba1753c6
commit
8a9c56ee1f
75 files changed
+759
-189
No files matched your search
@@ -269,9 +269,9 @@ void AdjustmentEditorTests::theEditorOpensOnlyForItsRequest()
|
||||
|
||||
void AdjustmentEditorTests::aFailedRenderEndsTheRequest()
|
||||
{
|
||||
// Past the export's hundred million pixels: the render refuses.
|
||||
// Past the export's one surface: the render refuses.
|
||||
EditorSession session;
|
||||
session.createDocument(10'001, 10'000);
|
||||
session.createDocument(20'001, 10'000);
|
||||
session.addAdjustment(AdjustmentKind::curves);
|
||||
const int steps = session.history.undoCount();
|
||||
int ran = 0;
|
||||
@@ -481,7 +481,7 @@ void AdjustmentEditorTests::everyStepIsAnnouncedAndItsLastSignalSeesIt()
|
||||
// A failed render's last signal sees the request end.
|
||||
EditorSession huge;
|
||||
connect(&huge, &EditorSession::changed, this, [&] { seen = described(huge); });
|
||||
huge.createDocument(10'001, 10'000);
|
||||
huge.createDocument(20'001, 10'000);
|
||||
huge.addAdjustment(AdjustmentKind::levels);
|
||||
seen.clear();
|
||||
QVERIFY(opened(huge, huge.adjustmentEditingID().value()));
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
#include <vector>
|
||||
|
||||
// A layer claiming its size in pixels, holding one.
|
||||
inline ImportedImage claimed(int width, int height, const QString &name = QStringLiteral("Claimed"))
|
||||
{
|
||||
const auto raster = std::make_shared<const RasterSnapshot>(width, height, BrushRaster::context(1, 1, false), QRectF(0, 0, width, height),
|
||||
std::vector<BrushPatch>{});
|
||||
return ImportedImage(raster, QImage(), name);
|
||||
}
|
||||
|
||||
// Layers claiming `pixels` in all, each within one surface.
|
||||
inline std::vector<ImportedImage> claiming(qint64 pixels)
|
||||
{
|
||||
std::vector<ImportedImage> layers;
|
||||
const int side = DocumentLimits::maxSide;
|
||||
while (pixels > 0) {
|
||||
const qint64 rows = std::min<qint64>(pixels / side, DocumentLimits::maxSurfacePixels / side);
|
||||
layers.push_back(rows > 0 ? claimed(side, int(rows)) : claimed(int(pixels), 1));
|
||||
pixels -= rows > 0 ? side * rows : pixels;
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "BudgetFixtures.h"
|
||||
#include "AddressSpaceLimit.h"
|
||||
#include "Document/CanvasSize.h"
|
||||
#include "Document/EditorSession.h"
|
||||
@@ -264,13 +265,18 @@ void CanvasSizeTests::masksPlacedApartMoveWithTheirLayers()
|
||||
|
||||
void CanvasSizeTests::theExtensionCountsAgainstTheBudgets()
|
||||
{
|
||||
// A painted 9000 x 9000 layer leaves 19 megapixels.
|
||||
const QUuid id = QUuid::createUuid();
|
||||
const std::shared_ptr<const RasterSnapshot> raster = painted(solid(9000, 9000, qRgba(0, 0, 0, 0)), {});
|
||||
const ProjectLayerRecord record{.id = id, .name = "Painted", .isVisible = true, .transform = {.origin = {0, 0}, .size = {9000, 9000}},
|
||||
.imageFile = uuidString(id) + ".png"};
|
||||
const ProjectSnapshot input{.manifest = {.documentID = QUuid::createUuid(), .width = 100, .height = 100, .activeLayerID = id, .layers = {record}},
|
||||
.images = {{id, ImportedImage(raster, solid(1, 1, 0), "Painted")}}};
|
||||
// Painted layers leave 19 megapixels of the document budget.
|
||||
ProjectSnapshot input{.manifest = {.documentID = QUuid::createUuid(), .width = 100, .height = 100, .activeLayerID = std::nullopt, .layers = {}},
|
||||
.images = {}};
|
||||
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 19'000'000)) {
|
||||
const QUuid layerID = QUuid::createUuid();
|
||||
input.manifest.layers.push_back({.id = layerID, .name = "Painted", .isVisible = true, .transform = {.origin = {0, 0}, .size = QSizeF(layer.size())},
|
||||
.imageFile = uuidString(layerID) + ".png"});
|
||||
input.images.insert({layerID, layer});
|
||||
}
|
||||
const QUuid id = input.manifest.layers.front().id;
|
||||
input.manifest.activeLayerID = id;
|
||||
const std::shared_ptr<const RasterSnapshot> raster = input.images.at(id).raster;
|
||||
const CanvasExtensionColor white{1, 1, 1};
|
||||
QVERIFY(!raster->hasMaterializedPixels());
|
||||
QCOMPARE(resizeError(input, {.width = 4750, .height = 4001, .fill = white}), std::optional(ProjectError::Kind::tooLarge));
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include <QtTest>
|
||||
#include <unistd.h>
|
||||
|
||||
// Swift 1.2.8's DocumentLimits: one surface, and a document's budget.
|
||||
class DocumentLimitsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theCeilingsAreSwifts();
|
||||
};
|
||||
|
||||
void DocumentLimitsTests::theCeilingsAreSwifts()
|
||||
{
|
||||
QCOMPARE(DocumentLimits::maxSide, 30'000);
|
||||
QCOMPARE(DocumentLimits::maxSurfacePixels, qint64(200'000'000));
|
||||
QCOMPARE(DocumentLimits::maxSurfaceMegapixels(), qint64(200));
|
||||
QCOMPARE(DocumentLimits::maxSideText(), QString("30,000"));
|
||||
// A quarter of memory, four bytes a pixel, clamped.
|
||||
const qint64 memory = qint64(sysconf(_SC_PHYS_PAGES)) * qint64(sysconf(_SC_PAGE_SIZE));
|
||||
const qint64 budget = std::clamp(memory / 16, qint64(200'000'000), qint64(800'000'000));
|
||||
QCOMPARE(DocumentLimits::documentPixelBudget(), budget);
|
||||
QCOMPARE(DocumentLimits::documentBudgetMegapixels(), budget / 1'000'000);
|
||||
// Swift's 537 MP on 8 GB; the cap, the floor.
|
||||
QCOMPARE(DocumentLimits::budgetFor(8ll << 30), qint64(536'870'912));
|
||||
QCOMPARE(DocumentLimits::budgetFor(16ll << 30), qint64(800'000'000));
|
||||
QCOMPARE(DocumentLimits::budgetFor(2ll << 30), qint64(200'000'000));
|
||||
// Both stay under a square at the side limit.
|
||||
QVERIFY(DocumentLimits::documentPixelBudget() < qint64(DocumentLimits::maxSide) * DocumentLimits::maxSide);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(DocumentLimitsTests)
|
||||
#include "DocumentLimitsTests.moc"
|
||||
@@ -104,7 +104,7 @@ void ExportTests::blankCanvasAndOversizedCanvas()
|
||||
const auto sized = [](int width, int height) {
|
||||
return ProjectSnapshot{.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = std::nullopt, .layers = {}}, .images = {}};
|
||||
};
|
||||
for (const QSize &size : {QSize(30'000, 30'000), QSize(30'001, 1), QSize(1, 30'001), QSize(0, 5), QSize(5, 0), QSize(10'001, 10'000)}) {
|
||||
for (const QSize &size : {QSize(30'000, 30'000), QSize(30'001, 1), QSize(1, 30'001), QSize(0, 5), QSize(5, 0), QSize(20'001, 10'000)}) {
|
||||
try {
|
||||
ImageExporter::render(sized(size.width(), size.height()));
|
||||
QFAIL("an oversized or empty canvas must be refused");
|
||||
|
||||
@@ -183,10 +183,10 @@ void FilterModelTests::aBlurGrowsTheGridAndNeverShrinksIt()
|
||||
// A motion blur's reach is half its streak.
|
||||
const FilterEdit motion(FilterKind::motionBlur, layer(4, 4), std::nullopt, FilterSettings{.distance = 9}, std::nullopt);
|
||||
QCOMPARE(motion.grownImage.value().size(), QSize(18, 18));
|
||||
// Past 30,000 a side or 100 million pixels: refused.
|
||||
// Past 30,000 a side or one surface's pixels: refused.
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(29990, 1), std::nullopt, FilterSettings{.radius = 3}, std::nullopt));
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(1, 29990), std::nullopt, FilterSettings{.radius = 3}, std::nullopt));
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, ImageLayer(painted(9990, 9990), QPointF()), std::nullopt,
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, ImageLayer(painted(14'140, 14'140), QPointF()), std::nullopt,
|
||||
FilterSettings{.radius = 3}, std::nullopt));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QtTest>
|
||||
|
||||
@@ -272,7 +273,7 @@ void GroupTests::projectErrorsCarryTheirMessages()
|
||||
const ProjectError tooLarge(ProjectError::Kind::tooLarge);
|
||||
QCOMPARE(tooLarge.kind, ProjectError::Kind::tooLarge);
|
||||
QCOMPARE(QString(tooLarge.what()),
|
||||
QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."));
|
||||
QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels()));
|
||||
|
||||
const ProjectError encode(ProjectError::Kind::encode);
|
||||
QCOMPARE(encode.kind, ProjectError::Kind::encode);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "BudgetFixtures.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
#include <QSignalSpy>
|
||||
@@ -8,7 +9,7 @@
|
||||
namespace {
|
||||
const QString unreadable = QStringLiteral("The image could not be read. It may be damaged or unavailable.");
|
||||
const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.");
|
||||
const QString tooLarge = QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
|
||||
const QString tooLarge = QStringLiteral("This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels());
|
||||
|
||||
// A 64 by 32 PNG, as Swift's fixture is.
|
||||
QUrl fixture(const QTemporaryDir &folder, const QString &name = QStringLiteral("fixture.png"))
|
||||
@@ -52,12 +53,6 @@ public:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Pixels the canvas holds without the memory for them.
|
||||
ImportedImage hollow(int width, int height)
|
||||
{
|
||||
return ImportedImage(std::make_shared<const RasterSnapshot>(width, height, QImage(), QRectF(), std::vector<BrushPatch>()), QImage(), "Hollow");
|
||||
}
|
||||
}
|
||||
|
||||
class ImageImportSessionTests : public QObject {
|
||||
@@ -314,17 +309,19 @@ void ImageImportSessionTests::thePixelBudgetCountsWhatTheCanvasHolds()
|
||||
QTemporaryDir folder;
|
||||
const QUrl url = fixture(folder);
|
||||
EditorSession session;
|
||||
// 100,000,000 less these leaves 2,048: the fixture exactly.
|
||||
session.insert(hollow(10'000, 9'999));
|
||||
session.insert(hollow(7'952, 1));
|
||||
// The budget less these leaves 2,048: the fixture exactly.
|
||||
const std::vector<ImportedImage> filling = claiming(DocumentLimits::documentPixelBudget() - 2'048);
|
||||
for (const ImportedImage &layer : filling)
|
||||
session.insert(layer);
|
||||
session.addBlankLayer();
|
||||
const int count = int(filling.size()) + 2;
|
||||
QVERIFY(imported(session, {url}));
|
||||
QCOMPARE(int(session.document().value().layers.size()), 4);
|
||||
QCOMPARE(int(session.document().value().layers.size()), count);
|
||||
QCOMPARE(session.importError(), std::nullopt);
|
||||
// Full now: the next pixel is one too many.
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*fixture\\.png: .*"));
|
||||
QVERIFY(imported(session, {url}));
|
||||
QCOMPARE(int(session.document().value().layers.size()), 4);
|
||||
QCOMPARE(int(session.document().value().layers.size()), count);
|
||||
QCOMPARE(session.importError(), std::optional("fixture.png: " + tooLarge));
|
||||
QVERIFY(!session.document().value().layers[0].asset.value().raster->hasMaterializedPixels());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "AddressSpaceLimit.h"
|
||||
#include "IO/ImageImporter.h"
|
||||
#include <QBuffer>
|
||||
@@ -461,7 +462,7 @@ void ImageImportTests::errorsCarrySwiftsDescriptions()
|
||||
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unreadable).what()), QString("The image could not be read. It may be damaged or unavailable."));
|
||||
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file."));
|
||||
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::tooLarge).what()),
|
||||
QString("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."));
|
||||
QStringLiteral("This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels()));
|
||||
QCOMPARE(ImageImportError(ImageImportError::Kind::tooLarge).kind, ImageImportError::Kind::tooLarge);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "UI/ColorPickerSheet.h"
|
||||
#include "UI/ImageSizeSheet.h"
|
||||
#include <QCheckBox>
|
||||
@@ -154,7 +155,7 @@ void ImageSizeSheetTests::aNewResolutionKeepsThePrintSize()
|
||||
// Zero changes nothing but the resolution, which is refused.
|
||||
shown.unit("Inches");
|
||||
shown.type("imageResolution", "0");
|
||||
QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
|
||||
QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
||||
shown.type("imageResolution", "150");
|
||||
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
|
||||
// Centimetres keep the print size alike.
|
||||
@@ -203,14 +204,14 @@ void ImageSizeSheetTests::anInvalidSizeRestsResize()
|
||||
QVERIFY(resize.isDefault() && resize.isEnabled());
|
||||
shown.find<QCheckBox>("imageLocked").click();
|
||||
shown.type("imageWidth", "30001");
|
||||
QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
|
||||
QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
||||
QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::BrightText);
|
||||
QVERIFY(!resize.isEnabled());
|
||||
// Past 100 megapixels while resampling.
|
||||
// Past one surface's 200 megapixels while resampling.
|
||||
shown.type("imageWidth", "20000");
|
||||
shown.type("imageHeight", "5001");
|
||||
shown.type("imageHeight", "10001");
|
||||
QVERIFY(!resize.isEnabled());
|
||||
shown.type("imageHeight", "5000");
|
||||
shown.type("imageHeight", "10000");
|
||||
QVERIFY(resize.isEnabled());
|
||||
QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::PlaceholderText);
|
||||
shown.type("imageResolution", "9601");
|
||||
|
||||
+43
-25
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "AddressSpaceLimit.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/LayerMask.h"
|
||||
@@ -170,8 +171,8 @@ void ImageSizeTests::limitsAreJudgedAtTheirEdges()
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'001, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 20'000, .height = 10'000, .resolution = 72}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 20'000, .height = 10'001, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 30'000, .height = 1, .resolution = 72}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 1, .height = 30'000, .resolution = 72}), std::nullopt);
|
||||
// The same size skips every other rule: only resolution changes.
|
||||
@@ -183,39 +184,56 @@ void ImageSizeTests::limitsAreJudgedAtTheirEdges()
|
||||
|
||||
void ImageSizeTests::budgetsCountEveryLayer()
|
||||
{
|
||||
// Scaled a hundredfold, the small layer takes a megapixel.
|
||||
// Widened twice: a narrow layer, the remainder; wide, rows.
|
||||
const qint64 budget = DocumentLimits::documentPixelBudget();
|
||||
const qint64 rows = (budget - 1) / 30'000, rest = budget - rows * 30'000;
|
||||
ProjectSnapshot input = imported();
|
||||
input.manifest.width = 100;
|
||||
input.manifest.height = 100;
|
||||
ProjectLayerRecord small = input.manifest.layers[0];
|
||||
small.transform = {.origin = {0, 0}, .size = {10, 10}};
|
||||
ProjectLayerRecord whole = small;
|
||||
whole.id = QUuid::createUuid();
|
||||
whole.imageFile = uuidString(whole.id) + ".png";
|
||||
whole.transform = {.origin = {0, 0}, .size = {100, 100}};
|
||||
input.images.insert({whole.id, asset(halfRed(), "whole")});
|
||||
input.manifest.layers = {small, whole};
|
||||
ProjectLayerRecord narrow = input.manifest.layers[0];
|
||||
narrow.transform = {.origin = {0, 0}, .size = {double(rest) / 2, 1}};
|
||||
ProjectLayerRecord wide = narrow;
|
||||
wide.id = QUuid::createUuid();
|
||||
wide.imageFile = uuidString(wide.id) + ".png";
|
||||
wide.transform = {.origin = {0, 0}, .size = {15'000, double(rows)}};
|
||||
const ImageSizeOptions widened{.width = 200, .height = 100, .resolution = 72};
|
||||
const auto tooLarge = std::optional(ProjectError::Kind::tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
// A megapixel and 99 more fill the budget exactly.
|
||||
input.manifest.layers[1].transform.size = {99, 100};
|
||||
const ProjectSnapshot full = ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(full.images.at(whole.id).size(), QSize(9'900, 10'000));
|
||||
input.manifest.layers[1].transform.size = {99.01, 100};
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
// Exactly the budget passes: the wide layer's missing image fails.
|
||||
input.manifest.layers = {narrow, wide};
|
||||
QCOMPARE(resizeError(input, widened), std::optional(ProjectError::Kind::missingImage));
|
||||
// One pixel more, counted across both layers, does not.
|
||||
input.manifest.layers[0].transform.size = {double(rest + 1) / 2, 1};
|
||||
QCOMPARE(resizeError(input, widened), tooLarge);
|
||||
input.manifest.layers = {wide};
|
||||
QCOMPARE(resizeError(input, widened), std::optional(ProjectError::Kind::missingImage));
|
||||
// Masks have their own budget, counted the same way.
|
||||
input.manifest.layers = {small, whole};
|
||||
input.manifest.layers = {narrow, wide};
|
||||
input.manifest.layers[0].transform.size = {double(rest + 1) / 2, 1};
|
||||
for (ProjectLayerRecord &layer : input.manifest.layers) {
|
||||
layer.imageFile = std::nullopt;
|
||||
layer.maskFile = uuidString(layer.id) + ".mask.png";
|
||||
input.masks.insert({layer.id, LayerMask::assetFrom(gray(4, 4, 200))});
|
||||
}
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
input.manifest.layers[1].transform.size = {99, 100};
|
||||
QCOMPARE(ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest})
|
||||
.masks.at(whole.id).size(), QSize(9'900, 10'000));
|
||||
input.manifest.layers[1].transform.size = {99.01, 100};
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, widened), tooLarge);
|
||||
// Exactly the budget passes: its surface then finds no memory.
|
||||
input.manifest.layers[0].transform.size = {double(rest) / 2, 1};
|
||||
std::optional<ExportError::Kind> kind;
|
||||
{
|
||||
const AddressSpaceLimit limit(16ll * 1024 * 1024);
|
||||
try {
|
||||
ImageResizer::resize(input, widened);
|
||||
} catch (const ExportError &error) {
|
||||
kind = error.kind;
|
||||
}
|
||||
}
|
||||
QCOMPARE(kind, std::optional(ExportError::Kind::render));
|
||||
// Past one surface, within the document's: a mask still fits.
|
||||
if (budget > DocumentLimits::maxSurfacePixels + 30'000) {
|
||||
const int past = int(DocumentLimits::maxSurfacePixels / 30'000) + 1;
|
||||
input.manifest.layers = {input.manifest.layers[1]};
|
||||
input.manifest.layers[0].transform.size = {15'000, double(past)};
|
||||
QCOMPARE(ImageResizer::resize(input, widened).masks.at(wide.id).size(), QSize(30'000, past));
|
||||
}
|
||||
// A side past 30,000 is refused though the pixels fit.
|
||||
ProjectSnapshot strip = imported();
|
||||
strip.manifest.layers[0].transform = {.origin = {0, 0}, .size = {64, 1}};
|
||||
|
||||
@@ -282,8 +282,8 @@ void ImageTrimTests::nothingToTrimOrACancelChangesNothing()
|
||||
void ImageTrimTests::aTrimThatFailsIsExplained()
|
||||
{
|
||||
Desk desk;
|
||||
// Past the render's hundred megapixels, the trim cannot look.
|
||||
desk.session.createDocument(20'000, 10'000);
|
||||
// Past the render's one surface, the trim cannot look.
|
||||
desk.session.createDocument(20'001, 10'000);
|
||||
const CanvasDocument before = desk.session.document().value();
|
||||
DialogDesk alerts;
|
||||
alerts.replies = {"OK"};
|
||||
|
||||
@@ -280,8 +280,8 @@ void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels()
|
||||
|
||||
void LayerEffectsKernelTests::theKernelRefusesWhatItCannotHold()
|
||||
{
|
||||
// Past a hundred million pixels, before any pixel is read.
|
||||
const QImage vast(10'001, 10'000, QImage::Format_Mono);
|
||||
// Past one surface's pixels, before any pixel is read.
|
||||
const QImage vast(20'001, 10'000, QImage::Format_Mono);
|
||||
LayerEffects effects;
|
||||
effects.stroke = StrokeEffect();
|
||||
try {
|
||||
|
||||
@@ -128,7 +128,7 @@ void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel()
|
||||
LayerEffects shadow;
|
||||
shadow.shadow = ShadowEffect{.distance = 10};
|
||||
try {
|
||||
LayerEffectsRenderer::render(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow);
|
||||
LayerEffectsRenderer::render(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow);
|
||||
QFAIL("a vast layer rendered");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.kind, ProjectError::Kind::tooLarge);
|
||||
@@ -188,7 +188,7 @@ void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget()
|
||||
// Too large to make: the layer draws bare.
|
||||
LayerEffects shadow;
|
||||
shadow.shadow = ShadowEffect{.distance = 10};
|
||||
QVERIFY(!LayerEffectsRenderer::cached(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow));
|
||||
QVERIFY(!LayerEffectsRenderer::cached(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow));
|
||||
}
|
||||
|
||||
void LayerEffectsRendererTests::theExportAndTheCompositeDrawTheEffects()
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#include "SelectionCanvasFixtures.h"
|
||||
|
||||
// Swift 1.2.10 (8eba077): ants from a screen-resolution outline.
|
||||
namespace {
|
||||
// Alternate single pixels: far past the 20,000 elements drawn whole.
|
||||
QPainterPath checkerboard(QRect region)
|
||||
{
|
||||
QPainterPath path;
|
||||
path.setFillRule(Qt::WindingFill);
|
||||
for (int y = region.top(); y <= region.bottom(); ++y) {
|
||||
for (int x = region.left(); x <= region.right(); ++x) {
|
||||
if ((x + y) % 2 == 0)
|
||||
path.addRect(QRectF(x, y, 1, 1));
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Pixels the ants changed within `area`, against the bare canvas.
|
||||
int marked(const QImage &shot, const QImage &bare, QRect area)
|
||||
{
|
||||
int count = 0;
|
||||
for (int y = area.top(); y <= area.bottom(); ++y) {
|
||||
for (int x = area.left(); x <= area.right(); ++x)
|
||||
count += shot.pixel(x, y) != bare.pixel(x, y);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A border band around a document rectangle, in view pixels.
|
||||
QRect band(Canvas &shown, QRect region)
|
||||
{
|
||||
const QSizeF size = shown.documentSize();
|
||||
return QRectF(shown.session.viewport.viewPoint(region.topLeft(), size), shown.session.viewport.viewPoint(region.bottomRight() + QPoint(1, 1), size))
|
||||
.toAlignedRect()
|
||||
.adjusted(-2, -2, 2, 2);
|
||||
}
|
||||
|
||||
QTimer &ants(Canvas &shown)
|
||||
{
|
||||
return *shown.canvas->findChild<QTimer *>(QStringLiteral("antsTimer"));
|
||||
}
|
||||
|
||||
QRect viewRect(Canvas &shown, QRect region)
|
||||
{
|
||||
const QSizeF size = shown.documentSize();
|
||||
return QRectF(shown.session.viewport.viewPoint(region.topLeft(), size), shown.session.viewport.viewPoint(region.bottomRight() + QPoint(1, 1), size))
|
||||
.toAlignedRect();
|
||||
}
|
||||
}
|
||||
|
||||
class MarchingAntsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aComplexOutlineZoomedOutIsTracedAtScreenResolution();
|
||||
void simpleOutlinesAndActualPixelsDrawInFull();
|
||||
void aTickWaitsForTheRepaintBeforeIt();
|
||||
void aNewSourceDropsTheOldOutline();
|
||||
void aNewStepKeepsTheOldOutlineUntilItLands();
|
||||
void theStepFollowsTheZoomAndTheFillRuleHolds();
|
||||
};
|
||||
|
||||
void MarchingAntsTests::aComplexOutlineZoomedOutIsTracedAtScreenResolution()
|
||||
{
|
||||
Canvas shown;
|
||||
shown.session.zoom(0.25);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage bare = shown.canvas->grab().toImage();
|
||||
const QRect region(100, 100, 150, 150);
|
||||
const QPainterPath board = checkerboard(region);
|
||||
QVERIFY(board.elementCount() > 20'000);
|
||||
shown.session.setSelection(DocumentSelection{board}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
ants(shown).stop();
|
||||
// While the first outline is traced, no ants show.
|
||||
QCOMPARE(shown.canvas->grab().toImage(), bare);
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
|
||||
// The trace covers the board: its edge alone shows.
|
||||
const QImage shot = shown.canvas->grab().toImage();
|
||||
const QRect view = viewRect(shown, region);
|
||||
QCOMPARE(marked(shot, bare, view.adjusted(3, 3, -3, -3)), 0);
|
||||
QVERIFY(marked(shot, bare, view.adjusted(-2, -2, 2, 2)) > view.width() * 2);
|
||||
}
|
||||
|
||||
void MarchingAntsTests::simpleOutlinesAndActualPixelsDrawInFull()
|
||||
{
|
||||
// At actual pixels the complex outline draws whole, at once.
|
||||
Canvas shown;
|
||||
const QImage bare = shown.canvas->grab().toImage();
|
||||
const QRect region(100, 100, 150, 150);
|
||||
shown.session.setSelection(DocumentSelection{checkerboard(region)}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
ants(shown).stop();
|
||||
const QImage full = shown.canvas->grab().toImage();
|
||||
QVERIFY(marked(full, bare, viewRect(shown, region).adjusted(10, 10, -10, -10)) > 1'000);
|
||||
// Zoomed out, a marquee's outline draws at once.
|
||||
Canvas other;
|
||||
other.session.zoom(0.25);
|
||||
other.canvas->synchronizeDisplay();
|
||||
const QImage plain = other.canvas->grab().toImage();
|
||||
other.session.applySelection(rectPath(QRectF(region)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
other.canvas->synchronizeDisplay();
|
||||
ants(other).stop();
|
||||
QVERIFY(other.canvas->grab().toImage() != plain);
|
||||
}
|
||||
|
||||
void MarchingAntsTests::aTickWaitsForTheRepaintBeforeIt()
|
||||
{
|
||||
Canvas first, second;
|
||||
for (Canvas *shown : {&first, &second}) {
|
||||
shown->session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
shown->canvas->synchronizeDisplay();
|
||||
ants(*shown).stop();
|
||||
}
|
||||
QCOMPARE(first.canvas->grab().toImage(), second.canvas->grab().toImage());
|
||||
// Two ticks before a repaint step once, as one.
|
||||
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
|
||||
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
|
||||
QVERIFY(QMetaObject::invokeMethod(&ants(second), "timeout"));
|
||||
const QImage stepped = first.canvas->grab().toImage();
|
||||
QCOMPARE(stepped, second.canvas->grab().toImage());
|
||||
// Once painted, the next tick steps again.
|
||||
QVERIFY(QMetaObject::invokeMethod(&ants(first), "timeout"));
|
||||
QVERIFY(first.canvas->grab().toImage() != stepped);
|
||||
}
|
||||
|
||||
void MarchingAntsTests::aNewSourceDropsTheOldOutline()
|
||||
{
|
||||
Canvas shown;
|
||||
shown.session.zoom(0.25);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage bare = shown.canvas->grab().toImage();
|
||||
const QRect first(10, 10, 120, 120), second(200, 100, 120, 120);
|
||||
shown.session.setSelection(DocumentSelection{checkerboard(first)}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
ants(shown).stop();
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
|
||||
// Another outline: the first goes at once; the second shows.
|
||||
shown.session.setSelection(DocumentSelection{checkerboard(second)}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
QCOMPARE(shown.canvas->grab().toImage(), bare);
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
|
||||
const QImage shot = shown.canvas->grab().toImage();
|
||||
QCOMPARE(marked(shot, bare, band(shown, first)), 0);
|
||||
QVERIFY(marked(shot, bare, band(shown, second)) > 0);
|
||||
}
|
||||
|
||||
void MarchingAntsTests::aNewStepKeepsTheOldOutlineUntilItLands()
|
||||
{
|
||||
Canvas shown;
|
||||
shown.session.zoom(0.5);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage half = shown.canvas->grab().toImage();
|
||||
shown.session.zoom(0.25);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage quarter = shown.canvas->grab().toImage();
|
||||
const QRect region(10, 10, 120, 120);
|
||||
shown.session.setSelection(DocumentSelection{checkerboard(region)}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
ants(shown).stop();
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != quarter);
|
||||
// A step in, the quarter's outline stands in meanwhile.
|
||||
shown.session.zoom(0.5);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
QVERIFY(marked(shown.canvas->grab().toImage(), half, band(shown, region)) > 0);
|
||||
}
|
||||
|
||||
void MarchingAntsTests::theStepFollowsTheZoomAndTheFillRuleHolds()
|
||||
{
|
||||
// Boards two pixels apart: a half step keeps the gap.
|
||||
Canvas shown;
|
||||
shown.session.zoom(0.5);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage bare = shown.canvas->grab().toImage();
|
||||
QPainterPath boards = checkerboard(QRect(10, 10, 60, 180));
|
||||
boards.addPath(checkerboard(QRect(72, 10, 60, 180)));
|
||||
shown.session.setSelection(DocumentSelection{boards}, QStringLiteral("Select"));
|
||||
shown.canvas->synchronizeDisplay();
|
||||
ants(shown).stop();
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != bare);
|
||||
const QSizeF size = shown.documentSize();
|
||||
const QPointF gap = shown.session.viewport.viewPoint(QPointF(71, 100), size);
|
||||
QVERIFY(marked(shown.canvas->grab().toImage(), bare, QRect(gap.toPoint() - QPoint(1, 20), QSize(3, 40))) > 0);
|
||||
// An odd-even ring keeps its hole through the trace.
|
||||
Canvas ring;
|
||||
ring.session.zoom(0.25);
|
||||
ring.canvas->synchronizeDisplay();
|
||||
const QImage plain = ring.canvas->grab().toImage();
|
||||
QPainterPath holed = checkerboard(QRect(40, 40, 200, 50));
|
||||
holed.addRect(QRectF(40, 40, 200, 200));
|
||||
holed.addRect(QRectF(100, 100, 80, 80));
|
||||
holed.setFillRule(Qt::OddEvenFill);
|
||||
ring.session.setSelection(DocumentSelection{holed}, QStringLiteral("Select"));
|
||||
ring.canvas->synchronizeDisplay();
|
||||
ants(ring).stop();
|
||||
QTRY_VERIFY(ring.canvas->grab().toImage() != plain);
|
||||
QVERIFY(marked(ring.canvas->grab().toImage(), plain, band(ring, QRect(100, 100, 80, 80))) > 0);
|
||||
}
|
||||
|
||||
QTEST_MAIN(MarchingAntsTests)
|
||||
#include "MarchingAntsTests.moc"
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "ContentView.h"
|
||||
#include "PSDFixture.h"
|
||||
#include <QLabel>
|
||||
@@ -300,7 +301,7 @@ void PSDImportTests::aFileTheSessionRefusesIsReported()
|
||||
session.insertPhotoshop(blanks(9'999), "Many");
|
||||
import(session, {url});
|
||||
QCOMPARE(session.importError(),
|
||||
std::optional<QString>("Sky.psd: This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."));
|
||||
std::optional(QStringLiteral("Sky.psd: This import exceeds the current %1-megapixel document budget or 30,000-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels())));
|
||||
QCOMPARE(session.document().value().layers.size(), size_t(9'999));
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ void PSDRoundTripTests::unsupportedHeadersAreRejected()
|
||||
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 8, 4)); }), std::optional(PSDError::Kind::unsupportedColorMode));
|
||||
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 16)); }), std::optional(PSDError::Kind::unsupportedDepth));
|
||||
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 30'001, 10)); }), std::optional(ImageImportError::Kind::tooLarge));
|
||||
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 10'001, 10'000)); }), std::optional(ImageImportError::Kind::tooLarge));
|
||||
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 20'001, 10'000)); }), std::optional(ImageImportError::Kind::tooLarge));
|
||||
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 0, 10)); }), std::optional(ImageImportError::Kind::tooLarge));
|
||||
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(QByteArray("8BPX")); }), std::optional(ImageImportError::Kind::unreadable));
|
||||
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header()); }), std::optional(PSDError::Kind::truncated));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "BudgetFixtures.h"
|
||||
#include "BrushFixtures.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
@@ -228,12 +229,14 @@ void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEvery
|
||||
QCOMPARE(session->history.undoCount(), 0);
|
||||
QCOMPARE(int(layerWith(*session, id).mask.value().asset.image().constScanLine(5)[5]), 0);
|
||||
QCOMPARE(pixel(layerWith(*session, id).asset.value().image(), 5, 5), (std::vector<int>{255, 0, 0, 255}));
|
||||
// Six layers of 16 megapixels leave a stroke 4 million.
|
||||
// The other layers leave a stroke 4 million pixels.
|
||||
const auto big = std::make_unique<EditorSession>();
|
||||
big->createDocument(4000, 4000, true);
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const auto raster = std::make_shared<const RasterSnapshot>(4000, 4000, BrushRaster::context(1, 1, false), QRectF(0, 0, 4000, 4000), std::vector<BrushPatch>{});
|
||||
big->insert(ImportedImage(raster, QImage(), QStringLiteral("Big %1").arg(i)));
|
||||
const QUuid first = big->activeLayerID().value();
|
||||
QSet<QUuid> claims;
|
||||
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 4'000'000)) {
|
||||
big->insert(layer);
|
||||
claims.insert(big->activeLayerID().value());
|
||||
}
|
||||
big->addBlankLayer();
|
||||
big->selectTool(NavigationTool::brush);
|
||||
@@ -248,14 +251,15 @@ void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEvery
|
||||
QVERIFY(big->brushStroke());
|
||||
big->cancelBrush();
|
||||
// A masked layer's budget counts the other masks too.
|
||||
QImage mask = BrushRaster::context(4000, 4000, true);
|
||||
const QUuid last = big->activeLayerID().value();
|
||||
rewrite(*big, [&](ProjectSnapshot &snapshot) {
|
||||
for (ProjectLayerRecord &record : snapshot.manifest.layers)
|
||||
setMask(snapshot, record.id, LayerMask::assetFrom(mask));
|
||||
setMask(snapshot, record.id, claims.contains(record.id) ? claimed(snapshot.images.at(record.id).size().width(), snapshot.images.at(record.id).size().height())
|
||||
: claimed(4000, 4000));
|
||||
});
|
||||
QVERIFY(layerWith(*big, first).mask);
|
||||
big->selectLayerTarget(last, false);
|
||||
// Seven other 16-million masks: the budget runs below zero.
|
||||
// The claims' masks and a 16-million one: below zero.
|
||||
QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(-12'000'000));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "BudgetFixtures.h"
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
#include "SessionFixtures.h"
|
||||
@@ -45,7 +46,7 @@ class ProjectCopyTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void whatMayNotBeCopiedChangesNothing();
|
||||
void theHundredMegapixelsCountAcrossBothProjects();
|
||||
void theBudgetCountsAcrossBothProjects();
|
||||
void aFolderTravelsWithItsContentsAndItsLinks();
|
||||
void aMaskFromOutsideTheCopyIsBakedIn();
|
||||
void anAdjustmentLetsGoOfItsBaseUnbaked();
|
||||
@@ -104,18 +105,25 @@ void ProjectCopyTests::whatMayNotBeCopiedChangesNothing()
|
||||
QCOMPARE(edits.count(), before + 6);
|
||||
}
|
||||
|
||||
void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects()
|
||||
void ProjectCopyTests::theBudgetCountsAcrossBothProjects()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
ProjectTab &from = workspace.current();
|
||||
from.session.createDocument(8, 8);
|
||||
from.session.insert(vast(10'000, 10'000));
|
||||
// A folder claiming the whole document budget.
|
||||
QSet<QUuid> claims;
|
||||
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget())) {
|
||||
from.session.insert(layer);
|
||||
claims.insert(from.session.activeLayerID().value());
|
||||
}
|
||||
from.session.selectLayers(claims, from.session.activeLayerID());
|
||||
from.session.groupSelectedLayers();
|
||||
const QUuid id = from.session.activeLayerID().value();
|
||||
ProjectTab &target = workspace.addTab();
|
||||
target.session.createDocument(8, 8);
|
||||
// Exactly the limit fits.
|
||||
QVERIFY(copied(workspace, id, target.id));
|
||||
QCOMPARE(names(target.session), (QStringList{"Vast"}));
|
||||
QCOMPARE(target.session.document().value().layers.size(), size_t(claims.size() + 1));
|
||||
QCOMPARE(target.session.importError(), std::nullopt);
|
||||
// One pixel more, counted across both, does not.
|
||||
target.session.undo();
|
||||
@@ -123,13 +131,15 @@ void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects()
|
||||
target.session.setRenamingLayerID(std::nullopt);
|
||||
QVERIFY(copied(workspace, id, target.id));
|
||||
QCOMPARE(names(target.session), (QStringList{"Dot"}));
|
||||
QCOMPARE(target.session.importError(), std::optional(QString("The copied layers exceed this project’s 100-megapixel limit.")));
|
||||
QCOMPARE(target.session.importError(),
|
||||
std::optional(QStringLiteral("The copied layers exceed this project’s %1-megapixel limit.").arg(DocumentLimits::documentBudgetMegapixels())));
|
||||
QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy());
|
||||
// While that error shows, the front tab lets nothing start.
|
||||
QVERIFY(!workspace.canSwitch() && copied(workspace, id, std::nullopt));
|
||||
QCOMPARE(int(workspace.tabs().size()), 2);
|
||||
target.session.setImportError(std::nullopt);
|
||||
// Refused for a new tab, that tab stays.
|
||||
from.session.selectLayers({}, std::nullopt);
|
||||
from.session.insert(vast(1, 1));
|
||||
const QUuid extra = from.session.activeLayerID().value();
|
||||
from.session.addGroup();
|
||||
|
||||
+44
-8
@@ -1,10 +1,38 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "Document/LayerMask.h"
|
||||
#include "BudgetFixtures.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include <QBuffer>
|
||||
#include <QImageWriter>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
// A PNG whose header claims pixels it never holds.
|
||||
QByteArray claimingPNG(int width, int height)
|
||||
{
|
||||
QByteArray data;
|
||||
QBuffer buffer(&data);
|
||||
QImageWriter(&buffer, "png").write(QImage(1, 1, QImage::Format_Grayscale8));
|
||||
for (int index = 0; index < 4; ++index) {
|
||||
data[16 + index] = char(quint32(width) >> (24 - 8 * index));
|
||||
data[20 + index] = char(quint32(height) >> (24 - 8 * index));
|
||||
}
|
||||
// The header chunk's CRC-32 over its type and fields.
|
||||
quint32 crc = 0xFFFFFFFF;
|
||||
for (int index = 12; index < 29; ++index) {
|
||||
crc ^= uchar(data[index]);
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
crc = (crc >> 1) ^ (0xEDB88320 & (0u - (crc & 1)));
|
||||
}
|
||||
crc = ~crc;
|
||||
for (int index = 0; index < 4; ++index)
|
||||
data[29 + index] = char(crc >> (24 - 8 * index));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
@@ -381,22 +409,30 @@ void ProjectTests::sizeLimitsHoldOnSaveAndLoad()
|
||||
ProjectSnapshot long1 = snapshot;
|
||||
long1.images.insert_or_assign(id, asset(gray(30'001, 1, 9).convertToFormat(QImage::Format_RGBA8888_Premultiplied), "long"));
|
||||
QCOMPARE(projectError([&] { ProjectStore::save(long1, root.filePath("Long.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||||
// Masks share 100 million pixels, apart from the images'.
|
||||
// Masks share the document budget, apart from the images'.
|
||||
ProjectSnapshot heavy = snapshot;
|
||||
const QUuid second = heavy.manifest.layers[1].id;
|
||||
heavy.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||||
heavy.manifest.layers[1].maskFile = uuidString(second) + ".mask.png";
|
||||
heavy.masks.insert({id, asset(gray(10'000, 10'000, 255), "whole budget")});
|
||||
heavy.masks.insert({second, asset(gray(1, 1, 255), "one too many")});
|
||||
// Rows of 30,000 and a remainder fill the budget exactly.
|
||||
const qint64 budget = DocumentLimits::documentPixelBudget();
|
||||
const qint64 rows = (budget - 1) / 30'000, rest = budget - rows * 30'000;
|
||||
// Saving judges sizes before pixels: one over, summed, refused.
|
||||
heavy.masks.insert({id, asset(gray(int(rest) + 1, 1, 255), "one too many")});
|
||||
heavy.masks.insert({second, claimed(30'000, int(rows))});
|
||||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||||
// 99,990,000 and 10,000 pixels fill the budget exactly.
|
||||
heavy.masks.insert_or_assign(id, asset(gray(10'000, 9'999, 255), "nearly all"));
|
||||
heavy.masks.insert_or_assign(second, asset(gray(100, 100, 255), "the rest"));
|
||||
QVERIFY(!heavy.masks.at(second).raster->hasMaterializedPixels());
|
||||
heavy.masks.insert_or_assign(id, asset(gray(int(rest), 1, 255), "the rest"));
|
||||
heavy.masks.insert_or_assign(second, asset(gray(1, 1, 255), "small"));
|
||||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::nullopt);
|
||||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::nullopt);
|
||||
// On load the same sum comes from the files' headers.
|
||||
// Loaded, headers sum exactly to it; images count apart.
|
||||
const QString firstMask = root.filePath("Heavy.comp/images/") + uuidString(id) + ".mask.png";
|
||||
overwrite(firstMask, png(gray(10'000, 10'000, 255)));
|
||||
const QString secondMask = root.filePath("Heavy.comp/images/") + uuidString(second) + ".mask.png";
|
||||
overwrite(secondMask, claimingPNG(30'000, int(rows)));
|
||||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::missingImage));
|
||||
// The first mask's one more pixel tips the sum over.
|
||||
overwrite(firstMask, png(gray(int(rest) + 1, 1, 255)));
|
||||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "Document/DocumentHistory.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
@@ -78,7 +79,7 @@ void RasterSnapshotTests::contextAllocationFailureThrowsARenderError()
|
||||
QCOMPARE(QString(error.what()), QString("The canvas could not be rendered. Try a smaller canvas."));
|
||||
}
|
||||
QCOMPARE(QString(ExportError(ExportError::Kind::tooLarge).what()),
|
||||
QString("Image export supports canvases up to 100 megapixels and 30,000 pixels per side."));
|
||||
QStringLiteral("Image export supports canvases up to %1 megapixels and 30,000 pixels per side.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
||||
QCOMPARE(QString(ExportError(ExportError::Kind::encode).what()), QString("The image could not be encoded."));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "BudgetFixtures.h"
|
||||
#include "ContentView.h"
|
||||
#include "DNGFixture.h"
|
||||
#include "IO/RawImporter.h"
|
||||
@@ -113,11 +114,13 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
|
||||
DNGFixture::Options wide, tall, big;
|
||||
wide.claimed = QSize(30'001, 48);
|
||||
tall.claimed = QSize(64, 30'001);
|
||||
big.claimed = QSize(20'000, 5'001);
|
||||
big.claimed = QSize(30'000, 30'000);
|
||||
// Rows of 30,000 within the document budget.
|
||||
const int rows = int(DocumentLimits::documentPixelBudget() / 30'000);
|
||||
DNGFixture::Options fits;
|
||||
fits.claimed = QSize(20'000, 5'000);
|
||||
fits.claimed = QSize(30'000, rows);
|
||||
const QUrl fitting = dng(folder, "Fits.dng", fits);
|
||||
QCOMPARE(RawImporter::pixelSize(fitting.toLocalFile()), std::optional(QSize(20'000, 5'000)));
|
||||
QCOMPARE(RawImporter::pixelSize(fitting.toLocalFile()), std::optional(QSize(30'000, rows)));
|
||||
EditorSession session;
|
||||
bool done = false;
|
||||
const QList<QUrl> urls{QUrl::fromLocalFile(fake.fileName()), dng(folder, "Wide.dng", wide), dng(folder, "Tall.dng", tall), dng(folder, "Big.dng", big), png(folder)};
|
||||
@@ -127,12 +130,6 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
|
||||
const QString unreadable = ImageImportError(ImageImportError::Kind::unreadable).what(), tooLarge = ImageImportError(ImageImportError::Kind::tooLarge).what();
|
||||
QCOMPARE(session.importError().value(), QStringList({"Fake.dng: " + unreadable, "Wide.dng: " + tooLarge, "Tall.dng: " + tooLarge, "Big.dng: " + tooLarge}).join("\n\n"));
|
||||
QCOMPARE(names(session), QStringList{"Plain"});
|
||||
// The canvas's 4 by 2 pixels count against the budget.
|
||||
session.setImportError(std::nullopt);
|
||||
done = false;
|
||||
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(session.importError().value(), "Fits.dng: " + tooLarge);
|
||||
// Accepted, then unreadable when the full develop runs.
|
||||
session.setImportError(std::nullopt);
|
||||
done = false;
|
||||
@@ -158,6 +155,22 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(session.importError().value(), "Warm.dng: " + QString(ImageImportError(ImageImportError::Kind::whiteBalance).what()));
|
||||
QCOMPARE(names(session), QStringList{"Plain"});
|
||||
// Past one surface yet within the document budget, it opens.
|
||||
session.setImportError(std::nullopt);
|
||||
done = false;
|
||||
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
|
||||
QTRY_VERIFY(session.rawDevelop());
|
||||
session.finishRawDevelop(std::nullopt);
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(session.importError(), std::nullopt);
|
||||
// The slack filled, the canvas's 8 pixels tip it.
|
||||
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - qint64(rows) * 30'000))
|
||||
session.insert(layer);
|
||||
session.setImportError(std::nullopt);
|
||||
done = false;
|
||||
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(session.importError().value(), "Fits.dng: " + tooLarge);
|
||||
}
|
||||
|
||||
void RawDevelopTests::theLimitsLetTheirEdgeThrough()
|
||||
|
||||
@@ -175,8 +175,8 @@ void ShapeLayerTests::whatIsNotRedrawn()
|
||||
QCOMPARE(session->activeLayer().value().transform.size, QSizeF(30.4, 19.6));
|
||||
QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity());
|
||||
QVERIFY(session->activeLayer().value().liveShape());
|
||||
// Past 100 megapixels it stretches as it is.
|
||||
resize(*session, QSizeF(20'001, 5'000));
|
||||
// Past one surface's 200 megapixels it stretches as it is.
|
||||
resize(*session, QSizeF(20'001, 10'000));
|
||||
session->commitTransform();
|
||||
QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity());
|
||||
session->undo();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "BrushFixtures.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
@@ -227,13 +228,13 @@ void ShapeToolTests::shiftSnapsALineToEighthsOfATurn()
|
||||
void ShapeToolTests::aShapeTooLargeIsRefused()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(20'000, 12'000, true);
|
||||
session.createDocument(30'000, 12'000, true);
|
||||
session.selectTool(NavigationTool::shape);
|
||||
// Each side truncated first: 10,001 × 10,000 is over.
|
||||
// Each side truncated first: 20,001 × 10,000 is over.
|
||||
session.beginShape(QPointF(0, 0));
|
||||
session.dragShape(QPointF(10'001, 10'000), false, false);
|
||||
session.dragShape(QPointF(20'001, 10'000), false, false);
|
||||
session.finishShape();
|
||||
QCOMPARE(session.brushError().value(), QString("That shape is too large. A shape can cover up to 100 megapixels."));
|
||||
QCOMPARE(session.brushError().value(), QStringLiteral("That shape is too large. A shape can cover up to %1 megapixels.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
||||
QCOMPARE(session.document().value().layers.size(), size_t(1));
|
||||
QVERIFY(!session.shapeDraft());
|
||||
}
|
||||
|
||||
@@ -246,6 +246,8 @@ compositor_test(SelectionSignalTests)
|
||||
compositor_test(SelectMenuTests)
|
||||
compositor_test(SelectionCanvasTests)
|
||||
compositor_test(SelectionOverlayTests)
|
||||
compositor_test(MarchingAntsTests)
|
||||
compositor_test(DocumentLimitsTests)
|
||||
compositor_test(LassoControlsTests)
|
||||
compositor_test(SelectionAmountSheetTests)
|
||||
compositor_test(MagicWandTests)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "BrushFixtures.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Rendering/TextLayout.h"
|
||||
@@ -187,18 +188,18 @@ void TextLayoutTests::imagesKeepSwiftsLimits()
|
||||
return std::nullopt;
|
||||
};
|
||||
QCOMPARE(refused(styled(QStringLiteral("Text"), 0)), std::optional(ProjectError::Kind::invalid));
|
||||
// A side past 30,000, then the area past 100 megapixels.
|
||||
// A side past 30,000, then the area past one surface.
|
||||
const LayerTextStyle wide = styled(QStringLiteral("M").repeated(20), 2000);
|
||||
const QSizeF wideSize = EditorSession::textBoxSize(wide);
|
||||
QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < 100'000'000);
|
||||
QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < DocumentLimits::maxSurfacePixels);
|
||||
QCOMPARE(refused(wide), std::optional(ProjectError::Kind::tooLarge));
|
||||
const LayerTextStyle tall = styled(QStringLiteral("a\n").repeated(13), 2000);
|
||||
const QSizeF tallSize = EditorSession::textBoxSize(tall);
|
||||
QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < 100'000'000);
|
||||
QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < DocumentLimits::maxSurfacePixels);
|
||||
QCOMPARE(refused(tall), std::optional(ProjectError::Kind::tooLarge));
|
||||
const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM"), 2000);
|
||||
const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM").repeated(3), 2000);
|
||||
const QSizeF largeSize = EditorSession::textBoxSize(large);
|
||||
QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > 100'000'000);
|
||||
QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > DocumentLimits::maxSurfacePixels);
|
||||
QCOMPARE(refused(large), std::optional(ProjectError::Kind::tooLarge));
|
||||
QVERIFY(!refused(styled(QStringLiteral("M").repeated(10), 2000)));
|
||||
// A fractional box rounds up to whole pixels.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Document/DocumentLimits.h"
|
||||
#include "BrushFixtures.h"
|
||||
#include "SelectionFixtures.h"
|
||||
#include "SessionRecord.h"
|
||||
@@ -299,7 +300,7 @@ void TypeSessionTests::aDraggedBoxIsRoundedAndBounded()
|
||||
session->cancelText();
|
||||
session->beginText(QRectF(0, 0, 30'001, 50));
|
||||
QVERIFY(!session->textDraft());
|
||||
QCOMPARE(session->brushError().value(), QString("That text box exceeds the 30,000-pixel or 100-megapixel limit."));
|
||||
QCOMPARE(session->brushError().value(), QStringLiteral("That text box exceeds the 30,000-pixel or %1-megapixel limit.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
||||
// No number at all is refused without a word.
|
||||
session->setBrushError(std::nullopt);
|
||||
session->beginText(QRectF(0, 0, INFINITY, 50));
|
||||
@@ -330,11 +331,11 @@ void TypeSessionTests::aStyleIsValidWithinSwiftsBounds()
|
||||
for (const double value : {low - 0.01, high + 0.01, double(NAN), double(INFINITY), -double(INFINITY)})
|
||||
QVERIFY(!valid([&](LayerTextStyle &style) { style.*member = value; }));
|
||||
}
|
||||
// A box: 16 to 30,000 a side, 100 megapixels.
|
||||
// A box: 16 to 30,000 a side, one surface's pixels.
|
||||
const auto box = [&](double width, double height) { return valid([&](LayerTextStyle &style) { style.boxSize = QSizeF(width, height); }); };
|
||||
QVERIFY(box(16, 16) && box(30'000, 3'000) && box(10'000, 10'000) && box(3'000, 30'000));
|
||||
QVERIFY(box(16, 16) && box(30'000, 3'000) && box(20'000, 10'000) && box(3'000, 30'000));
|
||||
QVERIFY(!box(15.99, 100) && !box(100, 15.99) && !box(30'000.5, 100) && !box(100, 30'000.5));
|
||||
QVERIFY(!box(10'000, 10'000.01) && !box(NAN, 100) && !box(100, INFINITY));
|
||||
QVERIFY(!box(20'000, 10'000.01) && !box(NAN, 100) && !box(100, INFINITY));
|
||||
}
|
||||
|
||||
void TypeSessionTests::everyChangeIsAnnouncedAndRefusalsAreSilent()
|
||||
@@ -461,7 +462,7 @@ void TypeSessionTests::applyingTakesWhatSwiftTakes()
|
||||
draft.style.boxSize = std::nullopt;
|
||||
session->setTextDraft(draft);
|
||||
QVERIFY(!session->finishText());
|
||||
QCOMPARE(session->brushError().value(), QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."));
|
||||
QCOMPARE(session->brushError().value(), QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels()));
|
||||
QCOMPARE(session->textDraft().value().style.content, QString("Kept and grown further"));
|
||||
QCOMPARE(layerWith(*session, id).liveText().value().style.content, QString("Kept and grown"));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user