diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c863c7cbb..e0c68aeff 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -14,6 +14,7 @@ include(Catch)
set(TEST_SOURCES
zm_comms.cpp
zm_crypt.cpp
+ zm_font.cpp
zm_utils.cpp)
add_executable(tests main.cpp ${TEST_SOURCES})
@@ -31,3 +32,9 @@ target_include_directories(tests
${CMAKE_CURRENT_BINARY_DIR})
catch_discover_tests(tests)
+
+add_custom_command(TARGET tests
+ PRE_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_directory
+ ${CMAKE_CURRENT_SOURCE_DIR}/data/ ${CMAKE_CURRENT_BINARY_DIR}/data/
+ BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/data/)
diff --git a/tests/data/fonts/01_bad_magic.zmfnt b/tests/data/fonts/01_bad_magic.zmfnt
new file mode 100644
index 000000000..a73d2b51f
Binary files /dev/null and b/tests/data/fonts/01_bad_magic.zmfnt differ
diff --git a/tests/data/fonts/02_variant_invalid.zmfnt b/tests/data/fonts/02_variant_invalid.zmfnt
new file mode 100644
index 000000000..9cbd0b024
Binary files /dev/null and b/tests/data/fonts/02_variant_invalid.zmfnt differ
diff --git a/tests/data/fonts/03_missing_cps.zmfnt b/tests/data/fonts/03_missing_cps.zmfnt
new file mode 100644
index 000000000..b66e696ed
Binary files /dev/null and b/tests/data/fonts/03_missing_cps.zmfnt differ
diff --git a/tests/data/fonts/04_valid.zmfnt b/tests/data/fonts/04_valid.zmfnt
new file mode 100644
index 000000000..3cb839931
Binary files /dev/null and b/tests/data/fonts/04_valid.zmfnt differ
diff --git a/tests/data/fonts/generate_fonts.py b/tests/data/fonts/generate_fonts.py
new file mode 100644
index 000000000..8fe24be8c
--- /dev/null
+++ b/tests/data/fonts/generate_fonts.py
@@ -0,0 +1,54 @@
+#!/bin/python
+
+import struct
+
+GOOD_MAGIC = b"ZMFNT\0"
+BAD_MAGIC = b"ABCDE\0"
+NUM_FONT_SIZES = 4
+
+
+class FontFile:
+ def __init__(self, path):
+ self.path = path
+
+ def write_file_header(self, magic):
+ with open(self.path, "wb") as f:
+ f.write(magic)
+ f.write(struct.pack("BB", 0, 0)) # pad
+
+ def write_bm_header(self, height, width, cp_count, idx):
+ with open(self.path, "ab") as f:
+ f.write(struct.pack("HHIII", height, width, cp_count, idx, 0))
+
+ def write_codepoints(self, value, height, count):
+ with open(self.path, "ab") as f:
+ for _ in range(height * count):
+ f.write(struct.pack("Q", value))
+
+
+font = FontFile("01_bad_magic.zmfnt")
+font.write_file_header(BAD_MAGIC)
+
+# height, width and number of codepoints out of bounds
+font = FontFile("02_variant_invalid.zmfnt")
+font.write_file_header(GOOD_MAGIC)
+font.write_bm_header(201, 65, 256, 0)
+
+# mismatch between number of codepoints specified in header and actually stored ones
+font = FontFile("03_missing_cps.zmfnt")
+font.write_file_header(GOOD_MAGIC)
+offs = 0
+for _ in range(NUM_FONT_SIZES):
+ font.write_bm_header(10, 10, 10, offs)
+ offs += 10 * 10
+for _ in range(NUM_FONT_SIZES):
+ font.write_codepoints(1, 10, 9)
+
+font = FontFile("04_valid.zmfnt")
+font.write_file_header(GOOD_MAGIC)
+offs = 0
+for i in range(NUM_FONT_SIZES):
+ font.write_bm_header(10 + i, 10 + i, 10, offs)
+ offs += 10 * (10 + i)
+for i in range(NUM_FONT_SIZES):
+ font.write_codepoints(i, 10 + i, 10)
diff --git a/tests/zm_font.cpp b/tests/zm_font.cpp
new file mode 100644
index 000000000..3f4d6010e
--- /dev/null
+++ b/tests/zm_font.cpp
@@ -0,0 +1,156 @@
+/*
+ * This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see .
+ */
+
+#include "catch2/catch.hpp"
+
+#include "zm_font.h"
+
+CATCH_REGISTER_ENUM(FontLoadError,
+ FontLoadError::kOk,
+ FontLoadError::kFileNotFound,
+ FontLoadError::kInvalidFile)
+
+TEST_CASE("FontVariant: construction") {
+ FontVariant variant;
+
+ SECTION("default construction") {
+ REQUIRE(variant.GetCharHeight() == 0);
+ REQUIRE(variant.GetCharWidth() == 0);
+ }
+
+ SECTION("values in range") {
+ constexpr uint8 height = 10;
+ constexpr uint8 width = 10;
+ std::vector bitmap(FontVariant::kMaxNumCodePoints * height);
+
+ REQUIRE_NOTHROW(variant = FontVariant(height, width, bitmap));
+
+ REQUIRE(variant.GetCharHeight() == height);
+ REQUIRE(variant.GetCharWidth() == width);
+ REQUIRE(variant.GetCodepointsCount() == FontVariant::kMaxNumCodePoints);
+ }
+
+ SECTION("height out of range") {
+ constexpr uint8 height = FontVariant::kMaxCharHeight + 1;
+ constexpr uint8 width = 10;
+ std::vector bitmap(FontVariant::kMaxNumCodePoints * height);
+
+ REQUIRE_THROWS(variant = FontVariant(height, width, bitmap));
+ }
+
+ SECTION("width out of range") {
+ constexpr uint8 height = 10;
+ constexpr uint8 width = FontVariant::kMaxCharWidth + 1;
+ std::vector bitmap(FontVariant::kMaxNumCodePoints * height);
+
+ REQUIRE_THROWS(variant = FontVariant(height, width, bitmap));
+ }
+
+ SECTION("bitmap of wrong size") {
+ constexpr uint8 height = 10;
+ constexpr uint8 width = 10;
+ std::vector bitmap(FontVariant::kMaxNumCodePoints * height + 1);
+
+ REQUIRE_THROWS(variant = FontVariant(height, width, bitmap));
+ }
+}
+
+TEST_CASE("FontVariant: GetCodepoint") {
+ constexpr uint8 height = 10;
+ constexpr uint8 width = 10;
+ std::vector bitmap(FontVariant::kMaxNumCodePoints * height);
+
+ // fill bitmap for each codepoint alternating with 1 and std::numeric_limits::max()
+ std::generate(bitmap.begin(), bitmap.end(),
+ [n = 0, zero = true]() mutable {
+ if (n == height) {
+ zero = !zero;
+ n = 0;
+ }
+ n++;
+ if (zero) {
+ return static_cast(1);
+ } else {
+ return std::numeric_limits::max();
+ }
+ });
+
+ FontVariant variant(height, width, bitmap);
+ nonstd::span cp;
+
+ SECTION("in bounds") {
+ cp = variant.GetCodepoint(0);
+ REQUIRE(std::all_of(cp.begin(), cp.end(),
+ [](uint64 l) { return l == 1; }) == true);
+
+ cp = variant.GetCodepoint(1);
+ REQUIRE(std::all_of(cp.begin(), cp.end(),
+ [](uint64 l) { return l == std::numeric_limits::max(); }) == true);
+ }
+
+ SECTION("out-of-bounds: all-zero bitmap") {
+ cp = variant.GetCodepoint(FontVariant::kMaxNumCodePoints);
+ REQUIRE(std::all_of(cp.begin(), cp.end(),
+ [](uint64 l) { return l == 0; }) == true);
+ }
+}
+
+TEST_CASE("ZmFont: variants not loaded") {
+ ZmFont font;
+
+ SECTION("returns empty variant") {
+ FontVariant variant;
+ REQUIRE_NOTHROW(variant = font.GetFontVariant(0));
+
+ REQUIRE(variant.GetCharHeight() == 0);
+ REQUIRE(variant.GetCharWidth() == 0);
+ REQUIRE(variant.GetCodepoint(0).empty() == true);
+ }
+
+ SECTION("variant idx out-of-bounds") {
+ REQUIRE_THROWS(font.GetFontVariant(kNumFontSizes));
+ }
+}
+
+TEST_CASE("ZmFont: load font file") {
+ ZmFont font;
+
+ SECTION("file not found") {
+ REQUIRE(font.LoadFontFile("does_not_exist.zmfnt") == FontLoadError::kFileNotFound);
+ }
+
+ SECTION("invalid files") {
+ REQUIRE(font.LoadFontFile("data/fonts/01_bad_magic.zmfnt") == FontLoadError::kInvalidFile);
+ REQUIRE(font.LoadFontFile("data/fonts/02_variant_invalid.zmfnt") == FontLoadError::kInvalidFile);
+ REQUIRE(font.LoadFontFile("data/fonts/03_missing_cps.zmfnt") == FontLoadError::kInvalidFile);
+ }
+
+ SECTION("valid file") {
+ REQUIRE(font.LoadFontFile("data/fonts/04_valid.zmfnt") == FontLoadError::kOk);
+
+ uint8 var_idx = GENERATE(range(static_cast(0), kNumFontSizes));
+ FontVariant variant = font.GetFontVariant(var_idx);
+ REQUIRE(variant.GetCharHeight() == 10 + var_idx);
+ REQUIRE(variant.GetCharWidth() == 10 + var_idx);
+
+ uint8 cp_idx =
+ GENERATE_COPY(range(static_cast(0), variant.GetCodepointsCount()));
+ nonstd::span cp = variant.GetCodepoint(cp_idx);
+ REQUIRE(std::all_of(cp.begin(), cp.end(),
+ [=](uint64 l) { return l == var_idx; }) == true);
+ }
+}