Files
Stu KabakoffandClaude Opus 5 3668729fd9 Keep each document's DOCTYPE through the rewrite
etree.tostring writes out the element tree alone, so the optimizer was
silently dropping the DOCTYPE of every document it re-serialized — and a
document that loses its DTD loses every named entity with it, leaving
  and friends as fatal "undefined entity" errors on-device. Hand
the declaration back to the serializer instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:41:36 -04:00

695 lines
25 KiB
Python

"""EPUB optimizer for CrossPoint Reader.
This is a Python port of the client-side optimizer that ships in the CrossPoint
web server (``FilesPage.html``). It mirrors the "core" optimization path used by
the device's web UI when no manual per-image splitting is selected:
* resolve a device profile (X4 = 480x800, X3 = 528x792, portrait short x long)
* for every raster image inside the EPUB:
- optional auto-crop of uniform margins
- scale to fit the device screen (preserving aspect ratio)
- flatten transparency onto white and convert to grayscale
- re-encode as JPEG (quality 85 by default)
* rewrite the container: rename raster images to ``.jpg``, strip stale
width/height from ``<img>`` tags, unwrap SVG covers / SVG-wrapped images,
fix OPF media-types + cover meta, sync the NCX identifier, inject a small
defensive stylesheet, and re-zip mimetype-first.
The interactive H-split / V-split / rotate picker from the web UI is intentionally
out of scope (the plugin transfers automatically, with no per-image prompts).
All image work uses Pillow (PIL), which Calibre bundles. PIL's ``.convert('L')``
applies the exact ITU-R 601 luma weights (0.299/0.587/0.114) used by the web
optimizer, and EPUB markup is rewritten with lxml (also bundled), with regex
fallbacks throughout.
"""
import io
import os
import re
import time
import zipfile
# --- Device profiles (short edge x long edge, portrait) ---------------------
DEVICE_PROFILES = {
'X4': {'width': 480, 'height': 800, 'label': 'X4'},
'X3': {'width': 528, 'height': 792, 'label': 'X3'},
}
DEFAULT_DEVICE = 'X4'
DEFAULT_JPEG_QUALITY = 85
# Auto-crop tuning (mirrors the web optimizer constants).
CROP_WHITE_THRESHOLD = 245
CROP_BACKGROUND_TOLERANCE = 28
CROP_BACKGROUND_MAX_SPREAD = 24
CROP_EDGE_SAMPLE_SIZE = 12
CROP_PADDING_PX = 8
MIN_CROP_SAVINGS_RATIO = 0.08
MIN_COLOR_CROP_SAVINGS_RATIO = 0.20
MIN_CROP_DIMENSION = 240
RASTER_RE = re.compile(r'\.(png|gif|webp|bmp|jpe?g)$', re.IGNORECASE)
# Anchored: rename a whole path/filename or an isolated attribute value.
RENAME_RE = re.compile(r'\.(png|gif|webp|bmp|jpeg)$', re.IGNORECASE)
# Unanchored: rename references embedded inline (href/src/url(...)) in markup/CSS.
REF_RE = re.compile(r'\.(?:png|gif|webp|bmp|jpeg)(?=["\'\)\s#?>])', re.IGNORECASE)
XHTML_RE = re.compile(r'\.(xhtml|html|htm)$', re.IGNORECASE)
DEFENSIVE_STYLE = (
'<style type="text/css">img,svg{max-width:100%;height:auto}'
'body{overflow-wrap:break-word}table{max-width:100%;table-layout:fixed}'
'pre,code{white-space:pre-wrap;word-wrap:break-word}*{box-sizing:border-box}</style>'
)
_COVER_NAME_RE = re.compile(
r'(^|/)(cover|thumbnail|thumb|icon)[^/]*\.(jpe?g|png|gif|webp|bmp)$', re.IGNORECASE)
def resolve_profile(device_target, detected_device):
"""Resolve 'auto' | 'X4' | 'X3' (+ detected model) to a concrete profile dict."""
if device_target and device_target.upper() in DEVICE_PROFILES:
key = device_target.upper()
else: # 'auto' (or anything unexpected)
key = (detected_device or '').upper()
if key not in DEVICE_PROFILES:
key = DEFAULT_DEVICE
return key, DEVICE_PROFILES[key]
class Options(object):
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)
# ---------------------------------------------------------------------------
# Image processing (Pillow)
# ---------------------------------------------------------------------------
#
# Calibre bundles Pillow (PIL) but not numpy, so the per-pixel work uses PIL.
# Conveniently, PIL's ``.convert('L')`` uses the exact ITU-R 601 luma weights
# (0.299 R + 0.587 G + 0.114 B) that the CrossPoint web optimizer applies, so
# grayscale output matches the device's web UI.
def _flatten_white_rgb(im):
"""Composite any transparency onto a white background; return an RGB image."""
from PIL import Image
if im.mode in ('RGBA', 'LA') or (im.mode == 'P' and 'transparency' in im.info):
im = im.convert('RGBA')
bg = Image.new('RGB', im.size, (255, 255, 255))
bg.paste(im, mask=im.split()[3])
return bg
if im.mode == 'RGB':
return im
return im.convert('RGB')
def _estimate_crop_background(rgb):
"""Sample 8 edge regions; return their mean colour, or None if non-uniform."""
from PIL import ImageStat
w, h = rgb.size
sample = min(CROP_EDGE_SAMPLE_SIZE, w // 8, h // 8)
if sample < 2:
return None
pts = [
(0, 0), (w - sample, 0), (0, h - sample), (w - sample, h - sample),
((w - sample) // 2, 0), ((w - sample) // 2, h - sample),
(0, (h - sample) // 2), (w - sample, (h - sample) // 2),
]
means = []
for x, y in pts:
box = rgb.crop((x, y, x + sample, y + sample))
means.append(ImageStat.Stat(box).mean[:3])
avg = [sum(c) / len(means) for c in zip(*means)]
spread = max(abs(m[i] - avg[i]) for m in means for i in range(3))
if spread > CROP_BACKGROUND_MAX_SPREAD:
return None
return tuple(avg)
def _find_crop_box(rgb):
"""Return a PIL crop box (left, top, right, bottom) trimming uniform margins.
Mirrors the web optimizer: detect a uniform edge background (else assume
white), keep pixels where ANY channel differs beyond tolerance, pad, and
require a minimum savings ratio. Returns None when cropping is not worth it.
"""
from PIL import Image, ImageChops
w, h = rgb.size
bg = _estimate_crop_background(rgb)
if bg is not None:
solid = Image.new('RGB', (w, h), tuple(int(round(c)) for c in bg))
diff = ImageChops.difference(rgb, solid)
r, g, b = diff.split()
per_pixel_max = ImageChops.lighter(ImageChops.lighter(r, g), b)
mask = per_pixel_max.point(
lambda v: 255 if v > CROP_BACKGROUND_TOLERANCE else 0)
else:
r, g, b = rgb.split()
def below_white(ch):
return ch.point(lambda v: 255 if v < CROP_WHITE_THRESHOLD else 0)
mask = ImageChops.lighter(
ImageChops.lighter(below_white(r), below_white(g)), below_white(b))
bbox = mask.getbbox()
if not bbox:
return None
left, top, right, bottom = bbox # right/bottom are exclusive in PIL
left = max(0, left - CROP_PADDING_PX)
top = max(0, top - CROP_PADDING_PX)
right = min(w, right + CROP_PADDING_PX)
bottom = min(h, bottom + CROP_PADDING_PX)
crop_w, crop_h = right - left, bottom - top
saved_ratio = 1.0 - (crop_w * crop_h) / float(w * h)
near_white = bg is not None and all(c >= CROP_WHITE_THRESHOLD for c in bg)
min_ratio = (MIN_CROP_SAVINGS_RATIO if (bg is None or near_white)
else MIN_COLOR_CROP_SAVINGS_RATIO)
if saved_ratio < min_ratio:
return None
return (left, top, right, bottom)
def _should_skip_auto_crop(image_path, w, h):
if w < MIN_CROP_DIMENSION or h < MIN_CROP_DIMENSION:
return True
return bool(_COVER_NAME_RE.search(image_path or ''))
def process_image(data, profile, opts, image_path=''):
"""Run the core optimization on a single image.
Returns (jpeg_bytes, meta) where meta has orig/final dims + sizes + flags.
Raises on decode failure so the caller can fall back to the original bytes.
"""
from PIL import Image
im = Image.open(io.BytesIO(data))
im.load()
orig_w, orig_h = im.size
orig_size = len(data)
max_w, max_h = profile['width'], profile['height']
src = im
src_w, src_h = orig_w, orig_h
cropped = False
# --- optional auto-crop -------------------------------------------------
if opts.auto_crop and not _should_skip_auto_crop(image_path, orig_w, orig_h):
box = _find_crop_box(_flatten_white_rgb(im))
if box is not None:
src = im.crop(box)
src_w, src_h = src.size
cropped = True
# --- scale to fit (preserve aspect ratio) -------------------------------
fits = src_w <= max_w and src_h <= max_h
if fits and not cropped:
scaled = src
final_w, final_h = src_w, src_h
else:
scale = min(max_w / float(src_w), max_h / float(src_h))
final_w = max(1, int(round(src_w * scale)))
final_h = max(1, int(round(src_h * scale)))
if (final_w, final_h) == (src_w, src_h):
scaled = src
else:
scaled = src.resize((final_w, final_h), Image.LANCZOS)
# --- flatten onto white, grayscale, JPEG --------------------------------
rgb = _flatten_white_rgb(scaled)
out = rgb.convert('L') if opts.grayscale else rgb
buf = io.BytesIO()
out.save(buf, 'JPEG', quality=int(opts.quality), optimize=True)
jpeg = buf.getvalue()
meta = {
'orig_w': orig_w, 'orig_h': orig_h, 'orig_size': orig_size,
'final_w': final_w, 'final_h': final_h, 'final_size': len(jpeg),
'cropped': cropped,
}
return jpeg, meta
# ---------------------------------------------------------------------------
# EPUB container rewriting
# ---------------------------------------------------------------------------
def _decode_text(raw):
if raw[:3] == b'\xef\xbb\xbf':
raw = raw[3:]
try:
return raw.decode('utf-8')
except UnicodeDecodeError:
head = raw[:512].decode('ascii', 'ignore')
m = (re.search(r'encoding=["\']([^"\']+)["\']', head, re.IGNORECASE)
or re.search(r'charset=["\']?([^"\'\s;]+)', head, re.IGNORECASE))
enc = m.group(1) if m else 'windows-1252'
try:
return raw.decode(enc, 'replace')
except (LookupError, UnicodeDecodeError):
return raw.decode('iso-8859-1', 'replace')
def _renamed_path(path):
"""png/gif/webp/bmp/jpeg -> .jpg (jpg stays jpg)."""
return RENAME_RE.sub('.jpg', path)
def _rename_basename_refs(text):
"""Rewrite raster-image extensions to .jpg inside hrefs/src/url() references."""
return REF_RE.sub('.jpg', text)
def _strip_img_dims(text):
"""Remove width/height attributes from every <img> tag (regex fallback)."""
def repl(m):
tag = re.sub(r'\s+(?:width|height)\s*=\s*"[^"]*"', '', m.group(0),
flags=re.IGNORECASE)
tag = re.sub(r"\s+(?:width|height)\s*=\s*'[^']*'", '', tag,
flags=re.IGNORECASE)
tag = _fix_img_style_text(tag)
return tag
return re.sub(r'<img\b[^>]*>', repl, text, flags=re.IGNORECASE)
_STYLE_DECL_RE = re.compile(r'''
(?:^|(?<=;))
(?P<leading>\s*)
(?P<prop>[-_a-zA-Z][-_a-zA-Z0-9]*)\s*:
(?P<value>
(?:
[^;"'()]
| "(?:\\.|[^"\\])*"
| '(?:\\.|[^'\\])*'
| \((?:\\.|[^()\\])*\)
)*
)
(?P<trailing>\s*)
(?P<semicolon>;?)
''', re.VERBOSE)
def _filter_style_declarations(style, predicate):
if not style:
return style, False
modified = False
def repl(m):
nonlocal modified
prop = m.group('prop').strip().lower()
value = m.group('value').strip().lower()
if predicate(prop, value):
modified = True
return ''
return m.group(0)
return _STYLE_DECL_RE.sub(repl, style), modified
def _is_problem_img_decl(prop, value):
return prop in ('width', 'height')
def _fix_img_style_attr(style):
return _filter_style_declarations(style, _is_problem_img_decl)
def _fix_img_style_text(tag):
def repl(m):
quote = m.group(1)
style = m.group(2)
fixed, modified = _fix_img_style_attr(style)
if not modified:
return m.group(0)
if fixed:
return ' style=%s%s%s' % (quote, fixed, quote)
return ''
return re.sub(r'\s+style=(["\'])(.*?)\1', repl, tag,
flags=re.IGNORECASE | re.DOTALL)
def _fix_img_element(img):
modified = False
if img.get('width') is not None:
del img.attrib['width']
modified = True
if img.get('height') is not None:
del img.attrib['height']
modified = True
style = img.get('style')
new_style, style_modified = _fix_img_style_attr(style)
if style_modified:
if new_style:
img.set('style', new_style)
else:
del img.attrib['style']
modified = True
src = img.get('src')
if src and RENAME_RE.search(src):
img.set('src', RENAME_RE.sub('.jpg', src))
modified = True
return modified
def _fix_xhtml(text, log):
"""Normalize image sizing, unwrap SVG covers/images, rename refs, inject CSS."""
fixes = 0
# SVG cover / SVG-wrapped images: unwrap to a plain <img>.
new_text, n = _unwrap_svg_images(text)
if n:
text = new_text
fixes += n
log('FIX', 'SVG images (%d)' % n)
try:
from lxml import etree
parser = etree.XMLParser(recover=True, resolve_entities=False, huge_tree=True)
root = etree.fromstring(text.encode('utf-8'), parser=parser)
if root is not None:
modified = False
for img in root.iter('{http://www.w3.org/1999/xhtml}img'):
modified |= _fix_img_element(img)
# also catch namespace-less <img> (malformed docs)
for img in root.iter('img'):
modified |= _fix_img_element(img)
if modified:
# tostring() writes out the element tree alone, so the DOCTYPE
# has to be handed back or the rewrite drops it — and a
# document that loses its DTD loses its named entities with it.
text = etree.tostring(
root, encoding='unicode', xml_declaration=False,
doctype=root.getroottree().docinfo.doctype or None)
if not text.lstrip().startswith('<?xml'):
text = '<?xml version="1.0" encoding="utf-8"?>\n' + text
except Exception:
# Regex fallback: rename refs and drop width/height on img tags.
text = _rename_basename_refs(text)
text = _strip_img_dims(text)
# Inject the defensive stylesheet just before </head>.
if '</head>' in text and DEFENSIVE_STYLE not in text:
text = text.replace('</head>', DEFENSIVE_STYLE + '</head>', 1)
return text, fixes
_SVG_IMG_RE = re.compile(
r'<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*?'
r'(?:xlink:href|href)=["\']([^"\']+)["\'][\s\S]*?</(?:svg:)?svg>',
re.IGNORECASE)
def _unwrap_svg_images(content):
if '<svg' not in content or 'href' not in content:
return content, 0
count = [0]
def repl(m):
count[0] += 1
return ('<img style="max-width:100%%;height:auto" src="%s" alt="" />'
% RENAME_RE.sub('.jpg', m.group(1)))
new = _SVG_IMG_RE.sub(repl, content)
return new, count[0]
def _fix_opf(text, log):
"""Fix media-types for renamed images, drop svg properties, ensure cover meta."""
try:
from lxml import etree
parser = etree.XMLParser(recover=True, resolve_entities=False, huge_tree=True)
root = etree.fromstring(text.encode('utf-8'), parser=parser)
if root is None:
raise ValueError('opf parse failed')
opf_ns = 'http://www.idpf.org/2007/opf'
def items():
for it in root.iter('{%s}item' % opf_ns):
yield it
for it in root.iter('item'):
yield it
seen = set()
for it in items():
if id(it) in seen:
continue
seen.add(id(it))
href = it.get('href') or ''
if RENAME_RE.search(href):
it.set('href', RENAME_RE.sub('.jpg', href))
it.set('media-type', 'image/jpeg')
props = it.get('properties')
if props and 'svg' in props.split():
new_props = ' '.join(p for p in props.split() if p != 'svg').strip()
if new_props:
it.set('properties', new_props)
else:
del it.attrib['properties']
_ensure_cover_meta_lxml(root, opf_ns, log)
out = etree.tostring(root, encoding='unicode', xml_declaration=False,
doctype=root.getroottree().docinfo.doctype or None)
if not out.lstrip().startswith('<?xml'):
out = '<?xml version="1.0" encoding="utf-8"?>\n' + out
return out
except Exception:
# Regex fallback.
text = re.sub(
r'(<(?:\w+:)?item\b[^>]*href="[^"]+\.jpg"[^>]*)media-type="image/(?:png|gif|webp|bmp)"',
r'\1media-type="image/jpeg"', text)
text = _rename_basename_refs(text)
text = re.sub(r'\s+svg(?=["\'\s>])', '', text)
return text
def _ensure_cover_meta_lxml(root, opf_ns, log):
def tag(name):
return '{%s}%s' % (opf_ns, name)
cover_id = None
items = list(root.iter(tag('item'))) or list(root.iter('item'))
for it in items:
if 'cover-image' in (it.get('properties') or '') and \
(it.get('media-type') or '').startswith('image/'):
cover_id = it.get('id')
break
if not cover_id:
for it in items:
mt = (it.get('media-type') or '')
if not mt.startswith('image/'):
continue
if 'cover' in (it.get('id') or '').lower() or \
'cover' in (it.get('href') or '').lower():
cover_id = it.get('id')
break
if not cover_id:
return
metas = list(root.iter(tag('meta'))) or list(root.iter('meta'))
for m in metas:
if m.get('name') == 'cover':
if m.get('content') != cover_id:
m.set('content', cover_id)
log('FIX', 'OPF cover meta')
return
metadata = None
for md in root.iter(tag('metadata')):
metadata = md
break
if metadata is None:
for md in root.iter('metadata'):
metadata = md
break
if metadata is None:
return
from lxml import etree
new_meta = etree.SubElement(metadata, tag('meta'))
new_meta.set('name', 'cover')
new_meta.set('content', cover_id)
log('FIX', 'OPF cover meta')
def _sync_ncx_identifier(text, identifier):
if not identifier:
return text
return re.sub(
r'(<meta\s+name=["\']dtb:uid["\']\s+content=)["\'][^"\']*["\']',
lambda m: m.group(1) + '"%s"' % identifier, text, flags=re.IGNORECASE)
def _extract_identifier(opf_text):
m = re.search(
r'<(?:\w+:)?package[^>]*unique-identifier=["\']([^"\']+)["\']',
opf_text, re.IGNORECASE)
if m:
uid = re.escape(m.group(1))
m2 = re.search(
r'<dc:identifier[^>]*id=["\']%s["\'][^>]*>([^<]+)</dc:identifier>' % uid,
opf_text, re.IGNORECASE)
if m2:
return m2.group(1).strip()
m3 = re.search(r'<dc:identifier[^>]*>([^<]+)<', opf_text, re.IGNORECASE)
return m3.group(1).strip() if m3 else None
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def optimize_epub(in_path, out_path, profile, opts, log_fn=None):
"""Optimize an EPUB at ``in_path``, writing the result to ``out_path``.
``log_fn(tag, message)`` receives per-step events (may be None).
Returns a summary dict.
"""
start = time.time()
steps = []
def log(tag, message):
steps.append((tag, message))
if log_fn is not None:
try:
log_fn(tag, message)
except Exception:
pass
orig_size = os.path.getsize(in_path)
summary = {
'name': os.path.basename(in_path),
'orig_size': orig_size,
'new_size': orig_size,
'images': 0,
'cropped': 0,
'errors': 0,
'fixes': 0,
'profile': profile['label'],
'steps': steps,
'elapsed': 0.0,
}
log('INFO', '%s (%s) — target %s %dx%d, quality %d%%, grayscale %s, auto-crop %s' % (
summary['name'], _human(orig_size), profile['label'],
profile['width'], profile['height'], opts.quality,
'ON' if opts.grayscale else 'OFF', 'ON' if opts.auto_crop else 'OFF'))
zin = zipfile.ZipFile(in_path, 'r')
try:
names = zin.namelist()
renamed = {n: _renamed_path(n) for n in names if RENAME_RE.search(n)}
opf_text = None
identifier = None
# First locate the OPF (for NCX identifier sync).
for n in names:
if n.lower().endswith('.opf'):
opf_text = _decode_text(zin.read(n))
identifier = _extract_identifier(opf_text)
break
with zipfile.ZipFile(out_path, 'w') as zout:
# mimetype FIRST, stored uncompressed, per the OCF spec.
if 'mimetype' in names:
zout.writestr('mimetype', zin.read('mimetype'),
compress_type=zipfile.ZIP_STORED)
for n in names:
if n == 'mimetype':
continue
info = zin.getinfo(n)
if info.is_dir():
continue
low = n.lower()
data = zin.read(n)
if RASTER_RE.search(low):
try:
jpeg, meta = process_image(data, profile, opts, n)
summary['images'] += 1
if meta['cropped']:
summary['cropped'] += 1
log('IMG', '%s (%dx%d %s) → %dx%d %s%s' % (
os.path.basename(n), meta['orig_w'], meta['orig_h'],
_human(meta['orig_size']), meta['final_w'], meta['final_h'],
_human(meta['final_size']),
' [cropped]' if meta['cropped'] else ''))
zout.writestr(renamed.get(n, _renamed_path(n)), jpeg,
compress_type=zipfile.ZIP_STORED)
except Exception as exc:
summary['errors'] += 1
log('IMG-ERR', '%s: %s (kept original)' % (
os.path.basename(n), exc))
zout.writestr(n, data, compress_type=zipfile.ZIP_STORED)
elif XHTML_RE.search(low):
text = _decode_text(data)
text, fixes = _fix_xhtml(text, log)
summary['fixes'] += fixes
zout.writestr(n, text.encode('utf-8'),
compress_type=zipfile.ZIP_DEFLATED)
elif low.endswith('.opf'):
before = summary['fixes']
text = _decode_text(data)
text = _rename_basename_refs(text)
text = _fix_opf(text, log)
zout.writestr(n, text.encode('utf-8'),
compress_type=zipfile.ZIP_DEFLATED)
summary['fixes'] = max(summary['fixes'], before)
elif low.endswith('.ncx'):
text = _decode_text(data)
text = _rename_basename_refs(text)
new_text = _sync_ncx_identifier(text, identifier)
if new_text != text:
log('FIX', 'NCX identifier synced')
summary['fixes'] += 1
zout.writestr(n, new_text.encode('utf-8'),
compress_type=zipfile.ZIP_DEFLATED)
elif low.endswith('.css'):
text = _decode_text(data)
zout.writestr(n, _rename_basename_refs(text).encode('utf-8'),
compress_type=zipfile.ZIP_DEFLATED)
else:
zout.writestr(n, data, compress_type=zipfile.ZIP_DEFLATED)
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']
pct = (saved / float(orig_size) * 100.0) if orig_size else 0.0
log('DONE', 'Optimized %d image(s), %d fix(es) — %s%s (%+.0f%%) in %.1fs' % (
summary['images'], summary['fixes'], _human(orig_size),
_human(summary['new_size']), -pct, summary['elapsed']))
return summary
def _human(n):
n = float(n or 0)
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024.0 or unit == 'GB':
return '%.1f %s' % (n, unit) if unit != 'B' else '%d B' % int(n)
n /= 1024.0
return '%.1f GB' % n