Skip to content

Commit e8a0dc8

Browse files
committed
Fix EPUB2 HTML charset metadata
1 parent 43718d8 commit e8a0dc8

2 files changed

Lines changed: 167 additions & 18 deletions

File tree

scripts/kindle_epub_fixer.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,31 +118,45 @@ def exit_if_cancelled() -> None:
118118
...
119119
sys.exit(0)
120120

121+
LOCK_FILE_PATH = os.path.join(tempfile.gettempdir(), 'kindle_epub_fixer.lock')
122+
lock_acquired = False
123+
121124
### LOCK FILES
122125
# Creates a lock file unless one already exists meaning an instance of the script is
123126
# already running, then the script is closed, the user is notified and the program
124127
# exits with code 2
125-
try:
126-
lock = open(tempfile.gettempdir() + '/kindle_epub_fixer.lock', 'x')
127-
lock.close()
128-
except FileExistsError:
129-
print_and_log("[cwa-kindle-epub-fixer] CANCELLING... kindle-epub-fixer was initiated but is already running")
130-
logger.info(f"\nCWA Kindle EPUB Fixer Service - Run Ended: {datetime.now()}")
131-
sys.exit(2)
128+
def acquire_lock():
129+
global lock_acquired
130+
try:
131+
with open(LOCK_FILE_PATH, 'x'):
132+
pass
133+
lock_acquired = True
134+
except FileExistsError:
135+
print_and_log("[cwa-kindle-epub-fixer] CANCELLING... kindle-epub-fixer was initiated but is already running")
136+
logger.info(f"\nCWA Kindle EPUB Fixer Service - Run Ended: {datetime.now()}")
137+
sys.exit(2)
132138

133139
# Defining function to delete the lock on script exit
134140
def removeLock():
141+
global lock_acquired
142+
if not lock_acquired:
143+
return
135144
try:
136-
os.remove(tempfile.gettempdir() + '/kindle_epub_fixer.lock')
145+
os.remove(LOCK_FILE_PATH)
137146
except FileNotFoundError:
138147
...
148+
finally:
149+
lock_acquired = False
139150

140151
# Will automatically run when the script exits
141152
atexit.register(removeLock)
142153

143154

144155
class EPUBFixer:
145156
def __init__(self, manually_triggered:bool=False, current_position:str=None):
157+
if not lock_acquired:
158+
acquire_lock()
159+
146160
self.manually_triggered = manually_triggered
147161
self.current_position = current_position # string in the form of "n/n"
148162

@@ -311,24 +325,46 @@ def _update_html_charset(self, content: str, target_encoding: str) -> str:
311325
if charset.startswith('utf-16'):
312326
charset = 'utf-16'
313327

314-
http_equiv_pattern = re.compile(
315-
r'(<meta[^>]+http-equiv=["\']content-type["\'][^>]*content=["\'][^"\']*charset=)([^"\'>\s;]+)([^"\']*["\'][^>]*>)',
316-
re.IGNORECASE
317-
)
318-
if http_equiv_pattern.search(content):
319-
return http_equiv_pattern.sub(rf"\1{charset}\3", content, count=1)
328+
http_equiv_meta = f'<meta http-equiv="Content-Type" content="text/html; charset={charset}" />'
329+
meta_tag_pattern = re.compile(r'<meta\b[^>]*>', re.IGNORECASE)
330+
http_equiv_pattern = re.compile(r'\bhttp-equiv\s*=\s*["\']content-type["\']', re.IGNORECASE)
331+
content_attr_pattern = re.compile(r'\bcontent\s*=\s*(["\'])(.*?)\1', re.IGNORECASE | re.DOTALL)
332+
charset_pattern = re.compile(r'(charset\s*=\s*)[^;\s"\']+', re.IGNORECASE)
333+
replaced_http_equiv = False
334+
335+
def update_http_equiv(match: re.Match) -> str:
336+
nonlocal replaced_http_equiv
337+
tag = match.group(0)
338+
if replaced_http_equiv or not http_equiv_pattern.search(tag):
339+
return tag
340+
341+
content_match = content_attr_pattern.search(tag)
342+
if not content_match or not charset_pattern.search(content_match.group(2)):
343+
return tag
344+
345+
content_value = charset_pattern.sub(
346+
lambda charset_match: f"{charset_match.group(1)}{charset}",
347+
content_match.group(2),
348+
count=1,
349+
)
350+
replaced_http_equiv = True
351+
return tag[:content_match.start(2)] + content_value + tag[content_match.end(2):]
352+
353+
updated_content = meta_tag_pattern.sub(update_http_equiv, content)
354+
if replaced_http_equiv:
355+
return updated_content
320356

321-
meta_charset_pattern = re.compile(r'<meta[^>]+charset=["\']?[^"\'>\s]+[^>]*>', re.IGNORECASE)
357+
meta_charset_pattern = re.compile(r'<meta\b[^>]+charset=["\']?[^"\'>\s]+[^>]*>', re.IGNORECASE)
322358
if meta_charset_pattern.search(content):
323-
return meta_charset_pattern.sub(f'<meta charset="{charset}">', content, count=1)
359+
return meta_charset_pattern.sub(http_equiv_meta, content, count=1)
324360

325361
head_pattern = re.compile(r'<head[^>]*>', re.IGNORECASE)
326362
match = head_pattern.search(content)
327363
if match:
328364
insert_at = match.end()
329-
return content[:insert_at] + f"\n <meta charset=\"{charset}\">" + content[insert_at:]
365+
return content[:insert_at] + f"\n {http_equiv_meta}" + content[insert_at:]
330366

331-
return f"<meta charset=\"{charset}\">\n" + content
367+
return content
332368

333369
def _extract_book_info_from_path(self, file_path: str) -> tuple[int | None, str]:
334370
"""Extract book ID and format from file path.
@@ -1152,6 +1188,8 @@ def get_all_epubs_in_library() -> list[str]:
11521188

11531189

11541190
def main():
1191+
acquire_lock()
1192+
11551193
parser = argparse.ArgumentParser(
11561194
prog='kindle-epub-fixer',
11571195
description='Checks the encoding of a given EPUB file and automatically corrects any errors that could \
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Calibre-Web Automated – fork of Calibre-Web
2+
# Copyright (C) 2018-2026 Calibre-Web contributors
3+
# Copyright (C) 2024-2026 Calibre-Web Automated contributors
4+
# SPDX-License-Identifier: GPL-3.0-or-later
5+
# See CONTRIBUTORS for full list of authors.
6+
7+
import sys
8+
from pathlib import Path
9+
from xml.etree import ElementTree
10+
11+
import pytest
12+
13+
pytestmark = pytest.mark.unit
14+
15+
project_root = Path(__file__).parent.parent.parent
16+
scripts_dir = project_root / "scripts"
17+
sys.path.insert(0, str(scripts_dir))
18+
19+
import kindle_epub_fixer
20+
from kindle_epub_fixer import EPUBFixer
21+
22+
23+
def _update_html_charset(content: str, target_encoding: str = "utf-8") -> str:
24+
fixer = EPUBFixer.__new__(EPUBFixer)
25+
return fixer._update_html_charset(content, target_encoding)
26+
27+
28+
def _fix_html_entry(content: str) -> tuple[str, list[str]]:
29+
fixer = EPUBFixer.__new__(EPUBFixer)
30+
filename = "OPS/chapter.html"
31+
fixer.files = {filename: content}
32+
fixer.file_target_encodings = {filename: "utf-8"}
33+
fixer.fixed_problems = []
34+
35+
fixer.fix_encoding()
36+
37+
return fixer.files[filename], fixer.fixed_problems
38+
39+
40+
def _assert_well_formed_xml(content: str) -> None:
41+
ElementTree.fromstring(content)
42+
43+
44+
def test_constructor_acquires_fixer_lock(monkeypatch, tmp_path):
45+
class FakeCwaDb:
46+
cwa_settings = {"kindle_epub_fixer_aggressive": 0}
47+
48+
lock_path = tmp_path / "kindle_epub_fixer.lock"
49+
monkeypatch.setattr(kindle_epub_fixer, "LOCK_FILE_PATH", str(lock_path))
50+
monkeypatch.setattr(kindle_epub_fixer, "lock_acquired", False)
51+
monkeypatch.setattr(kindle_epub_fixer, "CWA_DB", FakeCwaDb)
52+
53+
EPUBFixer()
54+
55+
assert lock_path.exists()
56+
assert kindle_epub_fixer.lock_acquired is True
57+
58+
kindle_epub_fixer.removeLock()
59+
assert not lock_path.exists()
60+
assert kindle_epub_fixer.lock_acquired is False
61+
62+
63+
def test_updates_http_equiv_charset_when_content_attribute_comes_first():
64+
content = (
65+
'<html><head><meta content="text/html; charset=WINDOWS-1252" '
66+
'http-equiv="Content-Type"/></head><body></body></html>'
67+
)
68+
69+
updated = _update_html_charset(content)
70+
71+
assert 'content="text/html; charset=utf-8"' in updated
72+
assert 'http-equiv="Content-Type"' in updated
73+
_assert_well_formed_xml(updated)
74+
75+
76+
def test_replaces_html5_meta_charset_with_xhtml_compatible_meta():
77+
content = '<html><head><meta charset="windows-1252"></head><body></body></html>'
78+
79+
updated = _update_html_charset(content)
80+
81+
assert '<meta charset="utf-8">' not in updated
82+
assert '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />' in updated
83+
_assert_well_formed_xml(updated)
84+
85+
86+
def test_inserts_xhtml_compatible_meta_inside_head():
87+
content = '<html><head></head><body></body></html>'
88+
89+
updated = _update_html_charset(content)
90+
91+
assert '<head>\n <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />' in updated
92+
_assert_well_formed_xml(updated)
93+
94+
95+
def test_leaves_html_without_head_unchanged():
96+
content = '<html><body><p>No head element</p></body></html>'
97+
98+
assert _update_html_charset(content) == content
99+
100+
101+
def test_fix_encoding_keeps_html_named_epub_content_well_formed():
102+
content = (
103+
'<html><head><meta content="text/html; charset=WINDOWS-1252" '
104+
'http-equiv="Content-Type"/></head><body><p>Chapter</p></body></html>'
105+
)
106+
107+
updated, fixed_problems = _fix_html_entry(content)
108+
109+
assert fixed_problems == ["Updated HTML charset in OPS/chapter.html to utf-8"]
110+
assert 'content="text/html; charset=utf-8"' in updated
111+
_assert_well_formed_xml(updated)

0 commit comments

Comments
 (0)