Merge branch 'master' of origin into perf-lut-cache

Resolved conflicts in Section.h and Section.cpp:
- Combined includes (vector for LUT cache + optional/string from master)
- Added imageRendering parameter to loadSectionFile declaration
- Kept non-const clearCache (needs to close file handle for LUT cache)
- Kept in-memory LUT cache in loadPageFromSectionFile (replaces master's
  per-page LUT seek)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jpirnay
2026-03-25 17:17:51 +01:00
co-authored by Claude Opus 4.6
179 changed files with 194143 additions and 203348 deletions
+8 -13
View File
@@ -103,14 +103,11 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
pos += strlen(pattern);
const auto endPos = coverPageHtml.find('"', pos);
if (endPos != std::string::npos) {
const auto ref = coverPageHtml.substr(pos, endPos - pos);
const auto ref = std::string_view{coverPageHtml}.substr(pos, endPos - pos);
// Check if it's an image file
if (ref.length() >= 4) {
const auto ext = ref.substr(ref.length() - 4);
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
imageRef = ref;
break;
}
if (FsHelpers::hasPngExtension(ref) || FsHelpers::hasJpgExtension(ref) || FsHelpers::hasGifExtension(ref)) {
imageRef = ref;
break;
}
}
pos = coverPageHtml.find(pattern, pos);
@@ -541,8 +538,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@@ -575,7 +571,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return success;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
@@ -629,8 +625,7 @@ bool Epub::generateThumbBmp(int height) const {
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
LOG_DBG("EBP", "No known cover image for thumbnail");
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@@ -666,7 +661,7 @@ bool Epub::generateThumbBmp(int height) const {
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
+6 -4
View File
@@ -274,11 +274,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
bool BookMetadataCache::cleanupTmpFiles() const {
if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
Storage.remove((cachePath + tmpSpineBinFile).c_str());
const auto spineBinFile = cachePath + tmpSpineBinFile;
if (Storage.exists(spineBinFile.c_str())) {
Storage.remove(spineBinFile.c_str());
}
if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
Storage.remove((cachePath + tmpTocBinFile).c_str());
const auto tocBinFile = cachePath + tmpTocBinFile;
if (Storage.exists(tocBinFile.c_str())) {
Storage.remove(tocBinFile.c_str());
}
return true;
}
+1
View File
@@ -2,6 +2,7 @@
#include <HalStorage.h>
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
+37 -35
View File
@@ -101,20 +101,19 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
applyParagraphIndent();
const int pageWidth = viewportWidth;
const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR);
auto wordWidths = calculateWordWidths(renderer, fontId);
std::vector<size_t> lineBreakIndices;
if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
}
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
}
// Remove consumed words so size() reflects only remaining words
@@ -138,15 +137,17 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
}
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
const int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) {
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
if (words.empty()) {
return {};
}
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -184,9 +185,8 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// Add space before word j, unless it's the first word on the line or a continuation
int gap = 0;
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
gap = spaceWidth;
gap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]),
wordStyles[j - 1]);
gap =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
@@ -272,12 +272,14 @@ void ParsedText::applyParagraphIndent() {
// Builds break indices while opportunistically splitting the word that would overflow the current line.
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
const int pageWidth, const int spaceWidth,
std::vector<uint16_t>& wordWidths,
const int pageWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) {
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -298,9 +300,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const bool isFirstWord = currentIndex == lineStart;
int spacing = 0;
if (!isFirstWord && !continuesVec[currentIndex]) {
spacing = spaceWidth;
spacing += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
} else if (!isFirstWord && continuesVec[currentIndex]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]),
@@ -434,19 +435,21 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
return true;
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths, const std::vector<bool>& continuesVec,
const std::vector<size_t>& lineBreakIndices,
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const GfxRenderer& renderer, const int fontId) {
const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt;
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const bool isFirstLine = breakIndex == 0;
const int firstLineIndent =
isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
isFirstLine && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -462,11 +465,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Count gaps: each word after the first creates a gap, unless it's a continuation
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
actualGapCount++;
int naturalGap = spaceWidth;
naturalGap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]),
wordStyles[lastBreakAt + wordIdx - 1]);
totalNaturalGaps += naturalGap;
totalNaturalGaps +=
renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
} else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
totalNaturalGaps +=
@@ -485,8 +486,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
? spareSpace / static_cast<int>(actualGapCount)
: 0;
// Calculate initial x position (first line starts at indent for left/justified text)
auto xpos = static_cast<uint16_t>(firstLineIndent);
// Calculate initial x position (first line starts at indent for left/justified text;
// may be negative for hanging indents, e.g. margin-left:3em; text-indent:-1em).
auto xpos = static_cast<int16_t>(firstLineIndent);
if (blockStyle.alignment == CssTextAlign::Right) {
xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
} else if (blockStyle.alignment == CssTextAlign::Center) {
@@ -495,7 +497,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Pre-calculate X positions for words
// Continuation words attach to the previous word with no space before them
std::vector<uint16_t> lineXPos;
std::vector<int16_t> lineXPos;
lineXPos.reserve(lineWordCount);
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
@@ -510,11 +512,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]);
xpos += advance;
} else {
int gap = spaceWidth;
int gap = 0;
if (wordIdx + 1 < lineWordCount) {
gap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
firstCodepoint(words[lastBreakAt + wordIdx + 1]),
wordStyles[lastBreakAt + wordIdx]);
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
firstCodepoint(words[lastBreakAt + wordIdx + 1]),
wordStyles[lastBreakAt + wordIdx]);
}
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
+3 -4
View File
@@ -21,14 +21,13 @@ class ParsedText {
bool hyphenationEnabled;
void applyParagraphIndent();
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, int spaceWidth,
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
int fontId);
+62 -13
View File
@@ -10,10 +10,10 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 14;
constexpr uint8_t SECTION_FILE_VERSION = 18;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint32_t);
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t);
} // namespace
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
@@ -36,7 +36,7 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool embeddedStyle) {
const bool embeddedStyle, const uint8_t imageRendering) {
if (!file) {
LOG_DBG("SCT", "File not open for writing header");
return;
@@ -44,7 +44,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(uint32_t),
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -55,13 +55,16 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, viewportHeight);
serialization::writePod(file, hyphenationEnabled);
serialization::writePod(file, embeddedStyle);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
serialization::writePod(file, imageRendering);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
}
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) {
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -83,6 +86,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
uint8_t fileParagraphAlignment;
bool fileHyphenationEnabled;
bool fileEmbeddedStyle;
uint8_t fileImageRendering;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
@@ -91,11 +95,13 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, fileViewportHeight);
serialization::readPod(file, fileHyphenationEnabled);
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileImageRendering);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering) {
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache(); // closes file before removal
return false;
@@ -157,7 +163,7 @@ bool Section::clearCache() {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const std::function<void()>& popupFn) {
const uint8_t imageRendering, const std::function<void()>& popupFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@@ -207,7 +213,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle);
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
std::vector<uint32_t> lut = {};
// Derive the content base directory and image cache path prefix for the parser
@@ -229,7 +235,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -262,10 +268,20 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
// Go back and write LUT offset
file.seek(HEADER_SIZE - sizeof(uint32_t) - sizeof(pageCount));
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
const uint32_t anchorMapOffset = file.position();
const auto& anchors = visitor.getAnchors();
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
for (const auto& [anchor, page] : anchors) {
serialization::writeString(file, anchor);
serialization::writePod(file, page);
}
// Patch header with final pageCount, lutOffset, and anchorMapOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount));
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
file.close();
if (cssParser) {
cssParser->clear();
@@ -303,3 +319,36 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return Page::deserialize(file);
// File is intentionally NOT closed; stays open for the next page load
}
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
FsFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
f.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
f.close();
return std::nullopt;
}
f.seek(anchorMapOffset);
uint16_t count;
serialization::readPod(f, count);
for (uint16_t i = 0; i < count; i++) {
std::string key;
uint16_t page;
serialization::readString(f, key);
serialization::readPod(f, page);
if (key == anchor) {
f.close();
return page;
}
}
f.close();
return std::nullopt;
}
+9 -3
View File
@@ -1,6 +1,8 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Epub.h"
@@ -18,7 +20,7 @@ class Section {
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
bool embeddedStyle);
bool embeddedStyle, uint8_t imageRendering);
uint32_t onPageComplete(std::unique_ptr<Page> page);
public:
@@ -32,10 +34,14 @@ class Section {
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default;
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle);
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering);
bool clearCache();
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
const std::function<void()>& popupFn = nullptr);
uint8_t imageRendering, const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
};
+1 -1
View File
@@ -74,7 +74,7 @@ bool TextBlock::serialize(FsFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint16_t wc;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
+2 -2
View File
@@ -13,12 +13,12 @@
class TextBlock final : public Block {
private:
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
public:
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
@@ -1,45 +1,360 @@
#include "JpegToFramebufferConverter.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <JPEGDEC.h>
#include <Logging.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <new>
#include "DitherUtils.h"
#include "PixelCache.h"
namespace {
// Context struct passed through JPEGDEC callbacks to avoid global mutable state.
// 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 {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
// Source dimensions after JPEGDEC's built-in scaling
int scaledSrcWidth;
int scaledSrcHeight;
// Final output dimensions
int dstWidth;
int dstHeight;
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU)
int32_t fineScaleFP; // src -> dst mapping
int32_t invScaleFP; // 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) {}
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
void* jpegOpen(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
if (!Storage.openFileForRead("JPG", std::string(filename), *f)) {
delete f;
return nullptr;
}
*size = f->size();
return f;
}
void jpegClose(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
if (f) {
f->close();
delete f;
}
}
// JPEGDEC tracks file position via pFile->iPos internally (e.g. JPEGGetMoreData
// checks iPos < iSize to decide whether more data is available). The callbacks
// MUST maintain iPos to match the actual file position, otherwise progressive
// JPEGs with large headers fail during parsing.
int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
int32_t bytesRead = f->read(pBuf, len);
if (bytesRead < 0) return 0;
pFile->iPos += bytesRead;
return bytesRead;
}
int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return -1;
if (!f->seek(pos)) return -1;
pFile->iPos = pos;
return pos;
}
// JPEGDEC object is ~17 KB due to internal decode buffers.
// Heap-allocate on demand so memory is only used during active decode.
constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024;
constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024;
// Choose JPEGDEC's built-in scale factor for coarse downscaling.
// Returns the scale denominator (1, 2, 4, or 8) and sets jpegScaleOption.
int chooseJpegScale(float targetScale, int& jpegScaleOption) {
if (targetScale <= 0.125f) {
jpegScaleOption = JPEG_SCALE_EIGHTH;
return 8;
}
if (targetScale <= 0.25f) {
jpegScaleOption = JPEG_SCALE_QUARTER;
return 4;
}
if (targetScale <= 0.5f) {
jpegScaleOption = JPEG_SCALE_HALF;
return 2;
}
jpegScaleOption = 0;
return 1;
}
// Fixed-point 16.16 arithmetic avoids software float emulation on ESP32-C3 (no FPU).
constexpr int FP_SHIFT = 16;
constexpr int32_t FP_ONE = 1 << FP_SHIFT;
constexpr int32_t FP_MASK = FP_ONE - 1;
int jpegDrawCallback(JPEGDRAW* pDraw) {
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0;
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
const int stride = pDraw->iWidth;
const int validW = pDraw->iWidthUsed;
const int blockH = pDraw->iHeight;
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
const int32_t fineScaleFP = ctx->fineScaleFP;
const int32_t invScaleFP = ctx->invScaleFP;
GfxRenderer& renderer = *ctx->renderer;
const int cfgX = ctx->config->x;
const int cfgY = ctx->config->y;
const int blockX = pDraw->x;
const int blockY = pDraw->y;
// Determine destination pixel range covered by this source block
const int srcYEnd = blockY + blockH;
const int srcXEnd = blockX + validW;
int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT);
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT);
int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT);
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT);
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
int clampYMax = ctx->dstHeight;
if (ctx->screenHeight - cfgY < clampYMax) clampYMax = ctx->screenHeight - cfgY;
if (dstYStart < -cfgY) dstYStart = -cfgY;
if (dstYEnd > clampYMax) dstYEnd = clampYMax;
int clampXMax = ctx->dstWidth;
if (ctx->screenWidth - cfgX < clampXMax) clampXMax = ctx->screenWidth - cfgX;
if (dstXStart < -cfgX) dstXStart = -cfgX;
if (dstXEnd > clampXMax) dstXEnd = clampXMax;
if (dstYStart >= dstYEnd || dstXStart >= dstXEnd) return 1;
// === 1:1 fast path: no scaling math ===
if (fineScaleFP == FP_ONE) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const uint8_t* row = &pixels[(dstY - blockY) * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
uint8_t gray = row[dstX - blockX];
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
// Smooths block boundaries that would otherwise create visible banding
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
if (fineScaleFP > FP_ONE) {
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT);
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT);
if (safeXStart < dstXStart) safeXStart = dstXStart;
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const int32_t srcFyFP = dstY * invScaleFP;
const int32_t fy = srcFyFP & FP_MASK;
const int32_t fyInv = FP_ONE - fy;
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
int ly1 = ly0 + 1;
if (ly0 < 0) ly0 = 0;
if (ly0 >= blockH) ly0 = blockH - 1;
if (ly1 >= blockH) ly1 = blockH - 1;
const uint8_t* row0 = &pixels[ly0 * stride];
const uint8_t* row1 = &pixels[ly1 * stride];
// Left edge (with X boundary clamping)
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int lx1 = lx0 + 1;
if (lx0 < 0) lx0 = 0;
if (lx1 < 0) lx1 = 0;
if (lx0 >= validW) lx0 = validW - 1;
if (lx1 >= validW) lx1 = validW - 1;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx0 + 1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
// Right edge (with X boundary clamping)
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int lx1 = lx0 + 1;
if (lx0 >= validW) lx0 = validW - 1;
if (lx1 >= validW) lx1 = validW - 1;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const int32_t srcFyFP = dstY * invScaleFP;
int ly = (srcFyFP >> FP_SHIFT) - blockY;
if (ly < 0) ly = 0;
if (ly >= blockH) ly = blockH - 1;
const uint8_t* row = &pixels[ly * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
int lx = (srcFxFP >> FP_SHIFT) - blockX;
if (lx < 0) lx = 0;
if (lx >= validW) lx = validW - 1;
uint8_t gray = row[lx];
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
} // namespace
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
file.close();
if (status != 0) {
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
if (!jpeg) {
LOG_ERR("JPG", "Failed to allocate JPEG decoder for dimensions");
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, nullptr);
if (rc != 1) {
LOG_ERR("JPG", "Failed to open JPEG for dimensions (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
delete jpeg;
return false;
}
out.width = jpeg->getWidth();
out.height = jpeg->getHeight();
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
jpeg->close();
delete jpeg;
return true;
}
@@ -47,250 +362,130 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
const RenderConfig& config) {
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
LOG_ERR("JPG", "picojpeg init failed: %d", status);
file.close();
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
if (!jpeg) {
LOG_ERR("JPG", "Failed to allocate JPEG decoder");
return false;
}
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
file.close();
JpegContext ctx;
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback);
if (rc != 1) {
LOG_ERR("JPG", "Failed to open JPEG (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
delete jpeg;
return false;
}
// Calculate output dimensions
int srcWidth = jpeg->getWidth();
int srcHeight = jpeg->getHeight();
if (srcWidth <= 0 || srcHeight <= 0) {
LOG_ERR("JPG", "Invalid JPEG dimensions: %dx%d", srcWidth, srcHeight);
jpeg->close();
delete jpeg;
return false;
}
if (!validateImageDimensions(srcWidth, srcHeight, "JPEG")) {
jpeg->close();
delete jpeg;
return false;
}
bool isProgressive = jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE;
if (isProgressive) {
LOG_INF("JPG", "Progressive JPEG detected - decoding DC coefficients only (lower quality)");
}
// Calculate overall target scale
float targetScale;
int destWidth, destHeight;
float scale;
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
destWidth = config.maxWidth;
destHeight = config.maxHeight;
scale = (float)destWidth / imageInfo.m_width;
targetScale = (float)destWidth / srcWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
? (float)config.maxWidth / imageInfo.m_width
: 1.0f;
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
? (float)config.maxHeight / imageInfo.m_height
: 1.0f;
scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
float scaleX = (config.maxWidth > 0 && srcWidth > config.maxWidth) ? (float)config.maxWidth / srcWidth : 1.0f;
float scaleY = (config.maxHeight > 0 && srcHeight > config.maxHeight) ? (float)config.maxHeight / srcHeight : 1.0f;
targetScale = (scaleX < scaleY) ? scaleX : scaleY;
if (targetScale > 1.0f) targetScale = 1.0f;
destWidth = (int)(imageInfo.m_width * scale);
destHeight = (int)(imageInfo.m_height * scale);
destWidth = (int)(srcWidth * targetScale);
destHeight = (int)(srcHeight * targetScale);
}
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
// Choose JPEGDEC built-in scaling for coarse downscaling.
// Progressive JPEGs: JPEGDEC forces JPEG_SCALE_EIGHTH internally (DC-only
// decode produces 1/8 resolution). We must match this to avoid the if/else
// priority chain in DecodeJPEG selecting a different scale.
int jpegScaleOption;
int jpegScaleDenom;
if (isProgressive) {
jpegScaleOption = JPEG_SCALE_EIGHTH;
jpegScaleDenom = 8;
} else {
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
}
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
file.close();
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.dstWidth = destWidth;
ctx.dstHeight = destHeight;
ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
isProgressive ? " [progressive]" : "");
// Set pixel type to 8-bit grayscale (must be after open())
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
jpeg->setUserPointer(&ctx);
// Allocate cache buffer using final output dimensions
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
ctx.caching = false;
}
}
unsigned long decodeStart = millis();
rc = jpeg->decode(0, 0, jpegScaleOption);
unsigned long decodeTime = millis() - decodeStart;
if (rc != 1) {
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
jpeg->close();
delete jpeg;
return false;
}
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Allocate pixel cache if cachePath is provided
PixelCache cache;
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
caching = false;
}
}
int mcuX = 0;
int mcuY = 0;
while (mcuY < imageInfo.m_MCUSPerCol) {
status = pjpeg_decode_mcu();
if (status == PJPG_NO_MORE_BLOCKS) {
break;
}
if (status != 0) {
LOG_ERR("JPG", "MCU decode failed: %d", status);
file.close();
return false;
}
// Source position in image coordinates
int srcStartX = mcuX * imageInfo.m_MCUWidth;
int srcStartY = mcuY * imageInfo.m_MCUHeight;
switch (imageInfo.m_scanType) {
case PJPG_GRAYSCALE:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (col < 8) ? 0 : 1;
int pixelIndex = row * 8 + (col % 8);
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (row < 8) ? 0 : 1;
int pixelIndex = (row % 8) * 8 + col;
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockX = (col < 8) ? 0 : 1;
int blockY = (row < 8) ? 0 : 1;
int blockIndex = blockY * 2 + blockX;
int pixelIndex = (row % 8) * 8 + (col % 8);
int blockOffset = blockIndex * 64;
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
}
mcuX++;
if (mcuX >= imageInfo.m_MCUSPerRow) {
mcuX = 0;
mcuY++;
}
}
LOG_DBG("JPG", "Decoding complete");
file.close();
jpeg->close();
delete jpeg;
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled
if (caching) {
cache.writeToFile(config.cachePath);
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
}
return true;
}
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
if (context->bufferPos >= context->bufferFilled) {
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
if (readCount <= 0) {
*pBytes_actually_read = 0;
return 0;
}
context->bufferFilled = readCount;
context->bufferPos = 0;
}
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
context->bufferPos += bytesToCopy;
*pBytes_actually_read = bytesToCopy;
return 0;
}
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".jpg" || ext == ".jpeg");
return FsHelpers::hasJpgExtension(extension);
}
@@ -1,4 +1,5 @@
#pragma once
#include <stdint.h>
#include <string>
@@ -17,8 +18,4 @@ class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "JPEG"; }
private:
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
};
@@ -1,5 +1,6 @@
#include "PngToFramebufferConverter.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
@@ -391,9 +392,5 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".png");
return FsHelpers::hasPngExtension(extension);
}
+74
View File
@@ -52,6 +52,29 @@ constexpr size_t MAX_SELECTOR_LENGTH = 256;
// Check if character is CSS whitespace
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
std::string_view stripTrailingImportant(std::string_view value) {
constexpr std::string_view IMPORTANT = "!important";
while (!value.empty() && isCssWhitespace(value.back())) {
value.remove_suffix(1);
}
if (value.size() < IMPORTANT.size()) {
return value;
}
const size_t suffixPos = value.size() - IMPORTANT.size();
if (value.substr(suffixPos) != IMPORTANT) {
return value;
}
value.remove_suffix(IMPORTANT.size());
while (!value.empty() && isCssWhitespace(value.back())) {
value.remove_suffix(1);
}
return value;
}
} // anonymous namespace
// String utilities implementation
@@ -317,6 +340,10 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
style.imageWidth = len;
style.defined.imageWidth = 1;
}
} else if (propNameBuf == "display") {
const std::string_view displayValue = stripTrailingImportant(propValueBuf);
style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block;
style.defined.display = 1;
}
}
@@ -692,6 +719,7 @@ bool CssParser::saveToCache() const {
writeLength(style.paddingRight);
writeLength(style.imageHeight);
writeLength(style.imageWidth);
file.write(static_cast<uint8_t>(style.display));
// Write defined flags as uint16_t
uint16_t definedBits = 0;
@@ -710,6 +738,7 @@ bool CssParser::saveToCache() const {
if (style.defined.paddingRight) definedBits |= 1 << 12;
if (style.defined.imageHeight) definedBits |= 1 << 13;
if (style.defined.imageWidth) definedBits |= 1 << 14;
if (style.defined.display) definedBits |= 1 << 15;
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
}
@@ -748,16 +777,44 @@ bool CssParser::loadFromCache() {
return false;
}
if (ruleCount > MAX_RULES) {
LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES);
rulesBySelector_.clear();
file.close();
return false;
}
auto hasRemainingBytes = [&file](const size_t neededBytes) -> bool {
return static_cast<size_t>(file.available()) >= neededBytes;
};
constexpr size_t CSS_LENGTH_FIELD_COUNT = 11;
constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t);
constexpr size_t CSS_FIXED_STYLE_BYTES =
4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t);
// Read each rule
for (uint16_t i = 0; i < ruleCount; ++i) {
// Read selector string
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;
}
std::string selector;
selector.resize(selectorLen);
if (file.read(&selector[0], selectorLen) != selectorLen) {
@@ -766,6 +823,13 @@ bool CssParser::loadFromCache() {
return false;
}
if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) {
LOG_DBG("CSS", "Truncated CSS cache while reading style payload");
rulesBySelector_.clear();
file.close();
return false;
}
// Read CssStyle fields
CssStyle style;
uint8_t enumVal;
@@ -820,6 +884,15 @@ bool CssParser::loadFromCache() {
return false;
}
// Read display value
uint8_t displayVal;
if (file.read(&displayVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.display = static_cast<CssDisplay>(displayVal);
// Read defined flags
uint16_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
@@ -842,6 +915,7 @@ bool CssParser::loadFromCache() {
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
style.defined.display = (definedBits & 1 << 15) != 0;
rulesBySelector_[selector] = style;
}
+1 -1
View File
@@ -31,7 +31,7 @@
class CssParser {
public:
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 3;
static constexpr uint8_t CSS_CACHE_VERSION = 4;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
+15 -3
View File
@@ -54,6 +54,9 @@ enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
// Text decoration options
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Display options - only None and Block are relevant for e-ink rendering
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
// Bitmask for tracking which properties have been explicitly set
struct CssPropertyFlags {
uint16_t textAlign : 1;
@@ -71,6 +74,7 @@ struct CssPropertyFlags {
uint16_t paddingRight : 1;
uint16_t imageHeight : 1;
uint16_t imageWidth : 1;
uint16_t display : 1;
CssPropertyFlags()
: textAlign(0),
@@ -87,19 +91,20 @@ struct CssPropertyFlags {
paddingLeft(0),
paddingRight(0),
imageHeight(0),
imageWidth(0) {}
imageWidth(0),
display(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
imageWidth;
imageWidth || display;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
imageHeight = imageWidth = 0;
imageHeight = imageWidth = display = 0;
}
};
@@ -123,6 +128,7 @@ struct CssStyle {
CssLength paddingRight; // Padding right
CssLength imageHeight; // Height for img (e.g. 2em) width derived from aspect ratio when only height set
CssLength imageWidth; // Width for img when both or only width set
CssDisplay display = CssDisplay::Block; // display property (Block or None)
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -189,6 +195,10 @@ struct CssStyle {
imageWidth = base.imageWidth;
defined.imageWidth = 1;
}
if (base.hasDisplay()) {
display = base.display;
defined.display = 1;
}
}
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -206,6 +216,7 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
[[nodiscard]] bool hasDisplay() const { return defined.display; }
void reset() {
textAlign = CssTextAlign::Left;
@@ -216,6 +227,7 @@ struct CssStyle {
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
imageHeight = imageWidth = CssLength{};
display = CssDisplay::Block;
defined.clearAll();
}
};
@@ -107,6 +107,17 @@ bool isPunctuation(const uint32_t cp) {
bool isAsciiDigit(const uint32_t cp) { return cp >= '0' && cp <= '9'; }
bool isApostrophe(const uint32_t cp) {
switch (cp) {
case '\'':
case 0x2018: // left single quotation mark
case 0x2019: // right single quotation mark
return true;
default:
return false;
}
}
bool isExplicitHyphen(const uint32_t cp) {
switch (cp) {
case '-':
@@ -19,6 +19,7 @@ bool isCyrillicLetter(uint32_t cp);
bool isAlphabetic(uint32_t cp);
bool isPunctuation(uint32_t cp);
bool isAsciiDigit(uint32_t cp);
bool isApostrophe(uint32_t cp);
bool isExplicitHyphen(uint32_t cp);
bool isSoftHyphen(uint32_t cp);
void trimSurroundingPunctuationAndFootnote(std::vector<CodepointInfo>& cps);
+120 -21
View File
@@ -1,6 +1,7 @@
#include "Hyphenator.h"
#include <algorithm>
#include <cassert>
#include <vector>
#include "HyphenationCommon.h"
@@ -59,6 +60,94 @@ std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<Cod
return breaks;
}
bool isSegmentSeparator(const uint32_t cp) { return isExplicitHyphen(cp) || isApostrophe(cp); }
void appendSegmentPatternBreaks(const std::vector<CodepointInfo>& cps, const LanguageHyphenator& hyphenator,
const bool includeFallback, std::vector<Hyphenator::BreakInfo>& outBreaks) {
size_t segStart = 0;
for (size_t i = 0; i <= cps.size(); ++i) {
const bool atEnd = i == cps.size();
const bool atSeparator = !atEnd && isSegmentSeparator(cps[i].value);
if (!atEnd && !atSeparator) {
continue;
}
if (i > segStart) {
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
auto segIndexes = hyphenator.breakIndexes(segment);
if (includeFallback && segIndexes.empty()) {
const size_t minPrefix = hyphenator.minPrefix();
const size_t minSuffix = hyphenator.minSuffix();
for (size_t idx = minPrefix; idx + minSuffix <= segment.size(); ++idx) {
segIndexes.push_back(idx);
}
}
for (const size_t idx : segIndexes) {
assert(idx > 0 && idx < segment.size());
if (idx == 0 || idx >= segment.size()) continue;
const size_t cpIdx = segStart + idx;
if (cpIdx < cps.size()) {
outBreaks.push_back({cps[cpIdx].byteOffset, true});
}
}
}
segStart = i + 1;
}
}
void appendApostropheContractionBreaks(const std::vector<CodepointInfo>& cps,
std::vector<Hyphenator::BreakInfo>& outBreaks) {
constexpr size_t kMinLeftSegmentLen = 3;
constexpr size_t kMinRightSegmentLen = 3;
size_t segmentStart = 0;
for (size_t i = 0; i < cps.size(); ++i) {
if (isSegmentSeparator(cps[i].value)) {
if (isApostrophe(cps[i].value) && i > 0 && i + 1 < cps.size() && isAlphabetic(cps[i - 1].value) &&
isAlphabetic(cps[i + 1].value)) {
size_t leftPrefixLen = 0;
for (size_t j = segmentStart; j < i; ++j) {
if (isAlphabetic(cps[j].value)) {
++leftPrefixLen;
}
}
size_t rightSuffixLen = 0;
for (size_t j = i + 1; j < cps.size() && !isSegmentSeparator(cps[j].value); ++j) {
if (isAlphabetic(cps[j].value)) {
++rightSuffixLen;
}
}
// Avoid stranding short clitics like "l'"/"d'" or contraction tails like "'ve"/"'re"/"'ll".
if (leftPrefixLen >= kMinLeftSegmentLen && rightSuffixLen >= kMinRightSegmentLen) {
outBreaks.push_back({cps[i + 1].byteOffset, false});
}
}
segmentStart = i + 1;
}
}
}
void sortAndDedupeBreakInfos(std::vector<Hyphenator::BreakInfo>& infos) {
std::sort(infos.begin(), infos.end(), [](const Hyphenator::BreakInfo& a, const Hyphenator::BreakInfo& b) {
if (a.byteOffset != b.byteOffset) {
return a.byteOffset < b.byteOffset;
}
return a.requiresInsertedHyphen < b.requiresInsertedHyphen;
});
infos.erase(std::unique(infos.begin(), infos.end(),
[](const Hyphenator::BreakInfo& a, const Hyphenator::BreakInfo& b) {
return a.byteOffset == b.byteOffset;
}),
infos.end());
}
} // namespace
std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& word, const bool includeFallback) {
@@ -71,6 +160,15 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
trimSurroundingPunctuationAndFootnote(cps);
const auto* hyphenator = cachedHyphenator_;
// Detect apostrophe-like separators early; used by both branches below.
bool hasApostropheLikeSeparator = false;
for (const auto& cp : cps) {
if (isApostrophe(cp.value)) {
hasApostropheLikeSeparator = true;
break;
}
}
// Explicit hyphen markers (soft or hard) take precedence over language breaks.
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
if (!explicitBreakInfos.empty()) {
@@ -89,31 +187,32 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
// @16 Satellitensys|tems (+hyphen)
// Result: 6 sorted break points; the line-breaker picks the widest prefix that fits.
if (hyphenator) {
size_t segStart = 0;
for (size_t i = 0; i <= cps.size(); ++i) {
const bool atEnd = (i == cps.size());
const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value);
if (atEnd || atHyphen) {
if (i > segStart) {
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
auto segIndexes = hyphenator->breakIndexes(segment);
for (const size_t idx : segIndexes) {
const size_t cpIdx = segStart + idx;
if (cpIdx < cps.size()) {
explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true});
}
}
}
segStart = i + 1;
}
}
// Merge explicit and pattern breaks into ascending byte-offset order.
std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(),
[](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; });
appendSegmentPatternBreaks(cps, *hyphenator, /*includeFallback=*/false, explicitBreakInfos);
}
// Also add apostrophe contraction breaks when present (e.g. "l'état-major"
// has both an explicit hyphen and an apostrophe that can independently break).
if (hasApostropheLikeSeparator) {
appendApostropheContractionBreaks(cps, explicitBreakInfos);
}
// Merge all break points into ascending byte-offset order.
sortAndDedupeBreakInfos(explicitBreakInfos);
return explicitBreakInfos;
}
// Apostrophe-like separators split compounds into alphabetic segments; run Liang on each segment.
// This allows words like "all'improvviso" to hyphenate within "improvviso" instead of becoming
// completely unsplittable due to the apostrophe punctuation. Apostrophe contraction breaks are
// applied regardless of whether a language hyphenator is available.
if (hasApostropheLikeSeparator) {
std::vector<BreakInfo> segmentedBreaks;
if (hyphenator) {
appendSegmentPatternBreaks(cps, *hyphenator, includeFallback, segmentedBreaks);
}
appendApostropheContractionBreaks(cps, segmentedBreaks);
sortAndDedupeBreakInfos(segmentedBreaks);
return segmentedBreaks;
}
// Ask language hyphenator for legal break points.
std::vector<size_t> indexes;
if (hyphenator) {
+10 -4
View File
@@ -11,7 +11,8 @@ class Hyphenator {
struct BreakInfo {
size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur.
bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks).
// false = the word already contains a hyphen at this position (explicit '-').
// false = break occurs at an existing visible separator boundary
// (explicit '-' or eligible apostrophe contraction boundary).
};
// Returns byte offsets where the word may be hyphenated.
@@ -19,12 +20,17 @@ class Hyphenator {
// Break sources (in priority order):
// 1. Explicit hyphens already present in the word (e.g. '-' or soft-hyphen U+00AD).
// When found, language patterns are additionally run on each alphabetic segment
// between hyphens so compound words can break within their parts.
// between separators so compound words can break within their parts.
// Example: "US-Satellitensystems" yields breaks after "US-" (no inserted hyphen)
// plus pattern breaks inside "Satellitensystems" (Sa|tel|li|ten|sys|tems).
// 2. Language-specific Liang patterns (e.g. German de_patterns).
// 2. Apostrophe contractions between letters (e.g. all'improvviso).
// Liang patterns are run per alphabetic segment around apostrophes.
// A direct break at the apostrophe boundary is allowed only when the left
// segment has at least 3 letters and the right segment has at least 3 letters,
// avoiding short clitics (e.g. l', d') and contraction tails (e.g. 've, 're, 'll).
// 3. Language-specific Liang patterns (e.g. German de_patterns).
// Example: "Quadratkilometer" -> Qua|drat|ki|lo|me|ter.
// 3. Fallback every-N-chars splitting (only when includeFallback is true AND no
// 4. Fallback every-N-chars splitting (only when includeFallback is true AND no
// pattern breaks were found). Used as a last resort to prevent a single oversized
// word from overflowing the page width.
static std::vector<BreakInfo> breakOffsets(const std::string& word, bool includeFallback);
+105 -20
View File
@@ -4,6 +4,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
#include <expat.h>
#include "../../Epub.h"
@@ -133,11 +134,21 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
// div's margin should be preserved, even though it has no direct text content.
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
return;
}
makePages();
}
// Record deferred anchor after previous block is flushed
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
@@ -151,7 +162,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Extract class and style attributes for CSS processing
// Extract class, style, and id attributes
std::string classAttr;
std::string styleAttr;
if (atts != nullptr) {
@@ -160,6 +171,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
classAttr = atts[i + 1];
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer recording until startNewTextBlock, after previous block is flushed to pages
self->pendingAnchorId = atts[i + 1];
}
}
}
@@ -168,6 +182,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
centeredBlockStyle.textAlignDefined = true;
centeredBlockStyle.alignment = CssTextAlign::Center;
// Compute CSS style for this element early so display:none can short-circuit
// before tag-specific branches emit any content or metadata.
CssStyle cssStyle;
if (self->cssParser) {
cssStyle = self->cssParser->resolveStyle(name, classAttr);
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
cssStyle.applyOver(inlineStyle);
}
}
// Skip elements with display:none before all fast paths (tables, links, etc.).
if (cssStyle.hasDisplay() && cssStyle.display == CssDisplay::None) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
// Special handling for tables/cells: flatten into per-cell paragraphs with a prefixed header.
if (strcmp(name, "table") == 0) {
// skip nested tables
@@ -243,7 +275,27 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
if (!src.empty()) {
// imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely
if (self->imageRendering == 2) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
// Skip image if CSS display:none
if (self->cssParser) {
CssStyle imgDisplayStyle = self->cssParser->resolveStyle("img", classAttr);
if (!styleAttr.empty()) {
imgDisplayStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
}
if (imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::None) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
if (!src.empty() && self->imageRendering != 1) {
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
{
@@ -278,8 +330,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
int displayWidth = 0;
int displayHeight = 0;
const float emSize =
static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
// Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules
if (!styleAttr.empty()) {
@@ -364,10 +415,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
}
// Flush any pending text block so it appears before the image
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) {
const BlockStyle parentBlockStyle = self->currentTextBlock->getBlockStyle();
self->startNewTextBlock(parentBlockStyle);
}
// Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create new page");
@@ -493,19 +554,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
// Compute CSS style for this element
CssStyle cssStyle;
if (self->cssParser) {
// Get combined tag + class styles
cssStyle = self->cssParser->resolveStyle(name, classAttr);
// Merge inline style (highest priority)
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
cssStyle.applyOver(inlineStyle);
}
}
const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle(
cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment), self->viewportWidth);
@@ -738,9 +787,30 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
}
// If we're about to run out of space, then cut the word off and start a new one
// If we're about to run out of space, then cut the word off and start a new one.
// For CJK text (no spaces), this is the primary word-breaking mechanism.
// We must avoid splitting multi-byte UTF-8 sequences across word boundaries,
// otherwise the trailing bytes become orphaned continuation bytes that the
// decoder can't interpret.
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->flushPartWordBuffer();
int safeLen = utf8SafeTruncateBuffer(self->partWordBuffer, self->partWordBufferIndex);
if (safeLen < self->partWordBufferIndex && safeLen > 0) {
// Incomplete UTF-8 sequence at the end — save it before flushing
int overflow = self->partWordBufferIndex - safeLen;
char saved[4];
for (int j = 0; j < overflow; j++) {
saved[j] = self->partWordBuffer[safeLen + j];
}
self->partWordBufferIndex = safeLen;
self->flushPartWordBuffer();
for (int j = 0; j < overflow; j++) {
self->partWordBuffer[j] = saved[j];
}
self->partWordBufferIndex = overflow;
} else {
self->flushPartWordBuffer();
}
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
@@ -752,8 +822,12 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
: self->viewportWidth;
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth,
self->renderer, self->fontId, effectiveWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
}
}
@@ -984,7 +1058,12 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
// Process last page if there is still text
if (currentTextBlock) {
makePages();
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
currentTextBlock.reset();
}
@@ -995,8 +1074,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
+11 -2
View File
@@ -5,6 +5,7 @@
#include <climits>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "../FootnoteEntry.h"
@@ -48,6 +49,7 @@ class ChapterHtmlSlimParser {
bool hyphenationEnabled;
const CssParser* cssParser;
bool embeddedStyle;
uint8_t imageRendering;
std::string contentBase;
std::string imageBasePath;
int imageCounter = 0;
@@ -68,6 +70,11 @@ class ChapterHtmlSlimParser {
int tableRowIndex = 0;
int tableColIndex = 0;
// Anchor-to-page mapping: tracks which page each HTML id attribute lands on
int completedPageCount = 0;
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
// Footnote link tracking
bool insideFootnoteLink = false;
int footnoteLinkDepth = -1;
@@ -94,8 +101,8 @@ class ChapterHtmlSlimParser {
const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr,
const CssParser* cssParser = nullptr)
const std::string& imageBasePath, const uint8_t imageRendering = 0,
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
: epub(epub),
filepath(filepath),
@@ -111,10 +118,12 @@ class ChapterHtmlSlimParser {
popupFn(popupFn),
cssParser(cssParser),
embeddedStyle(embeddedStyle),
imageRendering(imageRendering),
contentBase(contentBase),
imageBasePath(imageBasePath) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
};
+3 -5
View File
@@ -36,12 +36,10 @@ ContentOpfParser::~ContentOpfParser() {
if (tempItemStore) {
tempItemStore.close();
}
if (Storage.exists((cachePath + itemCacheFile).c_str())) {
Storage.remove((cachePath + itemCacheFile).c_str());
const auto itemCachePath = cachePath + itemCacheFile;
if (Storage.exists(itemCachePath.c_str())) {
Storage.remove(itemCachePath.c_str());
}
itemIndex.clear();
itemIndex.shrink_to_fit();
useItemIndex = false;
}
size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); }