Skip to content

Commit 984647e

Browse files
fix(epub-fixer): improve Kindle compatibility and fix XML parsing order
- Reorder process() execution: fix_encoding() now runs BEFORE fix_book_language() to clean malformed XML declarations before parsing attempts - Add try/except wrapper to fix_book_language() for graceful handling of non-standard EPUB structures (e.g., duplicate XML declarations) - Replace minidom.toxml() with regex-based updates in fix_book_language() to preserve XML attribute order (prevents breaking Amazon's strict parser) - Add strip_embedded_fonts() to remove TTF/OTF/WOFF fonts and @font-face CSS declarations, plus manifest entries (Kindle doesn't support custom fonts reliably) - Add remove_javascript() to strip <script> tags (not supported on Kindle) - Add validate_images() to check format/size compatibility and warn about issues - Add validate_css() for non-invasive syntax validation with Kindle compatibility warnings - Add strip_amazon_identifiers() function (implemented but not called by default per user preference to preserve complete metadata) - Extend fix_encoding() malformed XML detection to cover OPF/XML/NCX files, not just HTML/XHTML Tested with both working and failing EPUBs - execution order fix allows proper language validation on files with initially malformed XML, and all new fixes are non-destructive with proper error handling. Resolves #920
1 parent 3149237 commit 984647e

2 files changed

Lines changed: 283 additions & 20 deletions

File tree

CONTRIBUTORS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
315315
- zhiyue (1 commits)
316316
# Fork Contributors (crocodilestick/calibre-web-automated)
317317

318-
- crocodilestick (954 commits)
318+
- crocodilestick (956 commits)
319319
- jmarmstrong1207 (73 commits)
320320
- demitrix (30 commits)
321321
- sirwolfgang (29 commits)

scripts/kindle_epub_fixer.py

Lines changed: 282 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -301,24 +301,24 @@ def fix_book_language(self, default_language='en', epub_path=None):
301301
'glv', 'nor', 'nno', 'por', 'oci', 'roh', 'gla', 'spa', 'swe', 'tam', 'cym', 'wel',
302302
]
303303

304-
# Find OPF file
305-
if 'META-INF/container.xml' not in self.files:
306-
print('Cannot find META-INF/container.xml')
307-
return
304+
try:
305+
# Find OPF file
306+
if 'META-INF/container.xml' not in self.files:
307+
print('Cannot find META-INF/container.xml')
308+
return
308309

309-
container_xml = minidom.parseString(self.files['META-INF/container.xml'])
310-
opf_filename = None
311-
for rootfile in container_xml.getElementsByTagName('rootfile'):
312-
if rootfile.getAttribute('media-type') == 'application/oebps-package+xml':
313-
opf_filename = rootfile.getAttribute('full-path')
314-
break
310+
container_xml = minidom.parseString(self.files['META-INF/container.xml'])
311+
opf_filename = None
312+
for rootfile in container_xml.getElementsByTagName('rootfile'):
313+
if rootfile.getAttribute('media-type') == 'application/oebps-package+xml':
314+
opf_filename = rootfile.getAttribute('full-path')
315+
break
315316

316-
# Read OPF file
317-
if not opf_filename or opf_filename not in self.files:
318-
print('Cannot find OPF file!')
319-
return
317+
# Read OPF file
318+
if not opf_filename or opf_filename not in self.files:
319+
print('Cannot find OPF file!')
320+
return
320321

321-
try:
322322
opf = minidom.parseString(self.files[opf_filename])
323323
language_tags = opf.getElementsByTagName('dc:language')
324324
language = None
@@ -386,10 +386,30 @@ def fix_book_language(self, default_language='en', epub_path=None):
386386

387387
# Only write if we actually changed something
388388
if original_language != language or not original_language:
389-
self.files[opf_filename] = opf.toxml()
389+
# Use regex replacement to preserve XML formatting instead of minidom.toxml()
390+
# This prevents attribute reordering which can break Amazon's parser
391+
opf_content = self.files[opf_filename]
392+
393+
if not language_tags:
394+
# Need to add language tag - insert before </metadata>
395+
opf_content = opf_content.replace(
396+
'</metadata>',
397+
f' <dc:language>{language}</dc:language>\n </metadata>'
398+
)
399+
else:
400+
# Replace existing language tag content
401+
opf_content = re.sub(
402+
r'<dc:language>.*?</dc:language>',
403+
f'<dc:language>{language}</dc:language>',
404+
opf_content,
405+
count=1,
406+
flags=re.DOTALL
407+
)
408+
409+
self.files[opf_filename] = opf_content
390410

391411
except Exception as e:
392-
print(f'Error trying to parse OPF file as XML: {e}')
412+
print_and_log(f'[cwa-kindle-epub-fixer] Skipping language validation - EPUB has non-standard structure: {e}', log=self.manually_triggered)
393413

394414
def _detect_language_from_metadata(self, epub_path=None):
395415
"""Attempt to detect language from Calibre's metadata.db"""
@@ -449,6 +469,238 @@ def fix_stray_img(self):
449469
self.fixed_problems.append(f"Remove stray image tag(s) in {filename}")
450470
self.files[filename] = dom.toxml()
451471

472+
def strip_embedded_fonts(self):
473+
"""Remove embedded font files and @font-face CSS declarations for Kindle compatibility"""
474+
# Remove font files from binary files
475+
font_extensions = ('.ttf', '.otf', '.woff', '.woff2', '.eot')
476+
fonts_removed = []
477+
478+
for filename in list(self.binary_files.keys()):
479+
if filename.lower().endswith(font_extensions):
480+
del self.binary_files[filename]
481+
fonts_removed.append(filename)
482+
483+
if fonts_removed:
484+
self.fixed_problems.append(f"Removed {len(fonts_removed)} embedded font file(s) for Kindle compatibility")
485+
486+
# Also remove font references from OPF manifest
487+
opf_path = 'content.opf'
488+
if opf_path in self.files:
489+
opf_content = self.files[opf_path]
490+
for font_file in fonts_removed:
491+
# Remove manifest item for this font
492+
# Match: <item href="fonts/00001.ttf" id="..." media-type="..."/>
493+
pattern = re.compile(
494+
r'<item[^>]*href=["\']' + re.escape(font_file) + r'["\'][^>]*/?>',
495+
re.IGNORECASE
496+
)
497+
opf_content = pattern.sub('', opf_content)
498+
499+
self.files[opf_path] = opf_content
500+
501+
# Remove @font-face declarations from CSS files
502+
font_face_pattern = re.compile(r'@font-face\s*\{[^}]*\}', re.IGNORECASE | re.DOTALL)
503+
504+
for filename in list(self.files.keys()):
505+
if filename.endswith('.css'):
506+
original_css = self.files[filename]
507+
cleaned_css = font_face_pattern.sub('', original_css)
508+
509+
if cleaned_css != original_css:
510+
self.files[filename] = cleaned_css
511+
self.fixed_problems.append(f"Removed @font-face declarations from {filename}")
512+
513+
def remove_javascript(self):
514+
"""Remove JavaScript code for Kindle compatibility (not supported)"""
515+
script_pattern = re.compile(r'<script[^>]*>.*?</script>', re.IGNORECASE | re.DOTALL)
516+
517+
for filename in list(self.files.keys()):
518+
ext = filename.split('.')[-1]
519+
if ext in ['html', 'xhtml', 'htm']:
520+
original_content = self.files[filename]
521+
cleaned_content = script_pattern.sub('', original_content)
522+
523+
if cleaned_content != original_content:
524+
self.files[filename] = cleaned_content
525+
self.fixed_problems.append(f"Removed JavaScript from {filename}")
526+
527+
def validate_images(self):
528+
"""Validate images for Kindle compatibility and report issues"""
529+
issues = []
530+
total_size = 0
531+
532+
# Supported formats by Kindle
533+
supported_formats = {
534+
b'\xff\xd8\xff': 'JPEG',
535+
b'\x89PNG': 'PNG',
536+
b'GIF87a': 'GIF',
537+
b'GIF89a': 'GIF'
538+
}
539+
540+
for filename in list(self.binary_files.keys()):
541+
ext = filename.split('.')[-1].lower()
542+
if ext in ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp']:
543+
file_data = self.binary_files[filename]
544+
file_size = len(file_data)
545+
total_size += file_size
546+
547+
# Check for unsupported formats
548+
if ext in ['svg', 'webp']:
549+
issues.append(f"{filename}: {ext.upper()} format has limited Kindle support")
550+
551+
# Check individual file size (warn if > 2MB)
552+
if file_size > 2 * 1024 * 1024:
553+
size_mb = file_size / (1024 * 1024)
554+
issues.append(f"{filename}: Large image ({size_mb:.1f}MB) may cause issues")
555+
556+
# Verify actual format matches extension
557+
format_detected = None
558+
for magic_bytes, format_name in supported_formats.items():
559+
if file_data.startswith(magic_bytes):
560+
format_detected = format_name
561+
break
562+
563+
if format_detected and ext in ['jpg', 'jpeg'] and format_detected != 'JPEG':
564+
issues.append(f"{filename}: File type mismatch (ext: {ext}, actual: {format_detected})")
565+
elif format_detected and ext == 'png' and format_detected != 'PNG':
566+
issues.append(f"{filename}: File type mismatch (ext: {ext}, actual: {format_detected})")
567+
568+
if issues:
569+
for issue in issues:
570+
self.fixed_problems.append(f"Image validation warning: {issue}")
571+
572+
# Check total EPUB size (warn if approaching 50MB uncompressed)
573+
total_size_mb = total_size / (1024 * 1024)
574+
if total_size_mb > 40:
575+
self.fixed_problems.append(f"Warning: Total image size is {total_size_mb:.1f}MB (Kindle works best with <50MB total)")
576+
577+
def validate_css(self):
578+
"""Validate CSS and only fix actual syntax errors, warn about potential Kindle issues"""
579+
for filename in list(self.files.keys()):
580+
if filename.endswith('.css'):
581+
original_css = self.files[filename]
582+
issues_found = []
583+
584+
# Check for syntax errors that would break rendering
585+
# 1. Unclosed braces
586+
open_braces = original_css.count('{')
587+
close_braces = original_css.count('}')
588+
if open_braces != close_braces:
589+
issues_found.append(f"CSS syntax error: mismatched braces ({open_braces} open, {close_braces} close)")
590+
591+
# 2. Invalid @import statements (must be at top)
592+
lines = original_css.split('\n')
593+
import_after_rules = False
594+
seen_rule = False
595+
for line in lines:
596+
stripped = line.strip()
597+
if stripped and not stripped.startswith('/*') and not stripped.startswith('*/'):
598+
if '@import' in stripped:
599+
if seen_rule:
600+
import_after_rules = True
601+
elif stripped.startswith('@') or '{' in stripped:
602+
seen_rule = True
603+
604+
if import_after_rules:
605+
issues_found.append("CSS syntax error: @import must appear before other rules")
606+
607+
# 3. Check for common Kindle-problematic features (warning only, don't remove)
608+
kindle_warnings = []
609+
if re.search(r'position\s*:\s*(absolute|fixed)', original_css, re.IGNORECASE):
610+
kindle_warnings.append("absolute/fixed positioning")
611+
if re.search(r'@media', original_css, re.IGNORECASE):
612+
kindle_warnings.append("media queries")
613+
if re.search(r'javascript:', original_css, re.IGNORECASE):
614+
kindle_warnings.append("javascript URLs")
615+
616+
# Only report if there are actual syntax errors
617+
if issues_found:
618+
for issue in issues_found:
619+
self.fixed_problems.append(f"CSS validation: {issue} in {filename}")
620+
621+
# Add informational note about Kindle compatibility (not counted as a fix needing action)
622+
if kindle_warnings:
623+
warning_str = ', '.join(kindle_warnings)
624+
print_and_log(f"[cwa-kindle-epub-fixer] Note: {filename} uses {warning_str} (may render differently on Kindle)", log=self.manually_triggered)
625+
626+
def strip_amazon_identifiers(self):
627+
"""Remove ASIN/Amazon identifiers and Calibre metadata from OPF file.
628+
629+
Amazon may reject books that already have an ASIN in their system.
630+
Also removes Calibre-specific metadata that's not needed for Kindle.
631+
"""
632+
opf_path = 'content.opf'
633+
if opf_path not in self.files:
634+
return
635+
636+
opf_content = self.files[opf_path]
637+
638+
try:
639+
dom = minidom.parseString(opf_content)
640+
package = dom.getElementsByTagName('package')[0]
641+
metadata = dom.getElementsByTagName('metadata')[0]
642+
643+
changes_made = False
644+
645+
# Remove Calibre namespace from package tag
646+
if package.hasAttribute('xmlns:calibre'):
647+
package.removeAttribute('xmlns:calibre')
648+
changes_made = True
649+
print_and_log("[cwa-kindle-epub-fixer] Removing Calibre namespace from package tag", log=self.manually_triggered)
650+
651+
# Remove Calibre namespace from metadata tag
652+
if metadata.hasAttribute('xmlns:calibre'):
653+
metadata.removeAttribute('xmlns:calibre')
654+
changes_made = True
655+
656+
# Remove Amazon/MOBI-ASIN identifiers using regex (preserve structure)
657+
identifiers_removed = []
658+
659+
# Match AMAZON and MOBI-ASIN identifiers
660+
asin_pattern = re.compile(
661+
r'<dc:identifier[^>]*opf:scheme=["\'](?:AMAZON|MOBI-ASIN|mobi-asin|calibre)["\'][^>]*>.*?</dc:identifier>',
662+
re.IGNORECASE | re.DOTALL
663+
)
664+
665+
matches = asin_pattern.findall(opf_content)
666+
if matches:
667+
for match in matches:
668+
# Extract scheme and value for logging
669+
scheme_match = re.search(r'opf:scheme=["\']([^"\']+)["\']', match)
670+
if scheme_match:
671+
identifiers_removed.append(scheme_match.group(1))
672+
673+
opf_content = asin_pattern.sub('', opf_content)
674+
changes_made = True
675+
676+
if identifiers_removed:
677+
print_and_log(f"[cwa-kindle-epub-fixer] Removing Amazon/Calibre identifiers: {', '.join(identifiers_removed)}", log=self.manually_triggered)
678+
self.fixed_problems.append(f"Removed {len(identifiers_removed)} Amazon/Calibre identifier(s)")
679+
680+
# Remove Calibre-specific meta tags using regex (preserve structure)
681+
calibre_meta_pattern = re.compile(
682+
r'<meta[^>]*name=["\']calibre:[^"\']+["\'][^>]*/>',
683+
re.IGNORECASE
684+
)
685+
686+
calibre_matches = calibre_meta_pattern.findall(opf_content)
687+
if calibre_matches:
688+
opf_content = calibre_meta_pattern.sub('', opf_content)
689+
changes_made = True
690+
print_and_log(f"[cwa-kindle-epub-fixer] Removing {len(calibre_matches)} Calibre meta tag(s)", log=self.manually_triggered)
691+
self.fixed_problems.append(f"Removed {len(calibre_matches)} Calibre meta tag(s)")
692+
693+
# Remove xmlns:calibre from metadata tag if present
694+
if 'xmlns:calibre=' in opf_content:
695+
opf_content = re.sub(r'\s*xmlns:calibre="[^"]*"', '', opf_content)
696+
changes_made = True
697+
698+
if changes_made:
699+
self.files[opf_path] = opf_content
700+
701+
except Exception as e:
702+
print_and_log(f"[cwa-kindle-epub-fixer] Warning: Could not strip Amazon identifiers: {e}", log=self.manually_triggered)
703+
452704
def write_epub(self, output_path):
453705
"""Write EPUB file"""
454706
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zip_ref:
@@ -514,12 +766,23 @@ def process(self, input_path, output_path=None, default_language='en'):
514766
# Run fixing procedures
515767
print_and_log("[cwa-kindle-epub-fixer] Checking linking to body ID to prevent unresolved hyperlinks...", log=self.manually_triggered)
516768
self.fix_body_id_link()
769+
print_and_log("[cwa-kindle-epub-fixer] Checking UTF-8 encoding declaration...", log=self.manually_triggered)
770+
self.fix_encoding()
517771
print_and_log("[cwa-kindle-epub-fixer] Checking language field tag is valid...", log=self.manually_triggered)
518772
self.fix_book_language(default_language, input_path)
519773
print_and_log("[cwa-kindle-epub-fixer] Checking for stray images...", log=self.manually_triggered)
520774
self.fix_stray_img()
521-
print_and_log("[cwa-kindle-epub-fixer] Checking UTF-8 encoding declaration...", log=self.manually_triggered)
522-
self.fix_encoding()
775+
776+
# New Kindle-specific fixes
777+
print_and_log("[cwa-kindle-epub-fixer] Stripping embedded fonts for Kindle compatibility...", log=self.manually_triggered)
778+
self.strip_embedded_fonts()
779+
print_and_log("[cwa-kindle-epub-fixer] Removing JavaScript (not supported on Kindle)...", log=self.manually_triggered)
780+
self.remove_javascript()
781+
print_and_log("[cwa-kindle-epub-fixer] Validating images for Kindle compatibility...", log=self.manually_triggered)
782+
self.validate_images()
783+
print_and_log("[cwa-kindle-epub-fixer] Validating CSS syntax...", log=self.manually_triggered)
784+
self.validate_css()
785+
# NOTE: Skipping strip_amazon_identifiers() - users want complete metadata preserved
523786

524787
# Notify user and/or write to log
525788
self.export_issue_summary(input_path)

0 commit comments

Comments
 (0)