diff --git a/crosspoint_reader-v0.1.1.zip b/crosspoint_reader-v0.1.1.zip deleted file mode 100644 index eafef8e..0000000 Binary files a/crosspoint_reader-v0.1.1.zip and /dev/null differ diff --git a/crosspoint_reader-v0.2.4.zip b/crosspoint_reader-v0.2.4.zip new file mode 100644 index 0000000..79d0a2d Binary files /dev/null and b/crosspoint_reader-v0.2.4.zip differ diff --git a/crosspoint_reader/README.md b/crosspoint_reader/README.md index fa0b7dd..f7551fc 100644 --- a/crosspoint_reader/README.md +++ b/crosspoint_reader/README.md @@ -1,25 +1,125 @@ -# CrossPoint Reader Calibre Plugin - -This plugin adds CrossPoint Reader as a wireless device in Calibre. It uploads -EPUB files over WebSocket to the CrossPoint web server. - -Protocol: -- Connect to ws://:/ -- Send: START::: -- Wait for READY -- Send binary frames with file content -- Wait for DONE (or ERROR:) - -Default settings: -- Auto-discover device via UDP -- Host fallback: 192.168.4.1 -- Port: 81 -- Upload path: / - -Install: -1. Download the latest release from the [releases page](https://github.com/crosspoint-reader/calibre-plugins/releases) (or zip the contents of this directory). -2. In Calibre: Preferences > Plugins > Load plugin from file. -3. The device should appear in Calibre once it is discoverable on the network. - -No configuration needed. The plugin auto-discovers the device via UDP and -falls back to 192.168.4.1:81. +# CrossPoint Reader - Calibre Plugin + +A Calibre device driver plugin for CrossPoint e-readers with built-in EPUB image conversion for optimal e-reader compatibility. + +## Version 0.2.3 + +## Features + +### Wireless Book Transfer +- Automatic device discovery via UDP broadcast +- WebSocket-based file transfer +- Support for nested folder structures +- Configurable upload paths + +### EPUB Image Conversion +Automatically converts EPUB images before uploading for maximum e-reader compatibility: + +- **Image Format Conversion**: Converts PNG, GIF, WebP, and BMP to baseline JPEG +- **SVG Cover Fix**: Converts SVG-based covers to standard HTML img tags +- **Image Scaling**: Scales oversized images to fit your e-reader screen +- **Light Novel Mode**: Rotates horizontal images 90° and splits them into multiple pages for manga/comics reading on vertical e-reader screens + +### Configuration Options + +#### Connection Settings +- **Host**: Device IP address (default: 192.168.4.1) +- **Port**: WebSocket port (default: 81) +- **Upload Path**: Default upload directory (default: /) +- **Chunk Size**: Transfer chunk size in bytes (default: 2048) +- **Debug Logging**: Enable detailed logging +- **Fetch Metadata**: Read metadata from device (slower) + +#### Image Conversion Settings +- **Enable Conversion**: Turn EPUB image conversion on/off +- **JPEG Quality**: 1-95% (default: 85%) + - Presets: Low (60%), Medium (75%), High (85%), Max (95%) +- **Light Novel Mode**: Rotate and split wide images +- **Screen Size**: Target screen dimensions (default: 480×800 px) +- **Split Overlap**: Overlap percentage for split pages (default: 15%) + +## Installation + +1. Download the plugin ZIP file +2. In Calibre, go to Preferences → Plugins → Load plugin from file +3. Select the downloaded ZIP file +4. Restart Calibre + +## Usage + +1. Connect your CrossPoint Reader to the same WiFi network as your computer +2. The device should appear automatically in Calibre's device list +3. Configure settings via Preferences → Plugins → CrossPoint Reader → Customize plugin ++4. Send books to device as usual — images are converted only when “Enable EPUB image conversion” is turned on + +## What the Converter Does + +✓ Converts PNG/GIF/WebP/BMP to baseline JPEG +✓ Fixes SVG covers for e-reader compatibility +✓ Scales large images to fit your screen dimensions +✓ Light Novel Mode: rotates & splits wide images for manga/comics +✓ Maintains EPUB structure and metadata +✓ Preserves original file if conversion fails + +## Requirements + +- Calibre 5.0 or later +- CrossPoint Reader device with WebSocket server enabled +- Same WiFi network for device discovery + +## Troubleshooting + +### Device not detected +1. Ensure device and computer are on the same network +2. Check the Host setting in plugin configuration +3. Enable debug logging to see discovery attempts +4. Try manually entering the device IP address + +### Images not converting +1. Verify "Enable EPUB image conversion" is checked +2. Check the debug log for conversion errors +3. Ensure sufficient disk space for temporary files + +### Poor image quality +- Increase JPEG Quality setting (try 85% or 95%) + +### Split images not aligned +- Adjust Split Overlap percentage (try 15-20%) + +## License + +This plugin is provided as-is for use with CrossPoint Reader devices. + +## Changelog + +### v0.2.3 +- Fixed: Basename collision when EPUBs have duplicate filenames in different folders (e.g., `Images/cover.png` and `assets/cover.png`) - now uses full path as key +- Fixed: Split Overlap control now disabled when Light Novel Mode is off (reflects actual dependency) +- Fixed: Failed image conversions now preserve original extension instead of writing invalid `.jpg` files +- Fixed: Temp file cleanup errors are now logged instead of silently ignored + +### v0.2.2 +- Changed: Conversion disabled by default (opt-in for safe upgrades from v0.1.x) +- Fixed: OPF manifest href now uses correct relative paths for images in subdirectories +- Fixed: `` replacement limited to first occurrence +- Fixed: Temp file cleanup on conversion failure +- Fixed: Progress callback closure now correctly captures loop index +- Fixed: Added length validation for files/names to prevent silent truncation +- Removed: Unused TemporaryDirectory import + +### v0.2.1 +- Fixed: mimetype now written first in EPUB archive (EPUB OCF spec compliance) +- Fixed: Preset quality buttons now disable when conversion is toggled off +- Fixed: Closure variable binding in replacement functions (B023) + +### v0.2.0 +- Added EPUB image conversion +- Added Light Novel Mode (rotate & split) +- Added configurable JPEG quality +- Added screen size settings +- Improved configuration UI with grouped settings + +### v0.1.1 +- Initial release +- Wireless book transfer +- Device auto-discovery diff --git a/crosspoint_reader/__init__.py b/crosspoint_reader/__init__.py index 9aaedbd..f71d93c 100644 --- a/crosspoint_reader/__init__.py +++ b/crosspoint_reader/__init__.py @@ -1,5 +1,22 @@ +""" +CrossPoint Reader - Calibre Device Driver Plugin + +A wireless device driver for CrossPoint e-readers with built-in +EPUB image conversion for optimal e-reader compatibility. + +Features: +- Wireless book transfer via WebSocket +- Automatic EPUB image conversion to baseline JPEG +- PNG/GIF/WebP/BMP to JPEG conversion +- Fix ALL SVG-wrapped images (not just covers) +- Image scaling to fit e-reader screen +- Light Novel Mode: rotate and split wide images for manga/comics +- Configurable JPEG quality and screen dimensions +""" + from .driver import CrossPointDevice class CrossPointReaderDevice(CrossPointDevice): + """CrossPoint Reader device driver for Calibre.""" pass diff --git a/crosspoint_reader/config.py b/crosspoint_reader/config.py index 6e3e241..d904aed 100644 --- a/crosspoint_reader/config.py +++ b/crosspoint_reader/config.py @@ -4,19 +4,25 @@ QDialog, QDialogButtonBox, QFormLayout, + QGroupBox, QHBoxLayout, + QLabel, QLineEdit, QPlainTextEdit, QPushButton, + QSlider, QSpinBox, QVBoxLayout, QWidget, + Qt, ) from .log import get_log_text PREFS = JSONConfig('plugins/crosspoint_reader') + +# Connection settings PREFS.defaults['host'] = '192.168.4.1' PREFS.defaults['port'] = 81 PREFS.defaults['path'] = '/' @@ -24,11 +30,24 @@ PREFS.defaults['debug'] = False PREFS.defaults['fetch_metadata'] = False +# Conversion settings (disabled by default for safe upgrades) +PREFS.defaults['enable_conversion'] = False +PREFS.defaults['jpeg_quality'] = 85 +PREFS.defaults['light_novel_mode'] = False +PREFS.defaults['screen_width'] = 480 +PREFS.defaults['screen_height'] = 800 +PREFS.defaults['split_overlap'] = 15 # percentage + class CrossPointConfigWidget(QWidget): def __init__(self): super().__init__() - layout = QFormLayout(self) + layout = QVBoxLayout(self) + + # Connection Settings Group + conn_group = QGroupBox("Connection Settings") + conn_layout = QFormLayout() + self.host = QLineEdit(self) self.port = QSpinBox(self) self.port.setRange(1, 65535) @@ -45,33 +64,176 @@ def __init__(self): self.debug.setChecked(PREFS['debug']) self.fetch_metadata.setChecked(PREFS['fetch_metadata']) - layout.addRow('Host', self.host) - layout.addRow('Port', self.port) - layout.addRow('Upload path', self.path) - layout.addRow('Chunk size', self.chunk_size) - layout.addRow('', self.debug) - layout.addRow('', self.fetch_metadata) - + conn_layout.addRow('Host', self.host) + conn_layout.addRow('Port', self.port) + conn_layout.addRow('Upload path', self.path) + conn_layout.addRow('Chunk size', self.chunk_size) + conn_layout.addRow('', self.debug) + conn_layout.addRow('', self.fetch_metadata) + conn_group.setLayout(conn_layout) + layout.addWidget(conn_group) + + # Conversion Settings Group + conv_group = QGroupBox("Image Conversion Settings") + conv_layout = QFormLayout() + + self.enable_conversion = QCheckBox('Enable EPUB image conversion', self) + self.enable_conversion.setChecked(PREFS['enable_conversion']) + self.enable_conversion.setToolTip( + "Convert images to baseline JPEG format for e-reader compatibility.\n" + "Converts PNG/GIF/WebP/BMP to JPEG, fixes SVG covers, and scales images." + ) + conv_layout.addRow('', self.enable_conversion) + + # JPEG Quality slider with value display + quality_widget = QWidget() + quality_layout = QHBoxLayout(quality_widget) + quality_layout.setContentsMargins(0, 0, 0, 0) + + self.jpeg_quality = QSlider(Qt.Orientation.Horizontal) + self.jpeg_quality.setRange(1, 95) + self.jpeg_quality.setValue(PREFS['jpeg_quality']) + self.jpeg_quality.setTickPosition(QSlider.TickPosition.TicksBelow) + self.jpeg_quality.setTickInterval(10) + + self.quality_label = QLabel(f"{PREFS['jpeg_quality']}%") + self.quality_label.setMinimumWidth(40) + self.jpeg_quality.valueChanged.connect( + lambda v: self.quality_label.setText(f"{v}%") + ) + + quality_layout.addWidget(self.jpeg_quality) + quality_layout.addWidget(self.quality_label) + conv_layout.addRow('JPEG Quality', quality_widget) + + # Quality presets + presets_widget = QWidget() + presets_layout = QHBoxLayout(presets_widget) + presets_layout.setContentsMargins(0, 0, 0, 0) + + self.preset_buttons = [] # Track for enable/disable + for name, value in [('Low (60%)', 60), ('Medium (75%)', 75), + ('High (85%)', 85), ('Max (95%)', 95)]: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, v=value: self._set_quality(v)) + presets_layout.addWidget(btn) + self.preset_buttons.append(btn) + + conv_layout.addRow('Presets', presets_widget) + + # Light Novel Mode + self.light_novel_mode = QCheckBox('Light Novel Mode (rotate & split wide images)', self) + self.light_novel_mode.setChecked(PREFS['light_novel_mode']) + self.light_novel_mode.setToolTip( + "Rotate horizontal images 90° and split into multiple pages\n" + "for vertical reading on e-readers. Best for manga/comics." + ) + conv_layout.addRow('', self.light_novel_mode) + + # Screen dimensions + screen_widget = QWidget() + screen_layout = QHBoxLayout(screen_widget) + screen_layout.setContentsMargins(0, 0, 0, 0) + + self.screen_width = QSpinBox() + self.screen_width.setRange(100, 2000) + self.screen_width.setValue(PREFS['screen_width']) + self.screen_width.setSuffix(' px') + + screen_layout.addWidget(self.screen_width) + screen_layout.addWidget(QLabel('×')) + + self.screen_height = QSpinBox() + self.screen_height.setRange(100, 2000) + self.screen_height.setValue(PREFS['screen_height']) + self.screen_height.setSuffix(' px') + + screen_layout.addWidget(self.screen_height) + screen_layout.addStretch() + conv_layout.addRow('Screen Size', screen_widget) + + # Split overlap + overlap_widget = QWidget() + overlap_layout = QHBoxLayout(overlap_widget) + overlap_layout.setContentsMargins(0, 0, 0, 0) + + self.split_overlap = QSpinBox() + self.split_overlap.setRange(0, 50) + self.split_overlap.setValue(PREFS['split_overlap']) + self.split_overlap.setSuffix('%') + self.split_overlap.setToolTip("Overlap between split pages (for Light Novel Mode)") + + overlap_layout.addWidget(self.split_overlap) + overlap_layout.addStretch() + conv_layout.addRow('Split Overlap', overlap_widget) + + conv_group.setLayout(conv_layout) + layout.addWidget(conv_group) + + # Enable/disable conversion options based on checkbox + self.enable_conversion.toggled.connect(self._update_conversion_enabled) + self._update_conversion_enabled(self.enable_conversion.isChecked()) + + # Gate split_overlap on light_novel_mode + self.light_novel_mode.toggled.connect(self._update_split_overlap_enabled) + self._update_split_overlap_enabled(self.light_novel_mode.isChecked()) + + # Log section + log_group = QGroupBox("Debug Log") + log_layout = QVBoxLayout() + self.log_view = QPlainTextEdit(self) self.log_view.setReadOnly(True) - self.log_view.setPlaceholderText('Discovery log will appear here when debug is enabled.') + self.log_view.setMaximumHeight(150) + self.log_view.setPlaceholderText('Discovery and conversion log will appear here.') self._refresh_logs() refresh_btn = QPushButton('Refresh Log', self) refresh_btn.clicked.connect(self._refresh_logs) - log_layout = QHBoxLayout() + + log_layout.addWidget(self.log_view) log_layout.addWidget(refresh_btn) - - layout.addRow('Log', self.log_view) - layout.addRow('', log_layout) + log_group.setLayout(log_layout) + layout.addWidget(log_group) + + def _set_quality(self, value): + """Set JPEG quality from preset button.""" + self.jpeg_quality.setValue(value) + + def _update_conversion_enabled(self, enabled): + """Enable/disable conversion options based on master checkbox.""" + self.jpeg_quality.setEnabled(enabled) + self.quality_label.setEnabled(enabled) + for btn in self.preset_buttons: + btn.setEnabled(enabled) + self.light_novel_mode.setEnabled(enabled) + self.screen_width.setEnabled(enabled) + self.screen_height.setEnabled(enabled) + # split_overlap depends on both conversion enabled AND light_novel_mode + self._update_split_overlap_enabled(self.light_novel_mode.isChecked()) + + def _update_split_overlap_enabled(self, light_novel_enabled): + """Enable/disable split overlap based on Light Novel Mode.""" + # split_overlap is only enabled if BOTH conversion AND light_novel_mode are on + conversion_enabled = self.enable_conversion.isChecked() + self.split_overlap.setEnabled(conversion_enabled and light_novel_enabled) def save(self): + # Connection settings PREFS['host'] = self.host.text().strip() or PREFS.defaults['host'] PREFS['port'] = int(self.port.value()) PREFS['path'] = self.path.text().strip() or PREFS.defaults['path'] PREFS['chunk_size'] = int(self.chunk_size.value()) PREFS['debug'] = bool(self.debug.isChecked()) PREFS['fetch_metadata'] = bool(self.fetch_metadata.isChecked()) + + # Conversion settings + PREFS['enable_conversion'] = bool(self.enable_conversion.isChecked()) + PREFS['jpeg_quality'] = int(self.jpeg_quality.value()) + PREFS['light_novel_mode'] = bool(self.light_novel_mode.isChecked()) + PREFS['screen_width'] = int(self.screen_width.value()) + PREFS['screen_height'] = int(self.screen_height.value()) + PREFS['split_overlap'] = int(self.split_overlap.value()) def _refresh_logs(self): self.log_view.setPlainText(get_log_text()) @@ -84,6 +246,7 @@ class CrossPointConfigDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle('CrossPoint Reader') + self.setMinimumWidth(500) self.widget = CrossPointConfigWidget() layout = QVBoxLayout(self) layout.addWidget(self.widget) diff --git a/crosspoint_reader/converter.py b/crosspoint_reader/converter.py new file mode 100644 index 0000000..6a97981 --- /dev/null +++ b/crosspoint_reader/converter.py @@ -0,0 +1,571 @@ +""" +EPUB Image Converter for CrossPoint Reader + +Converts EPUB images to baseline JPEG format with various optimizations +for e-reader compatibility. + +Features: +- Convert PNG/GIF/WebP/BMP to baseline JPEG +- Fix ALL SVG-wrapped images for e-readers (not just covers) +- Scale large images to fit screen +- Light Novel Mode: rotate wide images and split into pages +- Configurable JPEG quality +""" + +import io +import os +import re +import zipfile +from contextlib import contextmanager + +# Pillow is bundled with Calibre +from PIL import Image + + +class EpubConverter: + """Convert EPUB images to baseline JPEG format.""" + + def __init__(self, + jpeg_quality=85, + max_width=480, + max_height=800, + enable_split_rotate=False, + overlap=0.15, + logger=None): + """ + Initialize converter. + + Args: + jpeg_quality: JPEG quality 1-95 (default 85) + max_width: Maximum image width in pixels (default 480) + max_height: Maximum image height in pixels (default 800) + enable_split_rotate: Enable Light Novel Mode (default False) + overlap: Overlap percentage for split images (default 0.15) + logger: Optional logging function + """ + self.jpeg_quality = max(1, min(95, jpeg_quality)) + self.max_width = max_width + self.max_height = max_height + self.enable_split_rotate = enable_split_rotate + self.overlap = overlap + self._log = logger or (lambda x: None) + + # Statistics + self.stats = { + 'images_converted': 0, + 'svg_covers_fixed': 0, + 'images_split': 0, + 'original_size': 0, + 'new_size': 0, + } + + def convert_epub(self, input_path, output_path=None): + """ + Convert an EPUB file. + + Args: + input_path: Path to input EPUB file + output_path: Path to output EPUB file (default: input_baseline.epub) + + Returns: + Path to converted EPUB file + """ + if output_path is None: + base, ext = os.path.splitext(input_path) + output_path = f"{base}_baseline{ext}" + + # Reset stats + self.stats = { + 'images_converted': 0, + 'svg_covers_fixed': 0, + 'images_split': 0, + 'original_size': os.path.getsize(input_path), + 'new_size': 0, + } + + # Track renamed files and split images + renamed = {} # old_path -> new_path + split_images = {} # orig_name -> [{'path', 'imgName', 'id'}, ...] + xhtml_files = {} # path -> content + opf_path = None + opf_content = None + + self._log(f"Converting: {os.path.basename(input_path)}") + self._log(f"Quality: {self.jpeg_quality}%") + self._log(f"Light Novel Mode: {'ON' if self.enable_split_rotate else 'OFF'}") + + with zipfile.ZipFile(input_path, 'r') as zin: + # Build rename map for non-JPEG images + for name in zin.namelist(): + low = name.lower() + if re.match(r'.*\.(png|gif|webp|bmp|jpeg)$', low): + new_name = re.sub(r'\.(png|gif|webp|bmp|jpeg)$', '.jpg', name, flags=re.IGNORECASE) + renamed[name] = new_name + + with zipfile.ZipFile(output_path, 'w') as zout: + # CRITICAL: Write mimetype FIRST per EPUB OCF spec + # It must be uncompressed and the first entry in the archive + if 'mimetype' in zin.namelist(): + zout.writestr('mimetype', zin.read('mimetype'), compress_type=zipfile.ZIP_STORED) + + # First pass: process images + for name in zin.namelist(): + if name == 'mimetype': + continue # Already written + low = name.lower() + + if re.match(r'.*\.(png|gif|webp|bmp|jpg|jpeg)$', low): + data = zin.read(name) + parts, conversion_success = self._process_image(data, name) + + base_name = re.sub(r'\.[^.]+$', '', name) + + if len(parts) == 1 and parts[0]['suffix'] == '': + # Single image, no split + if conversion_success: + # Conversion succeeded - use .jpg extension + new_path = renamed.get(name, re.sub(r'\.[^.]+$', '.jpg', name)) + else: + # Conversion failed - preserve original extension + new_path = name + zout.writestr(new_path, parts[0]['data'], + compress_type=zipfile.ZIP_DEFLATED) + self.stats['images_converted'] += 1 + else: + # Split image - use full path as key to avoid basename collisions + orig_basename = os.path.basename(name) + orig_dir = name[:name.rfind('/') + 1] if '/' in name else '' + split_images[name] = { + 'basename': orig_basename, + 'dir': orig_dir, + 'parts': [] + } + + for part in parts: + part_name = os.path.basename(base_name) + part['suffix'] + '.jpg' + part_path = orig_dir + part_name + + zout.writestr(part_path, part['data'], + compress_type=zipfile.ZIP_DEFLATED) + split_images[name]['parts'].append({ + 'path': part_path, + 'imgName': part_name, + 'id': os.path.basename(base_name) + part['suffix'] + }) + self.stats['images_converted'] += 1 + + self.stats['images_split'] += len(parts) - 1 + + elif re.match(r'.*\.(xhtml|html|htm)$', low): + xhtml_files[name] = zin.read(name).decode('utf-8', errors='ignore') + + elif low.endswith('.opf'): + opf_path = name + opf_content = zin.read(name).decode('utf-8', errors='ignore') + + # Second pass: update XHTML files + for path, content in xhtml_files.items(): + t = content + + # Fix SVG covers + fixed_svg = self._fix_svg_cover(t) + if fixed_svg['fixed']: + t = fixed_svg['content'] + self.stats['svg_covers_fixed'] += 1 + + # Update image references + for old, new in renamed.items(): + old_name = os.path.basename(old) + new_name = os.path.basename(new) + t = t.replace(old_name, new_name) + + # Update split image references + for orig_path, split_info in split_images.items(): + orig_basename = split_info['basename'] + parts = split_info['parts'] + new_basename = re.sub(r'\.(png|gif|webp|bmp|jpeg)$', '.jpg', orig_basename, flags=re.IGNORECASE) + + # Replace block patterns (p/div with span and img) + block_pattern = re.compile( + r'(<(?:p|div)[^>]*>\s*\s*]*src=["\'][^"\']*(?:' + + re.escape(orig_basename) + '|' + re.escape(new_basename) + + r')[^>]*/?>\s*\s*)', + re.IGNORECASE | re.DOTALL + ) + + # Bind loop variables via default arguments to avoid B023 + def replace_block(match, parts=parts, orig_basename=orig_basename, new_basename=new_basename): + result = [] + for i, part in enumerate(parts): + if i > 0: + result.append('\n') + new_block = match.group(0).replace(orig_basename, part['imgName']) + new_block = new_block.replace(new_basename, part['imgName']) + result.append(new_block) + return ''.join(result) + + t = block_pattern.sub(replace_block, t) + + # Replace simple img patterns + simple_pattern = re.compile( + r'(]*src=["\'])([^"\']*(?:' + + re.escape(orig_basename) + '|' + re.escape(new_basename) + + r'))([^>]*/>)', + re.IGNORECASE + ) + + # Bind loop variables via default arguments to avoid B023 + def replace_simple(match, parts=parts, orig_basename=orig_basename, new_basename=new_basename): + result = [] + for i, part in enumerate(parts): + if i > 0: + result.append('\n') + new_src = match.group(2).replace(orig_basename, part['imgName']) + new_src = new_src.replace(new_basename, part['imgName']) + result.append(match.group(1) + new_src + match.group(3)) + return ''.join(result) + + t = simple_pattern.sub(replace_simple, t) + + zout.writestr(path, t.encode('utf-8'), + compress_type=zipfile.ZIP_DEFLATED) + + # Third pass: update OPF + if opf_content: + t = opf_content + + # Update image references + for old, new in renamed.items(): + old_name = os.path.basename(old) + new_name = os.path.basename(new) + t = t.replace(old_name, new_name) + + # Fix media-types for converted images + t = re.sub( + r'href="([^"]+\.jpg)"([^>]*)media-type="image/(png|gif|webp|bmp)"', + r'href="\1"\2media-type="image/jpeg"', + t + ) + t = re.sub( + r'media-type="image/(png|gif|webp|bmp)"([^>]*)href="([^"]+\.jpg)"', + r'media-type="image/jpeg"\2href="\3"', + t + ) + + # Update split image references in OPF + # Calculate OPF directory for relative paths + opf_dir = os.path.dirname(opf_path) if '/' in opf_path else '' + + for orig_path, split_info in split_images.items(): + orig_basename = split_info['basename'] + parts = split_info['parts'] + orig_base = re.sub(r'\.[^.]+$', '', orig_basename) + + # Update original reference to part1 + pattern = re.compile( + r'(href=["\'][^"\']*/?)('+re.escape(orig_base)+r')\.(?:jpg|jpeg|png|gif|webp|bmp)(["\'])', + re.IGNORECASE + ) + t = pattern.sub(r'\g<1>' + orig_base + r'_part1.jpg\3', t) + + # Add manifest entries for additional parts + manifest_additions = '' + for j in range(1, len(parts)): + p = parts[j] + # Calculate href relative to OPF directory + part_full_path = p['path'] + if opf_dir and part_full_path.startswith(opf_dir + '/'): + href = part_full_path[len(opf_dir) + 1:] + elif opf_dir: + # Image is in different directory, use relative path + href = os.path.relpath(part_full_path, opf_dir).replace('\\', '/') + else: + href = part_full_path + manifest_additions += f'\n' + + if manifest_additions: + t = t.replace('', manifest_additions + '', 1) + + # Ensure cover meta + fixed_cover = self._ensure_cover_meta(t) + if fixed_cover['fixed']: + t = fixed_cover['content'] + self._log("Fixed cover meta") + + zout.writestr(opf_path, t.encode('utf-8'), + compress_type=zipfile.ZIP_DEFLATED) + + # Fourth pass: copy remaining files + for name in zin.namelist(): + if name == 'mimetype': + continue # Already written first + low = name.lower() + + # Skip already processed files + if re.match(r'.*\.(png|gif|webp|bmp|jpg|jpeg)$', low): + continue + if re.match(r'.*\.(xhtml|html|htm)$', low): + continue + if low.endswith('.opf'): + continue + + data = zin.read(name) + + # Update CSS and NCX references + if low.endswith('.css') or low.endswith('.ncx'): + text = data.decode('utf-8', errors='ignore') + for old, new in renamed.items(): + old_name = os.path.basename(old) + new_name = os.path.basename(new) + text = text.replace(old_name, new_name) + data = text.encode('utf-8') + + zout.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED) + + self.stats['new_size'] = os.path.getsize(output_path) + + saved = self.stats['original_size'] - self.stats['new_size'] + if saved > 0: + pct = (saved / self.stats['original_size']) * 100 + self._log(f"Converted {self.stats['images_converted']} images") + self._log(f"Saved {self._format_bytes(saved)} ({pct:.1f}%)") + else: + self._log(f"Converted {self.stats['images_converted']} images") + self._log(f"Size increased by {self._format_bytes(-saved)}") + + if self.stats['images_split'] > 0: + self._log(f"Created {self.stats['images_split']} additional pages from splits") + if self.stats['svg_covers_fixed'] > 0: + self._log(f"Fixed {self.stats['svg_covers_fixed']} SVG image(s)") + + return output_path + + def _process_image(self, data, name): + """ + Process a single image. + + Returns tuple: (list of {'data': bytes, 'suffix': str}, bool success) + If success is False, the original data is returned unchanged. + """ + try: + img = Image.open(io.BytesIO(data)) + + # Convert to RGB if necessary + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + if img.mode in ('RGBA', 'LA'): + background.paste(img, mask=img.split()[-1]) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + orig_w, orig_h = img.size + + # Check if horizontal and exceeds screen + is_horizontal = orig_w > orig_h + exceeds_screen = orig_w > self.max_width or orig_h > self.max_height + needs_rotation = is_horizontal and exceeds_screen + + if needs_rotation and self.enable_split_rotate: + return self._process_split_rotate(img, orig_w, orig_h), True + else: + return self._process_normal(img, orig_w, orig_h), True + + except Exception as e: + self._log(f"Error processing {name}: {e}") + # Return original data as fallback with success=False + return [{'data': data, 'suffix': ''}], False + + def _process_normal(self, img, orig_w, orig_h): + """Process image without rotation/split.""" + fits_in_screen = orig_w <= self.max_width and orig_h <= self.max_height + + if not fits_in_screen: + # Scale to fit + scale = min(self.max_width / orig_w, self.max_height / orig_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) + + # Save as baseline JPEG + buf = io.BytesIO() + img.save(buf, 'JPEG', quality=self.jpeg_quality, progressive=False) + return [{'data': buf.getvalue(), 'suffix': ''}] + + def _process_split_rotate(self, img, orig_w, orig_h): + """Process horizontal image with rotation and optional split.""" + # Step 1: Scale width to max_height (800) + scale = self.max_height / orig_w + scaled_w = self.max_height + scaled_h = int(orig_h * scale) + + img = img.resize((scaled_w, scaled_h), Image.Resampling.LANCZOS) + + # Step 2: Rotate 90° clockwise + img = img.transpose(Image.Transpose.ROTATE_270) + rot_w, rot_h = img.size + + # Step 3: Split if needed + if rot_w <= self.max_width: + # No split needed + buf = io.BytesIO() + img.save(buf, 'JPEG', quality=self.jpeg_quality, progressive=False) + return [{'data': buf.getvalue(), 'suffix': ''}] + else: + # Split by WIDTH (vertical cuts) - from RIGHT to LEFT + # After 90° CW rotation: right side becomes top, left becomes bottom + # So we cut from right to left to get top-to-bottom order + parts = [] + max_w = self.max_width + overlap_px = int(max_w * self.overlap) + step = max_w - overlap_px + num_parts = (rot_w - overlap_px + step - 1) // step # ceil division + + for i in range(num_parts): + # Start from right side (rot_w) and go left + x = rot_w - max_w - (i * step) + if i == num_parts - 1: + x = 0 # Last part starts at left edge + x = max(0, x) + + part_w = min(max_w, rot_w - x) + + part_img = img.crop((x, 0, x + part_w, rot_h)) + + buf = io.BytesIO() + part_img.save(buf, 'JPEG', quality=self.jpeg_quality, progressive=False) + parts.append({'data': buf.getvalue(), 'suffix': f'_part{i + 1}'}) + + return parts + + def _fix_svg_cover(self, content): + """Fix ALL SVG-wrapped images to regular HTML img tags. + + Replaces with . + Works for all SVG images, not just covers. + """ + if ']*>.*?]*xlink:href\s*=\s*["\']([^"\']+)["\'][^>]*/?>.*?', + re.DOTALL | re.IGNORECASE + ) + + for match in svg_pattern.finditer(result): + svg_tag = match.group(0) + image_path = match.group(1) + + # Extract title if present for alt text + title_match = re.search(r']*>([^<]*)', svg_tag, re.IGNORECASE) + alt_text = title_match.group(1).strip() if title_match else '' + + # Extract class from SVG if present + class_match = re.search(r'class=["\']([^"\']*)["\']', svg_tag, re.IGNORECASE) + svg_class = f' class="{class_match.group(1)}"' if class_match else '' + + # Build replacement img tag + img_tag = f'{alt_text}' + result = result.replace(svg_tag, img_tag) + fixed_count += 1 + + # Pattern 2: SVG with href attribute (without xlink:) + svg_pattern2 = re.compile( + r']*>\s*]*href=["\']([^"\']+)["\'][^>]*/?>\s*', + re.DOTALL | re.IGNORECASE + ) + + for match in svg_pattern2.finditer(result): + svg_tag = match.group(0) + image_path = match.group(1) + img_tag = f'' + result = result.replace(svg_tag, img_tag) + fixed_count += 1 + + return {'content': result, 'fixed': fixed_count > 0} + + def _ensure_cover_meta(self, content): + """Ensure OPF has correct cover meta tag.""" + cover_id = None + + # Try to find cover image ID + patterns = [ + r']+id="([^"]+)"[^>]+properties="[^"]*cover-image[^"]*"', + r']+properties="[^"]*cover-image[^"]*"[^>]+id="([^"]+)"', + r']+id="([^"]+)"[^>]+href="[^"]*cover[^"]*"[^>]*media-type="image/', + r']+href="[^"]*cover[^"]*"[^>]+id="([^"]+)"[^>]*media-type="image/', + r']+id="([^"]*cover[^"]*)"[^>]+media-type="image/', + r']+media-type="image/[^"]*"[^>]+id="([^"]*cover[^"]*)"', + ] + + for pattern in patterns: + match = re.search(pattern, content, re.IGNORECASE) + if match: + cover_id = match.group(1) + break + + if not cover_id: + return {'content': content, 'fixed': False} + + # Check if cover meta exists + meta_match = (re.search(r'', + f'', + content + ) + content = re.sub( + r'', + f'', + content + ) + return {'content': content, 'fixed': True} + return {'content': content, 'fixed': False} + + # Add missing cover meta (only replace first occurrence) + content = content.replace( + '', + f' \n ', + 1 + ) + return {'content': content, 'fixed': True} + + @staticmethod + def _format_bytes(b): + """Format bytes as human-readable string.""" + if b < 1024: + return f"{b} B" + elif b < 1024 * 1024: + return f"{b / 1024:.1f} KB" + elif b < 1024 * 1024 * 1024: + return f"{b / (1024 * 1024):.1f} MB" + else: + return f"{b / (1024 * 1024 * 1024):.1f} GB" + + +def convert_epub_file(input_path, output_path=None, **kwargs): + """ + Convenience function to convert an EPUB file. + + Args: + input_path: Path to input EPUB + output_path: Path to output EPUB (optional) + **kwargs: Options passed to EpubConverter + + Returns: + Path to converted EPUB + """ + converter = EpubConverter(**kwargs) + return converter.convert_epub(input_path, output_path) diff --git a/crosspoint_reader/driver.py b/crosspoint_reader/driver.py index 053a391..e34227d 100644 --- a/crosspoint_reader/driver.py +++ b/crosspoint_reader/driver.py @@ -9,19 +9,21 @@ from calibre.devices.usbms.deviceconfig import DeviceConfig from calibre.devices.usbms.books import Book, BookList from calibre.ebooks.metadata.book.base import Metadata +from calibre.ptempfile import PersistentTemporaryFile from . import ws_client from .config import CrossPointConfigWidget, PREFS +from .converter import EpubConverter from .log import add_log class CrossPointDevice(DeviceConfig, DevicePlugin): name = 'CrossPoint Reader' gui_name = 'CrossPoint Reader' - description = 'CrossPoint Reader wireless device' + description = 'CrossPoint Reader wireless device with EPUB image conversion' supported_platforms = ['windows', 'osx', 'linux'] author = 'CrossPoint Reader' - version = (0, 1, 1) + version = (0, 2, 4) # Invalid USB vendor info to avoid USB scans matching. VENDOR_ID = [0xFFFF] @@ -274,6 +276,48 @@ def _ensure_dir(self, parent_path, subdirs): return '/' + subdir_path return parent_path + '/' + subdir_path + def _convert_epub(self, input_path): + """Convert EPUB images to baseline JPEG format. + + Returns path to converted file (may be a temp file). + """ + if not PREFS['enable_conversion']: + return input_path + + temp_path = None + try: + # Create converter with settings from preferences + converter = EpubConverter( + jpeg_quality=PREFS['jpeg_quality'], + max_width=PREFS['screen_width'], + max_height=PREFS['screen_height'], + enable_split_rotate=PREFS['light_novel_mode'], + overlap=PREFS['split_overlap'] / 100.0, + logger=self._log, + ) + + # Create temp file for converted EPUB + temp_file = PersistentTemporaryFile(suffix='_baseline.epub') + temp_path = temp_file.name + temp_file.close() + + # Convert + self._log(f'[CrossPoint] Converting: {os.path.basename(input_path)}') + converter.convert_epub(input_path, temp_path) + + return temp_path + + except Exception as exc: + self._log(f'[CrossPoint] Conversion failed: {exc}') + # Clean up temp file on failure + if temp_path: + try: + os.remove(temp_path) + except Exception as cleanup_err: + self._log(f'[CrossPoint] Failed to clean up temp file {temp_path}: {cleanup_err}') + # Return original file if conversion fails + return input_path + def upload_books(self, files, names, on_card=None, end_session=True, metadata=None): host = self.device_host or PREFS['host'] port = self.device_port or PREFS['port'] @@ -284,6 +328,10 @@ def upload_books(self, files, names, on_card=None, end_session=True, metadata=No chunk_size = 2048 debug = PREFS['debug'] + # Validate input lengths + if len(files) != len(names): + raise ControlError(desc=f'Mismatch: {len(files)} files but {len(names)} names') + # Normalize base upload path base_path = upload_path if not base_path.startswith('/'): @@ -293,47 +341,67 @@ def upload_books(self, files, names, on_card=None, end_session=True, metadata=No paths = [] total = len(files) - for i, (infile, name) in enumerate(zip(files, names)): - if hasattr(infile, 'read'): - filepath = getattr(infile, 'name', None) - if not filepath: - raise ControlError(desc='In-memory uploads are not supported') - else: - filepath = infile - filename = os.path.basename(name) - subdirs = [] - if metadata and i < len(metadata): - subdirs, filename = self._format_upload_path(metadata[i], filename) - - if subdirs: - target_dir = self._ensure_dir(base_path, subdirs) - else: - target_dir = base_path - - if target_dir == '/': - lpath = '/' + filename - else: - lpath = target_dir + '/' + filename - - def _progress(sent, size): - if size > 0: - self.report_progress((i + sent / float(size)) / float(total), - 'Transferring books to device...') - - ws_client.upload_file( - host, - port, - target_dir, - filename, - filepath, - chunk_size=chunk_size, - debug=debug, - progress_cb=_progress, - logger=self._log, - ) - paths.append((lpath, os.path.getsize(filepath))) - - self.report_progress(1.0, 'Transferring books to device...') + temp_files = [] # Track temp files for cleanup + + try: + for i, (infile, name) in enumerate(zip(files, names)): + if hasattr(infile, 'read'): + filepath = getattr(infile, 'name', None) + if not filepath: + raise ControlError(desc='In-memory uploads are not supported') + else: + filepath = infile + + # Convert EPUB if enabled + converted_path = self._convert_epub(filepath) + if converted_path != filepath: + temp_files.append(converted_path) + filepath = converted_path + + filename = os.path.basename(name) + subdirs = [] + if metadata and i < len(metadata): + subdirs, filename = self._format_upload_path(metadata[i], filename) + + if subdirs: + target_dir = self._ensure_dir(base_path, subdirs) + else: + target_dir = base_path + + if target_dir == '/': + lpath = '/' + filename + else: + lpath = target_dir + '/' + filename + + # Bind loop variables via default arguments to avoid closure bug + def _progress(sent, size, i=i, total=total): + if size > 0: + self.report_progress((i + sent / float(size)) / float(total), + 'Transferring books to device...') + + ws_client.upload_file( + host, + port, + target_dir, + filename, + filepath, + chunk_size=chunk_size, + debug=debug, + progress_cb=_progress, + logger=self._log, + ) + paths.append((lpath, os.path.getsize(filepath))) + + self.report_progress(1.0, 'Transferring books to device...') + + finally: + # Clean up temp files + for temp_path in temp_files: + try: + os.remove(temp_path) + except Exception as cleanup_err: + self._log(f'[CrossPoint] Failed to clean up temp file {temp_path}: {cleanup_err}') + return paths def add_books_to_metadata(self, locations, metadata, booklists): diff --git a/crosspoint_reader/ws_client.py b/crosspoint_reader/ws_client.py index d87fa0b..4e36fc9 100644 --- a/crosspoint_reader/ws_client.py +++ b/crosspoint_reader/ws_client.py @@ -3,6 +3,7 @@ import select import socket import struct +import sys import time @@ -153,18 +154,31 @@ def _recv_exact(self, n): return data def drain_messages(self): + """Drain all pending messages from the socket. + + On Windows, skip draining to avoid socket timeout interference. + On Unix/Linux/Mac, use select.select() which works correctly. + """ if self.sock is None: return [] messages = [] - while True: - r, _, _ = select.select([self.sock], [], [], 0) - if not r: - break - opcode, payload = self._read_frame() - if opcode == 0x1: - messages.append(payload.decode('utf-8', 'ignore')) - elif opcode == 0x8: - raise WebSocketError('Connection closed') + + if sys.platform == 'win32': + # Windows: Skip draining to avoid interfering with socket timeout + # select.select() doesn't work with sockets on Windows + return [] + else: + # Unix/Linux/Mac: select.select() works fine with sockets + while True: + r, _, _ = select.select([self.sock], [], [], 0) + if not r: + break + opcode, payload = self._read_frame() + if opcode == 0x1: + messages.append(payload.decode('utf-8', 'ignore')) + elif opcode == 0x8: + raise WebSocketError('Connection closed') + return messages @@ -253,7 +267,7 @@ def discover_device(timeout=2.0, debug=False, logger=None, extra_hosts=None): def upload_file(host, port, upload_path, filename, filepath, chunk_size=16384, debug=False, progress_cb=None, logger=None): - client = WebSocketClient(host, port, timeout=10, debug=debug, logger=logger) + client = WebSocketClient(host, port, timeout=60, debug=debug, logger=logger) try: client.connect() size = os.path.getsize(filepath)