Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user