Dither's ASCII as readable text: monospaced cells on one baseline, Text Size (13.3a)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 01:31:10 -04:00
1 parent dcedfea53f
commit 462fe805b1
10 files changed
+240 -76

No files matched your search

+65 -21
View File
@@ -237,44 +237,88 @@ void DitherKernelTests::theCellSizeReachesTheScreen()
};
QVERIFY(!lit(12, 4, 3) && lit(12, 4, 7));
QVERIFY(lit(8, 4, 3) && !lit(8, 4, 7));
// ASCII: one character tiled at its own cell size.
// ASCII: one character tiled a letter wide, a line tall.
DitherSettings ascii = style(DitherStyle::ascii);
ascii.cellSize = 12;
ascii.textSize = 12;
ascii.characters = QStringLiteral("#");
const QImage out = ascii.apply(filled(24, 24, Qt::white));
for (int y = 0; y < 12; ++y)
for (int x = 0; x < 12; ++x)
QCOMPARE(out.pixel(x + 12, y + 12), out.pixel(x, y));
const std::vector<uint8_t> map = DitherSettings::glyphs(QStringLiteral("#"), 12).maps;
for (int index = 0; index < 144; ++index)
QCOMPARE(qRed(out.pixel(index % 12, index / 12)), int(std::lround(map[size_t(index)] / 255.0f * 255.0f)));
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 int cell = 64;
const std::vector<uint8_t> map = DitherSettings::glyphs(character, cell).maps;
const DitherSettings::Glyphs glyph = DitherSettings::glyphs(character, 64);
QFont font(QStringLiteral("monospace"));
font.setWeight(QFont::Bold);
font.setPixelSize(74);
QPainterPath path;
path.addText(0, 0, font, character);
const QRectF bounds = path.boundingRect();
QImage text(cell, cell, QImage::Format_Grayscale8);
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((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top()), character);
painter.drawText(QPointF(std::round((glyph.width - metrics.horizontalAdvance(character)) / 2), baseline), character);
}
int holes = 0;
for (int y = 0; y < cell; ++y)
for (int x = 0; x < cell; ++x)
holes += text.constScanLine(y)[x] > 200 && map[size_t(y * cell + x)] < 50;
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)));
}
+31 -2
View File
@@ -168,8 +168,9 @@ void DitherSheetTests::theControlsFollowTheStyle()
QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Angle", "Light on Dark"}));
// ASCII: Text Size, never chunky pixels or cells.
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Characters", "Light on Dark"}));
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Text Size", "Characters", "Density", "Contrast", "Colors", "Light on Dark"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::patterns; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Light on Dark"}));
// Pixel Shape needs chunky pixels; the swatches, Two Colors.
@@ -286,6 +287,19 @@ void DitherSheetTests::eachNumberWritesItsOwnSetting()
QVERIFY(glow && glow->isChecked());
glow->click();
QVERIFY(!shown.dither().lightOnDark);
// Text Size, under ASCII: whole pixels, its unit beside.
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QLineEdit &text = shown.child<QLineEdit>("textSizeField");
text.setFocus();
text.selectAll();
QTest::keyClicks(&text, QStringLiteral("20.4"));
QTest::keyClick(&text, Qt::Key_Return);
QCOMPARE(shown.dither().textSize, 20.0);
QCOMPARE(text.text(), QStringLiteral("20"));
QStringList units;
for (QLabel *label : text.parentWidget()->findChildren<QLabel *>())
units << label->text();
QVERIFY2(units.contains(QStringLiteral("px")), qPrintable(units.join(u',')));
}
void DitherSheetTests::leftTheCharactersShowWhatWasKept()
@@ -326,6 +340,17 @@ void DitherSheetTests::theSlidersReachTheirEnds()
}
QCOMPARE(shown.child<QSlider>("pixelSizeSlider").parentWidget()->toolTip(),
QString("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QSlider &text = shown.child<QSlider>("textSizeSlider");
text.setValue(text.maximum());
QCOMPARE(shown.dither().textSize, 64.0);
text.setValue(text.minimum());
QCOMPARE(shown.dither().textSize, 6.0);
// Quarter travel: 20.5, so 21; step 25: 7.45, so 7.
text.setValue(text.maximum() / 4);
QCOMPARE(shown.dither().textSize, 21.0);
text.setValue(25);
QCOMPARE(shown.dither().textSize, 7.0);
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::atkinson; });
for (const auto &[name, setting, low, high] : {std::tuple("tonesSlider", &DitherSettings::levels, 2.0, 8.0),
std::tuple("diffusionSlider", &DitherSettings::diffusion, 0.0, 100.0)}) {
@@ -413,7 +438,11 @@ void DitherSheetTests::rowsKeepSwiftsOrderHelpAndScale()
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|",
"Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QCOMPARE(rows().mid(2, 2), (QStringList{"Cell Size|", "Characters|The characters to draw with, in any order: each spot gets the one whose ink best matches its tone"}));
QCOMPARE(rows().mid(1, 2), (QStringList{"Text Size|The height of each line of characters", "Characters|The characters to draw with, in any order: each spot gets the one whose ink best matches its tone"}));
// Text Size, 6 to 64: half its travel is 35.
QSlider &text = shown.child<QSlider>("textSizeSlider");
text.setValue(text.maximum() / 2);
QCOMPARE(shown.dither().textSize, 35.0);
QCOMPARE(rows().last(), QString("Light on Dark|Draw the marks for the light tones on the dark color, like a glowing screen"));
// Linear: half the Pixel Size travel is 16.5, so 17.
QSlider &pixels = shown.child<QSlider>("pixelSizeSlider");
+94 -26
View File
@@ -1,6 +1,8 @@
#include "Document/BrushStroke.h"
#include "Document/Dither.h"
#include "Document/EditorSession.h"
#include <QPainter>
#include <QPainterPath>
#include <QtTest>
#include <set>
@@ -87,6 +89,7 @@ void DitherTests::normalizingClampsRoundsAndTrimsTheCharacters()
DitherSettings wild;
wild.pixelSize = 40.6;
wild.cellSize = 2;
wild.textSize = 64.6;
wild.angle = std::nan("");
wild.levels = 4.5;
wild.diffusion = -5;
@@ -98,11 +101,18 @@ void DitherTests::normalizingClampsRoundsAndTrimsTheCharacters()
const DitherSettings normal = wild.normalized();
QCOMPARE(normal.pixelSize, 32.0);
QCOMPARE(normal.cellSize, 4.0);
QCOMPARE(normal.textSize, 64.0);
QCOMPARE(normal.angle, 45.0);
QCOMPARE(normal.levels, 5.0);
QCOMPARE(normal.diffusion, 0.0);
QCOMPARE(normal.density, 100.0);
QCOMPARE(normal.contrast, 0.0);
wild.textSize = 5.4;
QCOMPARE(wild.normalized().textSize, 6.0);
wild.textSize = std::nan("");
QCOMPARE(wild.normalized().textSize, 14.0);
wild.textSize = 9.5;
QCOMPARE(wild.normalized().textSize, 10.0);
QVERIFY(normal.dark == AdjustmentColor(0, 0.5, 1) && normal.light == AdjustmentColor(1, 0, 0.25));
// Every line break goes, tabs and spaces stay; 64 kept.
QCOMPARE(normal.characters, QStringLiteral("abcdefghi\t j") + QString(52, u'x'));
@@ -263,36 +273,88 @@ void DitherTests::marksDrawLightOnDarkOrDarkOnLight()
void DitherTests::glyphsAreSortedByTheirInk()
{
const DitherSettings::Glyphs glyphs = DitherSettings::glyphs(QStringLiteral("@. .#"), 8);
// Four distinct: space, dot, hash, at; 8 × 8 maps.
const DitherSettings::Glyphs glyphs = DitherSettings::glyphs(QStringLiteral("@. .#"), 14);
// 14 tall; a bold monospace M at 12 pixels wide.
QFont font(QStringLiteral("monospace"));
font.setWeight(QFont::Bold);
font.setPixelSize(12);
QCOMPARE(glyphs.height, 14);
QCOMPARE(glyphs.width, int(std::lround(QFontMetricsF(font).horizontalAdvance(QStringLiteral("M")))));
QVERIFY(glyphs.width < glyphs.height);
// Four distinct: space, dot, hash, at.
QCOMPARE(glyphs.coverage.size(), size_t(4));
QCOMPARE(glyphs.maps.size(), size_t(4 * 64));
QCOMPARE(glyphs.maps.size(), size_t(4 * glyphs.width * glyphs.height));
QCOMPARE(glyphs.coverage[0], 0.0f);
QVERIFY(glyphs.coverage[1] > 0 && glyphs.coverage[1] < glyphs.coverage[2] && glyphs.coverage[2] <= glyphs.coverage[3]);
QVERIFY(*std::max_element(glyphs.maps.begin() + 192, glyphs.maps.end()) > 200);
QVERIFY(*std::max_element(glyphs.maps.begin() + 3 * glyphs.width * glyphs.height, glyphs.maps.end()) > 200);
QCOMPARE(DitherSettings::glyphs(DitherSettings::defaultCharacters(), 12).coverage.size(), size_t(10));
// A 20-pixel cell: M at 23 pixels, 15–19 tall.
const std::vector<uint8_t> m = DitherSettings::glyphs(QStringLiteral("M"), 20).maps;
int top = 20, bottom = -1;
bool smooth = false;
for (int index = 0; index < 400; ++index) {
if (m[size_t(index)] > 128) {
top = std::min(top, index / 20);
bottom = std::max(bottom, index / 20);
// A 20-pixel line: letters at 17 pixels, on one baseline.
font.setPixelSize(17);
const QFontMetricsF metrics(font);
const int baseline = 20 - int(std::round((20 - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent()));
const auto ink = [](const DitherSettings::Glyphs &glyph) {
int top = glyph.height, bottom = -1;
double sum = 0, sumX = 0;
for (int y = 0; y < glyph.height; ++y)
for (int x = 0; x < glyph.width; ++x) {
const int value = glyph.maps[size_t(y * glyph.width + x)];
sum += value;
sumX += value * (x + 0.5);
if (value > 128) {
top = std::min(top, y);
bottom = std::max(bottom, y);
}
}
return std::tuple(top, bottom, sumX / sum);
};
const auto [mTop, mBottom, mMiddle] = ink(DitherSettings::glyphs(QStringLiteral("M"), 20));
const auto [dotTop, dotBottom, dotMiddle] = ink(DitherSettings::glyphs(QStringLiteral("."), 20));
// M's caps stand on the baseline, as the dot does.
QVERIFY2(mBottom - mTop + 1 >= 11 && mBottom - mTop + 1 <= 14, qPrintable(QString::number(mBottom - mTop + 1)));
QCOMPARE(mBottom, baseline - 1);
QCOMPARE(dotBottom, baseline - 1);
QVERIFY(dotTop > mTop);
// Exactly the letter drawn on that baseline, filled.
const DitherSettings::Glyphs drawnM = DitherSettings::glyphs(QStringLiteral("M"), 20);
QImage reference(drawnM.width, drawnM.height, QImage::Format_Grayscale8);
reference.fill(0);
{
QPainterPath path;
path.addText(0, baseline, font, QStringLiteral("M"));
QPainter painter(&reference);
painter.setRenderHint(QPainter::Antialiasing);
painter.fillPath(path, Qt::white);
}
for (int y = 0; y < drawnM.height; ++y)
for (int x = 0; x < drawnM.width; ++x)
QCOMPARE(int(drawnM.maps[size_t(y * drawnM.width + x)]), int(reference.constScanLine(y)[x]));
// At 64: a 53-pixel face, a 32-wide cell, drawn exactly.
font.setPixelSize(53);
const QFontMetricsF large(font);
const DitherSettings::Glyphs bigM = DitherSettings::glyphs(QStringLiteral("M"), 64);
QCOMPARE(bigM.width, int(std::lround(large.horizontalAdvance(QStringLiteral("M")))));
QCOMPARE(bigM.width, 32);
const int bigBaseline = 64 - int(std::round((64 - (large.ascent() + large.descent())) / 2 + large.descent()));
QImage bigReference(bigM.width, bigM.height, QImage::Format_Grayscale8);
bigReference.fill(0);
{
QPainterPath path;
path.addText(0, bigBaseline, font, QStringLiteral("M"));
QPainter painter(&bigReference);
painter.setRenderHint(QPainter::Antialiasing);
painter.fillPath(path, Qt::white);
}
long inkSum = 0;
for (int y = 0; y < bigM.height; ++y)
for (int x = 0; x < bigM.width; ++x) {
QCOMPARE(int(bigM.maps[size_t(y * bigM.width + x)]), int(bigReference.constScanLine(y)[x]));
inkSum += bigM.maps[size_t(y * bigM.width + x)];
}
smooth = smooth || (m[size_t(index)] > 0 && m[size_t(index)] < 255);
}
QVERIFY2(bottom - top + 1 >= 15 && bottom - top + 1 <= 19, qPrintable(QString::number(bottom - top + 1)));
QVERIFY(smooth);
// Centred on its ink both ways: a dot mid cell.
const std::vector<uint8_t> dot = DitherSettings::glyphs(QStringLiteral("."), 20).maps;
double sum = 0, sumX = 0, sumY = 0;
for (int index = 0; index < 400; ++index) {
sum += dot[size_t(index)];
sumX += dot[size_t(index)] * (index % 20 + 0.5);
sumY += dot[size_t(index)] * (index / 20 + 0.5);
}
QVERIFY2(std::abs(sumX / sum - 10) < 0.6 && std::abs(sumY / sum - 10) < 0.6, qPrintable(QStringLiteral("%1 %2").arg(sumX / sum).arg(sumY / sum)));
// Coverage is the map's mean, ink over the whole cell.
QCOMPARE(bigM.coverage.front(), float(inkSum) / float(255 * bigM.width * bigM.height));
// Each centred across its cell by its advance.
const int width = DitherSettings::glyphs(QStringLiteral("M"), 20).width;
QVERIFY2(std::abs(mMiddle - width / 2.0) < 0.6 && std::abs(dotMiddle - width / 2.0) < 1, qPrintable(QStringLiteral("%1 %2").arg(mMiddle).arg(dotMiddle)));
// Canonically equal graphemes are one, as Swift's Characters.
QCOMPARE(DitherSettings::glyphs(QStringLiteral("\u00E9e\u0301"), 20).coverage.size(), size_t(1));
}
@@ -344,8 +406,14 @@ void DitherTests::theScreenTurnsWithItsAngle()
void DitherTests::asciiDrawsWithItsOwnCharacters()
{
DitherSettings ascii = style(DitherStyle::ascii);
ascii.cellSize = 12;
ascii.textSize = 12;
const QImage source = ramp(48, 24);
// Full resolution: Pixel Size and Shape leave ASCII alone.
DitherSettings chunky = ascii;
chunky.pixelSize = 8;
chunky.pixelShape = DitherPixelShape::dot;
ascii.pixelSize = 1;
QCOMPARE(chunky.apply(source), ascii.apply(source));
// A blank alphabet marks nothing: all the dark paper.
ascii.characters = QStringLiteral(" ");
QCOMPARE(colours(ascii.apply(source)), (std::set<QRgb>{qRgb(0, 0, 0)}));