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:
+8
-13
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user