Skip to content

Optimizer: split oversized paragraphs/chapters, strip fonts, extract data-URI images#39

Open
ituri wants to merge 4 commits into
crosspoint-reader:masterfrom
ituri:split-text-for-low-ram-firmware
Open

Optimizer: split oversized paragraphs/chapters, strip fonts, extract data-URI images#39
ituri wants to merge 4 commits into
crosspoint-reader:masterfrom
ituri:split-text-for-low-ram-firmware

Conversation

@ituri

@ituri ituri commented Jul 12, 2026

Copy link
Copy Markdown

Problem

The firmware lays out one paragraph at a time, holding every word of the paragraph in parallel in-RAM vectors (ParsedText.cpp in crosspoint-reader). On the ~380 KB-RAM devices, a single multi-KB <p> exhausts the heap during section indexing and crashes the reader with an out-of-memory error — even when the containing spine file is small. Page-long single paragraphs are common in literary fiction (Fosse, Knausgård, Bernhard, …), so affected users hit a hard crash mid-book that no image optimization can fix. Oversized spine files and multi-KB base64 data: URI images cause the same failure mode (see crosspoint-reader/crosspoint-reader#2163, crosspoint-reader/crosspoint-reader#1752).

Diagnosed on an X4: a German Fosse EPUB crashed reproducibly at the exact page where the layout engine had to ingest the next ~6 KB single-paragraph block; splitting the paragraphs (not the files) fixed it.

Change

Adds a text pass (textsplit.py) that runs after the existing image optimizer, gated by the optimizer checkbox plus a new "Split large chapters/paragraphs, remove fonts (prevents out-of-memory)" option (on by default):

  • Paragraph splitting — every <p> larger than 1.6 KB is split into ~1.2 KB siblings at sentence boundaries. Inline tags are kept atomic; continuation paragraphs reuse the opening tag minus its id. Paragraphs containing block-level tags are left alone.
  • File splitting — spine files whose <body> exceeds 9.5 KB are split into ~7 KB files; the OPF manifest/spine is expanded and href="…#fragment" references (content, nav, NCX) are remapped onto the chunk that now holds the anchor. The first chunk keeps the original filename and manifest id, so fragment-less links and guide references stay valid.
  • Data-URI extraction — base64 data:image/* attributes are decoded into real zip entries, re-encoded through the existing process_image pipeline, and added to the manifest (found in the wild: an 88 KB base64 attribute inside a spine file, invisible to the image optimizer).
  • Font removal — embedded font files, their manifest items and @font-face rules are dropped.
  • Page-list navs are removed (print page numbers, dead weight on-device).

Safety

Every text transformation verifies that the visible text of its output is identical to its input and reverts itself on any mismatch; any unexpected error leaves the EPUB exactly as the image pass produced it. This matches the existing optimizer contract that a transfer is never blocked or corrupted.

Testing

  • Ran against a 250-EPUB library. Worst cases: a 4.2 MB EPUB with 88 KB single-paragraph chapters plus the 88 KB data-URI page (492 KB after; visible text byte-identical), and a 3.9 MB EPUB with a single 1.6 MB spine file (240 files after; visible text byte-identical).
  • Verified output XML well-formedness, mimetype-first zip layout, manifest/spine consistency and anchor remapping for all processed books.
  • Idempotence: books already within the limits pass through unchanged.
  • On-device: the previously crashing Fosse EPUB now reads through the affected section on an X4.

Version is intentionally not bumped (left to your make bump-* release flow).

🤖 Generated with Claude Code

The firmware lays out one paragraph at a time, holding every word of the
paragraph in parallel in-RAM vectors (ParsedText.cpp). On ~380KB-RAM
devices a single multi-KB <p> — common in literary fiction with
page-long paragraphs (Fosse, Knausgård, Bernhard, ...) — exhausts the
heap during section indexing and crashes the reader with out-of-memory,
even when the containing file is small. Large spine files and multi-KB
base64 data-URI images cause the same failure mode (see
crosspoint-reader/crosspoint-reader#2163, #1752).

Add an optional text pass (on by default, gated by the existing
optimizer checkbox plus a new "Split large chapters/paragraphs, remove
fonts" option) that runs after the image optimizer:

- split every <p> larger than 1.6 KB into ~1.2 KB siblings at sentence
  boundaries, keeping inline tags atomic and dropping duplicate id
  attributes on continuation paragraphs
- split spine files whose <body> exceeds 9.5 KB into ~7 KB files,
  expanding the OPF manifest/spine and remapping href="...#fragment"
  references onto the chunk that now holds the anchor
- extract base64 data-URI images into real zip entries (re-encoded
  through the existing image pipeline) so neither the attribute value
  nor the device's streaming parser has to swallow them
- remove embedded font files, their manifest items and @font-face rules
- drop page-list navs (print page numbers, dead weight on-device)

Every transformation verifies the visible text is unchanged and reverts
itself on any mismatch; any unexpected error leaves the EPUB as the
image pass produced it, so a transfer is never blocked.

Tested against a library of 250 EPUBs including worst cases: a 4.2 MB
EPUB with 88 KB single-paragraph chapters and an 88 KB data-URI page
(492 KB after, text identical), and a 3.9 MB EPUB with a 1.6 MB single
spine file (240 files after, text identical). Books that already fit
the limits pass through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ituri, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 324df063-4b6b-4087-8ad0-7cc99de5fd62

📥 Commits

Reviewing files that changed from the base of the PR and between 25370f6 and a8de405.

📒 Files selected for processing (2)
  • crosspoint_reader/README.md
  • crosspoint_reader/textsplit.py
📝 Walkthrough

Walkthrough

The EPUB optimizer adds an optional text-restructuring pass that splits paragraphs and spine files, remaps links, extracts data images, removes fonts and page-list navigation, verifies visible text, and reports transformation counts.

Changes

EPUB text optimization

Layer / File(s) Summary
Text transformation helpers
crosspoint_reader/textsplit.py
Adds XHTML parsing, sentence-based paragraph splitting, document chunking, and visible-text normalization helpers.
EPUB rewrite pipeline
crosspoint_reader/textsplit.py
Rewrites EPUB ZIP contents, extracts embedded images, updates OPF/spine links, removes fonts and page-list navigation, and reverts changes when verification fails.
Optimizer configuration and wiring
crosspoint_reader/config.py, crosspoint_reader/driver.py, crosspoint_reader/optimizer.py, crosspoint_reader/README.md
Adds the split-text preference, passes it through optimizer options, aggregates transformation counts, and documents the behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant optimize_epub
  participant split_epub_text
  participant EPUB_ZIP_OPF
  optimize_epub->>split_epub_text: invoke enabled text processing
  split_epub_text->>EPUB_ZIP_OPF: extract images and rewrite XHTML
  split_epub_text->>EPUB_ZIP_OPF: expand and remap OPF/spine links
  EPUB_ZIP_OPF-->>split_epub_text: rewritten EPUB entries
  split_epub_text-->>optimize_epub: return transformation counts
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the new text-splitting, font stripping, and data-URI extraction optimizer work.
Description check ✅ Passed The description is directly about the EPUB optimization pass and its safety and testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crosspoint_reader/README.md (1)

63-69: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Step 3 option list omits the new split checkbox.

The feature is described at lines 34-44, but this walkthrough enumerates Device target, JPEG quality, grayscale, and auto-crop without mentioning "Split large chapters/paragraphs, remove fonts" (on by default). Add a bullet so users know it exists and can disable it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crosspoint_reader/README.md` around lines 63 - 69, Add a Step 3 bullet for
“Split large chapters/paragraphs, remove fonts,” noting that it is enabled by
default and can be disabled. Keep the existing option descriptions unchanged and
place the new bullet with the other checkbox options.
🧹 Nitpick comments (1)
crosspoint_reader/textsplit.py (1)

387-387: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

if anchor_map or True: is always true.

The or True makes the guard a no-op, which reads like leftover debugging and obscures intent (page-list nav removal is meant to always run, link remap only when anchors exist). Consider splitting the two concerns or dropping the dead condition.

♻️ Suggested clarification
-    # Remap fragment links onto the chunk holding the anchor; drop page-list navs.
-    if anchor_map or True:
-        for n in list(order):
+    # Remap fragment links onto the chunk holding the anchor; drop page-list navs.
+    for n in list(order):

(dedent the loop body accordingly)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crosspoint_reader/textsplit.py` at line 387, The condition around the
page-list navigation removal and link remapping is always true due to `or True`.
Split these concerns so page-list navigation removal always runs, while link
remapping remains guarded by `anchor_map`, and dedent the loop body as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crosspoint_reader/textsplit.py`:
- Around line 394-402: Update the split-chunk remapping logic around remap and
its re.sub pattern to also handle fragment-only references such as href="#...".
Resolve these refs against the current source file when looking up anchor_map,
and rewrite them to the owning sibling chunk while preserving the fragment and
existing handling for explicit filenames.

---

Outside diff comments:
In `@crosspoint_reader/README.md`:
- Around line 63-69: Add a Step 3 bullet for “Split large chapters/paragraphs,
remove fonts,” noting that it is enabled by default and can be disabled. Keep
the existing option descriptions unchanged and place the new bullet with the
other checkbox options.

---

Nitpick comments:
In `@crosspoint_reader/textsplit.py`:
- Line 387: The condition around the page-list navigation removal and link
remapping is always true due to `or True`. Split these concerns so page-list
navigation removal always runs, while link remapping remains guarded by
`anchor_map`, and dedent the loop body as needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 12d081c2-e582-4965-897c-db59f77abdb7

📥 Commits

Reviewing files that changed from the base of the PR and between 6d66f3d and 25370f6.

📒 Files selected for processing (5)
  • crosspoint_reader/README.md
  • crosspoint_reader/config.py
  • crosspoint_reader/driver.py
  • crosspoint_reader/optimizer.py
  • crosspoint_reader/textsplit.py
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.44.1)
crosspoint_reader/textsplit.py

[warning] 208-208: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling.
Context: re.search(r'\b%s="([^"]*)"' % attr, tag)
Note: [CWE-1333] Inefficient Regular Expression Complexity.

(redos-non-literal-regex-python)


[warning] 379-380: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling.
Context: re.sub(r'<(?:\w+:)?itemref\b[^>]\bidref="%s"[^>]/?>' % re.escape(sid),
lambda _m: '\n '.join(new_refs), opf, count=1)
Note: [CWE-1333] Inefficient Regular Expression Complexity.

(redos-non-literal-regex-python)

🪛 Ruff (0.15.20)
crosspoint_reader/textsplit.py

[warning] 122-122: Function definition does not bind loop variable groups

(B023)


[warning] 189-189: Unpacked variable dot is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


[warning] 223-223: Do not catch blind exception: Exception

(BLE001)


[warning] 248-248: Do not catch blind exception: Exception

(BLE001)


[warning] 258-258: Do not catch blind exception: Exception

(BLE001)


[warning] 330-330: Do not catch blind exception: Exception

(BLE001)


[warning] 346-346: Do not catch blind exception: Exception

(BLE001)


[warning] 381-381: Function definition does not bind loop variable new_refs

(B023)

🔇 Additional comments (3)
crosspoint_reader/optimizer.py (1)

82-89: LGTM!

Also applies to: 603-610

crosspoint_reader/config.py (1)

35-35: LGTM!

Also applies to: 57-58, 77-77, 114-114, 143-150

crosspoint_reader/driver.py (1)

468-468: LGTM!

Comment thread crosspoint_reader/textsplit.py
- Fragment-only refs (href="#note1") are relative to the file they sit
  in; after a file split the anchor may live in a sibling chunk. Track
  each chunk's original filename and rewrite such refs to the chunk
  that owns the anchor (verified with a synthetic EPUB: 25 local
  footnote links across 3 chunks plus one cross-file ref all resolve).
- Drop the dead `or True` condition; page-list nav removal always runs,
  link remapping only when anchors moved.
- List the new split checkbox in the README's step-by-step options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ituri ituri force-pushed the split-text-for-low-ram-firmware branch from a35c55c to 029ee7d Compare July 12, 2026 09:48
ituri and others added 2 commits July 12, 2026 11:58
- parse_nodes treated XML processing instructions (<?dp ...?> page
  markers, common in Random House EPUBs) as unclosed opening tags,
  aborting the text pass for every affected file.
- split_text_sentences dropped the boundary whitespace between pieces;
  when two pieces later landed in the same paragraph group the sentences
  were joined without a space. The visible-text verification caught it
  and reverted those files (no corrupt output), but the split was lost.
  Boundary whitespace now stays with the preceding piece, so
  ''.join(pieces) == text always holds.

Found by batch-converting a 253-book library: 4 books with PI page
markers now split normally, 2 books that previously reverted now pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some publishers wrap entire multi-KB paragraphs in one <span>; such a
paragraph survived splitting because tag tokens were atomic. Oversized
inline wrappers (span/em/i/b/strong/a/...) are now split recursively
into several same-tag siblings. <img> no longer blocks paragraph
splitting either — it is inline and stays atomic during grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ituri

ituri commented Jul 12, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant