Add KOReader XPath sync support for EPUB navigation
Implements bidirectional conversion between KOReader XPath positions and FreeInkBook character offsets. Uses streaming SAX parsing to match chapter text accounting with ChapterLayout, enabling position sync without requiring DOM or pagination. Handles element ancestry tracking, text offset calculation, and spine index extraction.
This commit is contained in:
@@ -310,6 +310,7 @@ STR_PROGRESS_FOUND: "Progress found!"
|
||||
STR_REMOTE_LABEL: "Remote:"
|
||||
STR_LOCAL_LABEL: "Local:"
|
||||
STR_PAGE_OVERALL_FORMAT: "Page %d, %.2f%% overall"
|
||||
STR_PERCENT_OVERALL_FORMAT: " %.2f%% overall"
|
||||
STR_PAGE_TOTAL_OVERALL_FORMAT: "Page %d/%d, %.2f%% overall"
|
||||
STR_DEVICE_FROM_FORMAT: " From: %s"
|
||||
STR_APPLY_REMOTE: "Apply remote progress"
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#include "BookXPath.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <epub/XmlSax.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
using freeink::book::Arena;
|
||||
using freeink::book::BookStatus;
|
||||
using freeink::book::XmlHandler;
|
||||
using freeink::book::XmlSax;
|
||||
|
||||
// XmlSax parse working set: inflate window + parse chunk (+ expat's own
|
||||
// bounded heap). One transient allocation per mapping call.
|
||||
constexpr size_t kParseScratchSize = 64 * 1024;
|
||||
|
||||
constexpr int kMaxDepth = 24; // element ancestry below <body>
|
||||
constexpr int kMaxSiblingTags = 24; // distinct child tag names tracked per level
|
||||
constexpr int kMaxSteps = 16; // parsed xpath ancestry steps
|
||||
|
||||
const char* localName(const char* qname) {
|
||||
const char* colon = strrchr(qname, ':');
|
||||
return colon != nullptr ? colon + 1 : qname;
|
||||
}
|
||||
|
||||
bool isSuppressedElement(const char* local) {
|
||||
return strcmp(local, "head") == 0 || strcmp(local, "style") == 0 || strcmp(local, "script") == 0 ||
|
||||
strcmp(local, "title") == 0;
|
||||
}
|
||||
|
||||
// ChapterLayout's block list — these flush the paragraph accumulator.
|
||||
bool isBlockElement(const char* local) {
|
||||
static const char* kBlocks[] = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
|
||||
"li", "div", "section", "article", "figure", "aside", "figcaption", "ul",
|
||||
"ol", "table", "tr", "td", "th", "dt", "dd"};
|
||||
for (const char* b : kBlocks) {
|
||||
if (strcmp(local, b) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t fnvHash(const char* s) {
|
||||
uint32_t hash = 2166136261u;
|
||||
while (*s != '\0') {
|
||||
hash ^= static_cast<uint8_t>(*s++);
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
struct AncestryLevel {
|
||||
char tag[16];
|
||||
int siblingIndex; // 1-based, among same-tag siblings
|
||||
// Child tag occurrence counts (for the level BELOW this one).
|
||||
uint32_t childTagHashes[kMaxSiblingTags];
|
||||
int childTagCounts[kMaxSiblingTags];
|
||||
int childTagKinds;
|
||||
};
|
||||
|
||||
struct XPathStep {
|
||||
char tag[16];
|
||||
int siblingIndex;
|
||||
};
|
||||
|
||||
// Parses the element steps between "]/body/" and the terminal "/text()..."
|
||||
// or ".NN" of a KOReader xpath. Returns the step count, 0 on failure.
|
||||
int parseXPathSteps(const std::string& xpath, XPathStep* steps, int* charOffsetOut) {
|
||||
*charOffsetOut = 0;
|
||||
static const char kFrag[] = "/body/DocFragment[";
|
||||
const size_t fragPos = xpath.find(kFrag);
|
||||
if (fragPos == std::string::npos) return 0;
|
||||
const size_t closeBracket = xpath.find(']', fragPos + strlen(kFrag));
|
||||
if (closeBracket == std::string::npos) return 0;
|
||||
static const char kBody[] = "/body/";
|
||||
if (xpath.compare(closeBracket + 1, strlen(kBody), kBody) != 0) return 0;
|
||||
size_t pos = closeBracket + 1 + strlen(kBody);
|
||||
|
||||
// Terminal: "/text()[K].N" or "/text().N" or ".N" directly on the element.
|
||||
size_t stepsEnd = xpath.rfind("/text()");
|
||||
if (stepsEnd == std::string::npos || stepsEnd < pos) {
|
||||
const size_t dot = xpath.rfind('.');
|
||||
stepsEnd = (dot != std::string::npos && dot > pos) ? dot : xpath.size();
|
||||
}
|
||||
const size_t dot = xpath.rfind('.');
|
||||
if (dot != std::string::npos && dot + 1 < xpath.size()) {
|
||||
int val = 0;
|
||||
bool numeric = true;
|
||||
for (size_t i = dot + 1; i < xpath.size(); ++i) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') {
|
||||
numeric = false;
|
||||
break;
|
||||
}
|
||||
val = val * 10 + (xpath[i] - '0');
|
||||
}
|
||||
if (numeric) *charOffsetOut = val;
|
||||
}
|
||||
if (stepsEnd <= pos) return 0;
|
||||
|
||||
int count = 0;
|
||||
while (pos < stepsEnd && count < kMaxSteps) {
|
||||
const size_t slash = xpath.find('/', pos);
|
||||
const size_t segEnd = (slash != std::string::npos && 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';
|
||||
step.siblingIndex = 1;
|
||||
if (bracket != std::string::npos && bracket < segEnd) {
|
||||
const size_t close = xpath.find(']', bracket + 1);
|
||||
if (close == std::string::npos || close > segEnd) return 0;
|
||||
int idx = 0;
|
||||
for (size_t i = bracket + 1; i < close; ++i) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 0;
|
||||
idx = idx * 10 + (xpath[i] - '0');
|
||||
}
|
||||
step.siblingIndex = idx > 0 ? idx : 1;
|
||||
}
|
||||
++count;
|
||||
pos = (slash != std::string::npos && slash < stepsEnd) ? slash + 1 : stepsEnd;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Streams the chapter, replicating ChapterLayout's extracted-text accounting
|
||||
// (which defines charStart) while tracking DOM element ancestry with per-tag
|
||||
// sibling indices. Runs in one of two modes:
|
||||
// Locate: find the ancestry containing character offset `target`
|
||||
// Resolve: find the character offset where a given ancestry begins
|
||||
class XPathScanner final : public XmlHandler {
|
||||
public:
|
||||
enum class Mode { Locate, Resolve };
|
||||
|
||||
XPathScanner(const Mode mode, const uint32_t targetChar, const XPathStep* steps, const int stepCount)
|
||||
: mode_(mode), targetChar_(targetChar), steps_(steps), stepCount_(stepCount) {
|
||||
memset(&root_, 0, sizeof(root_));
|
||||
}
|
||||
|
||||
void onStartElement(const char* name, const char** /*atts*/) override {
|
||||
const char* local = localName(name);
|
||||
if (isSuppressedElement(local)) {
|
||||
++suppress_;
|
||||
return;
|
||||
}
|
||||
if (strcmp(local, "body") == 0) {
|
||||
inBody_ = true;
|
||||
return;
|
||||
}
|
||||
if (!inBody_) return;
|
||||
|
||||
// Ancestry: sibling index among same-tag children of the current parent.
|
||||
if (depth_ < kMaxDepth) {
|
||||
AncestryLevel& parent = depth_ == 0 ? root_ : stack_[depth_ - 1];
|
||||
AncestryLevel& self = stack_[depth_];
|
||||
snprintf(self.tag, sizeof(self.tag), "%s", local);
|
||||
self.siblingIndex = bumpChildCount(parent, local);
|
||||
self.childTagKinds = 0;
|
||||
}
|
||||
++depth_;
|
||||
|
||||
if (suppress_ > 0) return;
|
||||
if (isBlockElement(local)) {
|
||||
flushParagraph();
|
||||
} else if (strcmp(local, "br") == 0) {
|
||||
++parChars_; // appendRaw('\n') — bypasses whitespace collapse
|
||||
checkLocate();
|
||||
} else if (strcmp(local, "hr") == 0 || strcmp(local, "img") == 0 || strcmp(local, "image") == 0) {
|
||||
flushParagraph();
|
||||
}
|
||||
|
||||
if (mode_ == Mode::Resolve && !resolved_ && depth_ <= kMaxDepth && depth_ == stepCount_ && ancestryMatchesSteps()) {
|
||||
// Element start + the xpath's text offset. A pending collapsed space
|
||||
// materializes before this element's first character, so it counts.
|
||||
resolvedChar_ = charBase_ + parChars_ + (pendingSpace_ ? 1u : 0u) + targetChar_;
|
||||
resolved_ = true;
|
||||
stopParse = true;
|
||||
}
|
||||
}
|
||||
|
||||
void onEndElement(const char* name) override {
|
||||
const char* local = localName(name);
|
||||
if (isSuppressedElement(local)) {
|
||||
if (suppress_ > 0) --suppress_;
|
||||
return;
|
||||
}
|
||||
if (strcmp(local, "body") == 0) {
|
||||
flushParagraph();
|
||||
inBody_ = false;
|
||||
return;
|
||||
}
|
||||
if (!inBody_) return;
|
||||
if (depth_ > 0) --depth_;
|
||||
if (suppress_ == 0 && isBlockElement(local)) flushParagraph();
|
||||
}
|
||||
|
||||
void onText(const char* text, const int len) override {
|
||||
if (!inBody_ || suppress_ > 0 || stopParse) return;
|
||||
for (int i = 0; i < len; ++i) {
|
||||
const char c = text[i];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
pendingSpace_ = parChars_ > 0;
|
||||
} else {
|
||||
if (pendingSpace_) {
|
||||
++parChars_; // the collapsed space materializes
|
||||
pendingSpace_ = false;
|
||||
checkLocate();
|
||||
if (stopParse) return;
|
||||
}
|
||||
if ((static_cast<uint8_t>(c) & 0xC0) != 0x80) {
|
||||
++parChars_; // one codepoint (lead or ASCII byte)
|
||||
checkLocate();
|
||||
if (stopParse) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool located() const { return located_; }
|
||||
bool resolved() const { return resolved_; }
|
||||
uint32_t resolvedChar() const { return resolvedChar_; }
|
||||
uint32_t locatedOffset() const { return locatedOffset_; }
|
||||
int locatedDepth() const { return locatedDepth_; }
|
||||
const AncestryLevel* locatedStack() const { return locatedStack_; }
|
||||
|
||||
private:
|
||||
int bumpChildCount(AncestryLevel& parent, const char* tag) {
|
||||
const uint32_t hash = fnvHash(tag);
|
||||
for (int i = 0; i < parent.childTagKinds; ++i) {
|
||||
if (parent.childTagHashes[i] == hash) return ++parent.childTagCounts[i];
|
||||
}
|
||||
if (parent.childTagKinds < kMaxSiblingTags) {
|
||||
parent.childTagHashes[parent.childTagKinds] = hash;
|
||||
parent.childTagCounts[parent.childTagKinds] = 1;
|
||||
++parent.childTagKinds;
|
||||
return 1;
|
||||
}
|
||||
return 1; // tag-table overflow: index degrades to 1 (rare, deep soup)
|
||||
}
|
||||
|
||||
void flushParagraph() {
|
||||
charBase_ += parChars_;
|
||||
parChars_ = 0;
|
||||
pendingSpace_ = false;
|
||||
}
|
||||
|
||||
// Locate mode: the character at index `targetChar_` was just appended —
|
||||
// capture the current ancestry and the offset within this paragraph.
|
||||
void checkLocate() {
|
||||
if (mode_ != Mode::Locate || located_) return;
|
||||
if (charBase_ + parChars_ > targetChar_) {
|
||||
locatedDepth_ = depth_ <= kMaxDepth ? depth_ : kMaxDepth;
|
||||
memcpy(locatedStack_, stack_, sizeof(AncestryLevel) * locatedDepth_);
|
||||
locatedOffset_ = targetChar_ >= charBase_ ? targetChar_ - charBase_ : 0;
|
||||
located_ = true;
|
||||
stopParse = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool ancestryMatchesSteps() const {
|
||||
for (int i = 0; i < stepCount_; ++i) {
|
||||
if (strcmp(stack_[i].tag, steps_[i].tag) != 0 || stack_[i].siblingIndex != steps_[i].siblingIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const Mode mode_;
|
||||
const uint32_t targetChar_;
|
||||
const XPathStep* steps_;
|
||||
const int stepCount_;
|
||||
|
||||
AncestryLevel root_;
|
||||
AncestryLevel stack_[kMaxDepth];
|
||||
int depth_ = 0;
|
||||
int suppress_ = 0;
|
||||
bool inBody_ = false;
|
||||
|
||||
uint32_t charBase_ = 0;
|
||||
uint32_t parChars_ = 0;
|
||||
bool pendingSpace_ = false;
|
||||
|
||||
bool located_ = false;
|
||||
uint32_t locatedOffset_ = 0;
|
||||
int locatedDepth_ = 0;
|
||||
AncestryLevel locatedStack_[kMaxDepth];
|
||||
|
||||
bool resolved_ = false;
|
||||
uint32_t resolvedChar_ = 0;
|
||||
};
|
||||
|
||||
bool runScan(freeink::book::BookSource& source, const freeink::book::ZipEntry& entry, XPathScanner& scanner) {
|
||||
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kParseScratchSize);
|
||||
if (!scratchBuf) {
|
||||
LOG_ERR("KOXP", "OOM: xpath scan scratch (%u B)", static_cast<unsigned>(kParseScratchSize));
|
||||
return false;
|
||||
}
|
||||
Arena scratch(scratchBuf.get(), kParseScratchSize);
|
||||
const BookStatus st = XmlSax::parseEntry(source, entry, scratch, scanner, /*filterHtmlEntities=*/true);
|
||||
return st == BookStatus::Ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BookXPath {
|
||||
|
||||
std::string xpathForCharStart(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/,
|
||||
const freeink::book::ZipEntry& entry, const int spineIndex, const uint32_t charStart) {
|
||||
const std::string chapterOnly = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]";
|
||||
|
||||
XPathScanner scanner(XPathScanner::Mode::Locate, charStart, nullptr, 0);
|
||||
if (!runScan(source, entry, scanner) || !scanner.located()) {
|
||||
return chapterOnly;
|
||||
}
|
||||
|
||||
std::string xpath = chapterOnly + "/body";
|
||||
const AncestryLevel* stack = scanner.locatedStack();
|
||||
for (int i = 0; i < scanner.locatedDepth(); ++i) {
|
||||
xpath += "/";
|
||||
xpath += stack[i].tag;
|
||||
xpath += "[" + std::to_string(stack[i].siblingIndex) + "]";
|
||||
}
|
||||
xpath += "/text()." + std::to_string(scanner.locatedOffset());
|
||||
return xpath;
|
||||
}
|
||||
|
||||
bool charStartForXpath(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/,
|
||||
const freeink::book::ZipEntry& entry, const std::string& xpath, uint32_t* charStartOut) {
|
||||
*charStartOut = 0;
|
||||
XPathStep steps[kMaxSteps];
|
||||
int charOffset = 0;
|
||||
const int stepCount = parseXPathSteps(xpath, steps, &charOffset);
|
||||
if (stepCount == 0) {
|
||||
// "/body/DocFragment[N]" with no element steps = chapter start.
|
||||
return spineIndexForXpath(xpath) >= 0;
|
||||
}
|
||||
|
||||
XPathScanner scanner(XPathScanner::Mode::Resolve, static_cast<uint32_t>(charOffset), steps, stepCount);
|
||||
if (!runScan(source, entry, scanner) || !scanner.resolved()) {
|
||||
return false;
|
||||
}
|
||||
*charStartOut = scanner.resolvedChar();
|
||||
return true;
|
||||
}
|
||||
|
||||
int spineIndexForXpath(const std::string& xpath) {
|
||||
static const char kFrag[] = "/body/DocFragment[";
|
||||
const size_t pos = xpath.find(kFrag);
|
||||
if (pos == std::string::npos) return -1;
|
||||
int val = 0;
|
||||
bool any = false;
|
||||
for (size_t i = pos + strlen(kFrag); i < xpath.size() && xpath[i] != ']'; ++i) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return -1;
|
||||
val = val * 10 + (xpath[i] - '0');
|
||||
any = true;
|
||||
}
|
||||
return any && val > 0 ? val - 1 : -1;
|
||||
}
|
||||
|
||||
} // namespace BookXPath
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
// KOReader xpath <-> FreeInkBook character offset, via one streaming SAX pass
|
||||
// over the chapter's XHTML (no DOM, no pagination — chapter character offsets
|
||||
// are layout-independent, so neither direction needs the page cache).
|
||||
//
|
||||
// The text accounting mirrors ChapterLayout exactly (suppressed head/style/
|
||||
// script/title, body gating, whitespace collapse, <br> newlines, block
|
||||
// flushes), so the offsets these functions produce address the same
|
||||
// characters the engine's page records anchor on. Known approximation:
|
||||
// display:none content is counted here but skipped by layout when the book's
|
||||
// CSS hides it — a paragraph-level drift on such books, corrected by the
|
||||
// percentage fallback.
|
||||
|
||||
#include <FreeInkBook.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace BookXPath {
|
||||
|
||||
// Real-ancestry KOReader xpath for a chapter character offset:
|
||||
// "/body/DocFragment[N]/body/div[2]/p[4]/text().17". Falls back to the
|
||||
// chapter-level "/body/DocFragment[N]" when the offset lies past the text or
|
||||
// the parse fails.
|
||||
std::string xpathForCharStart(freeink::book::BookSource& source, const freeink::book::ZipCatalog& zip,
|
||||
const freeink::book::ZipEntry& entry, int spineIndex, uint32_t charStart);
|
||||
|
||||
// Resolves a KOReader xpath's element ancestry to the chapter character
|
||||
// offset where that element's text begins (plus the xpath's text offset).
|
||||
// Returns false when the ancestry cannot be matched — caller falls back to
|
||||
// the synced percentage.
|
||||
bool charStartForXpath(freeink::book::BookSource& source, const freeink::book::ZipCatalog& zip,
|
||||
const freeink::book::ZipEntry& entry, const std::string& xpath, uint32_t* charStartOut);
|
||||
|
||||
// The 0-based spine index from "/body/DocFragment[N]/...", or -1.
|
||||
int spineIndexForXpath(const std::string& xpath);
|
||||
|
||||
} // namespace BookXPath
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// A KOReader-protocol reading position: an XPath-like locator into the book's
|
||||
// DOM plus a whole-book percentage. The percentage is the robust cross-device
|
||||
// mechanism; the xpath adds paragraph precision for readers that honor it.
|
||||
struct SavedProgressPosition {
|
||||
std::string xpath; // e.g. "/body/DocFragment[8]/body/div[1]/p[42]/text().17"
|
||||
float percentage; // 0.0 to 1.0 across the whole book
|
||||
};
|
||||
@@ -19,13 +19,7 @@ struct CrossPointPosition {
|
||||
char xpathAnchorId[64] = {}; // First <a id> captured inside the matched XPath element
|
||||
};
|
||||
|
||||
/**
|
||||
* Progress position representation.
|
||||
*/
|
||||
struct SavedProgressPosition {
|
||||
std::string xpath; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
};
|
||||
#include "KOReaderPosition.h" // SavedProgressPosition
|
||||
|
||||
/**
|
||||
* Maps between CrossPoint and SavedProgress position formats, such as those used by KOReader.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "EpubReaderActivity.h"
|
||||
|
||||
#include <BookXPath.h>
|
||||
#include <FontCacheManager.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
@@ -55,9 +56,7 @@ bool isInReadFolder(const std::string& path) {
|
||||
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
|
||||
}
|
||||
|
||||
std::string cacheDirForBook(const std::string& path) {
|
||||
return "/.crosspoint/epub_" + std::to_string(std::hash<std::string>{}(path));
|
||||
}
|
||||
using EpubReaderUtils::cacheDirForBook;
|
||||
|
||||
// Display fallback when the OPF carries no title: the bare filename.
|
||||
std::string filenameTitle(const std::string& path) {
|
||||
@@ -550,9 +549,19 @@ bool EpubReaderActivity::launchKOReaderSync() {
|
||||
const int totalPages = paginator.chapterReady() ? static_cast<int>(paginator.pageCount()) : 0;
|
||||
|
||||
// Pre-compute the local KOReader position and chapter name while the book
|
||||
// is still open. The synthetic xpath carries the chapter; the whole-book
|
||||
// percentage is the primary sync mechanism.
|
||||
SavedProgressPosition localKoPos{syntheticXPath(currentSpineIndex), currentBookFraction()};
|
||||
// is still open. A streaming scan turns the current page's character offset
|
||||
// into a real-ancestry xpath so KOReader devices land on the paragraph;
|
||||
// the whole-book percentage is the robust fallback.
|
||||
std::string localXPath = syntheticXPath(currentSpineIndex);
|
||||
if (!paginator.isTxt()) {
|
||||
const freeink::book::ManifestItem* item = paginator.book().spineItem(currentSpineIndex);
|
||||
const freeink::book::ZipEntry* entry = item != nullptr ? paginator.book().zip().find(item->href) : nullptr;
|
||||
if (entry != nullptr) {
|
||||
localXPath = BookXPath::xpathForCharStart(*paginator.bookSource(), paginator.book().zip(), *entry,
|
||||
currentSpineIndex, lastCharStart);
|
||||
}
|
||||
}
|
||||
SavedProgressPosition localKoPos{std::move(localXPath), currentBookFraction()};
|
||||
const int tocIdx = paginator.tocIndexForSpine(currentSpineIndex);
|
||||
std::string localChapterName = tocIdx >= 0 ? paginator.tocItem(tocIdx).title : "";
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
namespace EpubReaderUtils {
|
||||
|
||||
// Per-book cache directory, keyed by a hash of the book's path (moving or
|
||||
// renaming the file re-keys it — the long-standing CrossPoint convention).
|
||||
inline std::string cacheDirForBook(const std::string& path) {
|
||||
return "/.crosspoint/epub_" + std::to_string(std::hash<std::string>{}(path));
|
||||
}
|
||||
|
||||
// Reader progress, FreeInkBook locator model. `charStart` (chapter character
|
||||
// offset) is layout-parameter independent — it restores exactly across font,
|
||||
// margin, spacing, and orientation changes. When a position is known only as
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "KOReaderSyncActivity.h"
|
||||
|
||||
#include <BookXPath.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
@@ -9,9 +10,7 @@
|
||||
#include <esp_wifi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
|
||||
#include "Epub/Section.h"
|
||||
#include "EpubReaderUtils.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderDocumentId.h"
|
||||
@@ -51,36 +50,66 @@ void syncTimeWithNTP() {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void KOReaderSyncActivity::ensureEpubLoaded() {
|
||||
if (!epub) {
|
||||
LOG_DBG("KOSync", "Loading epub for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap());
|
||||
epub = std::make_shared<Epub>(epubPath, "/.crosspoint");
|
||||
epub->setupCacheDir();
|
||||
// Load metadata only (no CSS needed for progress mapping, don't rebuild if cache is missing).
|
||||
if (!epub->load(false, true)) {
|
||||
LOG_ERR("KOSync", "Failed to load epub for progress mapping");
|
||||
epub.reset();
|
||||
return;
|
||||
}
|
||||
LOG_DBG("KOSync", "Epub loaded (heap: %u)", (unsigned)ESP.getFreeHeap());
|
||||
bool KOReaderSyncActivity::ensureBookLoaded() {
|
||||
if (paginator.isOpen()) return true;
|
||||
LOG_DBG("KOSync", "Loading book for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap());
|
||||
if (!paginator.open(epubPath, EpubReaderUtils::cacheDirForBook(epubPath), renderer)) {
|
||||
LOG_ERR("KOSync", "Failed to open book for progress mapping");
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("KOSync", "Book loaded (heap: %u)", (unsigned)ESP.getFreeHeap());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve the fetched remote (xpath, percentage) into the new-engine locator:
|
||||
// spine from the xpath's DocFragment, character offset by matching the
|
||||
// xpath's element ancestry in a streaming scan of that chapter. Any failure
|
||||
// falls back to the percentage (mapped through spine byte weights).
|
||||
void KOReaderSyncActivity::resolveRemotePosition() {
|
||||
remotePosition = RemotePosition{};
|
||||
|
||||
int spine = BookXPath::spineIndexForXpath(remoteProgress.progress);
|
||||
float chapterFraction = 0.0f;
|
||||
if (spine < 0 || spine >= static_cast<int>(paginator.spineCount())) {
|
||||
spine = paginator.spineForBookFraction(remoteProgress.percentage, &chapterFraction);
|
||||
remotePosition.spineIndex = spine;
|
||||
remotePosition.chapterFraction = chapterFraction;
|
||||
return;
|
||||
}
|
||||
remotePosition.spineIndex = spine;
|
||||
|
||||
// Percentage-derived landing within the chapter as the fallback.
|
||||
const float chapterStart = paginator.bookProgress(spine, 0.0f);
|
||||
const float chapterEnd = paginator.bookProgress(spine, 1.0f);
|
||||
remotePosition.chapterFraction =
|
||||
chapterEnd > chapterStart
|
||||
? std::clamp((remoteProgress.percentage - chapterStart) / (chapterEnd - chapterStart), 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
|
||||
const freeink::book::ManifestItem* item = paginator.book().spineItem(spine);
|
||||
const freeink::book::ZipEntry* entry = item != nullptr ? paginator.book().zip().find(item->href) : nullptr;
|
||||
if (entry == nullptr) return;
|
||||
uint32_t charStart = 0;
|
||||
if (BookXPath::charStartForXpath(*paginator.bookSource(), paginator.book().zip(), *entry, remoteProgress.progress,
|
||||
&charStart)) {
|
||||
remotePosition.charStart = charStart;
|
||||
remotePosition.hasCharStart = true;
|
||||
LOG_DBG("KOSync", "Remote xpath resolved: spine %d char %u", spine, static_cast<unsigned>(charStart));
|
||||
} else {
|
||||
LOG_DBG("KOSync", "Remote xpath ancestry not matched; using percentage fallback");
|
||||
}
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
|
||||
// epub is guaranteed non-null here: ensureEpubLoaded() was called in performSync() before
|
||||
// SHOWING_RESULT state is entered, and this method is only called from that state.
|
||||
assert(epub);
|
||||
// The reader restores positions by chapter character offset; the remote
|
||||
// position arrives as an estimated (page, totalPages), so persist it as a
|
||||
// chapter fraction that resolves against totalChars() once the chapter's
|
||||
// page cache opens.
|
||||
const int totalPages = remotePosition.totalPages;
|
||||
const uint32_t fractionQ16 =
|
||||
(totalPages > 0 && page > 0 && page <= totalPages)
|
||||
? (static_cast<uint32_t>(page) << 16) / static_cast<uint32_t>(totalPages)
|
||||
: 0;
|
||||
if (!EpubReaderUtils::saveProgress(epub->getCachePath(), static_cast<uint16_t>(spineIndex),
|
||||
EpubReaderUtils::kNoCharStart, fractionQ16)) {
|
||||
void KOReaderSyncActivity::saveProgressAndReturn() {
|
||||
const uint32_t fractionQ16 = static_cast<uint32_t>(std::clamp(remotePosition.chapterFraction, 0.0f, 1.0f) * 65536.0f);
|
||||
const bool ok =
|
||||
remotePosition.hasCharStart
|
||||
? EpubReaderUtils::saveProgress(EpubReaderUtils::cacheDirForBook(epubPath),
|
||||
static_cast<uint16_t>(remotePosition.spineIndex), remotePosition.charStart)
|
||||
: EpubReaderUtils::saveProgress(EpubReaderUtils::cacheDirForBook(epubPath),
|
||||
static_cast<uint16_t>(remotePosition.spineIndex),
|
||||
EpubReaderUtils::kNoCharStart, fractionQ16);
|
||||
if (!ok) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_FAILED;
|
||||
@@ -171,10 +200,9 @@ void KOReaderSyncActivity::performSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Epub was released before sync to free RAM for the TLS handshake — reload it now.
|
||||
// The book was released before sync to free RAM for the TLS handshake — reload it now.
|
||||
hasRemoteProgress = true;
|
||||
ensureEpubLoaded();
|
||||
if (!epub) {
|
||||
if (!ensureBookLoaded()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_FAILED;
|
||||
@@ -184,8 +212,7 @@ void KOReaderSyncActivity::performSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||
resolveRemotePosition();
|
||||
|
||||
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
||||
{
|
||||
@@ -308,10 +335,10 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Remote chapter name requires Epub (loaded lazily in performSync before this state).
|
||||
const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex);
|
||||
// Remote chapter name requires the book (loaded lazily in performSync before this state).
|
||||
const int remoteTocIndex = paginator.tocIndexForSpine(remotePosition.spineIndex);
|
||||
const std::string remoteChapter =
|
||||
(remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title
|
||||
(remoteTocIndex >= 0) ? paginator.tocItem(remoteTocIndex).title
|
||||
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1));
|
||||
// Local chapter name was pre-computed before Epub was released.
|
||||
const std::string localChapter =
|
||||
@@ -323,9 +350,10 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
char remoteChapterStr[128];
|
||||
snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 65, remoteChapterStr);
|
||||
// No page number: the remote position is a character locator; its page is
|
||||
// whatever the local pagination derives when the reader reopens.
|
||||
char remotePageStr[64];
|
||||
snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1,
|
||||
remoteProgress.percentage * 100);
|
||||
snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PERCENT_OVERALL_FORMAT), remoteProgress.percentage * 100);
|
||||
renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 90, remotePageStr);
|
||||
|
||||
if (!remoteProgress.device.empty()) {
|
||||
@@ -420,7 +448,7 @@ void KOReaderSyncActivity::loop() {
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedOption == 0) {
|
||||
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
|
||||
saveProgressAndReturn();
|
||||
} else if (selectedOption == 1) {
|
||||
// Upload local progress
|
||||
performUpload();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
#include <KOReaderPosition.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "BookPaginator.h"
|
||||
#include "KOReaderSyncClient.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
@@ -18,6 +17,11 @@
|
||||
* 3. Fetch remote progress
|
||||
* 4. Show comparison and options (Apply/Upload)
|
||||
* 5. Apply or upload progress
|
||||
*
|
||||
* Positions are FreeInkBook locators: remote xpaths resolve to a chapter
|
||||
* character offset (BookXPath) when their element ancestry matches, else to
|
||||
* a chapter fraction from the synced percentage. Either lands exactly via
|
||||
* pageForChar() when the reader reopens.
|
||||
*/
|
||||
class KOReaderSyncActivity final : public Activity {
|
||||
public:
|
||||
@@ -33,7 +37,6 @@ class KOReaderSyncActivity final : public Activity {
|
||||
currentParagraphIndex(currentParagraphIndex),
|
||||
localChapterName(std::move(localChapterName)),
|
||||
remoteProgress{},
|
||||
remotePosition{},
|
||||
localProgress(std::move(localKoPos)) {}
|
||||
|
||||
void onEnter() override;
|
||||
@@ -55,7 +58,15 @@ class KOReaderSyncActivity final : public Activity {
|
||||
NO_CREDENTIALS
|
||||
};
|
||||
|
||||
std::shared_ptr<Epub> epub; // null until lazy-loaded after TLS in performSync()
|
||||
// Remote position resolved into the new-engine locator model.
|
||||
struct RemotePosition {
|
||||
int spineIndex = 0;
|
||||
uint32_t charStart = 0;
|
||||
bool hasCharStart = false; // xpath ancestry matched exactly
|
||||
float chapterFraction = 0; // percentage-derived fallback landing
|
||||
};
|
||||
|
||||
BookPaginator paginator; // closed until lazy-loaded after TLS in performSync()
|
||||
std::string epubPath;
|
||||
std::string localChapterName;
|
||||
int currentSpineIndex;
|
||||
@@ -70,9 +81,9 @@ class KOReaderSyncActivity final : public Activity {
|
||||
// Remote progress data
|
||||
bool hasRemoteProgress = false;
|
||||
KOReaderProgress remoteProgress;
|
||||
CrossPointPosition remotePosition;
|
||||
RemotePosition remotePosition;
|
||||
|
||||
// Local progress as KOReader format (pre-computed before Epub was released)
|
||||
// Local progress as KOReader format (pre-computed before the book was released)
|
||||
SavedProgressPosition localProgress;
|
||||
|
||||
// Selection in result screen (0=Apply, 1=Upload)
|
||||
@@ -87,7 +98,8 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void performSync();
|
||||
void performUpload();
|
||||
void ensureEpubLoaded();
|
||||
void saveProgressAndReturn(int spineIndex, int page);
|
||||
bool ensureBookLoaded();
|
||||
void resolveRemotePosition();
|
||||
void saveProgressAndReturn();
|
||||
void returnToReader();
|
||||
};
|
||||
|
||||
@@ -44,3 +44,4 @@ add_subdirectory(differential_rounding)
|
||||
add_subdirectory(hyphenation_eval)
|
||||
add_subdirectory(utf8_compose)
|
||||
add_subdirectory(cpfont_adapter)
|
||||
add_subdirectory(book_xpath)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BookXPath.h"
|
||||
|
||||
// ============================================================================
|
||||
// In-memory stored-ZIP fixture: BookXPath streams chapters through the
|
||||
// engine's ZipEntryReader, which reads the entry's local file header first,
|
||||
// so the buffer must be a structurally valid (uncompressed) zip member.
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
using freeink::book::BookSource;
|
||||
using freeink::book::ZipCatalog;
|
||||
using freeink::book::ZipEntry;
|
||||
|
||||
class MemorySource : public BookSource {
|
||||
public:
|
||||
explicit MemorySource(std::vector<uint8_t> bytes) : bytes_(std::move(bytes)) {}
|
||||
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
|
||||
if (offset >= bytes_.size()) return 0;
|
||||
const uint32_t n = std::min<uint64_t>(len, bytes_.size() - offset);
|
||||
memcpy(dst, bytes_.data() + offset, n);
|
||||
return static_cast<int32_t>(n);
|
||||
}
|
||||
uint64_t size() const override { return bytes_.size(); }
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> bytes_;
|
||||
};
|
||||
|
||||
void putU16(std::vector<uint8_t>& v, uint16_t x) {
|
||||
v.push_back(x & 0xFF);
|
||||
v.push_back(x >> 8);
|
||||
}
|
||||
void putU32(std::vector<uint8_t>& v, uint32_t x) {
|
||||
for (int i = 0; i < 4; ++i) v.push_back((x >> (8 * i)) & 0xFF);
|
||||
}
|
||||
|
||||
// One stored (method 0) zip member at offset 0, plus the matching ZipEntry.
|
||||
struct Fixture {
|
||||
MemorySource source;
|
||||
ZipEntry entry;
|
||||
ZipCatalog zip; // unused by BookXPath but part of its signature
|
||||
|
||||
static Fixture fromXhtml(const std::string& xhtml) {
|
||||
std::vector<uint8_t> buf;
|
||||
const char* name = "ch.xhtml";
|
||||
putU32(buf, 0x04034b50); // local file header signature
|
||||
putU16(buf, 20); // version needed
|
||||
putU16(buf, 0); // flags
|
||||
putU16(buf, 0); // method: stored
|
||||
putU16(buf, 0); // mod time
|
||||
putU16(buf, 0); // mod date
|
||||
putU32(buf, 0); // crc-32 (not validated on stored reads)
|
||||
putU32(buf, static_cast<uint32_t>(xhtml.size())); // compressed size
|
||||
putU32(buf, static_cast<uint32_t>(xhtml.size())); // uncompressed size
|
||||
putU16(buf, static_cast<uint16_t>(strlen(name))); // name length
|
||||
putU16(buf, 0); // extra length
|
||||
buf.insert(buf.end(), name, name + strlen(name));
|
||||
buf.insert(buf.end(), xhtml.begin(), xhtml.end());
|
||||
|
||||
Fixture f{MemorySource(std::move(buf)), ZipEntry{}, ZipCatalog{}};
|
||||
f.entry.name = "ch.xhtml";
|
||||
f.entry.method = 0;
|
||||
f.entry.compressedSize = static_cast<uint32_t>(xhtml.size());
|
||||
f.entry.uncompressedSize = static_cast<uint32_t>(xhtml.size());
|
||||
f.entry.localHeaderOffset = 0;
|
||||
return f;
|
||||
}
|
||||
};
|
||||
|
||||
// Character accounting reference for kChapter (mirrors ChapterLayout):
|
||||
// p1 "Hello world" chars 0..10
|
||||
// p2 "Second para here" chars 11..26
|
||||
// p3 "Third" chars 27..31
|
||||
const char kChapter[] =
|
||||
"<?xml version=\"1.0\"?>"
|
||||
"<html xmlns=\"http://www.w3.org/1999/xhtml\">"
|
||||
"<head><title>Ignored Title</title><style>p { color: red; }</style></head>"
|
||||
"<body>"
|
||||
"<div><p>Hello world</p><p>Second <i>para</i> here</p></div>"
|
||||
"<p>Third</p>"
|
||||
"</body></html>";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BookXPath, SpineIndexFromXpath) {
|
||||
EXPECT_EQ(BookXPath::spineIndexForXpath("/body/DocFragment[8]/body/p[4]/text().96"), 7);
|
||||
EXPECT_EQ(BookXPath::spineIndexForXpath("/body/DocFragment[1]"), 0);
|
||||
EXPECT_EQ(BookXPath::spineIndexForXpath("/body/nope"), -1);
|
||||
EXPECT_EQ(BookXPath::spineIndexForXpath(""), -1);
|
||||
}
|
||||
|
||||
TEST(BookXPath, CharStartToXpathRealAncestry) {
|
||||
auto f = Fixture::fromXhtml(kChapter);
|
||||
|
||||
// Char 0: first char of p1 inside the div.
|
||||
EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 0),
|
||||
"/body/DocFragment[3]/body/div[1]/p[1]/text().0");
|
||||
|
||||
// Char 13 = "cond..." — 3rd char (offset 2) of p2. Whitespace in p1
|
||||
// ("Hello world") must have collapsed to a single space for this to hold.
|
||||
EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 13),
|
||||
"/body/DocFragment[3]/body/div[1]/p[2]/text().2");
|
||||
|
||||
// Char 28 = 2nd char of p3 — body's FIRST p child (div doesn't count:
|
||||
// sibling indices are per-tag).
|
||||
EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 28),
|
||||
"/body/DocFragment[3]/body/p[1]/text().1");
|
||||
|
||||
// Past the end of the chapter: chapter-level fallback.
|
||||
EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 100000), "/body/DocFragment[3]");
|
||||
}
|
||||
|
||||
TEST(BookXPath, XpathToCharStartRoundTrip) {
|
||||
auto f = Fixture::fromXhtml(kChapter);
|
||||
|
||||
uint32_t ch = 0;
|
||||
ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry,
|
||||
"/body/DocFragment[3]/body/div[1]/p[2]/text().2", &ch));
|
||||
EXPECT_EQ(ch, 13u);
|
||||
|
||||
ASSERT_TRUE(
|
||||
BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]/body/p[1]/text().1", &ch));
|
||||
EXPECT_EQ(ch, 28u);
|
||||
|
||||
// Elements without an explicit index default to [1].
|
||||
ASSERT_TRUE(
|
||||
BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]/body/div/p[1]/text().5", &ch));
|
||||
EXPECT_EQ(ch, 5u);
|
||||
|
||||
// Chapter-level xpath resolves to the chapter start.
|
||||
ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]", &ch));
|
||||
EXPECT_EQ(ch, 0u);
|
||||
|
||||
// Ancestry that does not exist in the chapter.
|
||||
EXPECT_FALSE(BookXPath::charStartForXpath(f.source, f.zip, f.entry,
|
||||
"/body/DocFragment[3]/body/section[2]/p[9]/text().0", &ch));
|
||||
}
|
||||
|
||||
TEST(BookXPath, InlineElementTargetsResolve) {
|
||||
auto f = Fixture::fromXhtml(kChapter);
|
||||
// KOReader may point inside an inline element: <i>para</i> starts at
|
||||
// char 18 ("Second " = 7 chars into p2, which starts at 11).
|
||||
uint32_t ch = 0;
|
||||
ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry,
|
||||
"/body/DocFragment[3]/body/div[1]/p[2]/i[1]/text().0", &ch));
|
||||
EXPECT_EQ(ch, 18u);
|
||||
}
|
||||
|
||||
TEST(BookXPath, EntitiesAndBrAccounting) {
|
||||
// "A&B" is 3 chars; <br/> contributes one '\n' char (layout parity).
|
||||
auto f = Fixture::fromXhtml(
|
||||
"<html><body><p>A&B<br/>C</p></body></html>");
|
||||
EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 0, 4),
|
||||
"/body/DocFragment[1]/body/p[1]/text().4"); // 'C' after A,&,B,\n
|
||||
uint32_t ch = 0;
|
||||
ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[1]/body/p[1]/text().4", &ch));
|
||||
EXPECT_EQ(ch, 4u);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# BookXPath maps KOReader xpaths <-> FreeInkBook character offsets by
|
||||
# streaming chapters through the engine's XmlSax; the suite builds the
|
||||
# relevant engine sources host-side. Same FreeInkBook discovery as
|
||||
# cpfont_adapter (submodule first, sibling working checkout fallback).
|
||||
set(FREEINK_BOOK_DIR "${REPO_ROOT}/freeink-sdk/libs/book/FreeInkBook")
|
||||
if(NOT EXISTS "${FREEINK_BOOK_DIR}/include")
|
||||
set(FREEINK_BOOK_DIR "$ENV{HOME}/GitHub/freeink-sdk/libs/book/FreeInkBook")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${FREEINK_BOOK_DIR}/include")
|
||||
message(WARNING "FreeInkBook sources not found - skipping BookXPathTest")
|
||||
else()
|
||||
enable_language(C) # vendored expat/miniz translation units
|
||||
add_executable(BookXPathTest
|
||||
BookXPathTest.cpp
|
||||
${REPO_ROOT}/lib/KOReaderSync/BookXPath.cpp
|
||||
${FREEINK_BOOK_DIR}/src/epub/XmlSax.cpp
|
||||
${FREEINK_BOOK_DIR}/src/epub/ZipCatalog.cpp
|
||||
${FREEINK_BOOK_DIR}/src/text/EntityFilter.cpp
|
||||
${FREEINK_BOOK_DIR}/src/vendor/expat_xmlparse.c
|
||||
${FREEINK_BOOK_DIR}/src/vendor/expat_xmlrole.c
|
||||
${FREEINK_BOOK_DIR}/src/vendor/expat_xmltok.c
|
||||
${FREEINK_BOOK_DIR}/src/vendor/miniz_impl.c
|
||||
)
|
||||
|
||||
target_include_directories(BookXPathTest PRIVATE
|
||||
${REPO_ROOT}/lib/KOReaderSync
|
||||
${REPO_ROOT}/lib/Memory
|
||||
${FREEINK_BOOK_DIR}/include
|
||||
${FREEINK_BOOK_DIR}/third_party/expat
|
||||
${FREEINK_BOOK_DIR}/third_party/miniz
|
||||
)
|
||||
|
||||
# The firmware stubs (Logging/Memory) come from a tiny local shim dir.
|
||||
target_include_directories(BookXPathTest BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/shim)
|
||||
|
||||
target_link_libraries(BookXPathTest PRIVATE
|
||||
crosspoint_test_common
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
gtest_discover_tests(BookXPathTest)
|
||||
endif()
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
// Host-test shim for the firmware Logging.h (which drags in Arduino Serial).
|
||||
#include <cstdio>
|
||||
#define LOG_DBG(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n"))
|
||||
#define LOG_INF(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n"))
|
||||
#define LOG_ERR(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n"))
|
||||
Reference in New Issue
Block a user