Merge pull request #42 from jpirnay/chore-upstream

chore: Integrate upstream changes
This commit is contained in:
jpirnay
2026-04-08 11:58:54 +02:00
committed by GitHub
13 changed files with 517 additions and 250 deletions
+9 -6
View File
@@ -15,11 +15,12 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
return;
}
int32_t cursorXFP = fp4::fromPixel(startX); // 12.4 fixed-point accumulator
int lastBaseX = startX;
int lastBaseLeft = 0;
int lastBaseWidth = 0;
int lastBaseTop = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
@@ -31,20 +32,22 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
const EpdGlyph* glyph = getGlyph(cp);
if (!glyph) {
lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting
prevCp = 0;
prevAdvanceFP = 0;
continue;
}
const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0;
if (!isCombining && prevCp != 0) {
cursorXFP += getKerning(prevCp, cp); // 4.4 fixed-point kern
const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern
lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP);
}
const int cursorXPixels = fp4::toPixel(cursorXFP); // snap 12.4 fixed-point to nearest pixel
const int glyphBaseX =
isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width)
: cursorXPixels;
: lastBaseX;
const int glyphBaseY = startY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
@@ -53,11 +56,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorXPixels;
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point
lastBaseTop = glyph->top;
cursorXFP += glyph->advanceX; // 12.4 fixed-point advance
prevAdvanceFP = lastBaseAdvanceFP;
prevCp = cp;
}
}
+6 -4
View File
@@ -7,10 +7,12 @@
/// Font metrics use "fixed-point 4" (4 fractional bits, i.e. 1/16-pixel
/// resolution). Both the 12.4 glyph advances (uint16_t) and the 4.4 kern
/// values (int8_t) share the same 4 fractional bits, so they can be freely
/// added into a single int32_t accumulator during text layout. The
/// accumulator is snapped to the nearest whole pixel only at render time,
/// which avoids the per-character rounding errors that plagued integer-only
/// layout.
/// added before snapping to whole pixels.
///
/// Rendering and measurement use "differential rounding": each glyph step
/// (previous advance + current kern) is combined in fixed-point and snapped
/// to a pixel as one unit. This guarantees identical character pairs always
/// produce the same pixel spacing, regardless of position on the line.
///
/// The helpers below eliminate the raw bit-shifts that would otherwise be
/// scattered across every layout / measurement call site.
@@ -20,60 +20,37 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by setUserPointer()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()).
struct JpegContext {
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
int screenHeight{0};
// Source dimensions after JPEGDEC's built-in scaling
int scaledSrcWidth;
int scaledSrcHeight;
int scaledSrcWidth{0};
int scaledSrcHeight{0};
// Final output dimensions
int dstWidth;
int dstHeight;
int dstWidth{0};
int dstHeight{0};
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU)
int32_t fineScaleFP; // src -> dst mapping
int32_t invScaleFP; // dst -> src mapping
int32_t fineScaleFP{1 << 16}; // src -> dst mapping
int32_t invScaleFP{1 << 16}; // dst -> src mapping
PixelCache cache;
bool caching;
bool caching{false};
// See PngContext for the rationale: monochromeOutput requests a 1-bit Atkinson dither
// emitting only 0/3 so the BW DirectPixelWriter (`pixelValue < 3` rule) maps cleanly.
int oneBitDitherRow;
Atkinson1BitDitherer* atkinson1BitDitherer;
int oneBitDitherRow{-1};
Atkinson1BitDitherer* atkinson1BitDitherer{nullptr};
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
int currentDitherRow;
AtkinsonDitherer* atkinsonDitherer;
DiffusedBayerDitherer* diffusedBayerDitherer;
int currentDitherRow{-1};
AtkinsonDitherer* atkinsonDitherer{nullptr};
DiffusedBayerDitherer* diffusedBayerDitherer{nullptr};
#endif
JpegContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scaledSrcWidth(0),
scaledSrcHeight(0),
dstWidth(0),
dstHeight(0),
fineScaleFP(1 << 16),
invScaleFP(1 << 16),
caching(false),
oneBitDitherRow(-1),
atkinson1BitDitherer(nullptr)
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
,
currentDitherRow(-1),
atkinsonDitherer(nullptr),
diffusedBayerDitherer(nullptr)
#endif
{
}
~JpegContext() {
delete atkinson1BitDitherer;
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
@@ -20,62 +20,38 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
int screenHeight{0};
// Scaling state
float scale;
int srcWidth;
int srcHeight;
int dstWidth;
int dstHeight;
int lastDstY; // Track last rendered destination Y to avoid duplicates
float scale{1.f};
int srcWidth{0};
int srcHeight{0};
int dstWidth{0};
int dstHeight{0};
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching;
bool caching{false};
uint8_t* grayLineBuffer;
uint8_t* grayLineBuffer{nullptr};
// When the caller requests monochrome output (RenderConfig::monochromeOutput),
// we run a proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path)
// and emit only values 0 or 3, which round-trip cleanly through the BW writer's
// `pixelValue < 3` rule. The 4-level dither path collapses mid-grays to solid
// black under that rule.
int oneBitDitherRow;
Atkinson1BitDitherer* atkinson1BitDitherer;
int oneBitDitherRow{-1};
Atkinson1BitDitherer* atkinson1BitDitherer{nullptr};
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
int currentDitherRow;
AtkinsonDitherer* atkinsonDitherer;
DiffusedBayerDitherer* diffusedBayerDitherer;
int currentDitherRow{-1};
AtkinsonDitherer* atkinsonDitherer{nullptr};
DiffusedBayerDitherer* diffusedBayerDitherer{nullptr};
#endif
PngContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scale(1.0f),
srcWidth(0),
srcHeight(0),
dstWidth(0),
dstHeight(0),
lastDstY(-1),
caching(false),
grayLineBuffer(nullptr),
oneBitDitherRow(-1),
atkinson1BitDitherer(nullptr)
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
,
currentDitherRow(-1),
atkinsonDitherer(nullptr),
diffusedBayerDitherer(nullptr)
#endif
{
}
~PngContext() {
delete atkinson1BitDitherer;
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
+35 -9
View File
@@ -799,11 +799,21 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
lastBaseX += fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
lastBaseAdvanceFP = 0;
continue;
}
lastBaseLeft = glyph ? glyph->left : 0;
lastBaseWidth = glyph ? glyph->width : 0;
lastBaseTop = glyph ? glyph->top : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0;
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
lastBaseAdvanceFP = glyph->advanceX;
prevAdvanceFP = lastBaseAdvanceFP;
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
@@ -1773,7 +1783,13 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
prevAdvanceFP = glyph ? glyph->advanceX : 0;
if (!glyph) {
widthPx += fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
continue;
}
prevAdvanceFP = glyph->advanceX;
prevCp = cp;
}
widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance
@@ -1855,11 +1871,21 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
lastBaseY -= fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
lastBaseAdvanceFP = 0;
continue;
}
lastBaseLeft = glyph ? glyph->left : 0;
lastBaseWidth = glyph ? glyph->width : 0;
lastBaseTop = glyph ? glyph->top : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0;
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
lastBaseAdvanceFP = glyph->advanceX;
prevAdvanceFP = lastBaseAdvanceFP;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, x, lastBaseY, black, style);
+1 -1
View File
@@ -286,7 +286,7 @@ STR_UPLOAD_PROMPT: "Отправить текущую позицию?"
STR_UPLOAD_SUCCESS: "Прогресс отправлен!"
STR_PULL_SUCCESS: "Удалённый прогресс применён!"
STR_SYNC_FAILED_MSG: "Ошибка синхронизации"
STR_SECTION_PREFIX: "Раздел"
STR_SECTION_PREFIX: "Раздел "
STR_UPLOAD: "Отправить"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Встроенный стиль"
+2 -2
View File
@@ -284,8 +284,8 @@ STR_UPLOAD: "Завантажити"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Вбудований стиль"
STR_OPDS_SERVER_URL: "URL сервера OPDS"
STR_FOOTNOTES: "Зноски"
STR_NO_FOOTNOTES: "На цій сторінці немає зносок"
STR_FOOTNOTES: "Примітки"
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
STR_LINK: "[посилання]"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк.: "
+16 -26
View File
@@ -2,6 +2,7 @@
#include <HalClock.h>
#include <algorithm>
#include <string>
#define MAX_ENTRY_LEN 256
@@ -40,44 +41,33 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
va_start(args, format);
char buf[MAX_ENTRY_LEN];
char* c = buf;
// add the timestamp
// add timestamp, wall clock, level and origin
{
unsigned long ms = millis();
char wallClock[12];
HalClock::formatLogTime(wallClock, sizeof(wallClock));
int len;
if (wallClock[0] != '\0') {
len = snprintf(c, sizeof(buf), "[%lu %s] ", ms, wallClock);
len = snprintf(c, sizeof(buf), "[%lu %s] [%s] [%s] ", ms, wallClock, level, origin);
} else {
len = snprintf(c, sizeof(buf), "[%lu] ", ms);
len = snprintf(c, sizeof(buf), "[%lu] [%s] [%s] ", ms, level, origin);
}
// error while writing => return
if (len < 0) {
return; // encoding error, skip logging
va_end(args);
return;
}
c += len;
}
// add the level
{
const char* p = level;
size_t remaining = sizeof(buf) - (c - buf);
while (*p && remaining > 1) {
*c++ = *p++;
remaining--;
}
if (remaining > 1) {
*c++ = ' ';
}
}
// add the origin
{
int len = snprintf(c, sizeof(buf) - (c - buf), "[%s] ", origin);
if (len < 0) {
return; // encoding error, skip logging
}
c += len;
// clamp c to be in buffer range
c += std::min(len, MAX_ENTRY_LEN);
}
// add the user message
vsnprintf(c, sizeof(buf) - (c - buf), format, args);
{
int len = vsnprintf(c, sizeof(buf) - (c - buf), format, args);
if (len < 0) {
va_end(args);
return;
}
}
va_end(args);
if (logSerial) {
logSerial.print(buf);
+3 -3
View File
@@ -33,19 +33,19 @@ void logPrintf(const char* level, const char* origin, const char* format, ...);
#ifdef ENABLE_SERIAL_LOG
#if LOG_LEVEL >= 0
#define LOG_ERR(origin, format, ...) logPrintf("[ERR]", origin, format "\n", ##__VA_ARGS__)
#define LOG_ERR(origin, format, ...) logPrintf("ERR", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_ERR(origin, format, ...)
#endif
#if LOG_LEVEL >= 1
#define LOG_INF(origin, format, ...) logPrintf("[INF]", origin, format "\n", ##__VA_ARGS__)
#define LOG_INF(origin, format, ...) logPrintf("INF", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_INF(origin, format, ...)
#endif
#if LOG_LEVEL >= 2
#define LOG_DBG(origin, format, ...) logPrintf("[DBG]", origin, format "\n", ##__VA_ARGS__)
#define LOG_DBG(origin, format, ...) logPrintf("DBG", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_DBG(origin, format, ...)
#endif
+1 -2
View File
@@ -51,7 +51,6 @@ board_build.partitions = partitions.csv
extra_scripts =
pre:scripts/build_html.py
pre:scripts/gen_i18n.py
pre:scripts/patch_jpegdec.py
pre:scripts/git_branch.py
; Libraries
@@ -63,7 +62,7 @@ lib_deps =
bblanchon/ArduinoJson @ 7.4.2
QRCode=symlink://lib/QRCode
bitbank2/PNGdec @ ^1.0.0
bitbank2/JPEGDEC @ ^1.8.0
https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b
links2004/WebSockets @ 2.7.3
[env:default]
-117
View File
@@ -1,117 +0,0 @@
"""
PlatformIO pre-build script: patch JPEGDEC library for progressive JPEG support.
Two patches are applied:
1. JPEGMakeHuffTables: Skip AC Huffman table construction for progressive JPEGs.
JPEGDEC 1.8.x fails to open progressive JPEGs because JPEGMakeHuffTables()
cannot build AC tables with 11+-bit codes (the "slow tables" path is disabled).
Since progressive decode only uses DC coefficients, AC tables are not needed.
2. JPEGDecodeMCU_P: Guard pMCU writes against MCU_SKIP (-8).
The non-progressive JPEGDecodeMCU checks `iMCU >= 0` before writing to pMCU,
but JPEGDecodeMCU_P does not. When EIGHT_BIT_GRAYSCALE mode skips chroma
channels by passing MCU_SKIP, the unguarded write goes to a wild pointer
(sMCUs[0xFFFFF8]) and crashes.
Both patches are applied idempotently so it is safe to run on every build.
"""
Import("env")
import os
def patch_jpegdec(env):
# Find the JPEGDEC library in libdeps
libdeps_dir = os.path.join(env["PROJECT_DIR"], ".pio", "libdeps")
if not os.path.isdir(libdeps_dir):
return
for env_dir in os.listdir(libdeps_dir):
jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl")
if os.path.isfile(jpeg_inl):
_apply_ac_table_patch(jpeg_inl)
_apply_mcu_skip_patch(jpeg_inl)
def _apply_ac_table_patch(filepath):
MARKER = "// CrossPoint patch: skip AC tables for progressive JPEG"
with open(filepath, "r") as f:
content = f.read()
if MARKER in content:
return # already patched
OLD = """\
}
// now do AC components (up to 4 tables of 16-bit codes)"""
NEW = """\
}
""" + MARKER + """
// Progressive JPEG: only DC coefficients are decoded (first scan), so AC
// Huffman tables are not needed. Skip building them to avoid failing on
// 11+-bit AC codes that the optimized table builder cannot handle.
if (pJPEG->ucMode == 0xc2)
return 1;
// now do AC components (up to 4 tables of 16-bit codes)"""
if OLD not in content:
print("WARNING: JPEGDEC AC table patch target not found in %s — library may have been updated" % filepath)
return
content = content.replace(OLD, NEW, 1)
with open(filepath, "w") as f:
f.write(content)
print("Patched JPEGDEC: skip AC tables for progressive JPEG: %s" % filepath)
def _apply_mcu_skip_patch(filepath):
MARKER = "// CrossPoint patch: guard pMCU write for MCU_SKIP"
with open(filepath, "r") as f:
content = f.read()
if MARKER in content:
return # already patched
# Patch 1: Guard the unconditional pMCU[0] write in JPEGDecodeMCU_P.
# This is the DC coefficient store that crashes when iMCU = MCU_SKIP (-8).
OLD_DC = """\
pMCU[0] = (short)*iDCPredictor; // store in MCU[0]
}
// Now get the other 63 AC coefficients"""
NEW_DC = """\
""" + MARKER + """
if (iMCU >= 0)
pMCU[0] = (short)*iDCPredictor; // store in MCU[0]
}
// Now get the other 63 AC coefficients"""
if OLD_DC not in content:
print("WARNING: JPEGDEC MCU_SKIP patch target not found in %s — library may have been updated" % filepath)
return
content = content.replace(OLD_DC, NEW_DC, 1)
# Patch 2: Guard the successive approximation pMCU[0] write.
# This path is taken on subsequent scans (cApproxBitsHigh != 0), which we
# don't normally hit (we only decode first scan), but guard it for safety.
OLD_SA = """\
pMCU[0] |= iPositive;
}
goto mcu_done; // that's it"""
NEW_SA = """\
if (iMCU >= 0)
pMCU[0] |= iPositive;
}
goto mcu_done; // that's it"""
if OLD_SA in content:
content = content.replace(OLD_SA, NEW_SA, 1)
with open(filepath, "w") as f:
f.write(content)
print("Patched JPEGDEC: guard pMCU writes for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath)
# Apply patches immediately when this pre: script runs, before compilation starts.
# Previously used env.AddPreAction("buildprog", ...) which deferred patching until
# the link step — after the library was already compiled from unpatched source.
patch_jpegdec(env)
@@ -0,0 +1,381 @@
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include "lib/EpdFont/EpdFont.h"
#include "lib/EpdFont/EpdFontData.h"
static int testsPassed = 0;
static int testsFailed = 0;
#define ASSERT_EQ(a, b) \
do { \
if ((a) != (b)) { \
fprintf(stderr, " FAIL: %s:%d: %s == %d, expected %d\n", __FILE__, __LINE__, #a, (a), (b)); \
testsFailed++; \
return; \
} \
} while (0)
#define ASSERT_TRUE(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \
testsFailed++; \
return; \
} \
} while (0)
#define PASS() testsPassed++
// ============================================================================
// Synthetic test font
//
// Glyphs: 'T' (0x54), 'a' (0x61), 'o' (0x6F), 'x' (0x78)
// - 'x' advance is 136 FP (8.5px) -- frac = 8, exactly at the rounding
// boundary where absolute vs differential snapping diverges for "oo".
// - No U+FFFD replacement glyph, so unknown codepoints trigger the
// null-glyph path in getTextBounds.
//
// Kern pairs (4.4 fixed-point):
// T->a: -5 (-0.3125px) T->o: -7 (-0.4375px)
// o->a: -2 (-0.125px) o->o: -3 (-0.1875px)
// ============================================================================
// clang-format off
static const EpdGlyph kGlyphs[] = {
// idx width height advanceX left top dataLength dataOffset
/* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 },
/* 1 'a' */ { 7, 8, 130, 0, 8, 0, 0 },
/* 2 'o' */ { 8, 8, 145, 0, 8, 0, 0 },
/* 3 'x' */ { 7, 8, 136, 0, 8, 0, 0 },
};
static const EpdUnicodeInterval kIntervals[] = {
{ 0x54, 0x54, 0 }, // 'T' -> glyph[0]
{ 0x61, 0x61, 1 }, // 'a' -> glyph[1]
{ 0x6F, 0x6F, 2 }, // 'o' -> glyph[2]
{ 0x78, 0x78, 3 }, // 'x' -> glyph[3]
};
static const EpdKernClassEntry kKernLeft[] = {
{ 0x54, 1 }, // 'T' -> left class 1
{ 0x6F, 2 }, // 'o' -> left class 2
};
static const EpdKernClassEntry kKernRight[] = {
{ 0x61, 1 }, // 'a' -> right class 1
{ 0x6F, 2 }, // 'o' -> right class 2
};
// Flat matrix: leftClassCount(2) x rightClassCount(2), 4.4 fixed-point
// [L1,R1]=kern(T,a) [L1,R2]=kern(T,o) [L2,R1]=kern(o,a) [L2,R2]=kern(o,o)
static const int8_t kKernMatrix[] = { -5, -7, -2, -3 };
static const EpdFontData kTestFontData = {
.bitmap = nullptr,
.glyph = kGlyphs,
.intervals = kIntervals,
.intervalCount = 4,
.advanceY = 16,
.ascender = 12,
.descender = 0,
.is2Bit = false,
.groups = nullptr,
.groupCount = 0,
.glyphToGroup = nullptr,
.kernLeftClasses = kKernLeft,
.kernRightClasses = kKernRight,
.kernMatrix = kKernMatrix,
.kernLeftEntryCount = 2,
.kernRightEntryCount = 2,
.kernLeftClassCount = 2,
.kernRightClassCount = 2,
.ligaturePairs = nullptr,
.ligaturePairCount = 0,
};
// clang-format on
static EpdFont testFont(&kTestFontData);
// Helper: return width from getTextDimensions
static int textWidth(const char* str) {
int w = 0, h = 0;
testFont.getTextDimensions(str, &w, &h);
return w;
}
static int textHeight(const char* str) {
int w = 0, h = 0;
testFont.getTextDimensions(str, &w, &h);
return h;
}
// ============================================================================
// Part 1: Pure fp4 math tests
// ============================================================================
// Simulate the old absolute-snap gap for comparison
static int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) {
int32_t nextFP = startFP + advanceFP + kernFP;
return fp4::toPixel(nextFP) - fp4::toPixel(startFP);
}
void testFp4Basics() {
printf("testFp4Basics...\n");
for (int px = 0; px < 500; px++) {
ASSERT_EQ(fp4::toPixel(fp4::fromPixel(px)), px);
}
ASSERT_EQ(fp4::toPixel(0), 0);
ASSERT_EQ(fp4::toPixel(7), 0); // 0.4375 -> 0
ASSERT_EQ(fp4::toPixel(8), 1); // 0.5 -> 1 (round half up)
ASSERT_EQ(fp4::toPixel(15), 1); // 0.9375 -> 1
ASSERT_EQ(fp4::toPixel(16), 1); // 1.0 -> 1
ASSERT_EQ(fp4::toPixel(24), 2); // 1.5 -> 2
ASSERT_EQ(fp4::toPixel(-8), 0); // -0.5 -> 0
ASSERT_EQ(fp4::toPixel(-9), -1); // -0.5625 -> -1
ASSERT_EQ(fp4::toPixel(-16), -1);
ASSERT_EQ(fp4::toPixel(137 + (-9)), 8); // 128 = 8.0 exact
ASSERT_EQ(fp4::toPixel(137 + (-5)), 8); // 132 = 8.25
ASSERT_EQ(fp4::toPixel(137 + (-1)), 9); // 136 = 8.5 (half rounds up)
printf(" All fp4 basics passed\n");
PASS();
}
void testOldApproachInconsistency() {
printf("testOldApproachInconsistency...\n");
// 'oo' pair: advance=145 (9.0625px), kern=-3 (-0.1875px), combined=142 (8.875px)
const int32_t advance = 145;
const int32_t kern = -3;
int minGap = 999, maxGap = -999;
for (int startPx = 0; startPx < 100; startPx++) {
for (int frac = 0; frac < 16; frac++) {
int32_t startFP = fp4::fromPixel(startPx) + frac;
int gap = absoluteGap(startFP, advance, kern);
if (gap < minGap) minGap = gap;
if (gap > maxGap) maxGap = gap;
}
}
ASSERT_TRUE(maxGap - minGap >= 1);
printf(" Old absolute gap range: [%d, %d] -- varies by %d px\n", minGap, maxGap, maxGap - minGap);
int diffStep = fp4::toPixel(advance + kern);
printf(" Differential step: always %d px\n", diffStep);
PASS();
}
void testExhaustiveKernRange() {
printf("testExhaustiveKernRange...\n");
const int32_t baseAdvance = 128;
int checked = 0;
for (int advFrac = 0; advFrac < 16; advFrac++) {
int32_t advance = baseAdvance + advFrac;
for (int kern = -128; kern <= 127; kern++) {
int step = fp4::toPixel(advance + static_cast<int32_t>(kern));
float idealPx = fp4::toFloat(advance + kern);
if (std::abs(step - idealPx) >= 1.0f) {
fprintf(stderr, " FAIL: advance=%d, kern=%d, step=%d, ideal=%.4f\n", advance, kern, step, idealPx);
testsFailed++;
return;
}
checked++;
}
}
printf(" Checked %d (advance, kern) combinations -- all within 1px of ideal\n", checked);
PASS();
}
// ============================================================================
// Part 2: Integration tests using real EpdFont::getTextDimensions
// ============================================================================
void testKernLookup() {
printf("testKernLookup...\n");
ASSERT_EQ(testFont.getKerning('T', 'a'), -5);
ASSERT_EQ(testFont.getKerning('T', 'o'), -7);
ASSERT_EQ(testFont.getKerning('o', 'a'), -2);
ASSERT_EQ(testFont.getKerning('o', 'o'), -3);
ASSERT_EQ(testFont.getKerning('a', 'o'), 0); // 'a' has no left class
ASSERT_EQ(testFont.getKerning('x', 'o'), 0); // 'x' has no left class
ASSERT_EQ(testFont.getKerning('T', 'x'), 0); // 'x' has no right class
ASSERT_EQ(testFont.getKerning('T', 'T'), 0); // 'T' has no right class
printf(" All kern lookups correct\n");
PASS();
}
void testGlyphLookup() {
printf("testGlyphLookup...\n");
ASSERT_TRUE(testFont.getGlyph('T') != nullptr);
ASSERT_TRUE(testFont.getGlyph('a') != nullptr);
ASSERT_TRUE(testFont.getGlyph('o') != nullptr);
ASSERT_TRUE(testFont.getGlyph('x') != nullptr);
ASSERT_EQ(testFont.getGlyph('T')->advanceX, 137);
ASSERT_EQ(testFont.getGlyph('a')->advanceX, 130);
ASSERT_EQ(testFont.getGlyph('o')->advanceX, 145);
ASSERT_EQ(testFont.getGlyph('x')->advanceX, 136);
// No U+FFFD in font, so unknown codepoints return nullptr
ASSERT_TRUE(testFont.getGlyph('Z') == nullptr);
ASSERT_TRUE(testFont.getGlyph('b') == nullptr);
printf(" All glyph lookups correct\n");
PASS();
}
// Known-value regression tests. Expected widths are computed by hand using
// differential rounding. If someone reverts to absolute snapping, specific
// test cases will fail.
//
// Layout trace for each string (all glyphs have left=0):
// width = max glyph right edge = lastBaseX + glyph.width
//
// Differential step from glyph A to glyph B:
// step = fp4::toPixel(advanceA + kern(A,B))
void testKnownWidths() {
printf("testKnownWidths...\n");
// "o": single glyph at x=0, width=8
// w = 0 + 8 = 8
ASSERT_EQ(textWidth("o"), 8);
// "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9
// o1 at 0, o2 at 9. w = 9 + 8 = 17
ASSERT_EQ(textWidth("oo"), 17);
// "ooo": two steps of 9
// o1 at 0, o2 at 9, o3 at 18. w = 18 + 8 = 26
ASSERT_EQ(textWidth("ooo"), 26);
// "To": step = toPixel(137 + (-7)) = toPixel(130) = 8
// T at 0, o at 8. w = 8 + 8 = 16
ASSERT_EQ(textWidth("To"), 16);
// "Ta": step = toPixel(137 + (-5)) = toPixel(132) = 8
// T at 0, a at 8. w = 8 + 7 = 15
ASSERT_EQ(textWidth("Ta"), 15);
// "oa": step = toPixel(145 + (-2)) = toPixel(143) = 9
// o at 0, a at 9. w = 9 + 7 = 16
ASSERT_EQ(textWidth("oa"), 16);
// "Too": T at 0.
// step T->o = toPixel(137 + (-7)) = 8. o1 at 8.
// step o->o = toPixel(145 + (-3)) = 9. o2 at 17.
// w = 17 + 8 = 25
ASSERT_EQ(textWidth("Too"), 25);
// "xo": step = toPixel(136 + 0) = toPixel(136) = 9 (no kern: x has no left class)
// x at 0, o at 9. w = 9 + 8 = 17
ASSERT_EQ(textWidth("xo"), 17);
printf(" All known widths correct\n");
PASS();
}
// "oo" pair consistency: the pixel gap between two o's must be the same
// regardless of what prefix precedes them. This is THE key property of
// differential rounding. With absolute snapping, "xoo" would produce a
// different oo gap than "oo" because 'x' advance (136 FP) puts the first
// 'o' at fractional phase 8, crossing the rounding boundary differently.
void testPairConsistencyViaFont() {
printf("testPairConsistencyViaFont...\n");
// The oo gap = width(prefix + "oo") - width(prefix + "o")
// This isolates the pixel distance contributed by the second 'o'.
const int oo_gap_bare = textWidth("oo") - textWidth("o");
const int oo_gap_after_x = textWidth("xoo") - textWidth("xo");
const int oo_gap_after_T = textWidth("Too") - textWidth("To");
const int oo_gap_after_o = textWidth("ooo") - textWidth("oo");
printf(" oo gap (bare): %d\n", oo_gap_bare);
printf(" oo gap (after x): %d\n", oo_gap_after_x);
printf(" oo gap (after T): %d\n", oo_gap_after_T);
printf(" oo gap (after o): %d\n", oo_gap_after_o);
// All must be identical
ASSERT_EQ(oo_gap_after_x, oo_gap_bare);
ASSERT_EQ(oo_gap_after_T, oo_gap_bare);
ASSERT_EQ(oo_gap_after_o, oo_gap_bare);
printf(" All oo gaps identical (%d px) regardless of prefix\n", oo_gap_bare);
PASS();
}
// Null-glyph handling: when a codepoint has no glyph (and no replacement
// glyph), the pending advance from the previous glyph must still be flushed.
// Without the flush fix, the glyph after the null would overlap the one before.
void testNullGlyphAdvancePreserved() {
printf("testNullGlyphAdvancePreserved...\n");
// 'Z' (0x5A) is not in our font and there's no U+FFFD, so getGlyph returns null.
// "oZo" should lay out as: o1 at 0, Z skipped (advance flushed), o2 at 9.
// toPixel(145) = 9 (o's advance, no kern since Z resets prevCp).
// w = 9 + 8 = 17
int w = textWidth("oZo");
printf(" width(\"oZo\") = %d\n", w);
// Without the flush fix, o2 would land at 0 (overlapping o1), giving w = 8.
ASSERT_TRUE(w > 8);
ASSERT_EQ(w, 17);
// Multi-null: "oZZo" -- two consecutive nulls, advance still preserved.
w = textWidth("oZZo");
printf(" width(\"oZZo\") = %d\n", w);
ASSERT_EQ(w, 17);
// Null at start: "Zo" -- no pending advance to flush, o renders at 0.
w = textWidth("Zo");
printf(" width(\"Zo\") = %d\n", w);
ASSERT_EQ(w, 8);
printf(" Null-glyph advance correctly preserved\n");
PASS();
}
void testHeightCalculation() {
printf("testHeightCalculation...\n");
// 'T' is tallest: top=12, height=12 -> extent [0, 12)
// 'o' and 'a': top=8, height=8 -> extent [0, 8)
ASSERT_EQ(textHeight("o"), 8);
ASSERT_EQ(textHeight("T"), 12);
ASSERT_EQ(textHeight("To"), 12);
ASSERT_EQ(textHeight("oo"), 8);
printf(" All heights correct\n");
PASS();
}
int main() {
printf("=== Differential Rounding Tests ===\n\n");
// Part 1: Pure fp4 math
testFp4Basics();
testOldApproachInconsistency();
testExhaustiveKernRange();
// Part 2: Integration tests against real EpdFont
testKernLookup();
testGlyphLookup();
testKnownWidths();
testPairConsistencyViaFont();
testNullGlyphAdvancePreserved();
testHeightCalculation();
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
return testsFailed > 0 ? 1 : 0;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build/differential_rounding"
BINARY="$BUILD_DIR/DifferentialRoundingTest"
mkdir -p "$BUILD_DIR"
SOURCES=(
"$ROOT_DIR/test/differential_rounding/DifferentialRoundingTest.cpp"
"$ROOT_DIR/lib/EpdFont/EpdFont.cpp"
"$ROOT_DIR/lib/Utf8/Utf8.cpp"
)
CXXFLAGS=(
-std=c++20
-O2
-Wall
-Wextra
-pedantic
-I"$ROOT_DIR"
-I"$ROOT_DIR/lib"
-I"$ROOT_DIR/lib/EpdFont"
-I"$ROOT_DIR/lib/Utf8"
)
c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY"
"$BINARY" "$@"