Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
41 kB · 1085 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086#include "ProgressMapper.h"
#include <GfxRenderer.h>#include <Logging.h>
#include <algorithm>#include <cmath>#include <cstring>
#include "ChapterXPathResolver.h"#include "Epub/Section.h"#include "Epub/VisibleTextUtils.h"#include "Epub/htmlEntities.h"#include "Utf8.h"
namespace {int parseIndex(const std::string& xpath, const char* prefix, bool last = false) { const size_t prefixLen = strlen(prefix); const size_t pos = last ? xpath.rfind(prefix) : xpath.find(prefix); if (pos == std::string::npos) return -1; const size_t numStart = pos + prefixLen; const size_t numEnd = xpath.find(']', numStart); if (numEnd == std::string::npos || numEnd == numStart) return -1; int val = 0; for (size_t i = numStart; i < numEnd; i++) { if (xpath[i] < '0' || xpath[i] > '9') return -1; val = val * 10 + (xpath[i] - '0'); } return val;}
int parseCharOffset(const std::string& xpath) { const size_t textPos = xpath.rfind("text()"); const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.'); if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0; int val = 0; for (size_t i = dotPos + 1; i < xpath.size(); i++) { if (xpath[i] < '0' || xpath[i] > '9') return 0; val = val * 10 + (xpath[i] - '0'); } return val;}
// Parse the N from text()[N] in the XPath (1-based; defaults to 1 if absent or 1).int parseTextNodeIndex(const std::string& xpath) { const size_t textPos = xpath.rfind("text()["); if (textPos == std::string::npos) return 1; const size_t numStart = textPos + 7; // strlen("text()[") const size_t numEnd = xpath.find(']', numStart); if (numEnd == std::string::npos || numEnd == numStart) return 1; int val = 0; for (size_t i = numStart; i < numEnd; i++) { if (xpath[i] < '0' || xpath[i] > '9') return 1; val = val * 10 + (xpath[i] - '0'); } return val > 0 ? val : 1;}
bool isChapterStartXPath(const std::string& xpath) { if (xpath.find("/p[") != std::string::npos || xpath.find("/li[") != std::string::npos) { return false; }
static constexpr char kDocFragment[] = "/body/DocFragment["; const size_t docFragPos = xpath.find(kDocFragment); if (docFragPos == std::string::npos) { return false; } const size_t docFragEnd = xpath.find(']', docFragPos + strlen(kDocFragment)); if (docFragEnd == std::string::npos) { return false; } if (docFragEnd + 1 == xpath.size()) { return true; } if (xpath[docFragEnd + 1] == '.') { if (docFragEnd + 2 >= xpath.size()) { return false; } for (size_t i = docFragEnd + 2; i < xpath.size(); i++) { if (xpath[i] != '0') return false; } return true; }
static constexpr char kDocBody[] = "]/body"; const size_t docBodyPos = xpath.find(kDocBody); if (docBodyPos == std::string::npos) { return false; } size_t bodyContentStart = docBodyPos + strlen(kDocBody); if (bodyContentStart == xpath.size()) { return true; } if (xpath[bodyContentStart] != '/') { return false; } bodyContentStart++; if (bodyContentStart == xpath.size()) { return true; }
const size_t dotPos = xpath.rfind('.'); if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) { return false; } size_t terminalEnd = dotPos; static constexpr char kTextNode[] = "/text()"; const size_t textNodePos = xpath.rfind(kTextNode, dotPos); if (textNodePos != std::string::npos && textNodePos >= bodyContentStart) { terminalEnd = textNodePos; } if (xpath.find('/', bodyContentStart) < terminalEnd) { return false; }
for (size_t i = dotPos + 1; i < xpath.size(); i++) { if (xpath[i] != '0') return false; } return parseTextNodeIndex(xpath) <= 1;}
bool isBodyTextXPath(const std::string& xpath) { static constexpr char kBodyFrag[] = "/body/DocFragment["; const size_t fragPos = xpath.find(kBodyFrag); if (fragPos == std::string::npos) return false; const size_t afterBracket = xpath.find(']', fragPos + strlen(kBodyFrag)); if (afterBracket == std::string::npos) return false; static constexpr char kBody[] = "/body/"; if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return false; const size_t contentPos = afterBracket + 1 + strlen(kBody); return xpath.compare(contentPos, strlen("text()"), "text()") == 0;}
// Parsed representation of one step in the XPath ancestry.struct XPathStep { char tag[12]; // element name, null-terminated int siblingIndex; // 1-based sibling index, or 0 if unspecified (match any)};
static constexpr int MAX_XPATH_DEPTH = 16;
// Parse the XPath segment between /body/DocFragment[N]/body/ and the terminal position// into an ordered sequence of steps. Returns step count, 0 on failure.// Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51"// Fills steps with: {div,1}, {ul,1}, {li,4}int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH]) { static const char kBodyFrag[] = "/body/DocFragment["; const size_t fragPos = xpath.find(kBodyFrag); if (fragPos == std::string::npos) return 0; const size_t afterBracket = xpath.find(']', fragPos + strlen(kBodyFrag)); if (afterBracket == std::string::npos) return 0; static const char kBody[] = "/body/"; if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0; size_t pos = afterBracket + 1 + strlen(kBody);
size_t stepsEnd = xpath.rfind("/text()"); if (stepsEnd == std::string::npos) { stepsEnd = xpath.rfind('.'); if (stepsEnd == std::string::npos || stepsEnd <= pos || stepsEnd + 1 >= xpath.size()) return 0; for (size_t i = stepsEnd + 1; i < xpath.size(); i++) { if (xpath[i] < '0' || xpath[i] > '9') return 0; } } if (stepsEnd <= pos) return 0;
int count = 0; while (pos < stepsEnd && count < MAX_XPATH_DEPTH) { const size_t slash = xpath.find('/', pos); const size_t segEnd = (slash < stepsEnd) ? slash : stepsEnd;
XPathStep& step = steps[count]; const size_t bracket = xpath.find('[', pos); const size_t nameEnd = (bracket != std::string::npos && bracket < segEnd) ? bracket : segEnd; const size_t nameLen = nameEnd - pos; if (nameLen == 0 || nameLen >= sizeof(step.tag)) return 0; memcpy(step.tag, xpath.c_str() + pos, nameLen); step.tag[nameLen] = '\0';
if (bracket != std::string::npos && bracket < segEnd) { const size_t closeBracket = xpath.find(']', bracket + 1); if (closeBracket == std::string::npos || closeBracket > segEnd) return 0; int idx = 0; for (size_t i = bracket + 1; i < closeBracket; i++) { if (xpath[i] < '0' || xpath[i] > '9') return 0; idx = idx * 10 + (xpath[i] - '0'); } step.siblingIndex = idx; } else { step.siblingIndex = 0; }
count++; pos = (slash < stepsEnd) ? slash + 1 : stepsEnd; } return count;}
class ParagraphStreamer final : public Print { size_t bytesWritten = 0; bool globalInTag = false; bool globalInEntity = false; static constexpr size_t MAX_ENTITY_SIZE = 16; char entityBuffer[MAX_ENTITY_SIZE] = {}; size_t entityLen = 0; bool prevCR = false; // last counted visible byte was a CR (XML line-ending normalization)
// Forward mode: count <p> paragraphs at a byte offset (legacy, used by generateXPath) size_t fwdTarget; int fwdResult = 0; bool fwdCaptured = false;
// Reverse mode shared state int revChar; bool revPFound = false; bool revDone = false; int revVisChars = 0; size_t totalVisChars = 0; size_t targetVisChars = 0;
// --- Legacy reverse mode (paragraph index only, no ancestry) --- int revParagraph = 0; int pCount = 0; int paragraphAtMatch = 0; int liCount = 0; int liCountAtMatch = 0; int targetTextNode = 1; int currentTextNode = 0; int paragraphHtmlDepth = -1;
// --- Ancestry-aware reverse mode --- const XPathStep* steps = nullptr; int stepCount = 0; int siblingCounters[MAX_XPATH_DEPTH] = {}; bool insideStep[MAX_XPATH_DEPTH] = {}; int htmlDepth = 0; int bodyHtmlDepth = -1; int stepEnteredAtDepth[MAX_XPATH_DEPTH] = {}; bool relaxFirstStepDepth = false;
// Tag name accumulation enum TagParseState { TAG_IDLE, TAG_IN_NAME, TAG_ATTRS } tagState = TAG_IDLE; bool tagIsClose = false; char tagName[12] = {}; int tagNameLen = 0;
int matchedDepth = 0;
// Anchor ID capture static constexpr int MAX_ANCHOR_ID = 64; char capturedAnchorId[MAX_ANCHOR_ID] = {}; int capturedAnchorIdLen = 0; bool capturingAnchorTag = false; enum AnchorAttrState { ATTR_FIND_NAME, ATTR_READ_NAME, ATTR_AFTER_NAME, ATTR_BEFORE_VALUE, ATTR_CAPTURE_D, ATTR_CAPTURE_S } attrState = ATTR_FIND_NAME; uint8_t attrNameLen = 0; bool currentAttrIsId = false; bool inAttrQuote = false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close) char attrQuoteChar = 0; uint8_t nonVisibleDepth = 0; bool insideBody = false; bool targetBodyText = false;
bool isNonVisibleTag() const { return VisibleTextUtils::isNonVisibleElement(tagName); }
static bool isAttrWhitespace(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
static bool isAttrNameChar(uint8_t c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':' || c == '.'; }
void resetAnchorAttrScan() { attrState = ATTR_FIND_NAME; attrNameLen = 0; currentAttrIsId = false; }
void finishCapturedAnchorId() { capturedAnchorId[capturedAnchorIdLen] = '\0'; capturingAnchorTag = false; resetAnchorAttrScan(); }
void beginAnchorIdScan() { capturingAnchorTag = true; resetAnchorAttrScan(); }
void endAnchorIdScan() { if (capturingAnchorTag) { capturedAnchorIdLen = 0; } capturingAnchorTag = false; resetAnchorAttrScan(); }
void appendCapturedAnchorId(uint8_t c) { if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) { capturedAnchorId[capturedAnchorIdLen++] = c; } }
void scanAnchorAttribute(uint8_t c) { switch (attrState) { case ATTR_FIND_NAME: if (isAttrNameChar(c)) { attrState = ATTR_READ_NAME; attrNameLen = 1; currentAttrIsId = c == 'i'; } break; case ATTR_READ_NAME: if (isAttrNameChar(c)) { if (attrNameLen == 1) { currentAttrIsId = currentAttrIsId && c == 'd'; } else { currentAttrIsId = false; } attrNameLen++; } else { currentAttrIsId = currentAttrIsId && attrNameLen == 2; if (isAttrWhitespace(c)) { attrState = ATTR_AFTER_NAME; } else if (c == '=') { attrState = ATTR_BEFORE_VALUE; } else { resetAnchorAttrScan(); } } break; case ATTR_AFTER_NAME: if (isAttrWhitespace(c)) { break; } if (c == '=') { attrState = ATTR_BEFORE_VALUE; } else if (isAttrNameChar(c)) { attrState = ATTR_READ_NAME; attrNameLen = 1; currentAttrIsId = c == 'i'; } else { resetAnchorAttrScan(); } break; case ATTR_BEFORE_VALUE: if (isAttrWhitespace(c)) { break; } if (currentAttrIsId && c == '"') { capturedAnchorIdLen = 0; attrState = ATTR_CAPTURE_D; } else if (currentAttrIsId && c == '\'') { capturedAnchorIdLen = 0; attrState = ATTR_CAPTURE_S; } else if (c == '"') { attrState = ATTR_CAPTURE_D; } else if (c == '\'') { attrState = ATTR_CAPTURE_S; } else { resetAnchorAttrScan(); } break; case ATTR_CAPTURE_D: if (c == '"') { if (currentAttrIsId) { finishCapturedAnchorId(); } else { resetAnchorAttrScan(); } } else if (currentAttrIsId) { appendCapturedAnchorId(c); } break; case ATTR_CAPTURE_S: if (c == '\'') { if (currentAttrIsId) { finishCapturedAnchorId(); } else { resetAnchorAttrScan(); } } else if (currentAttrIsId) { appendCapturedAnchorId(c); } break; } }
void onVisibleCodepoint() { totalVisChars++; if (revPFound && !revDone) { // Ancestry mode: count only while inside the fully-matched element and in the target text node. // Legacy mode: count only while still inside the matched paragraph and in the target text node. const bool inTargetNode = (stepCount > 0) ? (matchedDepth == stepCount && htmlDepth == stepEnteredAtDepth[stepCount - 1] && currentTextNode == targetTextNode) : (paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth && currentTextNode == targetTextNode); if (inTargetNode) { revVisChars++; if (revVisChars >= revChar) { targetVisChars = totalVisChars; revDone = true; } } } }
void onVisibleText(const char* text) { if (!text) return; const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text); while (*ptr != 0) { utf8NextCodepoint(&ptr); onVisibleCodepoint(); } }
void flushEntityAsLiteral() { for (size_t i = 0; i < entityLen; i++) onVisibleCodepoint(); }
void finishEntity() { entityBuffer[entityLen] = '\0'; const char* resolved = lookupHtmlEntity(entityBuffer, entityLen); if (resolved) onVisibleText(resolved); else if (entityLen >= 3 && entityBuffer[1] == '#') // Numeric character reference (&#NNN; / &#xHH;): expat -- which builds the page LUT -- // decodes it to a single codepoint. Count one here too, not the raw "&#NNN" characters. onVisibleCodepoint(); else flushEntityAsLiteral(); globalInEntity = false; entityLen = 0; }
void onLegacyP() { pCount++; if (!revPFound && revParagraph > 0 && pCount >= revParagraph) { revPFound = true; revVisChars = 0; paragraphHtmlDepth = htmlDepth; currentTextNode = 1; if (revChar <= 0 && targetTextNode <= 1) { targetVisChars = totalVisChars; revDone = true; } } }
void onOpenTag() { htmlDepth++;
if (strcasecmp(tagName, "body") == 0) { insideBody = true; bodyHtmlDepth = htmlDepth; if (targetBodyText) { revPFound = true; paragraphHtmlDepth = htmlDepth; currentTextNode = 1; if (revChar <= 0 && targetTextNode <= 1) { targetVisChars = totalVisChars; revDone = true; } } return; } if (!insideBody) return;
if (nonVisibleDepth > 0 || isNonVisibleTag()) { nonVisibleDepth++; return; }
if (stepCount == 0) { if (strcasecmp(tagName, "p") == 0) onLegacyP(); return; }
// Capture a child <a id> inside the fully-matched element even after target char is found. if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) { beginAnchorIdScan(); }
if (revDone) return;
if (strcasecmp(tagName, "p") == 0) pCount++; if (strcasecmp(tagName, "li") == 0) liCount++;
if (matchedDepth < stepCount) { const XPathStep& target = steps[matchedDepth]; if (strcasecmp(tagName, target.tag) == 0) { // Count only direct children of the previously matched ancestor step. // For step 0 any depth is valid; subsequent steps must be exactly one level deeper. const bool atCorrectDepth = (matchedDepth == 0) ? (relaxFirstStepDepth || htmlDepth == bodyHtmlDepth + 1) : (htmlDepth == stepEnteredAtDepth[matchedDepth - 1] + 1); if (!atCorrectDepth) return; siblingCounters[matchedDepth]++; if (target.siblingIndex == 0 || siblingCounters[matchedDepth] == target.siblingIndex) { insideStep[matchedDepth] = true; stepEnteredAtDepth[matchedDepth] = htmlDepth; matchedDepth++; if (matchedDepth == stepCount) { beginAnchorIdScan(); paragraphAtMatch = pCount; liCountAtMatch = liCount; revPFound = true; capturedAnchorIdLen = 0; revVisChars = 0; currentTextNode = 1; // Reset text node counter for this element if (revChar <= 0 && targetTextNode <= 1) { targetVisChars = totalVisChars; revDone = true; } } } } } }
void onCloseTag() { if (strcasecmp(tagName, "body") == 0) { insideBody = false; if (htmlDepth > 0) htmlDepth--; return; } if (!insideBody) { if (htmlDepth > 0) htmlDepth--; return; }
if (nonVisibleDepth > 0) { nonVisibleDepth--; if (htmlDepth > 0) htmlDepth--; return; }
// Legacy mode: each direct child element closing advances the text node index. if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) { currentTextNode++; if (currentTextNode == targetTextNode && revChar <= 0) { targetVisChars = totalVisChars; revDone = true; } } // Legacy mode: stop tracking when the matched paragraph itself closes. if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth) { revPFound = false; paragraphHtmlDepth = -1; }
// Ancestry mode: advance text node when a direct child of the fully-matched element closes. if (stepCount > 0 && matchedDepth == stepCount && revPFound && !revDone) { const int elementDepth = stepEnteredAtDepth[stepCount - 1]; if (htmlDepth == elementDepth + 1) { currentTextNode++; if (currentTextNode == targetTextNode && revChar <= 0) { targetVisChars = totalVisChars; revDone = true; } } }
if (stepCount > 0 && matchedDepth > 0) { const int step = matchedDepth - 1; if (insideStep[step] && htmlDepth == stepEnteredAtDepth[step]) { insideStep[step] = false; matchedDepth--; // If the fully-matched element just closed without finding the target, abort. if (matchedDepth < stepCount && revPFound && !revDone) { revPFound = false; } for (int i = matchedDepth + 1; i < stepCount; i++) { siblingCounters[i] = 0; insideStep[i] = false; stepEnteredAtDepth[i] = -1; } } } if (htmlDepth > 0) htmlDepth--; }
void processByteInTag(uint8_t c) { switch (tagState) { case TAG_IDLE: if (c == '/') { tagIsClose = true; tagState = TAG_IN_NAME; } else if (c != '!' && c != '?') { tagIsClose = false; tagName[0] = static_cast<char>(c); tagNameLen = 1; tagState = TAG_IN_NAME; } break; case TAG_IN_NAME: if (c == '>' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/') { tagName[tagNameLen] = '\0'; if (tagNameLen > 0) { if (tagIsClose) onCloseTag(); else onOpenTag(); // Self-closing open tag (<br/>). Don't double-fire for close tags (</br/>). if (c == '/' && !tagIsClose) onCloseTag(); } tagNameLen = 0; tagState = (c == '>') ? TAG_IDLE : TAG_ATTRS; } else if (tagNameLen + 1 < static_cast<int>(sizeof(tagName))) { tagName[tagNameLen++] = static_cast<char>(c); } break; case TAG_ATTRS: // Track quoted attribute values so '/' inside them is not mistaken for self-closing. if (!inAttrQuote) { if (c == '"' || c == '\'') { inAttrQuote = true; attrQuoteChar = c; } } else if (c == attrQuoteChar) { inAttrQuote = false; attrQuoteChar = 0; } if (capturingAnchorTag) { scanAnchorAttribute(c); } // Only treat '/' as self-closing when outside a quoted attribute value. if (c == '/' && !inAttrQuote) { endAnchorIdScan(); onCloseTag(); } break; } }
public: explicit ParagraphStreamer(size_t targetByte) : fwdTarget(targetByte), revChar(0) { memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth)); }
ParagraphStreamer(int paragraph, int charOff, int textNodeIdx = 1) : fwdTarget(SIZE_MAX), revChar(charOff), revParagraph(paragraph), targetTextNode(textNodeIdx) { memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth)); }
ParagraphStreamer(const XPathStep* xpathSteps, int xpathStepCount, int charOff, int textNodeIdx = 1, bool relaxFirstStep = false) : fwdTarget(SIZE_MAX), revChar(charOff), steps(xpathSteps), stepCount(xpathStepCount), targetTextNode(textNodeIdx), relaxFirstStepDepth(relaxFirstStep) { memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth)); }
ParagraphStreamer(bool resolveBodyText, int charOff, int textNodeIdx) : fwdTarget(SIZE_MAX), revChar(charOff), targetTextNode(textNodeIdx), targetBodyText(resolveBodyText) { memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth)); }
size_t write(uint8_t c) override { if (!fwdCaptured && bytesWritten >= fwdTarget) { fwdResult = pCount; fwdCaptured = true; } bytesWritten++;
if (globalInEntity) { if (entityLen + 1 < MAX_ENTITY_SIZE) { entityBuffer[entityLen++] = static_cast<char>(c); } else { flushEntityAsLiteral(); globalInEntity = false; entityLen = 0; } if (globalInEntity) { if (c == ';') { finishEntity(); } else if (c == '<' || c == ' ' || c == '\t' || c == '\n' || c == '\r') { flushEntityAsLiteral(); globalInEntity = false; entityLen = 0; } } return 1; }
// XML 1.0 §2.11 line-ending normalization: expat (which builds the page LUT) collapses a // "\r\n" pair and a lone "\r" to a single "\n". Mirror that so this byte counter -- which the // resolved offset is measured against -- stays codepoint-for-codepoint identical. prevCR is // set only by the visible-text branch below, so any non-text byte clears it here. const bool afterCR = prevCR; prevCR = false;
if (c == '<') { globalInTag = true; tagState = TAG_IDLE; tagNameLen = 0; tagIsClose = false; capturingAnchorTag = false; resetAnchorAttrScan(); inAttrQuote = false; attrQuoteChar = 0; } else if (c == '>') { if (tagState == TAG_ATTRS) { endAnchorIdScan(); } globalInTag = false; inAttrQuote = false; if (tagState == TAG_IN_NAME && tagNameLen > 0) { tagName[tagNameLen] = '\0'; if (tagIsClose) onCloseTag(); else onOpenTag(); tagNameLen = 0; } tagState = TAG_IDLE; } else if (globalInTag) { processByteInTag(c); } else if (!insideBody || nonVisibleDepth > 0) { // Ignore head/style/script/title text. KOReader XPaths are body-relative, and CSS text // should not contribute to intra-spine progress. } else { if (c == '&') { globalInEntity = true; entityBuffer[0] = '&'; entityLen = 1; } else if (c == '\n' && afterCR) { // Second half of a CRLF: the newline was already counted on the preceding CR. } else { const bool startsCodepoint = (c & 0xC0) != 0x80; if (startsCodepoint) onVisibleCodepoint(); prevCR = (c == '\r'); // a lone/leading CR is the newline; swallow any '\n' that follows } } return 1; }
size_t write(const uint8_t* buffer, size_t size) override { for (size_t i = 0; i < size; i++) write(buffer[i]); return size; }
int paragraphCount() const { return fwdCaptured ? fwdResult : pCount; } int getParagraphAtMatch() const { return paragraphAtMatch; } int getListItemAtMatch() const { return liCountAtMatch; } const char* getCapturedAnchorId() const { return capturedAnchorIdLen > 0 ? capturedAnchorId : nullptr; } size_t totalBytes() const { return bytesWritten; } bool found() const { return revDone; } size_t getTotalVisChars() const { return totalVisChars; } size_t getTargetVisChars() const { return targetVisChars; } float progress() const { return totalVisChars > 0 ? static_cast<float>(targetVisChars) / static_cast<float>(totalVisChars) : 0.0f; }};
bool streamSpine(const std::shared_ptr<Epub>& epub, int spineIndex, ParagraphStreamer& s) { const auto href = epub->getSpineItem(spineIndex).href; return !href.empty() && epub->readItemContentsToStream(href, s, 1024);}} // namespace
SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) { SavedProgressPosition result; float intra = (pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f; result.percentage = epub->calculateProgress(pos.spineIndex, intra); if (pos.hasVisibleTextOffset) { result.xpath = ChapterXPathResolver::findXPathForVisibleTextOffset(epub, pos.spineIndex, pos.visibleTextOffset); } if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) { result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex); } // Fall back to progress-based XPath, then synthetic progress mapping. if (result.xpath.empty()) { result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra); } if (result.xpath.empty()) { result.xpath = generateXPath(epub, pos.spineIndex, intra); } LOG_DBG("PM", "-> Progress: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages, static_cast<double>(result.percentage * 100), result.xpath.c_str()); return result;}
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub, const KOReaderRichPosition& rich, GfxRenderer& renderer, bool xpathAlreadyTried) { const int spineCount = epub->getSpineItemsCount(); if (static_cast<int>(rich.spineIndex) >= spineCount) { LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount); return std::nullopt; }
CrossPointPosition result{}; result.spineIndex = rich.spineIndex; result.hasResolvedSpineIndex = true;
// The existing rich extension carries the same KOReader XPath as the standard // progress field. Resolve that content anchor first; remote page counts are // layout-dependent hints only. Skip it when the caller already resolved this exact // XPath -- re-streaming the same chapter for the same failure is pure waste. if (!xpathAlreadyTried && !rich.xpath.empty()) { SavedProgressPosition saved{rich.xpath, static_cast<float>(rich.pctQ) / 1000000.0f}; auto contentMapped = toCrossPoint(epub, saved, renderer); if (contentMapped.hasVisibleTextOffset) { return contentMapped; } }
Section tempSection(epub, result.spineIndex, renderer); const auto cachedCount = tempSection.getCachedPageCount(); if (!cachedCount || *cachedCount <= 0) { // No local layout for the target spine yet; the percentage/xpath mapping // handles density estimation better than a blind copy of remote pages. LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex); return std::nullopt; } result.totalPages = *cachedCount;
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1; if (result.totalPages == remotePages) { // Identical layout (same render settings) — the page transfers losslessly. result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1); result.hasMappedPage = true; LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages); return result; }
// Layout differs; the paragraph LUT is the most accurate anchor we have. if (rich.paragraphIndex.has_value()) { const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex); if (lutPage.has_value()) { result.paragraphIndex = *rich.paragraphIndex; result.hasParagraphIndex = true; result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1); result.hasMappedPage = true; LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex, result.pageNumber, result.totalPages); return result; } }
// Fall back to the intra-spine page fraction. const float intra = (remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f; result.pageNumber = std::max( 0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1)); LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber, remotePages, result.pageNumber, result.totalPages); return result;}
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos, GfxRenderer& renderer, int currentSpineIndex, int totalPagesInCurrentSpine, int fallbackTotalPages) { CrossPointPosition result{}; const size_t bookSize = epub->getBookSize(); if (bookSize == 0) return result;
const int spineCount = epub->getSpineItemsCount(); const float clampedPercentage = std::max(0.0f, std::min(1.0f, koPos.percentage)); const size_t targetBytes = static_cast<size_t>(static_cast<float>(bookSize) * clampedPercentage);
const int docFrag = parseIndex(koPos.xpath, "/body/DocFragment["); const int xpathP = parseIndex(koPos.xpath, "/p[", true); const int xpathChar = parseCharOffset(koPos.xpath); const int xpathTextNode = parseTextNodeIndex(koPos.xpath); const int xpathSpine = (docFrag >= 1) ? (docFrag - 1) : -1;
XPathStep xpathSteps[MAX_XPATH_DEPTH]; const int xpathStepCount = parseXPathSteps(koPos.xpath, xpathSteps); // Use ancestry mode whenever the XPath has a structured path (always more accurate than global counting). const bool useAncestry = xpathStepCount > 0; const bool useBodyText = !useAncestry && isBodyTextXPath(koPos.xpath);
if (xpathSpine >= 0 && xpathSpine < spineCount) { result.spineIndex = xpathSpine; result.hasResolvedSpineIndex = true; } else { for (int i = 0; i < spineCount; i++) { if (epub->getCumulativeSpineItemSize(i) >= targetBytes) { result.spineIndex = i; break; } } }
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) { result.totalPages = totalPagesInCurrentSpine; } else if (currentSpineIndex >= 0 && currentSpineIndex < spineCount && totalPagesInCurrentSpine > 0) { const size_t pc = (currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; const size_t cs = epub->getCumulativeSpineItemSize(currentSpineIndex) - pc; if (cs > 0) result.totalPages = std::max( 1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs))); }
if (result.totalPages <= 0) { Section tempSection(epub, result.spineIndex, renderer); if (auto cachedCount = tempSection.getCachedPageCount()) { result.totalPages = *cachedCount; } else if (fallbackTotalPages > 0) { result.totalPages = fallbackTotalPages; } else { result.totalPages = 1; // Prevent division by zero and give a fallback } }
float intra = 0.0f; if (useAncestry) { const auto applyResolvedXPath = [&](const ParagraphStreamer& s) { result.visibleTextOffset = static_cast<uint32_t>(std::min<size_t>(s.getTargetVisChars(), static_cast<size_t>(UINT32_MAX))); result.hasVisibleTextOffset = true; const int pAtMatch = s.getParagraphAtMatch(); if (pAtMatch > 0) { result.paragraphIndex = static_cast<uint16_t>(pAtMatch); result.hasParagraphIndex = true; } if (xpathStepCount > 0 && strcasecmp(xpathSteps[xpathStepCount - 1].tag, "li") == 0) { const int liAtMatch = s.getListItemAtMatch(); if (liAtMatch > 0) { result.liIndex = static_cast<uint16_t>(liAtMatch); result.hasLiIndex = true; } } const char* anchorId = s.getCapturedAnchorId(); if (anchorId) { strncpy(result.xpathAnchorId, anchorId, sizeof(result.xpathAnchorId) - 1); } LOG_DBG("PM", "XPath ancestry(%s[%d])/text()[%d]+%d -> %.1f%% (target=%zu total=%zu p~%d li~%d anchor=%s)", xpathSteps[xpathStepCount - 1].tag, xpathSteps[xpathStepCount - 1].siblingIndex, xpathTextNode, xpathChar, s.progress() * 100, s.getTargetVisChars(), s.getTotalVisChars(), pAtMatch, result.hasLiIndex ? static_cast<int>(result.liIndex) : 0, anchorId ? anchorId : "none"); };
ParagraphStreamer strict(xpathSteps, xpathStepCount, xpathChar, xpathTextNode); if (streamSpine(epub, result.spineIndex, strict) && strict.found()) { applyResolvedXPath(strict); } else { // Some KOReader producers omit an unindexed wrapper from the ancestry // (the compatibility case covered by PR #2777). Retry only after the // structurally exact path fails, allowing the first step at any body depth. ParagraphStreamer relaxed(xpathSteps, xpathStepCount, xpathChar, xpathTextNode, true); if (streamSpine(epub, result.spineIndex, relaxed) && relaxed.found()) { applyResolvedXPath(relaxed); } } } else if (useBodyText) { ParagraphStreamer s(true, xpathChar, xpathTextNode); if (streamSpine(epub, result.spineIndex, s) && s.found()) { result.visibleTextOffset = static_cast<uint32_t>(std::min<size_t>(s.getTargetVisChars(), static_cast<size_t>(UINT32_MAX))); result.hasVisibleTextOffset = true; LOG_DBG("PM", "XPath body/text()[%d]+%d -> offset=%u", xpathTextNode, xpathChar, result.visibleTextOffset); } } else if (xpathP > 0) { ParagraphStreamer s(xpathP, xpathChar, xpathTextNode); if (streamSpine(epub, result.spineIndex, s) && s.found()) { result.visibleTextOffset = static_cast<uint32_t>(std::min<size_t>(s.getTargetVisChars(), static_cast<size_t>(UINT32_MAX))); result.hasVisibleTextOffset = true; LOG_DBG("PM", "XPath p[%d]/text()[%d]+%d -> %.1f%% (target=%zu total=%zu)", xpathP, xpathTextNode, xpathChar, s.progress() * 100, s.getTargetVisChars(), s.getTotalVisChars()); } } if (!result.hasVisibleTextOffset && xpathSpine >= 0 && xpathSpine < spineCount && isChapterStartXPath(koPos.xpath)) { // Only fall back to "chapter start" when no intra-chapter offset was resolved above -- // otherwise a resolved deep position (e.g. body/div[3]/text().0) would be clobbered to page 0. result.visibleTextOffset = 0; result.hasVisibleTextOffset = true; LOG_DBG("PM", "Chapter-start XPath %s -> spine=%d page start", koPos.xpath.c_str(), result.spineIndex); } if (result.hasVisibleTextOffset) { Section tempSection(epub, result.spineIndex, renderer); const bool imageAnchor = useAncestry && (strcasecmp(xpathSteps[xpathStepCount - 1].tag, "img") == 0 || strcasecmp(xpathSteps[xpathStepCount - 1].tag, "image") == 0); if (const auto offsetPage = tempSection.getPageForVisibleTextOffset(result.visibleTextOffset, imageAnchor)) { result.pageNumber = *offsetPage; result.totalPages = std::max(result.totalPages, result.pageNumber + 1); result.hasMappedPage = true; LOG_DBG("PM", "XPath content offset %u -> spine=%d page=%d/%d", result.visibleTextOffset, result.spineIndex, result.pageNumber, result.totalPages); return result; } // A valid content anchor without a local pagination LUT cannot yet be turned // into a page. Retain it on the result, but use protocol percentage for the // immediate page fallback. LOG_DBG("PM", "No page-offset LUT for spine=%d offset=%u; using percentage fallback", result.spineIndex, result.visibleTextOffset); } const size_t bytesIn = (targetBytes > prevCum) ? (targetBytes - prevCum) : 0; intra = spineSize > 0 ? std::max(0.0f, std::min(1.0f, static_cast<float>(bytesIn) / static_cast<float>(spineSize))) : 0.0f;
result.pageNumber = std::max( 0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1)); LOG_DBG("PM", "<- Progress: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber, result.totalPages);
// Refine page using section cache LUTs: li index, anchor, or paragraph index. if (result.hasLiIndex || result.xpathAnchorId[0] != '\0' || result.hasParagraphIndex) { Section tempSection(epub, result.spineIndex, renderer); bool refined = false; if (result.hasLiIndex) { const auto liPage = tempSection.getPageForListItemIndex(result.liIndex); if (liPage.has_value()) { LOG_DBG("PM", "Li index %u -> page %d (was %d)", result.liIndex, *liPage, result.pageNumber); result.pageNumber = *liPage; result.hasMappedPage = true; refined = true; } else { LOG_DBG("PM", "Li index %u not found in section LUT", result.liIndex); } } if (!refined && result.xpathAnchorId[0] != '\0') { const auto anchorPage = tempSection.getPageForAnchor(std::string(result.xpathAnchorId)); if (anchorPage.has_value()) { LOG_DBG("PM", "Anchor '%s' -> page %d (was %d)", result.xpathAnchorId, *anchorPage, result.pageNumber); result.pageNumber = *anchorPage; result.hasMappedPage = true; refined = true; } else { LOG_DBG("PM", "Anchor '%s' not found in section cache", result.xpathAnchorId); } } if (!refined && result.hasParagraphIndex) { const auto paragraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex); const auto nextParagraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex + 1); if (paragraphPage.has_value()) { int refinedPage = std::max(result.pageNumber, static_cast<int>(*paragraphPage)); if (nextParagraphPage.has_value()) { const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*paragraphPage); // Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too // coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph // occupies different pages than at build time). if (lutSpan > 1 && refinedPage >= static_cast<int>(*nextParagraphPage)) { refinedPage = static_cast<int>(*nextParagraphPage) - 1; } } char nextParaBuf[8]; if (nextParagraphPage.has_value()) snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage); else snprintf(nextParaBuf, sizeof(nextParaBuf), "none"); LOG_DBG("PM", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d", result.paragraphIndex, *paragraphPage, nextParaBuf, result.pageNumber, refinedPage); result.pageNumber = refinedPage; result.hasMappedPage = true; } else { LOG_DBG("PM", "Paragraph %u not found in section LUT", result.paragraphIndex); } } } return result;}
std::string ProgressMapper::generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intra) { const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; if (intra <= 0.0f) return base;
size_t spineSize = 0; const auto href = epub->getSpineItem(spineIndex).href; if (href.empty() || !epub->getItemSize(href, &spineSize) || spineSize == 0) return base;
ParagraphStreamer s(static_cast<size_t>(spineSize * std::min(intra, 1.0f))); if (!streamSpine(epub, spineIndex, s)) return base;
const int p = s.paragraphCount(); return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base;}