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:
co-authored by
Claude Sonnet 4.6
parent
8c72efb3b9
commit
e4170e2272
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user