#include "Document/BrushStroke.h" #include "Document/Dither.h" #include #include #include #include // Dither's styles and colours, pixel for pixel, by hand. namespace { QImage filled(int width, int height, QColor colour) { QImage image = BrushRaster::context(width, height, false); image.fill(colour); return image; } DitherSettings style(DitherStyle each, double pixelSize = 1) { DitherSettings settings; settings.style = each; settings.pixelSize = pixelSize; return settings; } // White pixels as ones, row by row. QString bits(const QImage &image) { QString text; for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) text += qRed(image.pixel(x, y)) == 255 ? u'1' : u'0'; text += u'/'; } return text; } std::array bytes(const QImage &image, int x, int y) { const uchar *pixel = image.constScanLine(y) + x * 4; return {pixel[0], pixel[1], pixel[2], pixel[3]}; } } class DitherKernelTests : public QObject { Q_OBJECT private slots: void eachDiffusionPassesItsOwnShare(); void eachBayerMatrixHasItsOwnPattern(); void eachHalftoneShapeMarksItsOwnPixels(); void macPatternsFillByTheirTone(); void marksTakeTheOriginalOrTwoColours(); void aBlockAveragesPremultipliedPixels(); void coloursRoundAndOriginalDotsGapBlack(); void eachRangeClampsAndRounds(); void aStraightSourceIsPremultipliedFirst(); void theCellSizeReachesTheScreen(); void overlappingOutlinesStayInked(); }; void DitherKernelTests::eachDiffusionPassesItsOwnShare() { // Half gray: Atkinson passes 1/8 on, Floyd–Steinberg 7/16. QCOMPARE(bits(style(DitherStyle::atkinson).apply(filled(3, 1, QColor(128, 128, 128)))), QString("100/")); QCOMPARE(bits(style(DitherStyle::floydSteinberg).apply(filled(3, 1, QColor(128, 128, 128)))), QString("101/")); } void DitherKernelTests::eachBayerMatrixHasItsOwnPattern() { // Half gray: white where the threshold passes one half. QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(128, 128, 128)))), QString("0101/1010/0101/1010/")); QCOMPARE(bits(style(DitherStyle::bayer8).apply(filled(8, 2, QColor(128, 128, 128)))), QString("01010101/10101010/")); // Gray 96 tells the nested matrices apart. QCOMPARE(bits(style(DitherStyle::bayer2).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0101/1010/0101/1010/")); QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0001/1010/0100/1010/")); } void DitherKernelTests::eachHalftoneShapeMarksItsOwnPixels() { // Gray 87, 0°, cells of 8: dots, diamonds, lines differ. const QImage source = filled(8, 8, QColor(87, 87, 87)); const auto lit = [&source](DitherStyle each, int x, int y) { DitherSettings halftone = style(each); halftone.angle = 0; halftone.cellSize = 8; return qRed(halftone.apply(source).pixel(x, y)) == 255; }; QVERIFY(lit(DitherStyle::dots, 1, 4) && lit(DitherStyle::dots, 4, 1)); QVERIFY(!lit(DitherStyle::diamonds, 1, 4) && !lit(DitherStyle::diamonds, 4, 1)); QVERIFY(lit(DitherStyle::lines, 1, 4) && !lit(DitherStyle::lines, 4, 1)); } void DitherKernelTests::macPatternsFillByTheirTone() { // Half gray lights pattern 8: rows 0x88, 0x55, 0x22. QCOMPARE(bits(style(DitherStyle::patterns).apply(filled(8, 4, QColor(128, 128, 128)))), QString("10001000/01010101/00100010/01010101/")); } void DitherKernelTests::marksTakeTheOriginalOrTwoColours() { const QImage source = filled(8, 8, QColor(200, 100, 50)); // Original: marks in the pixel's colour on black or white. for (const bool glowing : {true, false}) { DitherSettings dots = style(DitherStyle::dots); dots.colors = DitherColors::original; dots.lightOnDark = glowing; const QImage out = dots.apply(source); std::set found; for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x) found.insert(out.pixel(x, y)); QCOMPARE(found, (std::set{qRgb(200, 100, 50), glowing ? qRgb(0, 0, 0) : qRgb(255, 255, 255)})); } // Two Colors: light ink on dark, dark on light. DitherSettings two = style(DitherStyle::dots); two.colors = DitherColors::twoColors; two.dark = AdjustmentColor(0, 0, 1); two.light = AdjustmentColor(1, 1, 0); two.angle = 0; const QImage glowing = two.apply(filled(8, 8, QColor(87, 87, 87))); two.lightOnDark = false; const QImage inked = two.apply(filled(8, 8, QColor(87, 87, 87))); QCOMPARE(glowing.pixel(4, 4), qRgb(255, 255, 0)); QCOMPARE(glowing.pixel(0, 0), qRgb(0, 0, 255)); QCOMPARE(inked.pixel(4, 4), qRgb(0, 0, 255)); QCOMPARE(inked.pixel(0, 0), qRgb(255, 255, 0)); } void DitherKernelTests::aBlockAveragesPremultipliedPixels() { // A straight (64, 128, 192, 128) beside three clear. QImage source = BrushRaster::context(2, 2, false); source.setPixelColor(0, 0, QColor(64, 128, 192, 128)); QCOMPARE(bytes(source, 0, 0), (std::array{32, 64, 96, 128})); DitherSettings original = style(DitherStyle::atkinson, 2); original.colors = DitherColors::original; original.levels = 8; original.diffusion = 0; // Averaged (8, 16, 24, 32); tones 2/7, 4/7, 5/7. const QImage out = original.apply(source); for (int y = 0; y < 2; ++y) for (int x = 0; x < 2; ++x) QCOMPARE(bytes(out, x, y), (std::array{9, 18, 23, 32})); } void DitherKernelTests::coloursRoundAndOriginalDotsGapBlack() { // (0.5, 0.1, 0.3) is (128, 26, 77), rounded. DitherSettings two = style(DitherStyle::bayer2); two.colors = DitherColors::twoColors; two.dark = AdjustmentColor(0.5, 0.1, 0.3); QCOMPARE(two.apply(filled(2, 2, Qt::black)).pixel(0, 0), qRgb(128, 26, 77)); // Original dots gap black, whatever dark colour is kept. DitherSettings dots = style(DitherStyle::bayer2, 8); dots.pixelShape = DitherPixelShape::dot; dots.colors = DitherColors::original; dots.dark = AdjustmentColor(1, 0, 0); const QImage out = dots.apply(filled(8, 8, Qt::white)); QCOMPARE(out.pixel(0, 0), qRgb(0, 0, 0)); QCOMPARE(out.pixel(4, 4), qRgb(255, 255, 255)); // A dot's rim is smoothed: part colour, part gap. const int rim = qRed(out.pixel(0, 4)); QVERIFY2(rim > 0 && rim < 255, qPrintable(QString::number(rim))); // One-pixel dots are squares: no gaps. dots.pixelSize = 1; QCOMPARE(dots.apply(filled(8, 8, Qt::white)).pixel(0, 0), qRgb(255, 255, 255)); } void DitherKernelTests::eachRangeClampsAndRounds() { const auto normal = [](const std::function &change) { DitherSettings settings; change(settings); return settings.normalized(); }; QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 2.5; }).pixelSize, 3.0); QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 32; }).pixelSize, 32.0); QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 0; }).pixelSize, 1.0); QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = -std::numeric_limits::infinity(); }).pixelSize, 2.0); QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 64; }).cellSize, 64.0); QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 65; }).cellSize, 64.0); QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 12.5; }).cellSize, 13.0); QCOMPARE(normal([](DitherSettings &d) { d.levels = 8; }).levels, 8.0); QCOMPARE(normal([](DitherSettings &d) { d.levels = 9; }).levels, 8.0); QCOMPARE(normal([](DitherSettings &d) { d.levels = 1; }).levels, 2.0); // Angle, diffusion, density and contrast keep their fractions. const DitherSettings fractions = normal([](DitherSettings &d) { d.angle = 12.25; d.diffusion = 33.5; d.density = -7.75; d.contrast = 99.5; }); QVERIFY(fractions.angle == 12.25 && fractions.diffusion == 33.5 && fractions.density == -7.75 && fractions.contrast == 99.5); QCOMPARE(normal([](DitherSettings &d) { d.angle = -91; }).angle, -90.0); QCOMPARE(normal([](DitherSettings &d) { d.angle = 180; }).angle, 90.0); QCOMPARE(normal([](DitherSettings &d) { d.angle = std::numeric_limits::infinity(); }).angle, 45.0); QCOMPARE(normal([](DitherSettings &d) { d.cellSize = std::nan(""); }).cellSize, 8.0); QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 1; }).cellSize, 4.0); QCOMPARE(normal([](DitherSettings &d) { d.levels = std::nan(""); }).levels, 2.0); QCOMPARE(normal([](DitherSettings &d) { d.diffusion = std::nan(""); }).diffusion, 100.0); QCOMPARE(normal([](DitherSettings &d) { d.diffusion = 101; }).diffusion, 100.0); QCOMPARE(normal([](DitherSettings &d) { d.density = std::nan(""); }).density, 0.0); QCOMPARE(normal([](DitherSettings &d) { d.density = -101; }).density, -100.0); QCOMPARE(normal([](DitherSettings &d) { d.contrast = -200; }).contrast, -100.0); QCOMPARE(normal([](DitherSettings &d) { d.contrast = 101; }).contrast, 100.0); // A lone CR is a line break too. QCOMPARE(normal([](DitherSettings &d) { d.characters = QStringLiteral("a\rb"); }).characters, QString("ab")); } void DitherKernelTests::aStraightSourceIsPremultipliedFirst() { // Straight alpha, clear neighbours hiding green: premultiplied first. QImage straight(2, 2, QImage::Format_RGBA8888); straight.fill(QColor(0, 255, 0, 0)); uchar *first = straight.scanLine(0); first[0] = 64; first[1] = 128; first[2] = 192; first[3] = 128; DitherSettings original = style(DitherStyle::atkinson, 2); original.colors = DitherColors::original; original.levels = 8; original.diffusion = 0; const QImage out = original.apply(straight); for (int y = 0; y < 2; ++y) for (int x = 0; x < 2; ++x) QCOMPARE(bytes(out, x, y), (std::array{9, 18, 23, 32})); } void DitherKernelTests::theCellSizeReachesTheScreen() { // Lines at gray 87, 0°: cells 12 and 8 differ. const auto lit = [](double cell, int x, int y) { DitherSettings lines = style(DitherStyle::lines); lines.angle = 0; lines.cellSize = cell; return qRed(lines.apply(filled(16, 16, QColor(87, 87, 87))).pixel(x, y)) == 255; }; QVERIFY(!lit(12, 4, 3) && lit(12, 4, 7)); QVERIFY(lit(8, 4, 3) && !lit(8, 4, 7)); // ASCII: one character tiled a letter wide, a line tall. DitherSettings ascii = style(DitherStyle::ascii); ascii.textSize = 12; ascii.characters = QStringLiteral("#"); const DitherSettings::Glyphs glyph = DitherSettings::glyphs(QStringLiteral("#"), 12); const int w = glyph.width, h = glyph.height; QCOMPARE(h, 12); const QImage out = ascii.apply(filled(4 * w, 2 * h, Qt::white)); for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { QCOMPARE(out.pixel(x + w, y + h), out.pixel(x, y)); QCOMPARE(qRed(out.pixel(x, y)), int(std::lround(glyph.maps[size_t(y * w + x)] / 255.0f * 255.0f))); } // Two characters over dark and light columns, cell by cell. DitherSettings two = style(DitherStyle::ascii); two.textSize = 12; two.characters = QStringLiteral(" #"); QImage halves(4 * w, 3 * h, QImage::Format_RGBA8888_Premultiplied); halves.fill(Qt::black); QPainter(&halves).fillRect(QRect(2 * w, 0, 2 * w, 3 * h), Qt::white); const QImage picked = two.apply(halves); for (int y = 0; y < 3 * h; ++y) for (int x = 0; x < 4 * w; ++x) { // Light columns draw the hash, dark ones the paper. const int expected = x < 2 * w ? 0 : int(std::lround(glyph.maps[size_t((y % h) * w + x % w)] / 255.0f * 255.0f)); QCOMPARE(qRed(picked.pixel(x, y)), expected); } // Each cell's pick from its whole cell's ink, edges included. DitherSettings many = style(DitherStyle::ascii); many.textSize = 12; const DitherSettings::Glyphs set = DitherSettings::glyphs(DitherSettings::defaultCharacters(), 12); const int pictureWidth = 5 * w + 3, pictureHeight = 3 * h + 5; QImage pattern(pictureWidth, pictureHeight, QImage::Format_RGBA8888_Premultiplied); for (int y = 0; y < pictureHeight; ++y) for (int x = 0; x < pictureWidth; ++x) pattern.setPixelColor(x, y, (x * 7 + y * 13) % 17 < 9 ? Qt::white : Qt::black); const QImage drawn = many.apply(pattern); for (int cellY = 0; cellY * h < pictureHeight; ++cellY) for (int cellX = 0; cellX * w < pictureWidth; ++cellX) { int white = 0, count = 0; for (int y = cellY * h; y < std::min(pictureHeight, (cellY + 1) * h); ++y) for (int x = cellX * w; x < std::min(pictureWidth, (cellX + 1) * w); ++x) { white += qRed(pattern.pixel(x, y)) == 255; ++count; } const float wanted = float(white) / float(count) * set.coverage.back(); size_t best = 0; for (size_t index = 1; index < set.coverage.size(); ++index) best = std::abs(set.coverage[index] - wanted) < std::abs(set.coverage[best] - wanted) ? index : best; for (int y = cellY * h; y < std::min(pictureHeight, (cellY + 1) * h); ++y) for (int x = cellX * w; x < std::min(pictureWidth, (cellX + 1) * w); ++x) QCOMPARE(qRed(drawn.pixel(x, y)), int(std::lround(set.maps[best * size_t(w * h) + size_t((y % h) * w + x % w)] / 255.0f * 255.0f))); } } void DitherKernelTests::overlappingOutlinesStayInked() { // An accent over a letter: text rendering inks their overlap. const QString character = QStringLiteral("A\u0340"); const DitherSettings::Glyphs glyph = DitherSettings::glyphs(character, 64); QFont font(QStringLiteral("monospace")); font.setWeight(QFont::Bold); font.setPixelSize(53); const QFontMetricsF metrics(font); // Text drawn on the glyph's own baseline and place. const double baseline = 64 - std::round((64 - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent()); QImage text(glyph.width, glyph.height, QImage::Format_Grayscale8); text.fill(0); { QPainter painter(&text); painter.setRenderHint(QPainter::Antialiasing); painter.setFont(font); painter.setPen(Qt::white); painter.drawText(QPointF(std::round((glyph.width - metrics.horizontalAdvance(character)) / 2), baseline), character); } int holes = 0, inked = 0; for (int y = 0; y < glyph.height; ++y) for (int x = 0; x < glyph.width; ++x) { holes += text.constScanLine(y)[x] > 200 && glyph.maps[size_t(y * glyph.width + x)] < 50; inked += text.constScanLine(y)[x] > 200; } QVERIFY(inked > 100); QVERIFY2(holes <= 2, qPrintable(QString::number(holes))); } QTEST_MAIN(DitherKernelTests) #include "DitherKernelTests.moc"