Skip to content

Commit 5c2f4b2

Browse files
committed
feat: add RTL/LTR ebook formatting support
RTL/LTR Formatting: - Add conditional text-align inline styles when source/target directions differ - Add primary-writing-mode OPF metadata (horizontal-rl/lr) - Add spine page-progression-direction attribute (rtl/ltr) - Only apply formatting when translating between different text directions - Support bidirectional translation (LTR→RTL and RTL→LTR) Language Direction Support: - Expand lang_directionality dictionary with comprehensive coverage - RTL: Arabic, Hebrew, Farsi, Dari, Urdu, Yiddish, Pashto - LTR: English, Spanish, French, German, Italian, Portuguese, Russian, CJK - Support both legacy (iw) and modern (he) Hebrew codes - Handle language codes with region suffixes (en-US → en) Technical Implementation: - Track source language in ElementHandler for direction comparison - Build inline styles with semicolon-separated properties - Add robust error handling for OPF metadata modifications - Use hasattr checks for OEB structure compatibility Bug Fixes: - Fix Cache Manager delete for non-consecutive rows (IndexError) - Collect selected rows before deletion to avoid index shifting
1 parent 2232a79 commit 5c2f4b2

5 files changed

Lines changed: 144 additions & 13 deletions

File tree

cache.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,17 @@ def delete_cache(self):
188188
_('Are you sure you want to delete the selected cache(s)?'))
189189
if action != 'yes':
190190
return
191-
for row in reversed(self.selectionModel().selectedRows()):
192-
filename = row.data(Qt.UserRole)
191+
192+
# Collect all selected rows with their data first
193+
selected_items = [(row.row(), row.data(Qt.UserRole)) for row in self.selectionModel().selectedRows()]
194+
# Sort by row number descending to delete from bottom to top
195+
selected_items.sort(key=lambda x: x[0], reverse=True)
196+
197+
# Delete in reverse order to avoid index shifting issues
198+
for row_num, filename in selected_items:
193199
TranslationCache.remove(filename)
194-
self.model().delete(row.row())
200+
self.model().delete(row_num)
201+
195202
self.clearSelection()
196203
if self.parent is not None:
197204
self.parent.cache_count.emit()

engines/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ def get_target_code(cls, lang) -> str:
9696

9797
@classmethod
9898
def get_iso639_target_code(cls, lang):
99-
return lang_as_iso639_1(cls.get_target_code(lang))
99+
code = cls.get_target_code(lang)
100+
# Handle legacy Hebrew code: iw → he (Calibre doesn't recognize 'iw')
101+
if code == 'iw':
102+
return 'he'
103+
return lang_as_iso639_1(code)
100104

101105
@classmethod
102106
def set_config(cls, config):

engines/languages.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,10 +1044,25 @@
10441044
}
10451045

10461046
lang_directionality = {
1047-
'iw': 'rtl',
1048-
'ar': 'rtl',
1049-
'en': 'ltr',
1050-
'es': 'ltr',
1051-
'it': 'ltr',
1052-
'de': 'ltr'
1047+
# RTL (Right-to-Left) languages
1048+
'ar': 'rtl', # Arabic
1049+
'iw': 'rtl', # Hebrew (legacy code)
1050+
'he': 'rtl', # Hebrew (modern code)
1051+
'fa': 'rtl', # Farsi/Persian
1052+
'fa-AF': 'rtl', # Dari (Afghan Persian)
1053+
'ur': 'rtl', # Urdu
1054+
'yi': 'rtl', # Yiddish
1055+
'ps': 'rtl', # Pashto
1056+
# LTR (Left-to-Right) languages - common examples
1057+
'en': 'ltr', # English
1058+
'es': 'ltr', # Spanish
1059+
'fr': 'ltr', # French
1060+
'de': 'ltr', # German
1061+
'it': 'ltr', # Italian
1062+
'pt': 'ltr', # Portuguese
1063+
'ru': 'ltr', # Russian
1064+
'zh-CN': 'ltr', # Chinese Simplified
1065+
'zh-TW': 'ltr', # Chinese Traditional
1066+
'ja': 'ltr', # Japanese
1067+
'ko': 'ltr', # Korean
10531068
}

lib/conversion.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ def convert_item(
201201
translator.placeholder, translator.separator, direction)
202202
element_handler.set_translation_lang(
203203
translator.get_iso639_target_code(target_lang))
204+
element_handler.set_source_lang(
205+
translator.get_source_code(translator.source_lang))
204206

205207
merge_length = str(element_handler.get_merge_length())
206208
_encoding = ''
@@ -311,6 +313,65 @@ def translate_done(self, job):
311313
# metadata.author_sort = 'bookfere.com'
312314
# metadata.book_producer = 'Ebook Translator'
313315
set_metadata(file, metadata, ebook.output_format)
316+
317+
# Add RTL/LTR OPF metadata by modifying the EPUB after write
318+
if ebook.output_format.lower() == 'epub' and ebook.target_direction:
319+
from ..engines.languages import lang_directionality
320+
321+
target_direction = ebook.target_direction.lower()
322+
translator = get_translator()
323+
source_lang_code = translator.get_source_code(
324+
ebook.source_lang)
325+
source_lang_base = (
326+
source_lang_code.split('-')[0]
327+
if source_lang_code else None)
328+
source_direction = lang_directionality.get(
329+
source_lang_code) or (
330+
lang_directionality.get(source_lang_base)
331+
if source_lang_base else None) or 'ltr'
332+
333+
if (target_direction in ('rtl', 'ltr')
334+
and source_direction != target_direction):
335+
try:
336+
from lxml import etree
337+
from calibre.ebooks.oeb.polish.container import (
338+
get_container)
339+
340+
container = get_container(
341+
output_path, tweak_mode=True)
342+
opf_root = container.opf
343+
ns = {'opf': 'http://www.idpf.org/2007/opf'}
344+
345+
# Set spine page-progression-direction
346+
spine_elem = opf_root.xpath(
347+
'//opf:spine', namespaces=ns)[0]
348+
spine_elem.set(
349+
'page-progression-direction', target_direction)
350+
351+
# Add primary-writing-mode meta
352+
writing_mode = (
353+
'horizontal-rl' if target_direction == 'rtl'
354+
else 'horizontal-lr')
355+
metadata_elem = opf_root.xpath(
356+
'//opf:metadata', namespaces=ns)[0]
357+
etree.SubElement(
358+
metadata_elem,
359+
'{http://www.idpf.org/2007/opf}meta',
360+
attrib={
361+
'name': 'primary-writing-mode',
362+
'content': writing_mode,
363+
})
364+
365+
container.dirty(container.opf_name)
366+
container.commit()
367+
log.info(
368+
'Added RTL metadata: '
369+
'primary-writing-mode=%s, '
370+
'page-progression-direction=%s'
371+
% (writing_mode, target_direction))
372+
except Exception as e:
373+
log.warn(
374+
'Failed to add RTL metadata to EPUB: %s' % e)
314375
else:
315376
metadata = self.api.get_metadata(ebook.id)
316377
ebook_title = ebook.title

lib/element.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(self, element, page_id=None, ignored=False):
3737

3838
self.position = None
3939
self.target_direction = None
40+
self.source_lang = None
4041
self.translation_lang = None
4142
self.original_color = None
4243
self.translation_color = None
@@ -62,6 +63,9 @@ def set_position(self, position):
6263
def set_target_direction(self, direction):
6364
self.target_direction = direction
6465

66+
def set_source_lang(self, lang):
67+
self.source_lang = lang
68+
6569
def set_translation_lang(self, lang):
6670
self.translation_lang = lang
6771

@@ -262,13 +266,43 @@ def _create_new_element(
262266
new_element.set('dir', self.target_direction or 'auto')
263267
if self.translation_lang is not None:
264268
new_element.set('lang', self.translation_lang)
269+
270+
# Build style attribute with color and conditional text-align
271+
style_parts = []
265272
if self.translation_color is not None:
266-
new_element.set('style', 'color:%s' % self.translation_color)
273+
style_parts.append('color:%s' % self.translation_color)
274+
275+
# Add text-align only if source and target directions differ
276+
if self.source_lang and self.target_direction in ('rtl', 'ltr'):
277+
# Import to get directionality function
278+
from ..engines.languages import lang_directionality
279+
source_direction = lang_directionality.get(self.source_lang, 'ltr')
280+
281+
# Only add text-align if directions differ
282+
if source_direction != self.target_direction:
283+
if self.target_direction == 'rtl':
284+
style_parts.append('text-align:right')
285+
elif self.target_direction == 'ltr':
286+
style_parts.append('text-align:left')
287+
288+
if style_parts:
289+
new_element.set('style', ';'.join(style_parts))
290+
267291
return new_element
268292

269293
def add_translation(self, translation=None):
270294
# self.element.tail = None # Make sure the element has no tail
271-
if self.original_color is not None:
295+
if self.original_color is not None or (self.source_lang and self.target_direction in ('rtl', 'ltr')):
296+
# Check if text-align should be added (only if directions differ)
297+
add_text_align = False
298+
text_align_value = None
299+
if self.source_lang and self.target_direction in ('rtl', 'ltr'):
300+
from ..engines.languages import lang_directionality
301+
source_direction = lang_directionality.get(self.source_lang, 'ltr')
302+
if source_direction != self.target_direction:
303+
add_text_align = True
304+
text_align_value = 'right' if self.target_direction == 'rtl' else 'left'
305+
272306
for element in self.element.iter():
273307
if element.text is not None or len(list(element)) > 0:
274308
# Some users encountered errors when trying to set the
@@ -278,7 +312,13 @@ def add_translation(self, translation=None):
278312
# since comment and processing instruction nodes do not
279313
# have string tags, e.g., etree.Comment and etree.PI.
280314
try:
281-
element.set('style', 'color:%s' % self.original_color)
315+
style_parts = []
316+
if self.original_color is not None:
317+
style_parts.append('color:%s' % self.original_color)
318+
if add_text_align:
319+
style_parts.append('text-align:%s' % text_align_value)
320+
if style_parts:
321+
element.set('style', ';'.join(style_parts))
282322
except TypeError:
283323
log.warn(
284324
'Failed to set style on element:',
@@ -642,6 +682,7 @@ def __init__(self, placeholder, separator, position):
642682

643683
self.merge_length = 0
644684
self.target_direction = None
685+
self.source_lang = None
645686

646687
self.translation_lang = None
647688
self.original_color = None
@@ -663,6 +704,9 @@ def get_merge_length(self):
663704
def set_target_direction(self, direction):
664705
self.target_direction = direction
665706

707+
def set_source_lang(self, lang):
708+
self.source_lang = lang
709+
666710
def set_translation_lang(self, lang):
667711
self.translation_lang = lang
668712

0 commit comments

Comments
 (0)