Font rendering performance improvements

- FontDecompressor: replace heap-allocated std::vector hot-group and glyph
  scratch buffers with static BSS arrays, eliminating per-page malloc/free
  and the heap fragmentation it causes during rendering
- FontDecompressor: add bounds checks on group/glyph buffer sizes; sort
  prewarm group list by ascending index for sequential flash reads
- ParsedText: upgrade line-break cost function to Knuth-Plass cubic badness
  (strongly penalises very loose lines, lenient on moderate looseness)
- ParsedText: exclude gaps before closing punctuation (. , ) » etc.) from
  justification distribution so they stay at natural space width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jpirnay
2026-04-22 07:33:39 +02:00
co-authored by Claude Sonnet 4.6
parent 8c72efb3b9
commit e4170e2272
3 changed files with 101 additions and 51 deletions
+43 -37
View File
@@ -33,12 +33,9 @@ void FontDecompressor::freePageBuffer() {
}
void FontDecompressor::freeHotGroup() {
hotGroup.clear();
hotGroup.shrink_to_fit();
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
hotGlyphBuf.clear();
hotGlyphBuf.shrink_to_fit();
_hotGroupBufUsed = 0;
}
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
@@ -61,9 +58,17 @@ bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t gro
uint32_t outSize) {
const EpdFontGroup& group = fontData->groups[groupIndex];
if (outSize > HOT_GROUP_BUF_SIZE) {
LOG_ERR("FDC", "Group %u uncompressed size %lu exceeds HOT_GROUP_BUF_SIZE %lu", groupIndex, outSize,
HOT_GROUP_BUF_SIZE);
return false;
}
const uint32_t tDecomp = millis();
inflateReader.init(false);
inflateReader.setSource(&fontData->bitmap[group.compressedOffset], group.compressedSize);
if (!inflateReader.read(outBuf, outSize)) {
stats.decompressTimeMs += millis() - tDecomp;
LOG_ERR("FDC", "Decompression failed for group %u", groupIndex);
@@ -170,48 +175,37 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
}
// Check if hot group already has this group decompressed — if not, decompress it
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
if (!(hotGroupFont == fontData && hotGroupIndex == groupIndex && _hotGroupBufUsed > 0)) {
stats.cacheMisses++;
const EpdFontGroup& group = fontData->groups[groupIndex];
hotGroup.resize(group.uncompressedSize);
if (hotGroup.empty()) {
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
hotGroup.clear();
hotGroup.shrink_to_fit();
if (!decompressGroup(fontData, groupIndex, _hotGroupBuf, group.uncompressedSize)) {
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
_hotGroupBufUsed = 0;
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
hotGroupFont = fontData;
hotGroupIndex = groupIndex;
_hotGroupBufUsed = group.uncompressedSize;
stats.hotGroupBytes = group.uncompressedSize;
} else {
stats.cacheHits++;
}
// Compact just the requested glyph from byte-aligned data into scratch buffer
if (glyph->dataLength > hotGlyphBuf.size()) {
hotGlyphBuf.resize(glyph->dataLength);
}
if (hotGlyphBuf.empty()) {
if (glyph->dataLength > HOT_GLYPH_BUF_SIZE) {
LOG_ERR("FDC", "Glyph dataLength %u exceeds HOT_GLYPH_BUF_SIZE %u", glyph->dataLength, HOT_GLYPH_BUF_SIZE);
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
compactSingleGlyph(&_hotGroupBuf[alignedOff], _hotGlyphBuf, glyph->width, glyph->height);
stats.getBitmapTimeUs += micros() - tStart;
return hotGlyphBuf.data();
return _hotGlyphBuf;
}
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
@@ -314,6 +308,18 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
stats.uniqueGroupsAccessed = groupCount;
// Sort neededGroups by ascending group index so flash reads are sequential.
// Uses insertion sort — groupCount is bounded at 128, typically <14 for Latin fonts.
for (uint8_t i = 1; i < groupCount; i++) {
uint16_t key = neededGroups[i];
int j = i - 1;
while (j >= 0 && neededGroups[j] > key) {
neededGroups[j + 1] = neededGroups[j];
j--;
}
neededGroups[j + 1] = key;
}
// Step 3: Allocate page buffer and lookup table for this slot
slot.buffer = static_cast<uint8_t*>(malloc(totalBytes));
slot.glyphs = static_cast<PageGlyphEntry*>(malloc(glyphCount * sizeof(PageGlyphEntry)));
@@ -417,7 +423,9 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
}
}
// Step 4: For each unique group, decompress to temp buffer and extract needed glyphs
// Step 4: For each unique group, decompress into the static _hotGroupBuf and extract needed glyphs.
// No heap allocation — _hotGroupBuf is reused for each group in turn.
// After prewarm, _hotGroupBuf is invalidated (hotGroupFont reset) since its contents are transient.
uint32_t writeOffset = 0;
int missed = 0;
@@ -425,37 +433,35 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
uint16_t groupIdx = neededGroups[g];
const EpdFontGroup& group = fontData->groups[groupIdx];
auto* tempBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
if (!tempBuf) {
LOG_ERR("FDC", "Failed to allocate temp buffer (%u bytes) for group %u", group.uncompressedSize, groupIdx);
missed++;
continue;
}
if (group.uncompressedSize > stats.peakTempBytes) {
stats.peakTempBytes = group.uncompressedSize;
}
if (!decompressGroup(fontData, groupIdx, tempBuf, group.uncompressedSize)) {
free(tempBuf);
if (!decompressGroup(fontData, groupIdx, _hotGroupBuf, group.uncompressedSize)) {
missed++;
continue;
}
// Extract needed glyphs directly from the byte-aligned temp buffer, compacting on the fly.
// Extract needed glyphs directly from the byte-aligned buffer, compacting on the fly.
// alignedOffset was pre-computed in step 3b — no full-group compact scan needed.
for (uint16_t i = 0; i < slot.glyphCount; i++) {
if (slot.glyphs[i].bufferOffset != UINT32_MAX) continue; // already extracted
if (getGroupIndex(fontData, slot.glyphs[i].glyphIndex) != groupIdx) continue;
const EpdGlyph& glyph = fontData->glyph[slot.glyphs[i].glyphIndex];
compactSingleGlyph(&tempBuf[slot.glyphs[i].alignedOffset], &slot.buffer[writeOffset], glyph.width, glyph.height);
compactSingleGlyph(&_hotGroupBuf[slot.glyphs[i].alignedOffset], &slot.buffer[writeOffset], glyph.width,
glyph.height);
slot.glyphs[i].bufferOffset = writeOffset;
writeOffset += glyph.dataLength;
}
free(tempBuf);
}
// Prewarm reused _hotGroupBuf transiently — invalidate hot group state so getBitmap()
// doesn't treat stale contents as a valid cache entry for a different glyph request.
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
_hotGroupBufUsed = 0;
LOG_DBG("FDC", "Prewarm: %u glyphs in %u bytes from %u groups (%d missed)", glyphCount, writeOffset, groupCount,
missed);
+12 -6
View File
@@ -2,8 +2,6 @@
#include <InflateReader.h>
#include <vector>
#include "EpdFontData.h"
class FontDecompressor {
@@ -65,15 +63,23 @@ class FontDecompressor {
PageSlot pageSlots[MAX_PAGE_SLOTS] = {};
uint8_t pageSlotCount = 0;
// Measured maxima across all built-in fonts:
// uncompressedSize: 50 KB (notosans_18 / bookerly_18)
// glyph dataLength: 500 B (bookerly_18)
// Static BSS arrays eliminate per-page heap alloc/free and the fragmentation it causes.
static constexpr uint32_t HOT_GROUP_BUF_SIZE = 51200; // 50 KB uncompressed group
static constexpr uint16_t HOT_GLYPH_BUF_SIZE = 512; // largest packed single glyph
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
// Kept in byte-aligned format; individual glyphs are compacted on demand into _hotGlyphBuf.
const EpdFontData* hotGroupFont = nullptr;
uint16_t hotGroupIndex = UINT16_MAX;
std::vector<uint8_t> hotGroup;
uint32_t _hotGroupBufUsed = 0;
uint8_t _hotGroupBuf[HOT_GROUP_BUF_SIZE];
// Scratch buffer for compacting a single glyph from the hot group.
// Scratch buffer for compacting a single glyph out of the byte-aligned hot group.
// Valid until the next getBitmap() call.
std::vector<uint8_t> hotGlyphBuf;
uint8_t _hotGlyphBuf[HOT_GLYPH_BUF_SIZE];
void freePageBuffer();
void freeHotGroup();
+46 -8
View File
@@ -17,6 +17,30 @@ constexpr int MAX_COST = std::numeric_limits<int>::max();
namespace {
// Closing punctuation that should not have extra space inserted before it during justification.
// Includes common closing brackets/quotes and sentence-ending marks.
bool isClosingPunctuation(const uint32_t cp) {
switch (cp) {
case '.':
case ',':
case '!':
case '?':
case ':':
case ';':
case ')':
case ']':
case '}':
case 0x00BB: // »
case 0x203A: //
case 0x2019: // ' right single quotation mark
case 0x201D: // " right double quotation mark
case 0x2026: // … ellipsis
return true;
default:
return false;
}
}
// Soft hyphen byte pattern used throughout EPUBs (UTF-8 for U+00AD).
constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD";
constexpr size_t SOFT_HYPHEN_BYTES = 2;
@@ -330,11 +354,19 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
int cost;
if (j == totalWordCount - 1) {
cost = 0; // Last line
cost = 0; // Last line — no penalty regardless of looseness
} else {
const int remainingSpace = effectivePageWidth - currlen;
// Use long long for the square to prevent overflow
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
// Knuth-Plass style demerits:
// badness = (gap/lineWidth)³ × 10000, clamped to [0, 10000]
// demerits = (1 + badness)²
// Cubic badness strongly penalises very loose lines while being
// lenient on moderately loose ones, producing visually balanced paragraphs.
const long long b_num = static_cast<long long>(remainingSpace) * remainingSpace * remainingSpace;
const long long b_den = static_cast<long long>(effectivePageWidth) * effectivePageWidth * effectivePageWidth;
const int badness = (b_den > 0) ? static_cast<int>(std::min(b_num * 10000LL / b_den, 10000LL)) : 10000;
const long long demerits = static_cast<long long>(1 + badness) * (1 + badness);
const long long cost_ll = demerits + dp[j + 1];
if (cost_ll > MAX_COST) {
cost = MAX_COST;
@@ -767,9 +799,12 @@ ParsedText::LineProcessResult ParsedText::extractLine(
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
// Count gaps: each word after the first creates a gap, unless it's a continuation
// Count gaps: each word after the first creates a gap, unless it's a continuation.
// Gaps before closing punctuation (. , ) » etc.) are excluded from justification
// distribution so they stay at natural space width.
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
actualGapCount++;
const bool beforeClosing = isClosingPunctuation(firstCodepoint(words[lastBreakAt + wordIdx]));
if (!beforeClosing) actualGapCount++;
totalNaturalGaps +=
renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
@@ -825,9 +860,12 @@ ParsedText::LineProcessResult ParsedText::extractLine(
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
firstCodepoint(words[lastBreakAt + wordIdx + 1]),
wordStyles[lastBreakAt + wordIdx]);
}
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
// Don't stretch the gap before closing punctuation — it looks wrong with
// extra space before ".", ")", "»" etc.
const bool nextIsClosing = isClosingPunctuation(firstCodepoint(words[lastBreakAt + wordIdx + 1]));
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && !nextIsClosing) {
gap += justifyExtra;
}
}
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
}