Files
Crosspoint/test/cpfont_adapter/CpFontAdapterTest.cpp
T
Justin Mitchell 0848649ce2 Add FreeInkBook EPUB engine migration guide
Document migration plan from CrossPoint's lib/Epub to FreeInkBook, a memory-efficient clean-room EPUB engine. Covers advantages (O(paragraph+page) memory via SAX parsing, arena allocation, host-testable, full UAX#14 typography, runtime TTF/OTF support), 9-step migration strategy, ESP32-C3 constraints, and integration approach preserving existing UI layer while replacing parsing/layout stack.
2026-07-08 00:22:21 -04:00

207 lines
7.4 KiB
C++

#include <gtest/gtest.h>
#include <cstdint>
#include "CpFontAdapter.h"
#include "lib/EpdFont/EpdFont.h"
#include "lib/EpdFont/EpdFontData.h"
#include "lib/EpdFont/EpdFontFamily.h"
#include "lib/Utf8/Utf8.h"
// ============================================================================
// Synthetic fonts (same convention as DifferentialRoundingTest): metrics
// chosen so per-glyph rounding and differential rounding disagree unless the
// adapter's kerning-delta trick is applied.
//
// Glyphs: 'T' (0x54), 'a' (0x61), 'o' (0x6F), 'x' (0x78), U+FFFD.
// Kern pairs (4.4 FP): T->a -5, T->o -7, o->a -2, o->o -3.
// Ligature: 'T'+'x' -> U+E000 (private use; also a glyph in the font).
// ============================================================================
namespace {
// clang-format off
const EpdGlyph kGlyphs16[] = {
/* 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 },
/* 4 U+E000 */ { 12, 12, 200, 0, 12, 0, 0 },
/* 5 U+FFFD */ { 9, 12, 160, 0, 12, 0, 0 },
};
const EpdUnicodeInterval kIntervals16[] = {
{ 0x54, 0x54, 0 },
{ 0x61, 0x61, 1 },
{ 0x6F, 0x6F, 2 },
{ 0x78, 0x78, 3 },
{ 0xE000, 0xE000, 4 },
{ 0xFFFD, 0xFFFD, 5 },
};
const EpdKernClassEntry kKernLeft[] = { { 0x54, 1 }, { 0x6F, 2 } };
const EpdKernClassEntry kKernRight[] = { { 0x61, 1 }, { 0x6F, 2 } };
const int8_t kKernMatrix[] = { -5, -7, -2, -3 };
const EpdLigaturePair kLigatures[] = {
{ (0x54u << 16) | 0x78u, 0xE000 }, // 'T' + 'x'
};
// clang-format on
EpdFontData makeFontData(const EpdGlyph* glyphs, const EpdUnicodeInterval* intervals,
uint32_t intervalCount, uint8_t advanceY, int ascender, int descender) {
EpdFontData d{};
d.bitmap = nullptr;
d.glyph = glyphs;
d.intervals = intervals;
d.intervalCount = intervalCount;
d.advanceY = advanceY;
d.ascender = ascender;
d.descender = descender;
d.kernLeftClasses = kKernLeft;
d.kernRightClasses = kKernRight;
d.kernMatrix = kKernMatrix;
d.kernLeftEntryCount = 2;
d.kernRightEntryCount = 2;
d.kernLeftClassCount = 2;
d.kernRightClassCount = 2;
d.ligaturePairs = kLigatures;
d.ligaturePairCount = 1;
return d;
}
// A second, wider font standing in for the 12 px ladder rung so quantization
// is observable ('T' advance differs).
const EpdGlyph kGlyphs12[] = {
/* 0 'T' */ {6, 9, 96, 0, 9, 0, 0}, // 6.0 px exactly
};
const EpdUnicodeInterval kIntervals12[] = {{0x54, 0x54, 0}};
// The reference: GfxRenderer's differential-rounding cursor walk
// (drawText/getTextWidth in GfxRenderer.cpp) — each step snaps
// (prev advance FP + kern FP) as one unit.
int rendererWidth(const EpdFontFamily& family, const uint32_t* cps, int n,
EpdFontFamily::Style style) {
int width = 0;
int32_t prevAdvanceFP = 0;
uint32_t prevCp = 0;
for (int i = 0; i < n; ++i) {
const uint32_t cp = cps[i];
if (prevCp != 0) {
width += fp4::toPixel(prevAdvanceFP + family.getKerning(prevCp, cp, style));
}
const EpdGlyph* glyph = family.getGlyph(cp, style);
prevAdvanceFP = glyph ? glyph->advanceX : 0;
prevCp = cp;
}
width += fp4::toPixel(prevAdvanceFP);
return width;
}
// The engine's accounting: advance(cp) plus kerning(prev, cp) per glyph
// (ChapterLayout::advanceFor).
int layoutWidth(CpFontAdapter& font, const uint32_t* cps, int n, uint16_t sizePx) {
int width = 0;
uint32_t prevCp = 0;
for (int i = 0; i < n; ++i) {
width += font.advance(cps[i], sizePx, 0);
if (prevCp != 0) width += font.kerning(prevCp, cps[i], sizePx, 0);
prevCp = cps[i];
}
return width;
}
class CpFontAdapterTest : public ::testing::Test {
protected:
CpFontAdapterTest()
: data16_(makeFontData(kGlyphs16, kIntervals16, 6, 20, 14, -5)),
data12_(makeFontData(kGlyphs12, kIntervals12, 1, 15, 11, -4)),
font16_(&data16_),
font12_(&data12_),
family16_(&font16_),
family12_(&font12_) {
adapter_.addSize(12, &family12_);
adapter_.addSize(16, &family16_);
}
EpdFontData data16_, data12_;
EpdFont font16_, font12_;
EpdFontFamily family16_, family12_;
CpFontAdapter adapter_;
};
TEST_F(CpFontAdapterTest, AdvanceSnapsFixedPointPerGlyph) {
// 137 FP -> 9 px, 130 -> 8, 145 -> 9, 136 -> 9 (round-half-up at 8/16).
EXPECT_EQ(adapter_.advance('T', 16, 0), 9);
EXPECT_EQ(adapter_.advance('a', 16, 0), 8);
EXPECT_EQ(adapter_.advance('o', 16, 0), 9);
EXPECT_EQ(adapter_.advance('x', 16, 0), 9);
}
TEST_F(CpFontAdapterTest, KerningDeltaMatchesRendererDifferentialRounding) {
// Every string the renderer can draw must measure to the renderer's own
// differentially-rounded width — the telescoping identity the adapter's
// kerning() implements. "oo" (advance frac 1, kern -3) is the case where
// naive per-glyph rounding diverges.
const uint32_t oo[] = {'o', 'o'};
const uint32_t tao[] = {'T', 'a', 'o'};
const uint32_t tooao[] = {'T', 'o', 'o', 'a', 'o'};
EXPECT_EQ(layoutWidth(adapter_, oo, 2, 16), rendererWidth(family16_, oo, 2, EpdFontFamily::REGULAR));
EXPECT_EQ(layoutWidth(adapter_, tao, 3, 16), rendererWidth(family16_, tao, 3, EpdFontFamily::REGULAR));
EXPECT_EQ(layoutWidth(adapter_, tooao, 5, 16),
rendererWidth(family16_, tooao, 5, EpdFontFamily::REGULAR));
}
TEST_F(CpFontAdapterTest, KerningPairValues) {
// kerning(T,o) = toPixel(137 + (-7)) - toPixel(137) = 8 - 9 = -1.
EXPECT_EQ(adapter_.kerning('T', 'o', 16, 0), -1);
// kerning(o,o) = toPixel(145 - 3) - toPixel(145) = 9 - 9 = 0.
EXPECT_EQ(adapter_.kerning('o', 'o', 16, 0), 0);
// No kern class pair -> 0.
EXPECT_EQ(adapter_.kerning('a', 'x', 16, 0), 0);
}
TEST_F(CpFontAdapterTest, QuantizesToNearestRungInMetricsToo) {
EXPECT_EQ(adapter_.quantize(12), 12);
EXPECT_EQ(adapter_.quantize(13), 12);
EXPECT_EQ(adapter_.quantize(14), 12); // tie keeps the smaller rung
EXPECT_EQ(adapter_.quantize(15), 16);
EXPECT_EQ(adapter_.quantize(24), 16); // above the ladder clamps to the top
// 'T' at 12 px comes from the 12 px font (96 FP -> 6 px), not a scale.
EXPECT_EQ(adapter_.advance('T', 12, 0), 6);
EXPECT_EQ(adapter_.advance('T', 13, 0), 6);
EXPECT_EQ(adapter_.advance('T', 24, 0), 9);
EXPECT_EQ(adapter_.lineHeight(13), 15);
EXPECT_EQ(adapter_.lineHeight(24), 20);
EXPECT_EQ(adapter_.ascent(13), 11);
EXPECT_EQ(adapter_.ascent(24), 14);
}
TEST_F(CpFontAdapterTest, HasGlyphIsNotFooledByReplacementFallback) {
EXPECT_TRUE(adapter_.hasGlyph('T'));
EXPECT_TRUE(adapter_.hasGlyph(0xFFFD));
// 'q' is missing; getGlyph would return U+FFFD, hasGlyph must say no.
EXPECT_FALSE(adapter_.hasGlyph('q'));
EXPECT_NE(family16_.getGlyph('q', EpdFontFamily::REGULAR), nullptr);
}
TEST_F(CpFontAdapterTest, LigatureLookup) {
EXPECT_EQ(adapter_.ligature('T', 'x', 0), 0xE000u);
EXPECT_EQ(adapter_.ligature('T', 'a', 0), 0u);
}
TEST_F(CpFontAdapterTest, CombiningMarksDoNotAdvance) {
// U+0301 combining acute: the renderer centers it over the base glyph and
// moves the cursor zero pixels; layout must agree.
EXPECT_EQ(adapter_.advance(0x0301, 16, 0), 0);
EXPECT_EQ(adapter_.kerning('a', 0x0301, 16, 0), 0);
}
TEST_F(CpFontAdapterTest, MissingGlyphAdvanceMatchesRendererReplacementDraw) {
// The renderer draws U+FFFD for a missing codepoint; layout must reserve
// the replacement glyph's width (160 FP -> 10 px), not zero.
EXPECT_EQ(adapter_.advance('q', 16, 0), 10);
}
} // namespace