diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
index d344ad73..2688f334 100644
--- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
+++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
@@ -22,6 +22,13 @@
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024;
+// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
+// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
+// chapter. A runaway count usually means a converter injected machine-generated IDs on
+// every text fragment (e.g. Kobo KePub spans). The cap prevents unbounded heap growth
+// on resource-constrained devices (~380KB heap). TOC anchors bypass this cap.
+constexpr size_t MAX_ANCHORS_PER_CHAPTER = 1024;
+
constexpr const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
@@ -49,6 +56,14 @@ const char* getAttribute(const XML_Char** atts, const char* attrName) {
return nullptr;
}
+// Returns true if the HTML element is a purely inline, non-navigable wrapper.
+// IDs on these elements are never meaningful navigation targets in epub content.
+// Reading-system converters (Kobo KePub, Calibre, etc.) frequently inject thousands
+// of such IDs for progress tracking or internal bookkeeping, and recording each one
+// as a navigation anchor exhausts the heap on memory-constrained devices.
+// Block-level, sectioning, and structural elements are always considered navigable.
+bool isNonNavigableInlineElement(const char* name) { return strcmp(name, "span") == 0; }
+
bool isInternalEpubLink(const char* href) {
if (!href || href[0] == '\0') return false;
if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false;
@@ -293,7 +308,17 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(atts[i], "id") == 0) {
// Defer both anchor recording and TOC page breaks until startNewTextBlock,
// after the previous block is flushed to pages via makePages().
- self->pendingAnchorId = atts[i + 1];
+ //
+ // Skip IDs on non-navigable inline elements (e.g. ): these are never
+ // link targets in epub content, but reading-system converters can inject tens
+ // of thousands of them per chapter, exhausting the heap. TOC anchors are
+ // always recorded regardless of element type, since they drive page breaks.
+ const char* idValue = atts[i + 1];
+ const bool isTocAnchor =
+ std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
+ if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) {
+ self->pendingAnchorId = idValue;
+ }
} else if (strcmp(atts[i], "dir") == 0) {
dirAttr = atts[i + 1];
}