diff --git a/AGENTS.md b/AGENTS.md index 45ac767..7460ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ Platform adaptations, each forced by a missing Qt analog: - Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests. - Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer). - The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sliders' ends and a linear interior, the rows' order and help, a commit's busy line, the menus and characters) pin the code, with chunky pixels against an independent box average under diffusion, exact Bayer counts for Density and Contrast, the halftone angle, custom, blank and missing alphabets, the glyphs' size, smoothing, centring and canonical duplicates, the live picker preview, both swatches, the filter's OK with a picker open, each numeric row and the Characters field on leaving, and `CompositorMenusTests` the Filter menu. +- Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/TASKS.md b/TASKS.md index e15904a..d62843e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -417,7 +417,9 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - [x] 13.2 Version 1.3.4, part one: a font change applies to the selected letters, a mixed selection takes the face the menu shows, the menu says (Multiple) (`0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test), with the manifest and both format documents. - 13.2 work: `LayerTextFontRun` and `fontRuns` with Swift's model (`fontNameAt` for `fontName(at:)`), the run members moved to `TypeTool+Runs.cpp`, each run's face as a layout format, format version 11, (Multiple) in the Type bar's menu; a fix beyond Swift keeps a folded base face. Tests: `TextFontRunsTests`, `TextFontControlsTests`. - 13.2 review: round 1 (codex) FAIL: the folded face's parity unstated, and test gaps (overflow, start-of-text inheritance, newlines, an independent bold reference, editor geometry); round 2 (codex) FAIL: test gaps (neighbouring faces, size and tracking in layout, colour with faces, a missing location); round 3 (Fable 5.1) FAIL: test gaps (a negative location, a run starting at the span's end, fractional numbers, an unlisted face's own name). All fixed, each mutant caught; closed after the third round. -- [ ] 13.3 Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet); Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling). +- [x] 13.3a Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet). +- 13.3a review (Fable 5.1): round 1 FAIL (test gaps: the face's divisor and the width's rounding at larger sizes, the coverage's mean, the Text Size row's ends, decimals and unit, the picked table over non-square cells); round 2 FAIL (the kernel's averaging window, height and width); round 3 PASS. +- [ ] 13.3b Part three: Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling). - [ ] 13.4 Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); Export JPEG's zoomable preview and tidier layout (`ac6f309`); `908bed9` and `158cd53` judged (Swift concurrency and warnings). - [ ] 13.5 Part two: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`); View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`). - [ ] 13.6 Part three: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`). diff --git a/kernels/DitherPixels.c b/kernels/DitherPixels.c index bd871bb..1f33852 100644 --- a/kernels/DitherPixels.c +++ b/kernels/DitherPixels.c @@ -195,7 +195,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons float cosA = cosf(p->angle), sinA = sinf(p->angle); float *ink = p->lightOnDark ? light : dark, *paper = p->lightOnDark ? dark : light; // Glyphs: each cell shares one, picked from the cell's average tone, worked out once per cell. - size_t columns = (width + (size_t)cell - 1) / (size_t)cell, cellRows = (height + (size_t)cell - 1) / (size_t)cell; + size_t gw = (size_t)(p->glyphWidth < 1 ? 1 : p->glyphWidth), gh = (size_t)(p->glyphHeight < 1 ? 1 : p->glyphHeight); + size_t columns = (width + gw - 1) / gw, cellRows = (height + gh - 1) / gh; int *picked = NULL; if (style == DITHER_GLYPHS && p->glyphCount > 0) { picked = malloc(columns * cellRows * sizeof(int)); @@ -203,8 +204,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons for (size_t row = 0; row < cellRows; ++row) for (size_t column = 0; column < columns; ++column) { float sum = 0; int n = 0; - for (size_t yy = row * (size_t)cell; yy < (row + 1) * (size_t)cell && yy < height; ++yy) - for (size_t xx = column * (size_t)cell; xx < (column + 1) * (size_t)cell && xx < width; ++xx) { + for (size_t yy = row * gh; yy < (row + 1) * gh && yy < height; ++yy) + for (size_t xx = column * gw; xx < (column + 1) * gw && xx < width; ++xx) { size_t i = yy * width + xx; if (alpha[i]) { sum += marks[i]; ++n; } } @@ -228,8 +229,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons if (!alpha[at]) continue; float amount; if (picked) { - int glyph = picked[(y / (size_t)cell) * columns + x / (size_t)cell]; - amount = p->glyphs[(size_t)glyph * (size_t)(cell * cell) + (y % (size_t)cell) * (size_t)cell + x % (size_t)cell] / 255.0f; + int glyph = picked[(y / gh) * columns + x / gw]; + amount = p->glyphs[(size_t)glyph * gw * gh + (y % gh) * gw + x % gw] / 255.0f; } else if (style == DITHER_PATTERNS) { float t = marks[at]; float coverage = p->lightOnDark ? t : 1 - t; diff --git a/kernels/DitherPixels.h b/kernels/DitherPixels.h index 4b54533..41561c8 100644 --- a/kernels/DitherPixels.h +++ b/kernels/DitherPixels.h @@ -29,8 +29,11 @@ typedef struct { int originalColors; uint8_t dark[3]; uint8_t light[3]; - // Glyphs: `glyphCount` coverage maps of `cell` × `cell` bytes (255 is fully inked), from least inked to most, - // with each map's mean coverage (0–1) in `glyphCoverage`. + // Glyphs: `glyphCount` coverage maps of `glyphWidth` × `glyphHeight` bytes (255 is fully inked), from least + // inked to most, with each map's mean coverage (0–1) in `glyphCoverage`. The image is laid out in cells that size, + // like lines of monospaced text. + int glyphWidth; + int glyphHeight; const uint8_t *glyphs; const float *glyphCoverage; int glyphCount; diff --git a/src/Document/Dither.cpp b/src/Document/Dither.cpp index 801ace2..24b0bd5 100644 --- a/src/Document/Dither.cpp +++ b/src/Document/Dither.cpp @@ -1,6 +1,7 @@ #include "Document/Dither.h" #include "Document/BrushStroke.h" #include "IO/ImageExporter.h" +#include <QFontMetricsF> #include <QPainter> #include <QPainterPath> #include <QTextBoundaryFinder> @@ -103,6 +104,7 @@ DitherSettings DitherSettings::normalized() const DitherSettings result = *this; result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2)); result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8)); + result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14)); result.angle = clamp(angle, -90, 90, 45); result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2)); result.diffusion = clamp(diffusion, 0, 100, 100); @@ -123,7 +125,8 @@ DitherSettings DitherSettings::normalized() const QImage DitherSettings::apply(const QImage &image) const { const DitherSettings settings = normalized(); - const int block = int(settings.pixelSize); + // ASCII draws at full resolution: chunks would blur its letters. + const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize); const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); if (source.isNull()) throw ExportError(ExportError::Kind::render); @@ -171,7 +174,7 @@ QImage DitherSettings::apply(const QImage &image) const QImage DitherSettings::dither(const QImage &image) const { const int cell = int(cellSize); - const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, cell) : Glyphs(); + const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs(); const bool two = colors == DitherColors::twoColors; const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0}; const std::array<uint8_t, 3> lightColor = two ? bytes(light) : std::array<uint8_t, 3>{255, 255, 255}; @@ -180,7 +183,7 @@ QImage DitherSettings::dither(const QImage &image) const DitherParams params{.style = int(style), .levels = int(levels), .diffusion = float(diffusion / 100), .density = float(density / 100), .contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0, .originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]}, - .light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphs = glyphs.maps.data(), + .light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(), .glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())}; failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), ¶ms) == 0; }); @@ -189,19 +192,23 @@ QImage DitherSettings::dither(const QImage &image) const return result; } -DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cell) +DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int lineHeight) { // Swift's bold system monospace, at a whole pixel size. QFont font(QStringLiteral("monospace")); font.setWeight(QFont::Bold); - font.setPixelSize(std::max(1, int(std::lround(cell * 1.15)))); + font.setPixelSize(std::max(1, int(std::lround(lineHeight / 1.2)))); + const QFontMetricsF metrics(font); + const int height = lineHeight, width = std::max(1, int(std::lround(metrics.horizontalAdvance(QStringLiteral("M"))))); + // One baseline, mid-line, as a terminal sets it. + const double baseline = height - std::round((height - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent()); std::vector<std::pair<std::vector<uint8_t>, float>> drawn; // Swift's Set<Character>: canonically equivalent graphemes are one. std::set<QString> seen; for (const QString &character : graphemes(characters)) { if (!seen.insert(character.normalized(QString::NormalizationForm_C)).second) continue; - QImage map(cell, cell, QImage::Format_Grayscale8); + QImage map(width, height, QImage::Format_Grayscale8); if (map.isNull()) throw ExportError(ExportError::Kind::render); map.fill(0); @@ -209,25 +216,24 @@ DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cel path.addText(0, 0, font, character); // Overlapping outlines stay inked, as text rendering fills them. path.setFillRule(Qt::WindingFill); - const QRectF bounds = path.boundingRect(); { - // Centred on its ink, as CTLineGetImageBounds centres it. + // Centred on its advance, on the shared baseline. QPainter painter(&map); painter.setRenderHint(QPainter::Antialiasing); - painter.translate((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top()); + painter.translate(std::round((width - metrics.horizontalAdvance(character)) / 2), baseline); painter.fillPath(path, Qt::white); } - std::vector<uint8_t> bytes(size_t(cell * cell)); + std::vector<uint8_t> bytes(size_t(width * height)); long sum = 0; - for (int y = 0; y < cell; ++y) { - std::copy_n(map.constScanLine(y), cell, bytes.begin() + y * cell); - for (int x = 0; x < cell; ++x) + for (int y = 0; y < height; ++y) { + std::copy_n(map.constScanLine(y), width, bytes.begin() + y * width); + for (int x = 0; x < width; ++x) sum += map.constScanLine(y)[x]; } - drawn.emplace_back(std::move(bytes), float(sum) / float(255 * cell * cell)); + drawn.emplace_back(std::move(bytes), float(sum) / float(255 * width * height)); } std::stable_sort(drawn.begin(), drawn.end(), [](const auto &a, const auto &b) { return a.second < b.second; }); - Glyphs result; + Glyphs result{.maps = {}, .coverage = {}, .width = width, .height = height}; for (const auto &[map, coverage] : drawn) { result.maps.insert(result.maps.end(), map.begin(), map.end()); result.coverage.push_back(coverage); diff --git a/src/Document/Dither.h b/src/Document/Dither.h index a1a5e29..06f88df 100644 --- a/src/Document/Dither.h +++ b/src/Document/Dither.h @@ -33,14 +33,17 @@ QString rawValue(DitherColors colors); struct DitherSettings { static constexpr double pixelSizeLow = 1, pixelSizeHigh = 32; static constexpr double cellSizeLow = 4, cellSizeHigh = 64; + static constexpr double textSizeLow = 6, textSizeHigh = 64; static constexpr double levelsLow = 2, levelsHigh = 8; static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); } DitherStyle style = DitherStyle::atkinson; // Each dithered pixel covers this many layer pixels a side. double pixelSize = 2; DitherPixelShape pixelShape = DitherPixelShape::square; - // Halftone and character cells, in dithered pixels. + // Halftone cells, in dithered pixels. double cellSize = 8; + // ASCII's line height in layer pixels; letters about 0.6 wide. + double textSize = 14; // The halftone screen's angle, in degrees. double angle = 45; // Tones per channel; 2 is 1-bit. @@ -65,8 +68,11 @@ struct DitherSettings { struct Glyphs { std::vector<uint8_t> maps; std::vector<float> coverage; + int width = 1; + int height = 1; }; - static Glyphs glyphs(const QString &characters, int cell); + // A monospaced cell: a line tall, a letter wide. + static Glyphs glyphs(const QString &characters, int lineHeight); private: QImage dither(const QImage &image) const; diff --git a/src/UI/FilterSheet+Dither.cpp b/src/UI/FilterSheet+Dither.cpp index 9e34525..5cca135 100644 --- a/src/UI/FilterSheet+Dither.cpp +++ b/src/UI/FilterSheet+Dither.cpp @@ -48,8 +48,12 @@ void FilterSheet::dither() }); control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false); box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look")); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; }); + control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false); + box()->setToolTip(QStringLiteral("The height of each line of characters")); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; }); control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false); - m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style) || settings.dither.style == DitherStyle::ascii; }); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); }); control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false); m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); }); // ASCII's characters, typed as they will draw. @@ -129,7 +133,7 @@ void FilterSheet::dither() const QSignalBlocker quiet(shape); shape->setCurrentIndex(int(settings.dither.pixelShape)); }); - m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1; }); + m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; }); flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"), [](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; }); m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); }); diff --git a/tests/DitherKernelTests.cpp b/tests/DitherKernelTests.cpp index 18245d6..554246e 100644 --- a/tests/DitherKernelTests.cpp +++ b/tests/DitherKernelTests.cpp @@ -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))); } diff --git a/tests/DitherSheetTests.cpp b/tests/DitherSheetTests.cpp index 3d5f4d1..387276e 100644 --- a/tests/DitherSheetTests.cpp +++ b/tests/DitherSheetTests.cpp @@ -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"); diff --git a/tests/DitherTests.cpp b/tests/DitherTests.cpp index cebbf43..7177ffd 100644 --- a/tests/DitherTests.cpp +++ b/tests/DitherTests.cpp @@ -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)}));