Add multi-language i18n support (Simplified Chinese, Japanese, Korean) (#1453)
* Add multi-language i18n support (English, Chinese, Japanese, Korean)
- Add i18n.py module for translation management via QTranslator
- Add Simplified Chinese (zh_CN), Japanese (ja_JP), and Korean (ko_KR) translations
- Integrate translation files into Qt resource system (KCC.qrc / KCC_rc.py)
- Add language switcher button in the top button bar of main window
- Language setting persists via QSettings
- Dynamic text updates work correctly after language switch
- Support %1, %2, %3 placeholders via tr_arg() helper function
- KCCGUI and KCCGUI_MetaEditor now inherit QObject for tr() support
* Complete Japanese/Korean/Chinese translations + fix language switch issues
- Fill 61 missing Japanese translations (tooltips, 3-state checkboxes, etc.)
- Fill 61 missing Korean translations (tooltips, 3-state checkboxes, etc.)
- Fill 6 missing Chinese translations
- Fix language menu: use QActionGroup for exclusive (single-select) checkmarks
- Fix jobList initial tips: refresh translations on language switch by
updating label text directly (preserves message order and user-added entries)
- Add PROJECT_I18N_README.md developer documentation
* Replace emoji language icon with PNG globe icon
Fixes PR review feedback from axu2: the emoji character doesn't render on Windows 7 (no emoji font support). Replaced with a PNG globe icon from Wikimedia Commons (CC license), loaded via Qt resource system for consistent cross-platform rendering.
* Fix RuntimeError when switching language: re-fetch labels from jobList
Previously, QLabel references were saved directly, but the underlying
C++ objects could be deleted by Qt, causing RuntimeError on access.
Now we save only source strings and re-fetch labels from jobList
items when updating, with try/except as a safety net.
* Use text label for language button instead of PNG icon
Replace the globe PNG icon with a plain text button (' 文 A ')
so its color automatically follows the system light/dark theme,
per review feedback. Removes icons/language.png and its QRC entry.
* Finalize i18n: retranslate jobList messages and translate CLI output
- Add source= to 16 GUI addMessage calls so already-shown messages retranslate on language switch
- Translate comic2ebook.py conversion/CLI messages via QCoreApplication.translate('KCCGUI', ...)
- Clear 29 unfinished WorkerThread translations (context unified to KCCGUI)
- Guard apply_language when QApplication is not yet created
- Trim dead imports and redundant code
* Avoid duplicated source strings: translate inside addMessage
Replace the self.tr/translate + source= pattern (same string written twice) with
QT_TRANSLATE_NOOP('KCCGUI', ...) at call sites; addMessage now translates internally
and stores the source text for retranslation on language switch.
- Drop the source parameter from addMessage; simplify signal to 3 args
- Include comic2ebook.py in lupdate (20 CLI/conversion messages)
* Add translation maintenance workflow
Add gen_translation_files.sh/.bat, TRANSLATION.md, and a README pointer; remove the superseded PROJECT_I18N_README.md.
* chmod +x
This commit is contained in:
1 parent
55342bd4f9
commit
a053b3ea9f
15 files changed
+15272
-211
No files matched your search
@@ -48,6 +48,7 @@ from subprocess import STDOUT, PIPE, CalledProcessError
|
||||
from psutil import virtual_memory, disk_usage
|
||||
from html import escape as hescape
|
||||
import pymupdf
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
|
||||
from .shared import IMAGE_TYPES, getImageFileName, walkSort, walkLevel, sanitizeTrace, subprocess_run, dot_clean, get_contain_resolution
|
||||
from .comicarchive import SEVENZIP, available_archive_tools
|
||||
@@ -58,8 +59,12 @@ from . import pdfjpgextract
|
||||
from . import dualmetafix
|
||||
from . import metadata
|
||||
from . import kindle
|
||||
from . import i18n
|
||||
from . import __version__
|
||||
|
||||
|
||||
# GUI 消息统一通过 QCoreApplication.translate("KCCGUI", ...) 翻译,
|
||||
# 与主窗口动态文案同 context;CLI 模式下无 QTranslator,返回原文不影响命令行输出。
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
OS_SORT_KEY = os_sort_keygen()
|
||||
KF7_KINDLES = ('K1', 'K2', 'K34', 'KDX')
|
||||
@@ -710,13 +715,13 @@ def imgDirectoryProcessing(path, job_progress=''):
|
||||
|
||||
if GUI and not GUI.conversionAlive:
|
||||
rmtree(os.path.join(path, '..', '..'), True)
|
||||
raise UserWarning("Conversion interrupted.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "Conversion interrupted."))
|
||||
if len(workerOutput) > 0:
|
||||
rmtree(os.path.join(path, '..', '..'), True)
|
||||
raise RuntimeError("One of workers crashed. Maybe restart PC. Cause: " + workerOutput[0][0], workerOutput[0][1])
|
||||
raise RuntimeError(i18n.tr_arg(QCoreApplication.translate("KCCGUI", "One of workers crashed. Maybe restart PC. Cause: %1"), workerOutput[0][0]), workerOutput[0][1])
|
||||
else:
|
||||
rmtree(os.path.join(path, '..', '..'), True)
|
||||
raise UserWarning("C2E: Source directory is empty.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "C2E: Source directory is empty."))
|
||||
|
||||
|
||||
def imgFileProcessingTick(output):
|
||||
@@ -856,7 +861,7 @@ def extract_page(vector):
|
||||
page = doc.load_page(i)
|
||||
image_list = page.get_images()
|
||||
if len(image_list) > 1:
|
||||
raise UserWarning("mupdf_pdf_extract_page_image() function can be used only with single image pages.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "mupdf_pdf_extract_page_image() function can be used only with single image pages."))
|
||||
if not image_list:
|
||||
continue
|
||||
else:
|
||||
@@ -895,7 +900,7 @@ def mupdf_pdf_process_pages_parallel(filename, output_dir, target_width, target_
|
||||
if not render:
|
||||
print('PDF input can also use legacy extract option if you have any problems')
|
||||
if GUI:
|
||||
GUI.addMessage.emit('PDF input can also use legacy extract option if you have any problems'
|
||||
GUI.addMessage.emit(QCoreApplication.translate("KCCGUI", 'PDF input can also use legacy extract option if you have any problems')
|
||||
, 'info', False)
|
||||
GUI.addMessage.emit('', '', False)
|
||||
|
||||
@@ -929,7 +934,7 @@ def getWorkFolder(afile, options, workdir=None):
|
||||
else:
|
||||
check_path = gettempdir()
|
||||
|
||||
DISK_WARNING = "Not enough disk space to perform conversion. Try Temp Directory option."
|
||||
DISK_WARNING = QCoreApplication.translate("KCCGUI", "Not enough disk space to perform conversion. Try Temp Directory option.")
|
||||
|
||||
if os.path.isdir(afile):
|
||||
if disk_usage(check_path)[2] < getDirectorySize(afile) * 2.5:
|
||||
@@ -940,7 +945,7 @@ def getWorkFolder(afile, options, workdir=None):
|
||||
return workdir
|
||||
except Exception:
|
||||
rmtree(workdir, True)
|
||||
raise UserWarning("Failed to prepare a workspace.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "Failed to prepare a workspace."))
|
||||
elif os.path.isfile(afile):
|
||||
if disk_usage(check_path)[2]< os.path.getsize(afile) * 2.5:
|
||||
raise UserWarning(DISK_WARNING)
|
||||
@@ -953,7 +958,7 @@ def getWorkFolder(afile, options, workdir=None):
|
||||
pdf = pdfjpgextract.PdfJpgExtract(afile, fullPath)
|
||||
njpg = pdf.extract()
|
||||
if njpg == 0:
|
||||
raise UserWarning("Failed to extract images from PDF file.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "Failed to extract images from PDF file."))
|
||||
return workdir
|
||||
target_width, target_height = options.profileData[1]
|
||||
if options.cropping == 1:
|
||||
@@ -966,7 +971,7 @@ def getWorkFolder(afile, options, workdir=None):
|
||||
mupdf_pdf_process_pages_parallel(afile, fullPath, target_width, target_height, options.pdfwidth)
|
||||
except Exception as e:
|
||||
rmtree(path, True)
|
||||
raise UserWarning(f"Failed to extract images from PDF file. {e}")
|
||||
raise UserWarning(i18n.tr_arg(QCoreApplication.translate("KCCGUI", "Failed to extract images from PDF file. %1"), e))
|
||||
return workdir
|
||||
else:
|
||||
if not os.path.exists(fullPath):
|
||||
@@ -1061,7 +1066,7 @@ def getWorkFolder(afile, options, workdir=None):
|
||||
finally:
|
||||
pass
|
||||
else:
|
||||
raise UserWarning("Failed to open source file/directory.")
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", "Failed to open source file/directory."))
|
||||
|
||||
|
||||
def getOutputFilename(srcpath, wantedname, ext, tomenumber):
|
||||
@@ -1319,7 +1324,7 @@ def chunk_directory(path):
|
||||
path.append(tome)
|
||||
return path
|
||||
else:
|
||||
raise UserWarning('Unsupported directory structure.')
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", 'Unsupported directory structure.'))
|
||||
|
||||
|
||||
def chunk_process(path, mode, parent):
|
||||
@@ -1396,27 +1401,27 @@ def detectSuboptimalProcessing(tmppath, orgpath):
|
||||
except Exception as err:
|
||||
rmtree(os.path.join(tmppath, '..', '..'), True)
|
||||
if 'decoder' in str(err) and 'not available' in str(err):
|
||||
raise RuntimeError('Pillow was compiled without JPG and/or PNG decoder.')
|
||||
raise RuntimeError(QCoreApplication.translate("KCCGUI", 'Pillow was compiled without JPG and/or PNG decoder.'))
|
||||
else:
|
||||
raise RuntimeError('Image file %s is corrupted. Error: %s' % (pathOrg, str(err)))
|
||||
raise RuntimeError(i18n.tr_arg(QCoreApplication.translate("KCCGUI", 'Image file %1 is corrupted. Error: %2'), pathOrg, str(err)))
|
||||
else:
|
||||
try:
|
||||
if os.path.exists(os.path.join(root, name)):
|
||||
os.remove(os.path.join(root, name))
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"{name}: {e}")
|
||||
raise RuntimeError(i18n.tr_arg(QCoreApplication.translate("KCCGUI", '%1: %2'), name, e))
|
||||
if alreadyProcessed:
|
||||
print("WARNING: Source files are probably created by KCC. The second conversion will decrease quality.")
|
||||
if GUI:
|
||||
GUI.addMessage.emit('Source files are probably created by KCC. The second conversion will decrease quality.'
|
||||
GUI.addMessage.emit(QCoreApplication.translate("KCCGUI", 'Source files are probably created by KCC. The second conversion will decrease quality.')
|
||||
, 'warning', False)
|
||||
GUI.addMessage.emit('', '', False)
|
||||
if imageSmaller > imageNumber * 0.25 and not options.upscale and not options.stretch and not options.profile.startswith('KS'):
|
||||
print("WARNING: More than 25% of images are smaller than target device resolution. "
|
||||
"Consider enabling stretching or upscaling to improve readability.")
|
||||
if GUI:
|
||||
GUI.addMessage.emit('More than 25% of images are smaller than target device resolution.', 'warning', False)
|
||||
GUI.addMessage.emit('Consider enabling stretching or upscaling to improve readability.', 'warning', False)
|
||||
GUI.addMessage.emit(QCoreApplication.translate("KCCGUI", 'More than 25% of images are smaller than target device resolution.'), 'warning', False)
|
||||
GUI.addMessage.emit(QCoreApplication.translate("KCCGUI", 'Consider enabling stretching or upscaling to improve readability.'), 'warning', False)
|
||||
GUI.addMessage.emit('', '', False)
|
||||
|
||||
|
||||
@@ -1631,7 +1636,7 @@ def checkOptions(options):
|
||||
options.noKepub = True
|
||||
|
||||
if not options.iskindle and ('MOBI' in options.format or 'EPUB-200MB' in options.format or 'KFX' in options.format):
|
||||
raise UserWarning('MOBI/Send to Kindle not supported for non-Kindle profiles')
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", 'MOBI/Send to Kindle not supported for non-Kindle profiles'))
|
||||
|
||||
if options.format == 'PDF-200MB':
|
||||
options.targetsize = 195
|
||||
@@ -1788,8 +1793,8 @@ def _detectKindleGen(GUI=None):
|
||||
versionCheck = line.split('V')[1].split(' ')[0]
|
||||
if Version(versionCheck) < Version('2.9'):
|
||||
if GUI:
|
||||
GUI.addMessage('Your <a href="https://www.amazon.com/b?node=23496309011">KindleGen</a>'
|
||||
' is outdated! MOBI conversion might fail.', 'warning')
|
||||
GUI.addMessage.emit(QCoreApplication.translate("KCCGUI", 'Your <a href="https://www.amazon.com/b?node=23496309011">KindleGen</a>'
|
||||
' is outdated! MOBI conversion might fail.'), 'warning', False)
|
||||
break
|
||||
return True
|
||||
except (FileNotFoundError, CalledProcessError):
|
||||
@@ -1799,7 +1804,7 @@ def _detectKindleGen(GUI=None):
|
||||
print(f"kindlegen: {e.strerror}")
|
||||
print('Re-install or re-open Rosetta/Kindle Previewer/other Intel app?')
|
||||
if GUI:
|
||||
error = f"kindlegen: {e.strerror}\n\n Re-install or re-open Rosetta/Kindle Previewer/other Intel app?"
|
||||
error = i18n.tr_arg(QCoreApplication.translate("KCCGUI", "kindlegen: %1\n\n Re-install or re-open Rosetta/Kindle Previewer/other Intel app?"), e.strerror)
|
||||
GUI.showDialog(error, 'error')
|
||||
return False
|
||||
|
||||
@@ -1812,7 +1817,7 @@ def checkPre(source='KCC-'):
|
||||
|
||||
def makeFusion(sources: List[str]):
|
||||
if len(sources) < 2:
|
||||
raise UserWarning('Fusion requires at least 2 sources. Did you forget to uncheck fusion?')
|
||||
raise UserWarning(QCoreApplication.translate("KCCGUI", 'Fusion requires at least 2 sources. Did you forget to uncheck fusion?'))
|
||||
start = perf_counter()
|
||||
first_path = Path(sources[0])
|
||||
|
||||
|
||||
Reference in new issue
Block a user