Picking up some itsthisjutin ideas

This commit is contained in:
jpirnay
2026-04-19 19:03:43 +02:00
parent 174a08831d
commit d6292920d9
18 changed files with 337 additions and 41 deletions
+59 -13
View File
@@ -12,7 +12,7 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 20;
constexpr uint8_t SECTION_FILE_VERSION = 21;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
sizeof(int) + // fontId
sizeof(float) + // lineCompression
@@ -309,12 +309,15 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, page);
}
// Write per-page paragraph index LUT for XPath-to-page resolution
// Write per-page paragraph LUT: count + array of {xhtmlByteOffset(u32), paragraphIndex(u16)}.
// The byte offset lets findXPathForParagraph seek near the target paragraph without scanning
// from the beginning of the XHTML file, reducing SD reads on large chapters.
const uint32_t paragraphLutOffset = file.position();
const auto& paragraphPerPage = visitor.getParagraphIndexPerPage();
serialization::writePod(file, static_cast<uint16_t>(paragraphPerPage.size()));
for (const uint16_t& pIdx : paragraphPerPage) {
serialization::writePod(file, pIdx);
const auto& paragraphLut = visitor.getParagraphLutPerPage();
serialization::writePod(file, static_cast<uint16_t>(paragraphLut.size()));
for (const auto& entry : paragraphLut) {
serialization::writePod(file, entry.xhtmlByteOffset);
serialization::writePod(file, entry.paragraphIndex);
}
// Patch header with final pageCount, lutOffset, anchorMapOffset, and paragraphLutOffset
@@ -573,18 +576,20 @@ std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex)
return std::nullopt;
}
// Validate that all entries fit within the file
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t);
// Each entry: uint32_t xhtmlByteOffset + uint16_t paragraphIndex
constexpr uint32_t ENTRY_SIZE = sizeof(uint32_t) + sizeof(uint16_t);
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * ENTRY_SIZE;
if (lutEnd > fileSize) {
f.close();
return std::nullopt;
}
// Find the first page whose paragraph index >= pIndex.
// Each entry stores the <p> index at the time that page was completed.
uint16_t resultPage = count - 1; // default to last page
for (uint16_t i = 0; i < count; i++) {
uint32_t byteOffset;
uint16_t pagePIdx;
serialization::readPod(f, byteOffset);
serialization::readPod(f, pagePIdx);
if (pagePIdx >= pIndex) {
resultPage = i;
@@ -620,18 +625,59 @@ std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) c
return std::nullopt;
}
// Validate that the target entry fits within the file
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t);
// Each entry: uint32_t xhtmlByteOffset + uint16_t paragraphIndex
constexpr uint32_t ENTRY_SIZE = sizeof(uint32_t) + sizeof(uint16_t);
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * ENTRY_SIZE;
if (entryEnd > fileSize) {
f.close();
return std::nullopt;
}
// Seek to the entry for the requested page
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t));
// Seek directly to the paragraphIndex field of the requested entry (skip xhtmlByteOffset)
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * ENTRY_SIZE + sizeof(uint32_t));
uint16_t pIdx;
serialization::readPod(f, pIdx);
f.close();
return pIdx;
}
std::optional<uint32_t> Section::getXhtmlByteOffsetForPage(const uint16_t page) 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 paragraphLutOffset;
serialization::readPod(f, paragraphLutOffset);
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
f.close();
return std::nullopt;
}
f.seek(paragraphLutOffset);
uint16_t count;
serialization::readPod(f, count);
if (count == 0 || page >= count) {
f.close();
return std::nullopt;
}
constexpr uint32_t ENTRY_SIZE = sizeof(uint32_t) + sizeof(uint16_t);
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * ENTRY_SIZE;
if (entryEnd > fileSize) {
f.close();
return std::nullopt;
}
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * ENTRY_SIZE);
uint32_t byteOffset;
serialization::readPod(f, byteOffset);
f.close();
// A zero offset means the entry was recorded post-parse (last page), so it's unusable as a hint.
return byteOffset > 0 ? std::optional<uint32_t>{byteOffset} : std::nullopt;
}
+8 -1
View File
@@ -68,7 +68,7 @@ class Section {
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Look up the page number for a paragraph index (1-based, from XPath p[N]).
// Uses the per-page paragraph index LUT stored in the section cache.
// Uses the per-page paragraph LUT stored in the section cache.
// Returns nullopt if the paragraph LUT is not available (old cache format).
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
@@ -76,4 +76,11 @@ class Section {
// Returns the 1-based paragraph index of the last <p> element on or before the page.
// Returns nullopt if the paragraph LUT is not available (old cache format).
std::optional<uint16_t> getParagraphIndexForPage(uint16_t page) const;
// Look up the XHTML byte offset recorded at the page break that started the given page.
// This is the Expat byte position within the decompressed spine XHTML file — useful as a
// seek hint for findXPathForParagraph to avoid scanning from byte 0 on large chapters.
// Returns nullopt if the paragraph LUT is unavailable (old cache format) or offset is 0
// (last page, recorded after parse completion).
std::optional<uint32_t> getXhtmlByteOffsetForPage(uint16_t page) const;
};
@@ -593,7 +593,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
(self->currentPageNextY + totalImageHeightWithSpacing > self->viewportHeight)) {
LOG_DBG("EHP", "Image page break: currentY=%d needed=%d viewportH=%d", self->currentPageNextY,
totalImageHeightWithSpacing, self->viewportHeight);
self->paragraphIndexPerPage.push_back(self->xpathParagraphIndex);
const uint32_t byteOff =
self->activeParser ? static_cast<uint32_t>(XML_GetCurrentByteIndex(self->activeParser)) : 0;
self->paragraphLutPerPage.push_back({byteOff, self->xpathParagraphIndex});
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
@@ -1344,6 +1346,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
activeParser = parser;
// Compute the time taken to parse and build pages
const uint32_t chapterStartTime = millis();
@@ -1354,6 +1357,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
activeParser = nullptr;
XML_ParserFree(parser);
file.close();
return false;
@@ -1376,6 +1380,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
activeParser = nullptr;
XML_ParserFree(parser);
file.close();
return false;
@@ -1389,6 +1394,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
activeParser = nullptr;
XML_ParserFree(parser);
file.close();
return false;
@@ -1400,6 +1406,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
activeParser = nullptr;
XML_ParserFree(parser);
file.close();
@@ -1410,7 +1417,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
paragraphIndexPerPage.push_back(xpathParagraphIndex);
paragraphLutPerPage.push_back({0u, xpathParagraphIndex}); // post-parse: no byte offset available
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
@@ -1431,7 +1438,8 @@ ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_p
}
if (currentPageNextY + lineHeight > viewportHeight) {
paragraphIndexPerPage.push_back(xpathParagraphIndex);
const uint32_t byteOff = activeParser ? static_cast<uint32_t>(XML_GetCurrentByteIndex(activeParser)) : 0;
paragraphLutPerPage.push_back({byteOff, xpathParagraphIndex});
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
+14 -4
View File
@@ -90,9 +90,19 @@ class ChapterHtmlSlimParser {
// Counts <p> sibling indices (1-based, matching XPath convention) during page building.
// Stored per page in the section cache so that XPath p[N] can be resolved to a page
// without reparsing, and current page can generate an XPath without reparsing.
uint16_t xpathParagraphIndex = 0; // current <p> sibling index (1-based)
int xpathBodyDepth = -1; // depth of the <body> element (-1 = not yet seen)
std::vector<uint16_t> paragraphIndexPerPage; // <p> index at each page completion
uint16_t xpathParagraphIndex = 0; // current <p> sibling index (1-based)
int xpathBodyDepth = -1; // depth of the <body> element (-1 = not yet seen)
struct ParagraphLutEntry {
uint32_t xhtmlByteOffset; // Expat byte offset at page break — used to seek near target paragraph
uint16_t paragraphIndex; // 1-based <p> index at page completion
};
std::vector<ParagraphLutEntry> paragraphLutPerPage; // deep LUT: one entry per page
// Active parser handle during parseAndBuildPages(), nullptr otherwise.
// Stored as a member so page-break sites (addLineToPage, image breaks) can call
// XML_GetCurrentByteIndex without needing the parser threaded through every call.
XML_Parser activeParser = nullptr;
// Footnote link tracking
bool insideFootnoteLink = false;
@@ -154,5 +164,5 @@ class ChapterHtmlSlimParser {
ParsedText::LineProcessResult addLineToPage(std::shared_ptr<TextBlock> line, bool lineEndsWithHyphenatedWord,
bool suppressHyphenationRetry);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
const std::vector<uint16_t>& getParagraphIndexPerPage() const { return paragraphIndexPerPage; }
const std::vector<ParagraphLutEntry>& getParagraphLutPerPage() const { return paragraphLutPerPage; }
};
@@ -124,6 +124,124 @@ size_t getTotalTextBytesCached(const std::shared_ptr<Epub>& epub, const int spin
} // namespace
// Paragraph-targeted forward mapper.
// Counts direct-body-child <p> elements (matching ChapterHtmlSlimParser's xpathBodyDepth guard)
// and stops at the Nth one, emitting its full-ancestry XPath. The seek hint avoids scanning
// from byte 0 when the section LUT has a byte offset for a nearby page break.
namespace {
struct ParagraphState : StackState {
int spineIndex;
uint16_t targetParagraph; // 1-based
uint16_t paragraphCount = 0;
std::string result;
XML_Parser parser = nullptr;
// When parsing from a seek offset, the DOM context (html/body ancestors) is missing from
// the parser's perspective. partialParse=true relaxes the bodyIdx() check and instead
// counts any <p> at depth 0 relative to the first element seen (a heuristic that works
// because we know we're already inside <body> in the source document).
bool partialParse = false;
int partialBaseDepth = -1; // stack depth when the first element is seen in partial mode
ParagraphState(const int spineIndex, const uint16_t targetParagraph, const uint16_t startParagraphCount,
const bool partialParse)
: spineIndex(spineIndex),
targetParagraph(targetParagraph),
paragraphCount(startParagraphCount),
partialParse(partialParse) {}
};
void XMLCALL paragraphStartCb(void* ud, const XML_Char* rawName, const XML_Char**) {
auto* s = static_cast<ParagraphState*>(ud);
s->pushElement(rawName);
if (!s->result.empty() || s->stack.empty() || s->stack.back().tag != "p") {
return;
}
bool isDirectBodyChild = false;
if (s->partialParse) {
// In partial mode the DOM context (html/body ancestors) is absent from the parser.
// We record the stack depth of the first element encountered as the body-equivalent
// depth; direct body children are one level deeper. This only works for flat EPUBs
// where paragraphs are direct children of <body> — for wrapped chapters the partial
// parse will find nothing and the caller retries from byte 0 with full context.
if (s->partialBaseDepth < 0) {
s->partialBaseDepth = static_cast<int>(s->stack.size()) - 1;
}
isDirectBodyChild = (static_cast<int>(s->stack.size()) - 1 == s->partialBaseDepth);
} else {
const int bi = s->bodyIdx();
isDirectBodyChild = (bi >= 0 && static_cast<int>(s->stack.size()) == bi + 2);
}
if (isDirectBodyChild) {
s->paragraphCount++;
if (s->paragraphCount >= s->targetParagraph) {
s->result = s->currentXPath(s->spineIndex);
if (s->parser) {
XML_StopParser(s->parser, XML_FALSE);
}
}
}
}
void XMLCALL paragraphEndCb(void* ud, const XML_Char*) { static_cast<ParagraphState*>(ud)->popElement(); }
} // namespace
std::string findXPathForParagraphInternal(const std::shared_ptr<Epub>& epub, const int spineIndex,
const uint16_t paragraphIndex, const uint32_t seekHint,
const uint16_t startParagraphCount) {
if (!epub || paragraphIndex == 0) {
return "";
}
const std::string tmpPath = decompressToTempFile(epub, spineIndex);
if (tmpPath.empty()) {
return "";
}
const bool partialParse = seekHint > 0;
ParagraphState state(spineIndex, paragraphIndex, partialParse ? startParagraphCount : 0, partialParse);
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
Storage.remove(tmpPath.c_str());
return "";
}
state.parser = parser;
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, paragraphStartCb, paragraphEndCb);
// No character data handler needed — we only care about element structure.
XML_SetDefaultHandlerExpand(parser, parserDefaultCb<ParagraphState>);
// Use seek hint from section LUT if available — avoids scanning the whole chapter.
// If the partial parse misses the target (e.g. the hint overshot), retry from byte 0.
runParseFromOffset(parser, tmpPath, seekHint);
if (state.result.empty() && seekHint > 0) {
// Partial parse missed — reset and retry from beginning with full-document context.
XML_ParserFree(parser);
parser = XML_ParserCreate(nullptr);
if (parser) {
ParagraphState fullState(spineIndex, paragraphIndex, 0, false);
fullState.parser = parser;
XML_SetUserData(parser, &fullState);
XML_SetElementHandler(parser, paragraphStartCb, paragraphEndCb);
XML_SetDefaultHandlerExpand(parser, parserDefaultCb<ParagraphState>);
runParse(parser, tmpPath);
state.result = fullState.result;
}
}
XML_ParserFree(parser);
Storage.remove(tmpPath.c_str());
LOG_DBG("KOX", "Paragraph: spine=%d p[%u] seekHint=%u -> %s", spineIndex, paragraphIndex, seekHint,
state.result.empty() ? "(not found)" : state.result.c_str());
return state.result;
}
std::string findXPathForProgressInternal(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
const std::string tmpPath = decompressToTempFile(epub, spineIndex);
@@ -9,4 +9,13 @@ namespace ChapterXPathIndexerInternal {
std::string findXPathForProgressInternal(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
// Find the full-ancestry XPath for the paragraphIndex-th direct-body-child <p> element.
// paragraphIndex is 1-based, matching the section paragraph LUT and KOReader XPath convention.
// seekHint is an optional XHTML byte offset to start scanning from (0 = scan from beginning).
// startParagraphCount is the number of body-child <p> elements already seen before seekHint
// (i.e. the paragraphIndex of the LUT entry at the seek page, minus 1). Ignored when seekHint=0.
// Returns empty string on failure; caller should fall back to findXPathForProgressInternal.
std::string findXPathForParagraphInternal(const std::shared_ptr<Epub>& epub, int spineIndex, uint16_t paragraphIndex,
uint32_t seekHint = 0, uint16_t startParagraphCount = 0);
} // namespace ChapterXPathIndexerInternal
+12 -3
View File
@@ -21,6 +21,12 @@ std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr<Epub
return findXPathForProgressInternal(epub, spineIndex, intraSpineProgress);
}
std::string ChapterXPathIndexer::findXPathForParagraph(const std::shared_ptr<Epub>& epub, const int spineIndex,
const uint16_t paragraphIndex, const uint32_t seekHint,
const uint16_t startParagraphCount) {
return findXPathForParagraphInternal(epub, spineIndex, paragraphIndex, seekHint, startParagraphCount);
}
bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& epub, const int spineIndex,
const std::string& xpath, float& outIntraSpineProgress,
bool& outExactMatch) {
@@ -83,9 +89,12 @@ bool ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(const std::string& x
return false;
}
// Only accept p[...] that is a direct child of the /body segment — reject
// paths with intermediate ancestor segments (e.g. /body/.../div[4]/p[1])
// which would collapse structurally different locations to the same index.
// Only accept p[...] that is a direct child of the /body segment.
// The section paragraph LUT counts only direct-body-child <p> elements (matching
// KOReader's crengine pure-XML counting). A nested path like /body/div[2]/p[4]
// cannot be mapped to our flat LUT index — the p[4] there is the 4th sibling inside
// div[2], not the 4th <p> child of <body>. Deeply-nested XPaths fall through to
// ChapterXPathIndexer::findProgressForXPath which handles full-ancestry matching.
const size_t bodyEnd = (secondBody != std::string::npos ? secondBody : 0) + bodyKey.size();
if (pos != bodyEnd) {
return false;
+16
View File
@@ -65,6 +65,22 @@ class ChapterXPathIndexer {
*/
static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex);
/**
* Find the full-ancestry XPath for the Nth direct-body-child <p> element.
*
* Counts only <p> elements that are direct children of <body>, matching the semantics
* of the section paragraph LUT built by ChapterHtmlSlimParser.
*
* @param epub Loaded EPUB instance
* @param spineIndex Spine item index to parse
* @param paragraphIndex 1-based paragraph index (from section LUT or XPath p[N])
* @param seekHint Optional XHTML byte offset to start scanning from (0 = from beginning).
* Pass Section::getXhtmlByteOffsetForPage() to avoid scanning the whole file.
* @return Full-ancestry XPath like "/body/DocFragment[N]/body/div[1]/p[3]", or empty on failure
*/
static std::string findXPathForParagraph(const std::shared_ptr<Epub>& epub, int spineIndex, uint16_t paragraphIndex,
uint32_t seekHint = 0, uint16_t startParagraphCount = 0);
/**
* Extract the paragraph index from a KOReader XPath.
* Looks for the first /p[N] segment after /body/ and returns N (1-based).
@@ -272,6 +272,42 @@ bool runParse(XML_Parser parser, const std::string& path) {
return ok;
}
bool runParseFromOffset(XML_Parser parser, const std::string& path, const uint32_t seekBytes) {
if (seekBytes == 0) {
return runParse(parser, path);
}
FsFile file;
if (!Storage.openFileForRead("KOX", path, file)) {
return false;
}
if (!file.seek(seekBytes)) {
file.close();
return runParse(parser, path); // fall back to full scan if seek fails
}
constexpr size_t kBufSize = 1024;
bool ok = true;
int done;
do {
void* const buf = XML_GetBuffer(parser, kBufSize);
if (!buf) {
ok = false;
break;
}
const size_t len = file.read(buf, kBufSize);
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
ok = (XML_GetErrorCode(parser) == XML_ERROR_ABORTED);
break;
}
} while (!done);
file.close();
return ok;
}
bool isEntityRef(const XML_Char* text, const int len) {
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
return false;
@@ -25,6 +25,10 @@ bool isAncestorPath(const std::string& prefix, const std::string& path);
std::string decompressToTempFile(const std::shared_ptr<Epub>& epub, int spineIndex);
bool runParse(XML_Parser parser, const std::string& path);
// Like runParse but skips the first seekBytes bytes before feeding data to the parser.
// Valid only when the parser is freshly created and the seek position is known to be on an XML
// boundary (e.g. the Expat byte offset recorded at a page break).
bool runParseFromOffset(XML_Parser parser, const std::string& path, uint32_t seekBytes);
bool isEntityRef(const XML_Char* text, int len);
size_t countTotalTextBytes(const std::string& tmpPath);
+11 -4
View File
@@ -58,10 +58,17 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath for the current position.
// Always use the indexer which SAX-parses the actual XHTML to find the correct
// element path — a naive "/body/DocFragment[N]/body/p[M]" would assume paragraphs
// are direct children of <body>, which breaks for wrapped chapters (e.g. div/section).
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
// When we have a paragraph index from the section LUT, target that specific <p> element
// directly — this produces a structurally precise full-ancestry path even for chapters
// where paragraphs are nested inside divs/sections. Fall back to the progress-based
// scan (which works for any content) when no paragraph index is available.
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
result.xpath =
ChapterXPathIndexer::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex, pos.xhtmlSeekHint);
}
if (result.xpath.empty()) {
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
}
if (result.xpath.empty()) {
result.xpath = generateXPath(pos.spineIndex);
}
+3 -2
View File
@@ -11,8 +11,9 @@ struct CrossPointPosition {
int spineIndex; // Current spine item (chapter) index
int pageNumber; // Current page within the spine item (estimated if no paragraph LUT)
int totalPages; // Total pages in the current spine item
uint16_t paragraphIndex = 0; // 1-based <p> index from XPath (0 if unavailable)
bool hasParagraphIndex = false; // True when paragraphIndex was resolved from XPath
uint16_t paragraphIndex = 0; // 1-based <p> index (0 if unavailable)
bool hasParagraphIndex = false; // True when paragraphIndex is valid
uint32_t xhtmlSeekHint = 0; // Byte offset hint for findXPathForParagraph (0 = no hint)
};
/**