// termshot (src/termshot): font lookup, palette, ANSI fixtures to PNG, pixel colors, caps, file write. #include #include #include #include #include #include #include "termshot_canvas.h" #include "termshot_capture.h" #include "termshot_consts.h" #include "termshot_font.h" #include "termshot_palette.h" #include "termshot_png.h" #include "termshot_renderer.h" #include "termshot_shooter.h" using namespace detz::termshot; static int failures = 0; #define CHECK(c) do { if (!(c)) { std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); ++failures; } } while (0) // Decode the RGB rows of a PNG written by TermshotPng (single IDAT, filter 0). static bool pixel_at(const std::string& png, int x, int y, std::uint32_t& rgb) { int w = 0, h = 0; if (!TermshotPng::dimensions(png, w, h)) return false; size_t at = 8; std::string idat; while (at + 8 <= png.size()) { uint32_t len = (uint32_t(uint8_t(png[at])) << 24) | (uint32_t(uint8_t(png[at + 1])) << 16) | (uint32_t(uint8_t(png[at + 2])) << 8) | uint32_t(uint8_t(png[at + 3])); std::string type = png.substr(at + 4, 4); if (type == "IDAT") idat += png.substr(at + 8, len); at += 12 + len; } uLongf raw_len = static_cast(h) * static_cast(w * 3 + 1); std::string raw(raw_len, '\0'); if (uncompress(reinterpret_cast(raw.data()), &raw_len, reinterpret_cast(idat.data()), idat.size()) != Z_OK) return false; size_t p = static_cast(y) * static_cast(w * 3 + 1) + 1 + static_cast(x) * 3; if (raw[static_cast(y) * static_cast(w * 3 + 1)] != 0) return false; rgb = (uint32_t(uint8_t(raw[p])) << 16) | (uint32_t(uint8_t(raw[p + 1])) << 8) | uint32_t(uint8_t(raw[p + 2])); return true; } static TermshotFrame frame(const std::string& ansi, int rows, int cols) { TermshotFrame f; f.ansi = ansi; f.rows = rows; f.cols = cols; f.source = "ansi"; return f; } int main() { // Font: sorted table, the coverage a TUI needs, fallback for the rest. CHECK(TermshotFont::size() == 1001); CHECK(TermshotFont::find(U'A') != nullptr); CHECK(TermshotFont::find(U'─') != nullptr); // box drawing CHECK(TermshotFont::find(U'█') != nullptr); // full block CHECK(TermshotFont::find(U'⠀') != nullptr); // braille CHECK(TermshotFont::find(U'中') == nullptr); // CJK not covered CHECK(TermshotFont::glyph(U'中').code == U'�' || TermshotFont::glyph(U'中').code == U'?'); const TermshotGlyph& block = TermshotFont::glyph(U'█'); CHECK(block.rows[0] == 0xFF && block.rows[15] == 0xFF); // Palette: named, bright, bold-brightening, 256 cube, gray ramp, truecolor. CHECK(TermshotPalette::fragment(std::string("31"), 0, false, false) == 0xCD0000); CHECK(TermshotPalette::fragment(std::string("31"), 0, false, true) == 0xFF0000); CHECK(TermshotPalette::fragment(std::string("94"), 0, false, false) == 0x5C5CFF); CHECK(TermshotPalette::fragment(std::string("38;5;196"), 0, false, false) == 0xFF0000); CHECK(TermshotPalette::fragment(std::string("38;5;232"), 0, false, false) == 0x080808); CHECK(TermshotPalette::fragment(std::string("48;2;1;2;3"), 0, true, false) == 0x010203); CHECK(TermshotPalette::fragment(std::nullopt, 0x123456, false, false) == 0x123456); // Dimensions follow the grid plus padding. std::string png = TermshotShooter::png(frame("hello", 3, 10)); int w = 0, h = 0; CHECK(png.compare(0, 8, std::string("\x89PNG\r\n\x1a\n", 8)) == 0); CHECK(TermshotPng::dimensions(png, w, h)); CHECK(w == 10 * kCellWidth + 2 * kPadding && h == 3 * kCellHeight + 2 * kPadding); // Pixels: padding is the default background, a red-background cell is red, // a full block in green fills its cell, reverse swaps fg and bg. std::string colored = TermshotShooter::png(frame("\x1b[41m \x1b[0m\x1b[32m\xe2\x96\x88\x1b[0m\x1b[7m \x1b[0m", 1, 4)); std::uint32_t c = 0; CHECK(pixel_at(colored, 0, 0, c) && c == kDefaultBg); CHECK(pixel_at(colored, kPadding + 3, kPadding + 8, c) && c == 0xCD0000); CHECK(pixel_at(colored, kPadding + kCellWidth + 3, kPadding + 8, c) && c == 0x00CD00); CHECK(pixel_at(colored, kPadding + 2 * kCellWidth + 3, kPadding + 8, c) && c == kDefaultFg); // Lines: \r\n moves down; text lands in row 2. std::string two = TermshotShooter::png(frame("a\r\n\x1b[44m \x1b[0m", 2, 2)); CHECK(pixel_at(two, kPadding + 2, kPadding + kCellHeight + 8, c) && c == 0x0000EE); // Cursor: drawn as a block on an empty cell. TermshotFrame cur = frame("", 2, 2); cur.cursor_x = 1; cur.cursor_y = 1; std::string cpng = TermshotShooter::png(cur); CHECK(pixel_at(cpng, kPadding + kCellWidth + 4, kPadding + kCellHeight + 8, c) && c == kCursorColor); // Caps: absurd sizes are clamped. int big_w = 0, big_h = 0; std::string capped = TermshotShooter::png(frame("x", 100000, 100000)); CHECK(TermshotPng::dimensions(capped, big_w, big_h)); CHECK(big_w == TermshotRenderer::width_for(kMaxCols) && big_h == TermshotRenderer::height_for(kMaxRows)); TermshotFrame nat = TermshotCapture::native(std::string(kMaxCaptureBytes + 10, 'x'), "1"); CHECK(nat.ansi.size() == kMaxCaptureBytes && nat.rows == kNativeDefaultRows && nat.cols == kNativeDefaultCols); CHECK(nat.source == "native pane 1"); // File: default path under /shots, bytes on disk equal the encoder output. auto home = std::filesystem::temp_directory_path() / ("termshot_test_" + std::to_string(::getpid())); TermshotShooter shooter(home.string()); TermshotShot shot = shooter.shoot(frame("\x1b[1;33mdetz\x1b[0m", 2, 8)); CHECK(shot.path.rfind((home / kShotDir).string(), 0) == 0); CHECK(std::filesystem::file_size(shot.path) == shot.bytes); CHECK(shot.width == TermshotRenderer::width_for(8) && shot.rows == 2); CHECK(shot.summary().find("image/png") != std::string::npos); std::filesystem::remove_all(home); if (failures == 0) std::printf("termshot_test: all checks passed\n"); return failures == 0 ? 0 : 1; }