Merge branch 'master' into fix/nested-block-styles

This commit is contained in:
Zach Nelson
2026-04-15 23:36:13 -05:00
102 changed files with 1965 additions and 3533 deletions
+18
View File
@@ -68,6 +68,24 @@ back to the other partition using the "Swap boot partition" button here https://
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
### Command line (specific firmware version)
1. Install [`esptool`](https://github.com/espressif/esptool) :
```bash
pip install esptool
```
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
3. Connect your Xteink X4 to your computer via USB-C.
4. Note the device location. On Linux, run `dmesg` after connecting. On MacOS, run :
```bash
log stream --predicate 'subsystem == "com.apple.iokit"' --info
```
5. Flash the firmware :
```bash
esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 921600 write_flash 0x10000 /path/to/firmware.bin
```
Change `/dev/ttyACM0` to the device for your system.
### Manual
See [Development](#development) below.
+1
View File
@@ -97,6 +97,7 @@ $exclude = @(
'lib\Epub\Epub\hyphenation\generated'
'lib\uzlib'
'.pio'
'.venv'
)
function Test-Excluded($fullPath) {
+22 -17
View File
@@ -15,11 +15,11 @@ 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 lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseLeft = 0;
int lastBaseWidth = 0;
int lastBaseTop = 0;
constexpr int MIN_COMBINING_GAP_PX = 1;
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,24 +31,29 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
const EpdGlyph* glyph = getGlyph(cp);
if (!glyph) {
prevCp = 0;
// Keep cursor movement stable when a base glyph is missing, but don't attach subsequent
// combining marks to stale base metrics.
if (!isCombining) {
lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
}
continue;
}
int raiseBy = 0;
if (isCombining) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
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 ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : cursorXPixels;
const int glyphBaseX =
isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width)
: lastBaseX;
const int glyphBaseY = startY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
@@ -57,10 +62,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorXPixels;
lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
cursorXFP += glyph->advanceX; // 12.4 fixed-point advance
prevAdvanceFP = glyph->advanceX; // 12.4 fixed-point
prevCp = cp;
}
}
+37 -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.
@@ -28,6 +30,37 @@ constexpr int toPixel(int32_t fp) { return static_cast<int>((fp + HALF) >> FRAC_
constexpr float toFloat(int32_t fp) { return fp / static_cast<float>(1 << FRAC_BITS); }
} // namespace fp4
/// Helpers for positioning Unicode combining marks (U+0300 ff.) over a
/// preceding base glyph without GPOS anchor tables.
namespace combiningMark {
constexpr int MIN_GAP_PX = 1;
/// Compute the cursor-X at which to render a combining mark so its bitmap
/// is visually centered over the base glyph's bitmap.
constexpr int centerOver(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos + baseLeft + baseWidth / 2 - markWidth / 2 - markLeft;
}
/// Rotated-90CW variant of centerOver. In the rotated coordinate system
/// renderCharImpl uses (cursorY - left) instead of (cursorX + left), so
/// every left/width term inverts sign.
constexpr int centerOverRotated90CW(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos - baseLeft - baseWidth / 2 + markWidth / 2 + markLeft;
}
/// For combining marks that sit entirely above the baseline, compute how many
/// pixels to raise the mark so there is at least MIN_GAP_PX between its bottom
/// edge and the top of the base glyph. Returns 0 for marks that extend to or
/// below the baseline (e.g. cedilla, dot-below, ogonek).
constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) {
if (markTop - markHeight <= 0) return 0;
const int gap = markTop - markHeight - baseTop;
return (gap < MIN_GAP_PX) ? (MIN_GAP_PX - gap) : 0;
}
} // namespace combiningMark
/// Fixed-point conventions used by EpdGlyph and EpdFontData:
/// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel)
/// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel)
+15 -9
View File
@@ -155,6 +155,7 @@ bool Epub::parseTocNcxFile() const {
return false;
}
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
// Explicitly close() file before reopening for reading
tempNcxFile.close();
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
@@ -165,14 +166,12 @@ bool Epub::parseTocNcxFile() const {
if (!ncxParser.setup()) {
LOG_ERR("EBP", "Could not setup toc ncx parser");
tempNcxFile.close();
return false;
}
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) {
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
tempNcxFile.close();
return false;
}
@@ -184,12 +183,12 @@ bool Epub::parseTocNcxFile() const {
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc ncx data");
free(ncxBuffer);
tempNcxFile.close();
return false;
}
}
free(ncxBuffer);
// Explicitly close() file before calling Storage.remove()
tempNcxFile.close();
Storage.remove(tmpNcxPath.c_str());
@@ -212,6 +211,7 @@ bool Epub::parseTocNavFile() const {
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
// Explicitly close() file before reopening for reading
tempNavFile.close();
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
@@ -241,12 +241,12 @@ bool Epub::parseTocNavFile() const {
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc nav data");
free(navBuffer);
tempNavFile.close();
return false;
}
}
free(navBuffer);
// Explicitly close() file before calling Storage.remove()
tempNavFile.close();
Storage.remove(tmpNavPath.c_str());
@@ -304,10 +304,12 @@ void Epub::parseCssFiles() const {
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
// Explicitly close() file before calling Storage.remove()
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
// Explicitly close() file before reopening for reading
tempCssFile.close();
// Parse the CSS file
@@ -317,6 +319,7 @@ void Epub::parseCssFiles() const {
continue;
}
cssParser->loadFromStream(tempCssFile);
// Explicitly close() file before calling Storage.remove()
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
}
@@ -547,6 +550,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
@@ -555,10 +559,10 @@ bool Epub::generateCoverBmp(bool cropped) const {
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
// Explicitly close() files before calling Storage.remove()
coverJpg.close();
coverBmp.close();
Storage.remove(coverJpgTempPath.c_str());
@@ -580,6 +584,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
@@ -588,10 +593,10 @@ bool Epub::generateCoverBmp(bool cropped) const {
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverPng.close();
return false;
}
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
// Explicitly close() files before calling Storage.remove()
coverPng.close();
coverBmp.close();
Storage.remove(coverPngTempPath.c_str());
@@ -634,6 +639,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
@@ -642,7 +648,6 @@ bool Epub::generateThumbBmp(int height) const {
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
@@ -651,6 +656,7 @@ bool Epub::generateThumbBmp(int height) const {
int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
// Explicitly close() files before calling Storage.remove()
coverJpg.close();
thumbBmp.close();
Storage.remove(coverJpgTempPath.c_str());
@@ -670,6 +676,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
@@ -678,13 +685,13 @@ bool Epub::generateThumbBmp(int height) const {
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverPng.close();
return false;
}
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success =
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
// Explicitly close() files before calling Storage.remove()
coverPng.close();
thumbBmp.close();
Storage.remove(coverPngTempPath.c_str());
@@ -702,7 +709,6 @@ bool Epub::generateThumbBmp(int height) const {
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close();
return false;
}
+16 -8
View File
@@ -4,7 +4,7 @@
#include <Serialization.h>
#include <ZipFile.h>
#include <vector>
#include <deque>
#include "FsHelpers.h"
@@ -33,6 +33,7 @@ bool BookMetadataCache::beginContentOpfPass() {
}
bool BookMetadataCache::endContentOpfPass() {
// Explicit close() required: member variable persists beyond function scope
spineFile.close();
return true;
}
@@ -44,13 +45,14 @@ bool BookMetadataCache::beginTocPass() {
return false;
}
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
// Explicit close() required: member variable persists beyond function scope
spineFile.close();
return false;
}
if (spineCount >= LARGE_SPINE_THRESHOLD) {
spineHrefIndex.clear();
spineHrefIndex.reserve(spineCount);
spineHrefIndex.resize(spineCount);
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto entry = readSpineEntry(spineFile);
@@ -58,7 +60,7 @@ bool BookMetadataCache::beginTocPass() {
idx.hrefHash = fnvHash64(entry.href);
idx.hrefLen = static_cast<uint16_t>(entry.href.size());
idx.spineIndex = static_cast<int16_t>(i);
spineHrefIndex.push_back(idx);
spineHrefIndex[i] = idx;
}
std::sort(spineHrefIndex.begin(), spineHrefIndex.end(),
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
@@ -75,6 +77,7 @@ bool BookMetadataCache::beginTocPass() {
}
bool BookMetadataCache::endTocPass() {
// Explicit close() required: member variables persist beyond function scope
tocFile.close();
spineFile.close();
@@ -103,11 +106,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
// Explicit close() required: member variable persists beyond function scope
bookFile.close();
return false;
}
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
return false;
@@ -153,7 +158,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
// Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m))
std::vector<int16_t> spineToTocIndex(spineCount, -1);
std::deque<int16_t> spineToTocIndex(spineCount, -1);
tocFile.seek(0);
for (int j = 0; j < tocCount; j++) {
auto tocEntry = readTocEntry(tocFile);
@@ -168,6 +173,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Pre-open zip file to speed up size calculations
if (!zip.open()) {
LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
tocFile.close();
@@ -181,14 +187,14 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion.
// See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134
std::vector<uint32_t> spineSizes;
std::deque<uint32_t> spineSizes;
bool useBatchSizes = false;
if (spineCount >= LARGE_SPINE_THRESHOLD) {
LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
std::vector<ZipFile::SizeTarget> targets;
targets.reserve(spineCount);
std::deque<ZipFile::SizeTarget> targets;
targets.resize(spineCount);
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
@@ -199,7 +205,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
t.hash = ZipFile::fnvHash64(path.c_str(), path.size());
t.len = static_cast<uint16_t>(path.size());
t.index = static_cast<uint16_t>(i);
targets.push_back(t);
targets[i] = t;
}
std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) {
@@ -265,6 +271,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
writeTocEntry(bookFile, tocEntry);
}
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
tocFile.close();
@@ -373,6 +380,7 @@ bool BookMetadataCache::load() {
serialization::readPod(bookFile, version);
if (version != BOOK_CACHE_VERSION) {
LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
// Explicit close() required: member variable persists beyond function scope
bookFile.close();
return false;
}
+2 -2
View File
@@ -3,8 +3,8 @@
#include <HalStorage.h>
#include <algorithm>
#include <deque>
#include <string>
#include <vector>
class BookMetadataCache {
public:
@@ -61,7 +61,7 @@ class BookMetadataCache {
uint16_t hrefLen; // length for collision reduction
int16_t spineIndex;
};
std::vector<SpineHrefIndexEntry> spineHrefIndex;
std::deque<SpineHrefIndexEntry> spineHrefIndex;
bool useSpineHrefIndex = false;
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;
+8 -3
View File
@@ -74,6 +74,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
uint8_t version;
serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) {
// Explicit close() required: member variable persists beyond function scope
file.close();
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
clearCache();
@@ -103,6 +104,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering) {
// Explicit close() required: member variable persists beyond function scope
file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache();
@@ -111,6 +113,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
}
serialization::readPod(file, pageCount);
// Explicit close() required: member variable persists beyond function scope
file.close();
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
return true;
@@ -165,6 +168,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
// Explicitly close() file before calling Storage.remove()
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
@@ -214,6 +218,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Storage.remove(tmpHtmlPath.c_str());
if (!success) {
LOG_ERR("SCT", "Failed to parse XML and build pages");
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
@@ -235,6 +240,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
if (hasFailedLutRecords) {
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(filePath.c_str());
return false;
@@ -254,6 +260,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
// Explicit close() required: member variable persists beyond function scope
file.close();
if (cssParser) {
cssParser->clear();
@@ -275,6 +282,7 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
file.seek(pagePos);
auto page = Page::deserialize(file);
// Explicit close() required: member variable persists beyond function scope
file.close();
return page;
}
@@ -290,7 +298,6 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
f.close();
return std::nullopt;
}
@@ -303,11 +310,9 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
serialization::readString(f, key);
serialization::readPod(f, page);
if (key == anchor) {
f.close();
return page;
}
}
f.close();
return std::nullopt;
}
-5
View File
@@ -37,7 +37,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
uint16_t cachedWidth, cachedHeight;
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
cacheFile.close();
return false;
}
@@ -47,7 +46,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
if (widthDiff > 1 || heightDiff > 1) {
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
expectedHeight);
cacheFile.close();
return false;
}
@@ -62,7 +60,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
LOG_ERR("IMG", "Failed to allocate row buffer");
cacheFile.close();
return false;
}
@@ -73,7 +70,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
LOG_ERR("IMG", "Cache read error at row %d", row);
free(rowBuffer);
cacheFile.close();
return false;
}
@@ -89,7 +85,6 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
}
free(rowBuffer);
cacheFile.close();
LOG_DBG("IMG", "Cache render complete");
return true;
}
+13 -8
View File
@@ -15,6 +15,7 @@
struct DirectPixelWriter {
uint8_t* fb;
GfxRenderer::RenderMode mode;
uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99)
// Orientation is collapsed into a linear transform:
// phyX = phyXBase + x * phyXStepX + y * phyXStepY
@@ -29,29 +30,33 @@ struct DirectPixelWriter {
void init(GfxRenderer& renderer) {
fb = renderer.getFrameBuffer();
mode = renderer.getRenderMode();
displayWidthBytes = renderer.getDisplayWidthBytes();
const int phyW = renderer.getDisplayWidth();
const int phyH = renderer.getDisplayHeight();
switch (renderer.getOrientation()) {
case GfxRenderer::Portrait:
// phyX = y, phyY = (DISPLAY_HEIGHT-1) - x
// phyX = y, phyY = (phyH-1) - x
phyXBase = 0;
phyYBase = HalDisplay::DISPLAY_HEIGHT - 1;
phyYBase = phyH - 1;
phyXStepX = 0;
phyYStepX = -1;
phyXStepY = 1;
phyYStepY = 0;
break;
case GfxRenderer::LandscapeClockwise:
// phyX = (DISPLAY_WIDTH-1) - x, phyY = (DISPLAY_HEIGHT-1) - y
phyXBase = HalDisplay::DISPLAY_WIDTH - 1;
phyYBase = HalDisplay::DISPLAY_HEIGHT - 1;
// phyX = (phyW-1) - x, phyY = (phyH-1) - y
phyXBase = phyW - 1;
phyYBase = phyH - 1;
phyXStepX = -1;
phyYStepX = 0;
phyXStepY = 0;
phyYStepY = -1;
break;
case GfxRenderer::PortraitInverted:
// phyX = (DISPLAY_WIDTH-1) - y, phyY = x
phyXBase = HalDisplay::DISPLAY_WIDTH - 1;
// phyX = (phyW-1) - y, phyY = x
phyXBase = phyW - 1;
phyYBase = 0;
phyXStepX = 0;
phyYStepX = 1;
@@ -115,7 +120,7 @@ struct DirectPixelWriter {
const int phyX = rowPhyXBase + logicalX * phyXStepX;
const int phyY = rowPhyYBase + logicalX * phyYStepX;
const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX >> 3);
const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3);
const uint8_t bitMask = 1 << (7 - (phyX & 7));
if (state) {
@@ -19,38 +19,25 @@ 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;
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) {}
bool caching{false};
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
@@ -19,37 +19,23 @@ 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;
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) {}
uint8_t* grayLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
+1 -16
View File
@@ -743,7 +743,6 @@ bool CssParser::saveToCache() const {
}
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
file.close();
return true;
}
@@ -765,6 +764,7 @@ bool CssParser::loadFromCache() {
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
CssParser::CSS_CACHE_VERSION);
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove((cachePath + rulesCache).c_str());
return false;
@@ -773,14 +773,12 @@ bool CssParser::loadFromCache() {
// Read rule count
uint16_t ruleCount = 0;
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
file.close();
return false;
}
if (ruleCount > MAX_RULES) {
LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES);
rulesBySelector_.clear();
file.close();
return false;
}
@@ -799,19 +797,16 @@ bool CssParser::loadFromCache() {
uint16_t selectorLen = 0;
if (!hasRemainingBytes(sizeof(selectorLen))) {
rulesBySelector_.clear();
file.close();
return false;
}
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
rulesBySelector_.clear();
file.close();
return false;
}
if (selectorLen == 0 || selectorLen > MAX_SELECTOR_LENGTH || !hasRemainingBytes(selectorLen)) {
LOG_DBG("CSS", "Invalid selector length in cache: %u", selectorLen);
rulesBySelector_.clear();
file.close();
return false;
}
@@ -819,14 +814,12 @@ bool CssParser::loadFromCache() {
selector.resize(selectorLen);
if (file.read(&selector[0], selectorLen) != selectorLen) {
rulesBySelector_.clear();
file.close();
return false;
}
if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) {
LOG_DBG("CSS", "Truncated CSS cache while reading style payload");
rulesBySelector_.clear();
file.close();
return false;
}
@@ -836,28 +829,24 @@ bool CssParser::loadFromCache() {
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textAlign = static_cast<CssTextAlign>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontStyle = static_cast<CssFontStyle>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontWeight = static_cast<CssFontWeight>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
@@ -880,7 +869,6 @@ bool CssParser::loadFromCache() {
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
!readLength(style.imageHeight) || !readLength(style.imageWidth)) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -888,7 +876,6 @@ bool CssParser::loadFromCache() {
uint8_t displayVal;
if (file.read(&displayVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.display = static_cast<CssDisplay>(displayVal);
@@ -897,7 +884,6 @@ bool CssParser::loadFromCache() {
uint16_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear();
file.close();
return false;
}
style.defined.textAlign = (definedBits & 1 << 0) != 0;
@@ -921,6 +907,5 @@ bool CssParser::loadFromCache() {
}
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
file.close();
return true;
}
+23 -2
View File
@@ -12,11 +12,24 @@ const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr;
namespace {
// Maps a BCP-47 language tag to a language-specific hyphenator.
// Normalize ISO 639-2 (three-letter) codes to ISO 639-1 (two-letter) codes used by the
// hyphenation registry. EPUBs may use either form in their dc:language metadata (e.g.
// "eng" instead of "en"). Both the bibliographic ("fre"/"ger") and terminological
// ("fra"/"deu") ISO 639-2 variants are mapped.
struct Iso639Mapping {
const char* iso639_2;
const char* iso639_1;
};
static constexpr Iso639Mapping kIso639Mappings[] = {
{"eng", "en"}, {"fra", "fr"}, {"fre", "fr"}, {"deu", "de"}, {"ger", "de"},
{"rus", "ru"}, {"spa", "es"}, {"ita", "it"}, {"ukr", "uk"},
};
// Maps a BCP-47 or ISO 639-2 language tag to a language-specific hyphenator.
const LanguageHyphenator* hyphenatorForLanguage(const std::string& langTag) {
if (langTag.empty()) return nullptr;
// Extract primary subtag and normalize to lowercase (e.g., "en-US" -> "en").
// Extract primary subtag and normalize to lowercase (e.g., "en-US" -> "en", "ENG" -> "en").
std::string primary;
primary.reserve(langTag.size());
for (char c : langTag) {
@@ -26,6 +39,14 @@ const LanguageHyphenator* hyphenatorForLanguage(const std::string& langTag) {
}
if (primary.empty()) return nullptr;
// Normalize ISO 639-2 three-letter codes to two-letter equivalents.
for (const auto& mapping : kIso639Mappings) {
if (primary == mapping.iso639_2) {
primary = mapping.iso639_1;
break;
}
}
return getLanguageHyphenatorForPrimaryTag(primary);
}
+22 -11
View File
@@ -726,14 +726,29 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// Collect footnote link display text (for the number label)
// Skip whitespace and brackets to normalize noterefs like "[1]" → "1"
if (self->insideFootnoteLink) {
for (int i = 0; i < len; i++) {
unsigned char c = static_cast<unsigned char>(s[i]);
if (isWhitespace(c) || c == '[' || c == ']') continue;
if (self->currentFootnoteLinkTextLen < static_cast<int>(sizeof(self->currentFootnoteLinkText)) - 1) {
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c;
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
}
int start = 0;
int end = len - 1;
// Example input and output texts:
// " [ 12 ] " => "12"
// " turn to 256 " => "turn to 256"
// Ignore leading whitespaces and left square brackets
while (start < len && (isWhitespace(s[start]) || (s[start] == '['))) {
++start;
}
// Ignore trailing whitespaces and right square brackets
while (end >= start && (isWhitespace(s[end]) || (s[end] == ']'))) {
--end;
}
// Extract footnote link text
for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnoteLinkText) - 1) && (i <= end);
++i) {
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = s[i];
}
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
}
for (int i = 0; i < len; i++) {
@@ -1053,7 +1068,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
@@ -1065,7 +1079,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
@@ -1078,7 +1091,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
} while (!done);
@@ -1088,7 +1100,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
// Process last page if there is still text
if (currentTextBlock) {
+2 -1
View File
@@ -2,6 +2,7 @@
#include <Print.h>
#include <algorithm>
#include <deque>
#include <vector>
#include "Epub.h"
@@ -37,7 +38,7 @@ class ContentOpfParser final : public Print {
uint16_t idLen; // length for collision reduction
uint32_t fileOffset; // offset in .items.bin
};
std::vector<ItemIndexEntry> itemIndex;
std::deque<ItemIndexEntry> itemIndex;
bool useItemIndex = false;
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;
+8
View File
@@ -78,4 +78,12 @@ bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(file
bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); }
std::string extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
return "/";
}
return filePath.substr(0, lastSlash);
}
} // namespace FsHelpers
+2
View File
@@ -55,4 +55,6 @@ inline bool hasTxtExtension(const String& fileName) {
// Check for .md extension (case-insensitive)
bool hasMarkdownExtension(std::string_view fileName);
std::string extractFolderPath(const std::string& filePath);
} // namespace FsHelpers
+6 -6
View File
@@ -108,7 +108,7 @@ uint8_t quantize1bit(int gray, int x, int y) {
return (gray >= adjustedThreshold) ? 1 : 0;
}
void createBmpHeader(BmpHeader* bmpHeader, int width, int height) {
void createBmpHeader(BmpHeader* bmpHeader, int width, int height, BmpRowOrder rowOrder) {
if (!bmpHeader) return;
// Zero out the memory to ensure no garbage data if called on uninitialized stack memory
@@ -126,15 +126,15 @@ void createBmpHeader(BmpHeader* bmpHeader, int width, int height) {
bmpHeader->infoHeader.biSize = sizeof(bmpHeader->infoHeader);
bmpHeader->infoHeader.biWidth = width;
bmpHeader->infoHeader.biHeight = height;
bmpHeader->infoHeader.biHeight = (rowOrder == BmpRowOrder::TopDown) ? -height : height;
bmpHeader->infoHeader.biPlanes = 1;
bmpHeader->infoHeader.biBitCount = 1;
bmpHeader->infoHeader.biCompression = 0;
bmpHeader->infoHeader.biSizeImage = imageSize;
bmpHeader->infoHeader.biXPelsPerMeter = 0;
bmpHeader->infoHeader.biYPelsPerMeter = 0;
bmpHeader->infoHeader.biClrUsed = 0;
bmpHeader->infoHeader.biClrImportant = 0;
bmpHeader->infoHeader.biXPelsPerMeter = 2835; // 72 DPI
bmpHeader->infoHeader.biYPelsPerMeter = 2835; // 72 DPI
bmpHeader->infoHeader.biClrUsed = 2;
bmpHeader->infoHeader.biClrImportant = 2;
// Color 0 (black)
bmpHeader->colors[0].rgbBlue = 0;
+3 -1
View File
@@ -11,8 +11,10 @@ uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
enum class BmpRowOrder { BottomUp, TopDown };
// Populates a 1-bit BMP header in the provided memory.
void createBmpHeader(BmpHeader* bmpHeader, int width, int height);
void createBmpHeader(BmpHeader* bmpHeader, int width, int height, BmpRowOrder rowOrder);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):

Some files were not shown because too many files have changed in this diff Show More