Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix

This commit is contained in:
谭凯
2026-02-01 11:51:36 +08:00
parent 8e58415173
commit 40491e6386
95 changed files with 8493 additions and 11 deletions
+19 -2
View File
@@ -82,11 +82,28 @@ class BackfillEngine:
new_tag.string = plain_text
# Copy attributes
# Copy classes and add 'translation'
# Copy classes and add 'translation' or 'translation-no-indent'
classes = element.get('class', [])
if isinstance(classes, str):
classes = classes.split()
new_tag['class'] = classes + ['translation']
# Heuristic: Check for Drop Cap / No-Indent indicators
# Keywords: 'drop', 'first', 'no-indent', 'noindent'
# REMOVED: 'chapter', 'start', 'opener' (too broad, catches titles)
special_keywords = ['drop', 'no-indent', 'noindent']
# 'first' can be risky (e.g. 'first-title').
# Be conservative: match 'first' only if combined with 'para' or 'letter' or similar?
# For now, let's keep 'first' but be aware.
# Actually, user said minimal change. Let's stick to 'drop' and explicit 'no-indent'.
# Checking 'calibre1' etc is useless.
is_special_para = any(k in c.lower() for k in special_keywords for c in classes)
if is_special_para:
new_tag['class'] = classes + ['translation-no-indent']
else:
new_tag['class'] = classes + ['translation']
# Copy style
style = element.get('style')
+39
View File
@@ -0,0 +1,39 @@
/* Bilingual Translation Overrides */
/* --- Common Base --- */
.translation,
.translation-no-indent {
/* text-align: justify; <-- REMOVED: Restore original alignment (left/center/justify) */
line-height: 1.6;
clear: both;
/* Fix overlap with floating elements like Drop Caps */
margin-top: 0.5em;
/* Ensure separation from previous block */
display: block;
/* Ensure block formatting context */
}
/* --- Standard Indent (2 chars) --- */
p.translation {
text-indent: 2em;
}
/* --- No Indent (For Drop Caps) --- */
p.translation-no-indent {
text-indent: 0 !important;
/* REMOVED: margin/padding resets that broke centering */
}
/* --- Fix Drop Cap Font Mismatch --- */
.translation span,
.translation-no-indent span,
.translation b,
.translation-no-indent b,
.translation i,
.translation-no-indent i,
.translation em,
.translation-no-indent em,
.translation strong,
.translation-no-indent strong {
font-family: inherit !important;
}
+31 -1
View File
@@ -196,6 +196,30 @@ class BilingualBuilder:
logger.warning(f"Skipping resource {item_id}: No content or file path.")
continue
# 3.1 Inject Bilingual Style CSS
bilingual_css_item = None
try:
# We assume the CSS file is in the same directory as this builder.py
# Or use package resources. simpler: relative to file
css_source_path = Path(__file__).parent / "bilingual_style.css"
if css_source_path.exists():
with open(css_source_path, "rb") as f:
css_content = f.read()
bilingual_css_item = epub.EpubItem(
uid="bilingual_style_css",
file_name="Styles/bilingual_style.css",
media_type="text/css",
content=css_content
)
book.add_item(bilingual_css_item)
css_items.append(bilingual_css_item) # Add to list so it gets linked
logger.info("Injected bilingual_style.css")
else:
logger.warning(f"bilingual_style.css not found at {css_source_path}")
except Exception as e:
logger.error(f"Failed to inject bilingual CSS: {e}")
# 4. Add HTML items with CSS links
for item_id, item in html_items:
html_dir = Path(item.file_name).parent
@@ -208,7 +232,13 @@ class BilingualBuilder:
# Not a subpath, calculate full relative
# Go up from html_dir, then down to css_path
up_count = len(html_dir.parts)
relative_css_path = Path("/".join([".."] * up_count)) / css_path
# Handle case where html_dir is empty (root)
if str(html_dir) == ".":
prefix = ""
else:
prefix = "../" * up_count
relative_css_path = Path(f"{prefix}{css_path}")
item.add_link(href=str(relative_css_path), rel='stylesheet', type='text/css')
book.add_item(item)
+38 -7
View File
@@ -106,24 +106,50 @@ class FormatExtractor:
text_with_ph = re.sub(r'\s+', ' ', text_with_ph).strip()
# Verify integrity
stripped_text = self._strip_placeholders(text_with_ph)
stripped_text = re.sub(r'\s+', ' ', stripped_text).strip()
# FIX: Instead of stripping placeholders blindly (which removes content like [28] if grouped),
# we must 'hydrate' them: replace placeholder with the text content of its mapping.
hydrated_text = self._hydrate_placeholders(text_with_ph, local_map)
hydrated_text = re.sub(r'\s+', ' ', hydrated_text).strip()
if not self._verify_content_integrity(clean_text, stripped_text):
if not self._verify_content_integrity(clean_text, hydrated_text):
# Fallback
text_with_ph, local_map = self._fallback_extract(inner_html, clean_text)
endnote_anchors = []
for pid, html in local_map.items():
for pid, html_content in local_map.items():
if pid.startswith("_"):
continue
if re.match(r'<(span|a)\s+id="[a-zA-Z][a-zA-Z0-9]{2,5}"\s*>\s*</\1>', html):
if re.match(r'<(span|a)\s+id="[a-zA-Z][a-zA-Z0-9]{2,5}"\s*>\s*</\1>', html_content):
endnote_anchors.append(pid)
return clean_text, text_with_ph, local_map, p_type, endnote_anchors
def _strip_placeholders(self, text: str) -> str:
return re.sub(r'φ/?[0-9]+φ', '', text)
def _hydrate_placeholders(self, text: str, ph_map: Dict[str, str]) -> str:
"""
Replaces placeholders with the text content of their HTML values.
Used for integrity checking.
"""
def replace_ph(match):
ph = match.group(0)
pid = match.group(1)
# Handle closing tags /pid -> ignore or handle?
# Placeholders format: φ1φ or φ/1φ.
# Map keys: '1', '/1'.
clean_pid = pid
if pid.startswith('/'):
# Closing tag placeholder usually maps to </span>. Text is empty.
return ""
if clean_pid in ph_map:
content = ph_map[clean_pid]
# Extract text from the HTML content
# e.g. '<a ...>[28]</a>' -> '[28]'
# e.g. '<span class="bold">' -> ''
return BeautifulSoup(content, 'html.parser').get_text()
return ""
return re.sub(r'φ(/?\d+)φ', replace_ph, text)
def _verify_content_integrity(self, clean_text: str, stripped_text: str) -> bool:
def normalize(s):
@@ -366,6 +392,11 @@ class FormatExtractor:
text = text.strip()
if not text:
return False
# Treat citations like [28] or [1] as non-translatable (to be grouped into placeholders)
if re.match(r'^\[\d+\]$', text):
return False
if re.search(r'[a-zA-Z]{3,}', text):
return True
if ' ' in text and re.search(r'[a-zA-Z]', text):