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 # split

whose 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). +# 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'', j) + if k == -1: + raise ValueError('unterminated comment') + k += 2 + elif tag.startswith('') 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+)[^>]*>)(.*)()$', 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

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'(]*>)(.*)(

)$', node, re.S) if node.startswith(' PARA_LIMIT: + mm = re.match(r'(<(\w+)[^>]*>)(.*)()$', 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), '' % 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'(]*>)(.*)()', doc, re.S) + if not m: + return [] + body = m.group(2) + if len(body) <= SPLIT_LIMIT: + return [] + head = doc[:m.start(2)] + tail = doc[m.end(2):] + chunks = chunk_nodes(parse_nodes(body), CHUNK_TARGET) + if len(chunks) < 2: + return [] + base, dot, ext = basename.rpartition('.') + out = [] + for k, chunk in enumerate(chunks): + name = basename if k == 0 else '%s_ek%d.%s' % (base, k, ext) + out.append((name, head + chunk + tail)) + return out + + +def visible_text(s): + m = re.search(r'(.*)', s, re.S) + body = m.group(1) if m else s + body = re.sub(r'', '', body, flags=re.S) + return re.sub(r'\s+', ' ', re.sub(r'<[^>]*>', ' ', body)).strip() + + +# --------------------------------------------------------------------------- +# Zip-level pass +# --------------------------------------------------------------------------- + +def _find_attr(tag, attr): + m = re.search(r'\b%s="([^"]*)"' % attr, tag) + return m.group(1) if m else None + + +def split_epub_text(path, log, profile=None, opts=None): + """Rewrite the EPUB at ``path`` in place. Returns a small summary dict. + + ``log(tag, message)`` receives per-step events. ``profile``/``opts`` (from + the optimizer) enable re-encoding of extracted data-URI images. Never + raises: on any unexpected error the file is left as it was. + """ + result = {'paras': 0, 'file_splits': 0, 'fonts': 0, 'dataimgs': 0} + try: + _split_epub_text(path, log, result, profile, opts) + except Exception as exc: + log('SPLIT-ERR', 'text splitting skipped: %s' % exc) + return result + + +_DATA_URI_RE = re.compile( + r'(src|xlink:href)="data:image/(png|jpe?g|gif|webp|bmp);base64,([^"]+)"', + re.IGNORECASE) + + +def _extract_data_uris(entries, order, zname, doc, opf, opf_dir, counter, log, profile, opts): + """Replace base64 data-URI images with real zip entries + manifest items. + + A multi-KB base64 attribute inside a spine file defeats both the image + optimizer (which only sees zip entries) and the device's streaming HTML + parser. Returns (new_doc, new_opf). + """ + import base64 + + zdir = posixpath.dirname(zname) + + def repl(m): + attr, ext, b64 = m.group(1), m.group(2).lower(), m.group(3) + try: + raw = base64.b64decode(re.sub(r'\s+', '', b64)) + except Exception: + return m.group(0) + name = 'dataimg_%d' % counter[0] + counter[0] += 1 + data, out_ext, mt = raw, ('jpg' if ext == 'jpeg' else ext), None + if profile is not None and opts is not None: + try: + from .optimizer import process_image + data, _meta = process_image(raw, profile, opts, name) + out_ext, mt = 'jpg', 'image/jpeg' + except Exception as exc: + log('SPLIT-ERR', 'data-URI image kept raw (%s)' % exc) + if mt is None: + mt = 'image/jpeg' if out_ext in ('jpg', 'jpeg') else 'image/%s' % out_ext + fname = '%s.%s' % (name, out_ext) + new_zname = posixpath.join(zdir, fname) if zdir else fname + entries[new_zname] = data + order.append(new_zname) + href = posixpath.relpath(new_zname, opf_dir) if opf_dir else new_zname + item = '' % (name, href, mt) + nonlocal_opf[0] = re.sub(r'()', item + r'\n \1', + nonlocal_opf[0], count=1) + log('SPLIT', 'extracted %.1f KB data-URI image -> %s' % (len(raw) / 1024.0, fname)) + return '%s="%s"' % (m.group(1), fname) + + nonlocal_opf = [opf] + new_doc = _DATA_URI_RE.sub(repl, doc) + return new_doc, nonlocal_opf[0] + + +def _split_epub_text(path, log, result, profile=None, opts=None): + zin = zipfile.ZipFile(path, 'r') + entries = {} # name -> bytes, insertion-ordered + order = [] + for n in zin.namelist(): + if zin.getinfo(n).is_dir(): + continue + entries[n] = zin.read(n) + order.append(n) + zin.close() + + opf_name = next((n for n in order if n.lower().endswith('.opf')), None) + if not opf_name: + log('SPLIT-ERR', 'no OPF found, text splitting skipped') + return + opf = entries[opf_name].decode('utf-8', 'replace') + opf_dir = posixpath.dirname(opf_name) + + # Manifest: id -> (href, full item tag); attribute-order independent. + items = {} + for tag in re.findall(r'<(?:\w+:)?item\b[^>]*/?>', opf): + iid, href = _find_attr(tag, 'id'), _find_attr(tag, 'href') + if iid and href: + items[iid] = (href, tag) + spine_ids = [i for i in re.findall(r'<(?:\w+:)?itemref\b[^>]*\bidref="([^"]+)"', opf)] + + anchor_map = {} # (basename_of_orig_href, frag) -> new basename + chunk_origin = {} # zip name of a chunk -> basename of the file it was split from + + for sid in spine_ids: + if sid not in items: + continue + href, item_tag = items[sid] + zname = posixpath.normpath(posixpath.join(opf_dir, href)) if opf_dir else posixpath.normpath(href) + if zname not in entries or not XHTML_RE.search(zname): + continue + doc = entries[zname].decode('utf-8', 'replace') + + # Pass 0: pull base64 data-URI images out into real files. + if 'data:image/' in doc: + counter = [result['dataimgs']] + doc, opf = _extract_data_uris(entries, order, zname, doc, opf, opf_dir, + counter, log, profile, opts) + result['dataimgs'] = counter[0] + entries[zname] = doc.encode('utf-8') + + orig_text = visible_text(doc) + + # Pass 1: paragraph splitting, in place. + m = re.search(r'(]*>)(.*)()', doc, re.S) + if m: + try: + new_body, changed = split_big_paragraphs(m.group(2)) + except Exception as exc: + log('SPLIT-ERR', '%s: paragraph pass skipped (%s)' % (posixpath.basename(zname), exc)) + new_body, changed = m.group(2), False + if changed: + new_doc = doc[:m.start(2)] + new_body + doc[m.end(2):] + if visible_text(new_doc) == orig_text: + doc = new_doc + entries[zname] = doc.encode('utf-8') + result['paras'] += 1 + else: + log('SPLIT-ERR', '%s: paragraph pass produced text diff, reverted' + % posixpath.basename(zname)) + + # Pass 2: file splitting. + try: + pieces = split_xhtml_doc(doc, posixpath.basename(zname)) + except Exception as exc: + log('SPLIT-ERR', '%s: file split skipped (%s)' % (posixpath.basename(zname), exc)) + pieces = [] + if not pieces: + continue + joined = ' '.join(visible_text(c) for _, c in pieces) + if re.sub(r'\s+', ' ', joined).strip() != orig_text: + log('SPLIT-ERR', '%s: file split produced text diff, reverted' + % posixpath.basename(zname)) + continue + + zdir = posixpath.dirname(zname) + hdir = posixpath.dirname(href) + insert_at = order.index(zname) + for k, (name, content) in enumerate(pieces): + new_zname = posixpath.join(zdir, name) if zdir else name + entries[new_zname] = content.encode('utf-8') + chunk_origin[new_zname] = posixpath.basename(href) + if k > 0: + order.insert(insert_at + k, new_zname) + for frag in re.findall(r'id="([^"]+)"', content): + anchor_map[(posixpath.basename(href), frag)] = name + + new_items = [] + new_refs = [] + for k, (name, _) in enumerate(pieces): + piece_href = posixpath.join(hdir, name) if hdir else name + piece_id = sid if k == 0 else '%s_ek%d' % (sid, k) + if k == 0: + new_items.append(item_tag) # original tag keeps id/href/properties + else: + new_items.append('' + % (piece_id, piece_href)) + new_refs.append('' % piece_id) + opf = opf.replace(item_tag, '\n '.join(new_items), 1) + opf = re.sub(r'<(?:\w+:)?itemref\b[^>]*\bidref="%s"[^>]*/?>' % re.escape(sid), + lambda _m: '\n '.join(new_refs), opf, count=1) + result['file_splits'] += 1 + log('SPLIT', '%s -> %d files (max %d B)' % ( + posixpath.basename(zname), len(pieces), max(len(c) for _, c in pieces))) + + # Remap fragment links onto the chunk holding the anchor; drop page-list navs. + for n in list(order): + if not (XHTML_RE.search(n) or n.lower().endswith('.ncx')): + continue + s = entries[n].decode('utf-8', 'replace') + before = s + + if anchor_map: + def remap(mm): + pre, fname, frag = mm.group(1), mm.group(2), mm.group(3) + new = anchor_map.get((posixpath.basename(fname), frag)) + if new and new != posixpath.basename(fname): + d = posixpath.dirname(fname) + fname = posixpath.join(d, new) if d else new + return '%s%s#%s"' % (pre, fname, frag) + + s = re.sub(r'((?:href|src)=")([^"#]+)#([^"]+)"', remap, s) + + # Fragment-only refs ("#note1") are relative to the file they sit + # in; when that file was split, the anchor may now live in a + # sibling chunk, so resolve against the chunk's original name. + self_origin = chunk_origin.get(n, posixpath.basename(n)) + + def remap_local(mm): + pre, frag = mm.group(1), mm.group(2) + new = anchor_map.get((self_origin, frag)) + if new and new != posixpath.basename(n): + return '%s%s#%s"' % (pre, new, frag) + return mm.group(0) + + s = re.sub(r'(href=")#([^"]+)"', remap_local, s) + + s = re.sub(r']*epub:type="page-list".*?', '', s, flags=re.S) + if s != before: + entries[n] = s.encode('utf-8') + + # Remove embedded fonts and @font-face rules. + for n in list(order): + if FONT_RE.search(n): + order.remove(n) + del entries[n] + result['fonts'] += 1 + elif n.lower().endswith('.css'): + s = entries[n].decode('utf-8', 'replace') + s2 = re.sub(r'@font-face\s*\{[^}]*\}\s*', '', s) + if s2 != s: + entries[n] = s2.encode('utf-8') + if result['fonts']: + opf = re.sub(r'\s*<(?:\w+:)?item\b[^>]*\.(?:otf|ttf|woff2?)[^>]*/?>', '', opf, + flags=re.IGNORECASE) + log('SPLIT', 'removed %d embedded font(s)' % result['fonts']) + + entries[opf_name] = opf.encode('utf-8') + + tmp = path + '.split-tmp' + with zipfile.ZipFile(tmp, 'w') as zout: + if 'mimetype' in entries: + zout.writestr('mimetype', entries['mimetype'], compress_type=zipfile.ZIP_STORED) + for n in order: + if n == 'mimetype': + continue + zout.writestr(n, entries[n], compress_type=zipfile.ZIP_DEFLATED) + for n in entries: + if n not in order and n != 'mimetype': + zout.writestr(n, entries[n], compress_type=zipfile.ZIP_DEFLATED) + + import os + os.replace(tmp, path) + if result['paras']: + log('SPLIT', 'split oversized paragraphs in %d file(s)' % result['paras'])