diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index 10af14df..fda79fad 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -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(malloc(totalBytes)); slot.glyphs = static_cast(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(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); diff --git a/lib/EpdFont/FontDecompressor.h b/lib/EpdFont/FontDecompressor.h index 54e75a86..bd1c25ac 100644 --- a/lib/EpdFont/FontDecompressor.h +++ b/lib/EpdFont/FontDecompressor.h @@ -2,8 +2,6 @@ #include -#include - #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 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 hotGlyphBuf; + uint8_t _hotGlyphBuf[HOT_GLYPH_BUF_SIZE]; void freePageBuffer(); void freeHotGroup(); diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 40a125e4..aad424cf 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -17,6 +17,30 @@ constexpr int MAX_COST = std::numeric_limits::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 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(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(remainingSpace) * remainingSpace * remainingSpace; + const long long b_den = static_cast(effectivePageWidth) * effectivePageWidth * effectivePageWidth; + const int badness = (b_den > 0) ? static_cast(std::min(b_num * 10000LL / b_den, 10000LL)) : 10000; + const long long demerits = static_cast(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; }