diff --git a/crosspoint_reader/README.md b/crosspoint_reader/README.md index ac83d18..c0bbbdc 100644 --- a/crosspoint_reader/README.md +++ b/crosspoint_reader/README.md @@ -31,6 +31,17 @@ enabled (Preferences > Plugins > device config > width/height are stripped, SVG covers/wrapped images are unwrapped, OPF media-types and cover meta are fixed, the NCX identifier is synced, a small defensive stylesheet is injected, and the archive is re-zipped mimetype-first. +- Text is restructured for the firmware's layout memory limits (optional, + "Split large chapters/paragraphs" checkbox, on by default): paragraphs larger + than ~1.6 KB are split into ~1.2 KB `
` siblings at sentence boundaries + (the firmware lays out a whole paragraph at once, holding every word in RAM, + so a single multi-KB paragraph can OOM the device even in a small file); + spine files larger than ~9.5 KB are split into ~7 KB files with the OPF + manifest/spine expanded and `href="...#fragment"` links remapped onto the + chunk holding the anchor; embedded fonts and `@font-face` rules are removed; + page-list navs are dropped; and base64 `data:` URI images are extracted into + real (optimized) image files. Every text transformation verifies that the + visible text is byte-identical and reverts itself on any mismatch or error. The target screen size comes from the device profile — **X4 = 480×800**, **X3 = 528×792** — which is auto-detected from the device's `/api/status` @@ -56,6 +67,9 @@ never blocked. - **JPEG quality** — 1–100 (default 85). Lower = smaller files. - **Convert images to grayscale** — on by default (recommended for e-ink). - **Auto-crop uniform margins** — off by default; trims solid page borders. + - **Split large chapters/paragraphs, remove fonts (prevents out-of-memory)** + — on by default; restructures text for the firmware's layout memory limits + (see above). Disable it to keep the EPUB's file/paragraph structure as-is. 4. Click **OK**, then **restart Calibre** if it was already running so the new settings take effect. 5. Send a book to the device as usual (right-click → *Send to device*, or the diff --git a/crosspoint_reader/config.py b/crosspoint_reader/config.py index f191b1d..96603f4 100644 --- a/crosspoint_reader/config.py +++ b/crosspoint_reader/config.py @@ -32,6 +32,7 @@ PREFS.defaults['optimize_grayscale'] = True PREFS.defaults['optimize_auto_crop'] = False PREFS.defaults['optimize_quality'] = 85 +PREFS.defaults['optimize_split'] = True PREFS.defaults['device_target'] = 'auto' # 'auto' | 'X4' | 'X3' @@ -53,6 +54,8 @@ def __init__(self): self.optimize = QCheckBox('Optimize EPUBs before transfer', self) self.optimize_grayscale = QCheckBox('Convert images to grayscale', self) self.optimize_auto_crop = QCheckBox('Auto-crop uniform margins', self) + self.optimize_split = QCheckBox( + 'Split large chapters/paragraphs, remove fonts (prevents out-of-memory)', self) self.optimize_quality = QSpinBox(self) self.optimize_quality.setRange(1, 100) self.optimize_quality.setSuffix('%') @@ -71,6 +74,7 @@ def __init__(self): self.optimize.setChecked(PREFS['optimize']) self.optimize_grayscale.setChecked(PREFS['optimize_grayscale']) self.optimize_auto_crop.setChecked(PREFS['optimize_auto_crop']) + self.optimize_split.setChecked(PREFS['optimize_split']) self.optimize_quality.setValue(PREFS['optimize_quality']) idx = self.device_target.findData(PREFS['device_target']) self.device_target.setCurrentIndex(idx if idx >= 0 else 0) @@ -107,6 +111,7 @@ def __init__(self): layout.addRow('JPEG quality', self.optimize_quality) layout.addRow('', self.optimize_grayscale) layout.addRow('', self.optimize_auto_crop) + layout.addRow('', self.optimize_split) self.optimize.toggled.connect(self._sync_optimizer_enabled) self._sync_optimizer_enabled(self.optimize.isChecked()) @@ -135,12 +140,13 @@ def save(self): PREFS['optimize'] = bool(self.optimize.isChecked()) PREFS['optimize_grayscale'] = bool(self.optimize_grayscale.isChecked()) PREFS['optimize_auto_crop'] = bool(self.optimize_auto_crop.isChecked()) + PREFS['optimize_split'] = bool(self.optimize_split.isChecked()) PREFS['optimize_quality'] = int(self.optimize_quality.value()) PREFS['device_target'] = self.device_target.currentData() def _sync_optimizer_enabled(self, enabled): for w in (self.optimize_grayscale, self.optimize_auto_crop, - self.optimize_quality, self.device_target): + self.optimize_split, self.optimize_quality, self.device_target): w.setEnabled(enabled) def _refresh_logs(self): diff --git a/crosspoint_reader/driver.py b/crosspoint_reader/driver.py index 8816192..dc3d30b 100644 --- a/crosspoint_reader/driver.py +++ b/crosspoint_reader/driver.py @@ -465,6 +465,7 @@ def _optimize_book(self, filepath, profile, step_cb=None): quality=PREFS['optimize_quality'], grayscale=PREFS['optimize_grayscale'], auto_crop=PREFS['optimize_auto_crop'], + split_text=PREFS['optimize_split'], ) def _step(tag, message): diff --git a/crosspoint_reader/optimizer.py b/crosspoint_reader/optimizer.py index a5a3e80..b617c05 100644 --- a/crosspoint_reader/optimizer.py +++ b/crosspoint_reader/optimizer.py @@ -79,10 +79,14 @@ def resolve_profile(device_target, detected_device): class Options(object): - def __init__(self, quality=DEFAULT_JPEG_QUALITY, grayscale=True, auto_crop=False): + def __init__(self, quality=DEFAULT_JPEG_QUALITY, grayscale=True, auto_crop=False, + split_text=True): self.quality = int(quality) self.grayscale = bool(grayscale) self.auto_crop = bool(auto_crop) + # Split oversized paragraphs/chapters and strip fonts for the + # low-RAM firmware layout engine (see textsplit.py). + self.split_text = bool(split_text) # --------------------------------------------------------------------------- @@ -596,6 +600,14 @@ def log(tag, message): finally: zin.close() + if getattr(opts, 'split_text', False): + from .textsplit import split_epub_text + split_summary = split_epub_text(out_path, log, profile, opts) + summary['fixes'] += (split_summary.get('paras', 0) + + split_summary.get('file_splits', 0) + + split_summary.get('fonts', 0) + + split_summary.get('dataimgs', 0)) + summary['new_size'] = os.path.getsize(out_path) summary['elapsed'] = time.time() - start saved = orig_size - summary['new_size'] diff --git a/crosspoint_reader/textsplit.py b/crosspoint_reader/textsplit.py new file mode 100644 index 0000000..67b9e95 --- /dev/null +++ b/crosspoint_reader/textsplit.py @@ -0,0 +1,492 @@ +"""Text-side EPUB transformations for low-RAM CrossPoint firmware. + +The device lays out one
at a time, holding every word of the paragraph in +parallel in-RAM vectors, and caches whole spine sections built from single +files. Two consequences for ~380KB-RAM hardware: + + * a single multi-KB paragraph OOMs the layout engine even in a tiny file + (observed: ~6KB single-
crashed an X4; ~1.2KB is comfortable), and + * spine files beyond ~10KB make section indexing fragile. + +This module post-processes the optimizer's output zip: + + * split every
larger than PARA_LIMIT into ~PARA_TARGET-byte siblings, + cutting at sentence boundaries (inline tags kept atomic), + * split spine XHTML files whose
exceeds SPLIT_LIMIT into + ~CHUNK_TARGET-byte files, expanding OPF manifest + spine accordingly, + * remap href/src="...#fragment" references onto the chunk that now holds + the anchor, + * remove embedded fonts (files, manifest items, @font-face rules), + * drop page-list navs (print page numbers, dead weight on-device). + +Everything is best-effort: each transformation verifies that the visible text +is unchanged and falls back to the untouched input on any error, so a transfer +is never blocked or corrupted by this pass. +""" + +import posixpath +import re +import zipfile + +SPLIT_LIMIT = 9500 # split content bigger than this +CHUNK_TARGET = 7000 # aim for files of this many bytes of body content +PARA_LIMIT = 1600 # splitwhose inner content exceeds this +PARA_TARGET = 1200 # aim for paragraphs of this many bytes + +VOID = ('meta', 'link', 'img', 'br', 'hr', 'image', 'input', 'col', 'source') +FONT_RE = re.compile(r'\.(otf|ttf|woff2?)$', re.IGNORECASE) +XHTML_RE = re.compile(r'\.(xhtml|html|htm)$', re.IGNORECASE) +# Tags that make a
unsplittable (block-level content inside a paragraph).
+# bigger than PARA_LIMIT into several sibling elements.
+ Only inline content is split; a containing block-level tags is left alone.
+ Returns (new_html, changed)."""
+ out, changed = [], False
+ for node in parse_nodes(html):
+ m = re.match(r'( ]*>)(.*)( PARA_LIMIT:
+ mm = re.match(r'(<(\w+)[^>]*>)(.*)(\2>)$', node, re.S)
+ if mm and mm.group(2) not in VOID:
+ inner, was = split_big_paragraphs(mm.group(3))
+ if was:
+ out.append(mm.group(1) + inner + mm.group(4))
+ changed = True
+ continue
+ out.append(node)
+ continue
+ open_tag, inner, close_tag = m.groups()
+ groups = _group_inline(inner)
+ if len(groups) < 2:
+ out.append(node)
+ continue
+ cont_tag = re.sub(r'\s+id="[^"]*"', '', open_tag)
+ out.append(''.join((open_tag if k == 0 else cont_tag) + g + close_tag
+ for k, g in enumerate(groups)))
+ changed = True
+ return ''.join(out), changed
+
+
+def chunk_nodes(nodes, target):
+ """Greedy-pack nodes into chunks <= ~target, recursing into big containers."""
+ chunks, cur, cur_len = [], [], 0
+
+ def flush():
+ nonlocal cur, cur_len
+ if ''.join(cur).strip():
+ chunks.append(''.join(cur))
+ cur, cur_len = [], 0
+
+ for node in nodes:
+ if len(node) > SPLIT_LIMIT and node.startswith('<'):
+ m = re.match(r'<(\w+)[^>]*>', node)
+ open_tag, close_tag = m.group(0), '%s>' % m.group(1)
+ inner = node[len(open_tag):-len(close_tag)]
+ flush()
+ for sub in chunk_nodes(parse_nodes(inner), target):
+ chunks.append(open_tag + sub + close_tag)
+ continue
+ if len(node) > SPLIT_LIMIT:
+ flush()
+ for piece in split_text_sentences(node, target):
+ chunks.append(piece)
+ continue
+ if cur_len + len(node) > target and cur_len > 0 and node.strip():
+ flush()
+ cur.append(node)
+ cur_len += len(node)
+ flush()
+ return chunks
+
+
+def split_xhtml_doc(doc, basename):
+ """Return list of (relname, content); [] if no split needed/possible."""
+ m = re.search(r'( is inline and stays atomic during grouping, so it does not block.
+BLOCK_RE = re.compile(r'<(?:p|div|table|ul|ol)\b')
+# Inline wrappers that may be split into several same-tag siblings. Some
+# publishers wrap entire multi-KB paragraphs in a single .
+INLINE_WRAP = ('span', 'em', 'i', 'b', 'strong', 'a', 'u', 'small', 'sub', 'sup')
+
+
+# ---------------------------------------------------------------------------
+# Markup tokenizing / chunking
+# ---------------------------------------------------------------------------
+
+def parse_nodes(s):
+ """Split markup into a list of top-level balanced nodes (tags or text)."""
+ nodes, i, n = [], 0, len(s)
+ while i < n:
+ if s[i] == '<':
+ depth, j = 0, i
+ while True:
+ k = s.find('>', j)
+ if k == -1:
+ raise ValueError('unterminated tag')
+ tag = s[j:k + 1]
+ name = re.match(r'?(\w+)', tag)
+ name = name.group(1).lower() if name else ''
+ if tag.startswith('', j)
+ if k == -1:
+ raise ValueError('unterminated comment')
+ k += 2
+ elif tag.startswith(''):
+ depth -= 1
+ elif not (tag.endswith('/>') or name in VOID
+ or tag.startswith((') and declarations
+ # never nest; everything else opens an element
+ depth += 1
+ if depth <= 0:
+ break
+ j = s.find('<', k)
+ if j == -1:
+ raise ValueError('unbalanced markup')
+ nodes.append(s[i:k + 1])
+ i = k + 1
+ else:
+ j = s.find('<', i)
+ j = n if j == -1 else j
+ nodes.append(s[i:j])
+ i = j
+ return nodes
+
+
+def split_text_sentences(text, target):
+ """Split a text run into pieces of ~target bytes at sentence/space boundaries.
+
+ Lossless: the boundary whitespace stays with the preceding piece, so
+ ``''.join(pieces) == text`` always holds (pieces may later be re-joined
+ inside one paragraph, where a dropped space would corrupt the text)."""
+ pieces = []
+ while len(text) > target:
+ cut = text.rfind('. ', 0, target)
+ if cut != -1:
+ cut += 2
+ else:
+ cut = text.rfind(' ', 0, target) + 1
+ if cut == 0:
+ break
+ pieces.append(text[:cut])
+ text = text[cut:]
+ pieces.append(text)
+ return pieces
+
+
+def _group_inline(inner):
+ """Pack inline content into ~PARA_TARGET-byte chunks (lossless concat).
+
+ Oversized bare text is cut at sentence boundaries; an oversized inline
+ wrapper (e.g. a around a whole paragraph) is split recursively into
+ several same-tag siblings. Anything else stays atomic."""
+ groups, cur, cur_len = [], [], 0
+
+ def flush():
+ nonlocal cur, cur_len
+ if ''.join(cur).strip():
+ groups.append(''.join(cur))
+ cur, cur_len = [], 0
+
+ for tok in parse_nodes(inner):
+ if len(tok) > PARA_LIMIT and not tok.startswith('<'):
+ pieces = split_text_sentences(tok, PARA_TARGET)
+ elif len(tok) > PARA_LIMIT and tok.startswith('<'):
+ mm = re.match(r'(<(\w+)[^>]*>)(.*)(\2>)$', tok, re.S)
+ if mm and mm.group(2).lower() in INLINE_WRAP \
+ and not BLOCK_RE.search(mm.group(3)):
+ pieces = [mm.group(1) + g + mm.group(4)
+ for g in _group_inline(mm.group(3))]
+ else:
+ pieces = [tok]
+ else:
+ pieces = [tok]
+ for piece in pieces:
+ if cur_len + len(piece) > PARA_TARGET and cur_len > 0 and piece.strip():
+ flush()
+ cur.append(piece)
+ cur_len += len(piece)
+ flush()
+ return groups
+
+
+def split_big_paragraphs(html):
+ """Rewrite every