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:
Ryuga6
2026-10-13 11:10:33 -07:00
committed by GitHub
parent 55342bd4f9
commit a053b3ea9f
15 changed files with 15272 additions and 211 deletions
+4
View File
@@ -395,6 +395,10 @@ If you want to edit the `.ui` files, use `pyside6-designer` which is included in
If new objects have been added, verify that correct tab order has been applied by using [Tab Order Editing Mode](https://doc.qt.io/qt-6/designer-tab-order.html).
Then use the `gen_ui_files` scripts to autogenerate the python UI.
When adding or changing translatable strings (`self.tr(...)`), run the
`gen_translation_files` scripts to regenerate the translation sources and
recompile them; see `TRANSLATION.md` for the full workflow.
An example PR adding a new checkbox is here: https://github.com/ciromattia/kcc/pull/1445
video of adding a new checkbox: https://youtu.be/g3I8DU74C7g
+126
View File
@@ -0,0 +1,126 @@
# Translating KCC
KCC ships its UI and CLI strings in English by default, with optional
translations loaded at runtime. This file documents how to keep those
translations up to date when strings are added or changed.
## Why translation files need maintenance
Translatable strings are wrapped in `self.tr(...)` or
`QT_TRANSLATE_NOOP('KCCGUI', ...)` in the source. The English text lives in
the code, but the actual translations live in `translations/*.ts`. If a new
string is added to the code and the `.ts` files are not regenerated, the new
string has no translation and silently falls back to English.
The `.ts` files are the human-editable source of truth. The `.qm` files are
the compiled, runtime-format translations. Both are committed to the
repository.
## Prerequisites
- Python 3 with PySide6 installed in the active environment, which provides
the `pyside6-lupdate`, `pyside6-lrelease`, and `pyside6-rcc` tools.
- Run all commands from the repository root.
## Updating translations after adding a string
The whole flow is wrapped in one script:
```sh
./gen_translation_files.sh # macOS / Linux
gen_translation_files.bat # Windows
```
It runs three steps in order:
1. `pyside6-lupdate` scans the source files and updates every
`translations/*.ts` in place.
2. `pyside6-lrelease` compiles each `.ts` into its `.qm` runtime file.
3. `pyside6-rcc` re-bundles the `.qm` files into `KCC_rc.py`, because the
`.qm` files are loaded from the Qt resource system, not from disk.
After running the script, open the `.ts` files and fill in the newly added
`<translation>` entries. `lupdate` extracts strings but does not translate
them.
### Manual equivalent
If you prefer to run the steps yourself, the script is equivalent to:
```sh
for ts in translations/*.ts; do
pyside6-lupdate \
kindlecomicconverter/KCC_gui.py \
kindlecomicconverter/comic2ebook.py \
gui/KCC.ui \
gui/MetaEditor.ui \
-ts "$ts"
done
for ts in translations/*.ts; do
pyside6-lrelease "$ts" -qm "${ts%.ts}.qm"
done
pyside6-rcc gui/KCC.qrc > kindlecomicconverter/KCC_rc.py
```
The source file list is significant. `comic2ebook.py` contains the CLI-side
messages; omitting it drops 20 strings (250 -> 230 extracted). The `.ui`
files contribute the `mainWindow` and `editorDialog` contexts.
`pyside6-project` is intentionally not used. Its `lupdate` scans the whole
project, which would pick up both `gui/KCC.ui` and the generated
`kindlecomicconverter/KCC_ui.py` and extract the `mainWindow` context twice.
## Generated files and what to commit
| File | Generated by | Commit? |
|------|--------------|---------|
| `translations/*.ts` | edited by hand (updated by `lupdate`) | yes |
| `translations/*.qm` | `pyside6-lrelease` | yes |
| `kindlecomicconverter/KCC_rc.py` | `pyside6-rcc` | yes |
The `.qm` files are also embedded into `KCC_rc.py` via `gui/KCC.qrc`; both
the standalone `.qm` and the resource module are kept in sync by the script.
## Adding a new language
1. Generate the new source file:
```sh
pyside6-lupdate \
kindlecomicconverter/KCC_gui.py \
kindlecomicconverter/comic2ebook.py \
gui/KCC.ui \
gui/MetaEditor.ui \
-ts translations/kcc_xx_XX.ts
```
2. Fill in the `<translation>` entries.
3. Register the language in `kindlecomicconverter/i18n.py` in the
`LANGUAGES` dict.
4. Add the `.qm` to `gui/KCC.qrc` under the `/i18n` prefix:
```xml
<file alias="kcc_xx_XX.qm">../translations/kcc_xx_XX.qm</file>
```
5. Run `gen_translation_files` to compile and re-bundle the new language.
## Verifying the result
- Run the script twice: the second run should produce no further changes
(`git diff` stays empty), confirming the flow is idempotent.
- Launch KCC and switch languages via the language button; verify the UI,
the metadata editor, and the conversion progress messages all switch.
## Common problems
- **`pyside6-lupdate: command not found`** — PySide6 is not installed or the
virtual environment is not active. Install with `pip install pyside6`.
- **Hundreds of `<location>` lines changed after running `lupdate`** — the
`-ts` target was written outside `translations/`. Location paths are
relative to the `.ts` file, so always update the files in place.
- **`dropping duplicate messages` warnings from `lrelease`** — a `.ts` file
has the same `(context, source)` entry twice. Regenerate it with `lupdate`.
- **Some strings stay English after switching languages** — the `.qm` was not
re-bundled into `KCC_rc.py`. Run `gen_translation_files` (step 3).
- **`type="vanished"` entries accumulate** — this is normal: `lupdate` marks
entries whose source string is no longer found instead of deleting them.
`lrelease` skips them, so they do not affect the runtime translations.
+29
View File
@@ -0,0 +1,29 @@
@echo off
rem Update translation sources (.ts) and rebuild the runtime translation
rem files (.qm), then re-bundle the .qm files into the Qt resource module
rem (KCC_rc.py). Run from the repository root.
setlocal
where pyside6-lupdate >nul 2>nul || (echo Error: pyside6-lupdate not found. Install it with: pip install pyside6 & exit /b 1)
where pyside6-lrelease >nul 2>nul || (echo Error: pyside6-lrelease not found. Install it with: pip install pyside6 & exit /b 1)
where pyside6-rcc >nul 2>nul || (echo Error: pyside6-rcc not found. Install it with: pip install pyside6 & exit /b 1)
rem 1. Update every translation source file in place.
rem comic2ebook.py must stay in the list: the CLI-side messages live there.
for %%f in (translations\*.ts) do (
pyside6-lupdate kindlecomicconverter\KCC_gui.py kindlecomicconverter\comic2ebook.py gui\KCC.ui gui\MetaEditor.ui -ts "%%f"
if errorlevel 1 exit /b 1
)
rem 2. Compile each .ts into its .qm runtime file.
for %%f in (translations\*.ts) do (
pyside6-lrelease "%%f" -qm "%%~dpnf.qm"
if errorlevel 1 exit /b 1
)
rem 3. Re-bundle the .qm files into KCC_rc.py via the Qt resource file.
pyside6-rcc gui\KCC.qrc > kindlecomicconverter\KCC_rc.py
if errorlevel 1 exit /b 1
echo Translation files updated and re-bundled.
endlocal
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Update translation sources (.ts) and rebuild the runtime translation
# files (.qm), then re-bundle the .qm files into the Qt resource module
# (KCC_rc.py). Run from the repository root.
set -e
# Fail early if the PySide6 translation tools are missing.
for tool in pyside6-lupdate pyside6-lrelease pyside6-rcc; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "Error: $tool not found. Install it with: pip install pyside6" >&2
exit 1
fi
done
# 1. Update every translation source file in place.
# comic2ebook.py must stay in the list: the CLI-side messages live there.
for ts in translations/*.ts; do
pyside6-lupdate \
kindlecomicconverter/KCC_gui.py \
kindlecomicconverter/comic2ebook.py \
gui/KCC.ui \
gui/MetaEditor.ui \
-ts "$ts"
done
# 2. Compile each .ts into its .qm runtime file.
for ts in translations/*.ts; do
pyside6-lrelease "$ts" -qm "${ts%.ts}.qm"
done
# 3. Re-bundle the .qm files into KCC_rc.py via the Qt resource file.
pyside6-rcc gui/KCC.qrc > kindlecomicconverter/KCC_rc.py
echo "Translation files updated and re-bundled."
+6 -1
View File
@@ -34,4 +34,9 @@
<file>../icons/Bindle_Red.png</file>
<file>../icons/fanatical.png</file>
</qresource>
</RCC>
<qresource prefix="/i18n">
<file alias="kcc_zh_CN.qm">../translations/kcc_zh_CN.qm</file>
<file alias="kcc_ja_JP.qm">../translations/kcc_ja_JP.qm</file>
<file alias="kcc_ko_KR.qm">../translations/kcc_ko_KR.qm</file>
</qresource>
</RCC>
+235 -129
View File
@@ -21,9 +21,11 @@ from datetime import datetime, timezone
import itertools
import json
from pathlib import Path
from PySide6.QtCore import (QSize, QUrl, Qt, Signal, QIODeviceBase, QEvent, QThread, QSettings)
from PySide6.QtGui import (QColor, QIcon, QImage, QKeyEvent, QPixmap, QDesktopServices)
from PySide6.QtWidgets import (QApplication, QDialogButtonBox, QHBoxLayout, QLabel, QListWidgetItem, QMainWindow, QSizePolicy, QSystemTrayIcon, QFileDialog, QMessageBox, QDialog, QAbstractItemView, QListView, QTreeView, QWidget)
from PySide6.QtCore import (QSize, QUrl, Qt, Signal, QIODeviceBase, QEvent, QThread, QSettings,
QCoreApplication, QT_TRANSLATE_NOOP, QObject)
from PySide6.QtGui import (QActionGroup, QColor, QIcon, QImage, QKeyEvent, QPixmap, QDesktopServices)
from PySide6.QtWidgets import (QApplication, QDialogButtonBox, QHBoxLayout, QLabel, QListWidgetItem, QMainWindow, QSizePolicy, QSystemTrayIcon, QFileDialog, QMessageBox, QDialog, QAbstractItemView, QListView, QTreeView, QWidget,
QToolButton, QMenu)
from PySide6.QtNetwork import (QLocalSocket, QLocalServer)
import os
@@ -48,6 +50,7 @@ from .shared import HTMLStripper, sanitizeTrace, walkLevel
from .comicarchive import SEVENZIP, TAR, available_archive_tools
from .comic2ebook import OS_SORT_KEY, KF7_KINDLES, flattenTree, getWorkFolder, removeNonImages, sanitizeTree, detectKindleGen
from . import __version__
from . import i18n
from . import comic2ebook
from . import metadata
from . import kindle
@@ -184,8 +187,8 @@ class VersionThread(QThread):
if ("b" not in __version__ and Version(latest_version) > Version(__version__)) \
or ("b" in __version__
and Version(latest_version) >= Version(re.sub(r'b.*', '', __version__))):
MW.addMessage.emit('<a href="' + html_url + '"><b>The new version is available!</b></a>', 'warning',
False)
MW.addMessage.emit('<a href="' + html_url + '"><b>' + self.tr('The new version is available!')
+ '</b></a>', 'warning', False)
except Exception:
pass
@@ -196,6 +199,8 @@ class VersionThread(QThread):
'X-GitHub-Api-Version': '2022-11-28'}).json()
for category, payloads in announcements.items():
for payload in payloads:
if not isinstance(payload, dict):
continue
expiration = datetime.fromisoformat(payload['expiration'])
if expiration < datetime.now(timezone.utc):
continue
@@ -407,8 +412,8 @@ class WorkerThread(QThread):
GUI.progress.stop()
GUI.needClean = True
MW.hideProgressBar.emit()
MW.addMessage.emit('<b>Conversion interrupted.</b>', 'error', False)
MW.addTrayMessage.emit('Conversion interrupted.', 'Critical')
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', '<b>Conversion interrupted.</b>'), 'error', False)
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'Conversion interrupted.'), 'Critical')
MW.modeConvert.emit(1)
# noinspection PyUnboundLocalVariable
@@ -427,7 +432,7 @@ class WorkerThread(QThread):
fusion_cover_path = None
if options.filefusion:
bookDir = []
MW.addMessage.emit('Attempting file fusion', 'info', False)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Attempting file fusion'), 'info', False)
for job in currentJobs:
bookDir.append(job)
try:
@@ -438,13 +443,13 @@ class WorkerThread(QThread):
currentJobs.clear()
job, fusion_cover_path = comic2ebook.makeFusion(bookDir)
currentJobs.append(job)
MW.addMessage.emit('Created fusion at ' + currentJobs[0], 'info', False)
MW.addMessage.emit(i18n.tr_arg(QCoreApplication.translate('KCCGUI', 'Created fusion at %1'), currentJobs[0]), 'info', False)
except Exception as e:
print('Fusion Failed. ' + str(e))
MW.addMessage.emit('Fusion Failed. ' + str(e), 'error', True)
MW.addMessage.emit(i18n.tr_arg(QCoreApplication.translate('KCCGUI', 'Fusion Failed. %1'), str(e)), 'error', True)
elif len(currentJobs) > 1 and options.title != 'defaulttitle':
currentJobs.clear()
error_message = 'Process Failed. Custom title can\'t be set when processing more than 1 source.\nDid you forget to check fusion?'
error_message = QCoreApplication.translate('KCCGUI', 'Process Failed. Custom title can\'t be set when processing more than 1 source.\nDid you forget to check fusion?')
print(error_message)
MW.addMessage.emit(error_message, 'error', True)
for i, job in enumerate(currentJobs, start=1):
@@ -454,19 +459,19 @@ class WorkerThread(QThread):
self.clean()
return
self.errors = False
MW.addMessage.emit(f'<b>{job_progress_number}Source:</b> ' + job, 'info', False)
MW.addMessage.emit(i18n.tr_arg(QCoreApplication.translate('KCCGUI', '<b>%1Source:</b> %2'), job_progress_number, job), 'info', False)
if gui_current_format == 'CBZ':
MW.addMessage.emit('Creating CBZ files', 'info', False)
GUI.progress.content = 'Creating CBZ files'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating CBZ files'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Creating CBZ files')
elif gui_current_format == 'FOLDER':
MW.addMessage.emit('Creating folders', 'info', False)
GUI.progress.content = 'Creating folders'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating folders'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Creating folders')
elif gui_current_format == 'PDF':
MW.addMessage.emit('Creating PDF files', 'info', False)
GUI.progress.content = 'Creating PDF files'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating PDF files'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Creating PDF files')
else:
MW.addMessage.emit('Creating EPUB files', 'info', False)
GUI.progress.content = 'Creating EPUB files'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating EPUB files'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Creating EPUB files')
jobargv = list(argv)
jobargv.append(job)
try:
@@ -481,20 +486,20 @@ class WorkerThread(QThread):
GUI.progress.content = ''
self.errors = True
MW.addMessage.emit(str(warn), 'warning', False)
MW.addMessage.emit('Error during conversion! Please consult '
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Error during conversion! Please consult '
'<a href="https://github.com/ciromattia/kcc/wiki/Error-messages">wiki</a> '
'for more details.', 'error', False)
MW.addTrayMessage.emit('Error during conversion!', 'Critical')
'for more details.'), 'error', False)
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'Error during conversion!'), 'Critical')
except Exception as err:
GUI.progress.content = ''
self.errors = True
_, _, traceback = sys.exc_info()
MW.showDialog.emit("Error during conversion %s:\n\n%s\n\nTraceback:\n%s"
% (jobargv[-1], str(err), sanitizeTrace(traceback)), 'error')
MW.addMessage.emit('Error during conversion! Please consult '
MW.showDialog.emit(i18n.tr_arg(QCoreApplication.translate('KCCGUI', "Error during conversion %1:\n\n%2\n\nTraceback:\n%3"),
jobargv[-1], str(err), sanitizeTrace(traceback)), 'error')
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Error during conversion! Please consult '
'<a href="https://github.com/ciromattia/kcc/wiki/Error-messages">wiki</a> '
'for more details.', 'error', False)
MW.addTrayMessage.emit('Error during conversion!', 'Critical')
'for more details.'), 'error', False)
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'Error during conversion!'), 'Critical')
if not self.conversionAlive:
if 'outputPath' in locals():
for item in outputPath:
@@ -505,19 +510,19 @@ class WorkerThread(QThread):
if not self.errors:
GUI.progress.content = ''
if gui_current_format == 'CBZ':
MW.addMessage.emit('Creating CBZ files... <b>Done!</b>', 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating CBZ files... <b>Done!</b>'), 'info', True)
elif gui_current_format == 'FOLDER':
MW.addMessage.emit('Creating folders... <b>Done!</b>', 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating folders... <b>Done!</b>'), 'info', True)
elif gui_current_format == 'PDF':
MW.addMessage.emit('Creating PDF files... <b>Done!</b>', 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating PDF files... <b>Done!</b>'), 'info', True)
else:
MW.addMessage.emit('Creating EPUB files... <b>Done!</b>', 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating EPUB files... <b>Done!</b>'), 'info', True)
if 'MOBI' in gui_current_format and not options.lightnovel:
MW.progressBarTick.emit(f'{job_progress_number}Creating MOBI files')
MW.progressBarTick.emit(job_progress_number + QCoreApplication.translate('KCCGUI', 'Creating MOBI files'))
MW.progressBarTick.emit(str(len(outputPath) * 2 + 1))
MW.progressBarTick.emit('tick')
MW.addMessage.emit('Creating MOBI files', 'info', False)
GUI.progress.content = 'Creating MOBI files'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating MOBI files'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Creating MOBI files')
work = []
for item in outputPath:
work.append([item])
@@ -537,9 +542,9 @@ class WorkerThread(QThread):
return
if self.kindlegenErrorCode[0] == 0:
GUI.progress.content = ''
MW.addMessage.emit('Creating MOBI files... <b>Done!</b>', 'info', True)
MW.addMessage.emit('Processing MOBI files', 'info', False)
GUI.progress.content = 'Processing MOBI files'
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Creating MOBI files... <b>Done!</b>'), 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Processing MOBI files'), 'info', False)
GUI.progress.content = QCoreApplication.translate('KCCGUI', 'Processing MOBI files')
self.workerOutput = []
for item in outputPath:
self.workerOutput.append(comic2ebook.makeMOBIFix(
@@ -558,7 +563,7 @@ class WorkerThread(QThread):
move(mobiPath, GUI.targetDirectory)
except Exception:
pass
MW.addMessage.emit('Processing MOBI files... <b>Done!</b>', 'info', True)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Processing MOBI files... <b>Done!</b>'), 'info', True)
k = kindle.Kindle(options.profile)
if k.path and k.coverSupport:
for item in outputPath:
@@ -566,15 +571,15 @@ class WorkerThread(QThread):
if cover:
cover.saveToKindle(
k, comic2ebook.options.covers[outputPath.index(item)][1])
MW.addMessage.emit('Kindle detected. Uploading covers... <b>Done!</b>', 'info', False)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Kindle detected. Uploading covers... <b>Done!</b>'), 'info', False)
else:
GUI.progress.content = ''
for item in outputPath:
mobiPath = item.replace('.epub', '.mobi')
if os.path.exists(mobiPath):
os.remove(mobiPath)
MW.addMessage.emit('Failed to process MOBI file!', 'error', False)
MW.addTrayMessage.emit('Failed to process MOBI file!', 'Critical')
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Failed to process MOBI file!'), 'error', False)
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'Failed to process MOBI file!'), 'Critical')
else:
GUI.progress.content = ''
epubSize = (os.path.getsize(self.kindlegenErrorCode[2])) // 1024 // 1024
@@ -583,15 +588,15 @@ class WorkerThread(QThread):
os.remove(item)
if os.path.exists(item.replace('.epub', '.mobi')):
os.remove(item.replace('.epub', '.mobi'))
MW.addMessage.emit('KindleGen failed to create MOBI!', 'error', False)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'KindleGen failed to create MOBI!'), 'error', False)
MW.addMessage.emit(self.kindlegenErrorCode[1], 'error', False)
MW.addTrayMessage.emit('KindleGen failed to create MOBI!', 'Critical')
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'KindleGen failed to create MOBI!'), 'Critical')
if self.kindlegenErrorCode[0] == 1 and self.kindlegenErrorCode[1] != '':
MW.showDialog.emit("KindleGen error:\n\n" + self.kindlegenErrorCode[1], 'error')
MW.showDialog.emit(QCoreApplication.translate('KCCGUI', "KindleGen error:\n\n") + self.kindlegenErrorCode[1], 'error')
if self.kindlegenErrorCode[0] == 23026:
MW.addMessage.emit('Created EPUB file was too big. Weird file structure?', 'error', False)
MW.addMessage.emit('EPUB file: ' + str(epubSize) + 'MB. Supported size: ~350MB.', 'error',
False)
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', 'Created EPUB file was too big. Weird file structure?'), 'error', False)
MW.addMessage.emit(i18n.tr_arg(QCoreApplication.translate('KCCGUI', 'EPUB file: %1MB. Supported size: ~350MB.'), str(epubSize)),
'error', False)
else:
for item in outputPath:
if GUI.targetDirectory and GUI.targetDirectory != os.path.dirname(item):
@@ -604,8 +609,8 @@ class WorkerThread(QThread):
MW.hideProgressBar.emit()
GUI.needClean = True
if not self.errors:
MW.addMessage.emit('<b>All jobs completed.</b>', 'info', False)
MW.addTrayMessage.emit('All jobs completed.', 'Information')
MW.addMessage.emit(QT_TRANSLATE_NOOP('KCCGUI', '<b>All jobs completed.</b>'), 'info', False)
MW.addTrayMessage.emit(QCoreApplication.translate('KCCGUI', 'All jobs completed.'), 'Information')
MW.modeConvert.emit(1)
@@ -627,9 +632,9 @@ class SystemTrayIcon(QSystemTrayIcon):
self.showMessage('Kindle Comic Converter', message, icon)
class KCCGUI(KCC_ui.Ui_mainWindow):
class KCCGUI(QObject, KCC_ui.Ui_mainWindow):
def selectDefaultOutputFolder(self):
dname = QFileDialog.getExistingDirectory(MW, 'Select default output folder', self.defaultOutputFolder)
dname = QFileDialog.getExistingDirectory(MW, self.tr('Select default output folder'), self.defaultOutputFolder)
if self.is_directory_on_kindle(dname):
return
if dname != '':
@@ -641,11 +646,11 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
path = Path(dname)
for parent in itertools.chain([path], path.parents):
if parent.name == 'documents' and parent.parent.joinpath('system').joinpath('thumbnails').is_dir():
self.addMessage("Cannot select Kindle as output directory", 'error')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', "Cannot select Kindle as output directory"), 'error')
return True
def selectOutputFolder(self):
dname = QFileDialog.getExistingDirectory(MW, 'Select output directory', self.lastPath)
dname = QFileDialog.getExistingDirectory(MW, self.tr('Select output directory'), self.lastPath)
if self.is_directory_on_kindle(dname):
return
if dname != '':
@@ -661,10 +666,10 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.needClean = False
GUI.jobList.clear()
if self.tar or self.sevenzip:
fnames = QFileDialog.getOpenFileNames(MW, 'Select file', self.lastPath,
fnames = QFileDialog.getOpenFileNames(MW, self.tr('Select file'), self.lastPath,
'Comic (*.cbz *.cbr *.cb7 *.zip *.rar *.7z *.epub *.pdf);;All (*.*)')
else:
fnames = QFileDialog.getOpenFileNames(MW, 'Select file', self.lastPath,
fnames = QFileDialog.getOpenFileNames(MW, self.tr('Select file'), self.lastPath,
'Comic (*.pdf);;All (*.*)')
for fname in fnames[0]:
if fname != '':
@@ -677,7 +682,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.needClean = False
GUI.jobList.clear()
dialog = QFileDialog(MW, 'Select input folder(s)', self.lastPath)
dialog = QFileDialog(MW, self.tr('Select input folder(s)'), self.lastPath)
dialog.setFileMode(QFileDialog.FileMode.Directory)
dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
@@ -778,7 +783,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if not sname:
if QApplication.keyboardModifiers() == Qt.ShiftModifier:
# Multi-directory selection for bulk editing ComicInfo.xml
dialog = QFileDialog(MW, 'Select volume directories', self.lastPath)
dialog = QFileDialog(MW, self.tr('Select volume directories'), self.lastPath)
dialog.setFileMode(QFileDialog.FileMode.Directory)
dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
@@ -798,15 +803,15 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.lastPath = os.path.dirname(selected_dirs[0])
else:
if self.sevenzip:
fnames = QFileDialog.getOpenFileNames(MW, 'Select file(s)', self.lastPath,
fnames = QFileDialog.getOpenFileNames(MW, self.tr('Select file(s)'), self.lastPath,
'Comic (*.cbz *.cbr *.cb7)')
files = fnames[0]
if files:
self.lastPath = os.path.abspath(os.path.join(files[0], os.pardir))
else:
self.showDialog("Editor is disabled due to a lack of 7z.", 'error')
self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
' to enable metadata editing.', 'warning')
self.showDialog(self.tr("Editor is disabled due to a lack of 7z."), 'error')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
' to enable metadata editing.'), 'warning')
else:
files = [sname]
@@ -815,8 +820,8 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.editor.loadData(files)
except Exception as err:
_, _, traceback = sys.exc_info()
self.showDialog("Failed to parse metadata!\n\n%s\n\nTraceback:\n%s"
% (str(err), sanitizeTrace(traceback)), 'error')
self.showDialog(i18n.tr_arg(self.tr("Failed to parse metadata!\n\n%1\n\nTraceback:\n%2"),
str(err), sanitizeTrace(traceback)), 'error')
else:
self.editor.ui.exec_()
@@ -965,7 +970,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
icon = QIcon()
icon.addPixmap(QPixmap(":/Other/icons/convert.png"), QIcon.Mode.Normal, QIcon.State.Off)
GUI.convertButton.setIcon(icon)
GUI.convertButton.setText('Convert')
GUI.convertButton.setText(self.tr('Convert'))
GUI.centralWidget.setAcceptDrops(True)
elif enable == 0:
self.conversionAlive = True
@@ -973,7 +978,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
icon = QIcon()
icon.addPixmap(QPixmap(":/Other/icons/clear.png"), QIcon.Mode.Normal, QIcon.State.Off)
GUI.convertButton.setIcon(icon)
GUI.convertButton.setText('Abort')
GUI.convertButton.setText(self.tr('Abort'))
GUI.centralWidget.setAcceptDrops(False)
elif enable == -1:
self.conversionAlive = True
@@ -1004,8 +1009,8 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def togglewebtoonBox(self, value):
if value:
self.addMessage('You can choose a taller device profile to get taller cuts in webtoon mode.', 'info')
self.addMessage('Try reading webtoon panels side by side in landscape!', 'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'You can choose a taller device profile to get taller cuts in webtoon mode.'), 'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Try reading webtoon panels side by side in landscape!'), 'info')
GUI.qualityBox.setEnabled(False)
GUI.qualityBox.setChecked(False)
GUI.mangaBox.setEnabled(False)
@@ -1044,9 +1049,9 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
profile = GUI.profiles[str(GUI.deviceBox.currentText())]
if value == 2:
if profile['Label'] not in ('K57', 'KPW', 'K810') :
self.addMessage('This option is intended for older Kindle models.', 'warning')
self.addMessage('On this device, there will be conversion speed and quality issues.', 'warning')
self.addMessage('Use the Kindle Scribe profile if you want higher resolution when zooming.', 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'This option is intended for older Kindle models.'), 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'On this device, there will be conversion speed and quality issues.'), 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Use the Kindle Scribe profile if you want higher resolution when zooming.'), 'warning')
GUI.upscaleBox.setEnabled(False)
GUI.upscaleBox.setChecked(True)
else:
@@ -1060,7 +1065,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
current_format = GUI.formats[str(GUI.formatBox.currentText())]['format']
for bad_format in ('MOBI', 'EPUB'):
if bad_format in current_format:
self.addMessage('Scribe PNG MOBI/EPUB has a lot of problems like blank pages/sections. Use JPG instead.', 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Scribe PNG MOBI/EPUB has a lot of problems like blank pages/sections. Use JPG instead.'), 'warning')
break
GUI.pngLegacyBox.setEnabled(True)
GUI.noQuantizeBox.setEnabled(True)
@@ -1108,19 +1113,73 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
valueRaw = int(5 * round(float(value) / 5))
value = '%.2f' % (float(valueRaw) / 100)
if float(value) <= 0.09:
GUI.gammaLabel.setText('Gamma: Auto')
GUI.gammaLabel.setText(self.tr('Gamma: Auto'))
else:
GUI.gammaLabel.setText('Gamma: ' + str(value))
GUI.gammaLabel.setText(i18n.tr_arg(self.tr('Gamma: %1'), value))
GUI.gammaSlider.setValue(valueRaw)
self.gammaValue = value
def changeCroppingPower(self, value):
valueRaw = int(5 * round(float(value) / 5))
value = '%.2f' % (float(valueRaw) / 100)
GUI.croppingPowerLabel.setText('Cropping Power: ' + str(value))
GUI.croppingPowerLabel.setText(i18n.tr_arg(self.tr('Cropping Power: %1'), value))
GUI.croppingPowerSlider.setValue(valueRaw)
self.croppingPowerValue = value
def updateDynamicText(self):
"""语言切换后,重新应用所有"代码层动态写入"的文案。"""
# 窗口标题
MW.setWindowTitle(i18n.tr_arg(self.tr("Kindle Comic Converter %1"), __version__))
# 转换按钮状态
if getattr(self, 'conversionAlive', False):
GUI.convertButton.setText(self.tr('Abort'))
else:
GUI.convertButton.setText(self.tr('Convert'))
# Gamma / Cropping Power 标签:直接 setText,避免触发滑条 valueChanged 信号
if float(self.gammaValue) <= 0.09:
GUI.gammaLabel.setText(self.tr('Gamma: Auto'))
else:
GUI.gammaLabel.setText(i18n.tr_arg(self.tr('Gamma: %1'), self.gammaValue))
GUI.croppingPowerLabel.setText(i18n.tr_arg(self.tr('Cropping Power: %1'), self.croppingPowerValue))
# 语言按钮 tooltip
self.languageButton.setToolTip(self.tr('Switch language / 切换语言 / 言語 / 언어'))
def _addInitialTips(self):
"""添加初始提示消息(一次性占位提示,首次操作后由 needClean 机制清空)。"""
# 文本必须以内联字面量直接传入 QT_TRANSLATE_NOOP('KCCGUI', ...),lupdate 才能抽取;
# 不要先存入变量再传(那样消息会从 .ts 中消失)。
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<b>Tip:</b> Hover mouse over options/buttons to see explanations. Boxes can be partially/fully checked.'),
'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<b>Tip:</b> You can drag and drop image folders or comic files/archives into this window to convert.'),
'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', "<b>Tip:</b> Calibre may add margins! USB drop directly into the device's documents folder instead."),
'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<b>Tip:</b> You can toggle easy/expert mode using button at top right.'),
'info')
if self.startNumber < 5:
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Since you are a new user of <b>KCC</b> please see few '
'<a href="https://github.com/ciromattia/kcc/wiki/Important-tips">important tips</a>.'),
'info')
if not any([self.tar, self.sevenzip]):
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
' to enable CBZ/CBR/ZIP/etc processing.'),
'warning')
def changeLanguage(self, lang_code):
self.language = i18n.apply_language(lang_code)
self.settings.setValue('language', self.language)
self.retranslateUi(MW)
self.updateDynamicText()
self.retranslateJobList()
# 同步刷新元数据编辑器弹窗
self.editor.retranslateUi(self.editor.ui)
self.editor.updateDynamicText()
# 更新菜单勾选
for act in self.languageMenu.actions():
act.setChecked(act.data() == self.language)
def changeDevice(self):
profile = GUI.profiles[str(GUI.deviceBox.currentText())]
if profile['ForceExpert']:
@@ -1143,7 +1202,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
current_format = GUI.formats[str(GUI.formatBox.currentText())]['format']
for bad_format in ('MOBI', 'EPUB'):
if bad_format in current_format:
self.addMessage('Colorsoft MOBI/EPUB can have blank pages. Just go back a few pages, exit, and reenter book.', 'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Colorsoft MOBI/EPUB can have blank pages. Just go back a few pages, exit, and reenter book.'), 'info')
break
elif profile['Label'] in KF7_KINDLES:
GUI.mozJpegBox.setCheckState(Qt.CheckState.PartiallyChecked)
@@ -1152,8 +1211,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if not profile['PVOptions']:
GUI.qualityBox.setChecked(False)
if str(GUI.deviceBox.currentText()) == 'Other':
self.addMessage('<a href="https://github.com/ciromattia/kcc/wiki/NonKindle-devices">'
'List of supported Non-Kindle devices.</a>', 'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', '<a href="https://github.com/ciromattia/kcc/wiki/NonKindle-devices">List of supported Non-Kindle devices.</a>'), 'info')
def changeFormat(self, outputformat=None):
profile = GUI.profiles[str(GUI.deviceBox.currentText())]
@@ -1178,7 +1236,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
elif not GUI.webtoonBox.isChecked():
GUI.chunkSizeCheckBox.setEnabled(True)
if GUI.formats[str(GUI.formatBox.currentText())]['format'] in ('CBZ', 'FOLDER', 'PDF') and not GUI.webtoonBox.isChecked():
self.addMessage("Partially check W/B Margins if you don't want KCC to extend the image margins.", 'info')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', "Partially check W/B Margins if you don't want KCC to extend the image margins."), 'info')
GUI.borderBox.setCheckState(Qt.CheckState.PartiallyChecked)
GUI.mozJpegBox.setCheckState(Qt.CheckState.PartiallyChecked)
else:
@@ -1190,27 +1248,48 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
return s.get_data()
def addMessage(self, message, icon, replace=False):
# message 为英文原文(QT_TRANSLATE_NOOP 返回);内部翻译并保存原文供语言切换时重译。
# 已翻译/动态文本经 self.tr() 查不到 key 会原样返回,因此同样安全。
translated = self.tr(message)
if icon != '':
icon = getattr(self.icons, icon)
item = QListWidgetItem(icon, ' ' + self.stripTags(message))
item = QListWidgetItem(icon, ' ' + self.stripTags(translated))
else:
item = QListWidgetItem(' ' + self.stripTags(message))
item = QListWidgetItem(' ' + self.stripTags(translated))
if replace:
GUI.jobList.takeItem(GUI.jobList.count() - 1)
# Due to lack of HTML support in QListWidgetItem we overlay text field with QLabel
# We still fill original text field with transparent content to trigger creation of horizontal scrollbar
item.setForeground(QColor('transparent'))
label = QLabel(message)
label = QLabel(translated)
label.setOpenExternalLinks(True)
# 保存英文原文,供语言切换时重新翻译;不保存 label 引用(C++ 对象可能被删除,导致 RuntimeError)
label.setProperty('i18n_source', message)
GUI.jobList.addItem(item)
GUI.jobList.setItemWidget(item, label)
GUI.jobList.scrollToBottom()
def retranslateJobList(self):
"""语言切换后,刷新 jobList 中带 i18n_source 标记的消息文本。"""
for i in range(GUI.jobList.count()):
item = GUI.jobList.item(i)
if item is None:
continue
label = GUI.jobList.itemWidget(item)
if label is None:
continue
source = label.property('i18n_source')
if source:
try:
label.setText(self.tr(source))
except RuntimeError:
pass # C++ 对象已删除,跳过
def showDialog(self, message, kind):
if kind == 'error':
QMessageBox.critical(MW, 'KCC - Error', message, QMessageBox.StandardButton.Ok)
QMessageBox.critical(MW, self.tr('KCC - Error'), message, QMessageBox.StandardButton.Ok)
elif kind == 'question':
GUI.versionCheck.setAnswer(QMessageBox.question(MW, 'KCC - Question', message,
GUI.versionCheck.setAnswer(QMessageBox.question(MW, self.tr('KCC - Question'), message,
QMessageBox.Yes,
QMessageBox.No))
@@ -1232,7 +1311,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def convertStart(self):
if self.conversionAlive:
GUI.convertButton.setEnabled(False)
self.addMessage('The process will be interrupted. Please wait.', 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'The process will be interrupted. Please wait.'), 'warning')
self.conversionAlive = False
self.worker.sync()
else:
@@ -1248,7 +1327,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.needClean = False
GUI.jobList.clear()
if GUI.jobList.count() == 0:
self.addMessage('No files selected! Please choose files to convert.', 'error')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'No files selected! Please choose files to convert.'), 'error')
self.needClean = True
return
if GUI.defaultOutputFolderBox.checkState() == Qt.CheckState.PartiallyChecked:
@@ -1259,7 +1338,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
self.targetDirectory = str(target_path)
if self.currentMode > 2 and (GUI.widthBox.value() == 0 or GUI.heightBox.value() == 0):
GUI.jobList.clear()
self.addMessage('Target resolution is not set!', 'error')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'Target resolution is not set!'), 'error')
self.needClean = True
return
if 'MOBI' in GUI.formats[str(GUI.formatBox.currentText())]['format'] and not self.kindleGen:
@@ -1273,14 +1352,14 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def display_kindlegen_missing(self):
self.addMessage(
'<a href="https://github.com/ciromattia/kcc#kindlegen"><b>Install KindleGen (link)</b></a> to enable MOBI conversion for Kindles!',
QT_TRANSLATE_NOOP('KCCGUI', '<a href="https://github.com/ciromattia/kcc#kindlegen"><b>Install KindleGen (link)</b></a> to enable MOBI conversion for Kindles!'),
'error'
)
def saveSettings(self, event):
if self.conversionAlive:
GUI.convertButton.setEnabled(False)
self.addMessage('The process will be interrupted. Please wait.', 'warning')
self.addMessage(QT_TRANSLATE_NOOP('KCCGUI', 'The process will be interrupted. Please wait.'), 'warning')
self.conversionAlive = False
self.worker.sync()
event.ignore()
@@ -1371,7 +1450,8 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
GUI.jobList.addItem(message)
GUI.jobList.scrollToBottom()
else:
self.addMessage('Unsupported file type for ' + message, 'error')
# 占位符消息不补 source:刷新时无法还原 %1(文件名),保持写入时语言
self.addMessage(i18n.tr_arg(self.tr('Unsupported file type for %1'), message), 'error')
def dragAndDrop(self, e):
e.accept()
@@ -1395,13 +1475,16 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def __init__(self, kccapp, kccwindow):
global APP, MW, GUI
QObject.__init__(self)
APP = kccapp
MW = kccwindow
GUI = self
self.settings = QSettings('ciromattia', 'kcc10')
self.language = str(self.settings.value('language', i18n.DEFAULT_LANGUAGE))
self.language = i18n.apply_language(self.language)
self.setupUi(MW)
self.editor = KCCGUI_MetaEditor()
self.icons = Icons()
self.settings = QSettings('ciromattia', 'kcc10')
self.settingsVersion = self.settings.value('settingsVersion', '', type=str)
self.lastPath = self.settings.value('lastPath', '', type=str)
self.defaultOutputFolder = str(self.settings.value('defaultOutputFolder', '', type=str))
@@ -1630,16 +1713,17 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
"Kobo Mini/Touch",
]
# 这些多为约定俗成的缩写,翻译时可保留原文
link_dict = {
'README': "https://github.com/ciromattia/kcc?tab=readme-ov-file#kcc",
'FAQ': "https://github.com/ciromattia/kcc/blob/master/README.md#faq",
'WIKI': "https://github.com/ciromattia/kcc/wiki",
'YOUTUBE': "https://www.youtube.com/@eink-dude",
'TUTORIAL': "https://youtu.be/QQ6zJcMF2Iw?si=80rfm6DU6OUJdFqa",
'EMAIL': "https://github.com/ciromattia/kcc?tab=readme-ov-file#commissions",
'DONATE': "https://github.com/ciromattia/kcc/blob/master/README.md#issues--new-features--donations",
'FORUM': "http://www.mobileread.com/forums/showthread.php?t=207461",
'DISCORD': "https://discord.com/invite/qj7wpnUHav",
self.tr('README'): "https://github.com/ciromattia/kcc?tab=readme-ov-file#kcc",
self.tr('FAQ'): "https://github.com/ciromattia/kcc/blob/master/README.md#faq",
self.tr('WIKI'): "https://github.com/ciromattia/kcc/wiki",
self.tr('YOUTUBE'): "https://www.youtube.com/@eink-dude",
self.tr('TUTORIAL'): "https://youtu.be/QQ6zJcMF2Iw?si=80rfm6DU6OUJdFqa",
self.tr('EMAIL'): "https://github.com/ciromattia/kcc?tab=readme-ov-file#commissions",
self.tr('DONATE'): "https://github.com/ciromattia/kcc/blob/master/README.md#issues--new-features--donations",
self.tr('FORUM'): "http://www.mobileread.com/forums/showthread.php?t=207461",
self.tr('DISCORD'): "https://discord.com/invite/qj7wpnUHav",
}
link_html_list = [f'<a href="{v}">{k}</a>' for k, v in link_dict.items()]
@@ -1648,20 +1732,29 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
statusBarLabel.setOpenExternalLinks(True)
GUI.statusBar.addPermanentWidget(statusBarLabel, 1)
self.addMessage('<b>Tip:</b> Hover mouse over options/buttons to see explanations. Boxes can be partially/fully checked.', 'info')
self.addMessage('<b>Tip:</b> You can drag and drop image folders or comic files/archives into this window to convert.', 'info')
self.addMessage("<b>Tip:</b> Calibre may add margins! USB drop directly into the device's documents folder instead.", 'info')
self.addMessage("<b>Tip:</b> You can toggle easy/expert mode using button at top right.", 'info')
if self.startNumber < 5:
self.addMessage('Since you are a new user of <b>KCC</b> please see few '
'<a href="https://github.com/ciromattia/kcc/wiki/Important-tips">important tips</a>.',
'info')
self.tar = TAR in available_archive_tools()
self.sevenzip = SEVENZIP in available_archive_tools()
if not any([self.tar, self.sevenzip]):
self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
' to enable CBZ/CBR/ZIP/etc processing.', 'warning')
self._addInitialTips()
# Language switch button: plain text so it adapts to light/dark mode automatically
self.languageButton = QToolButton()
self.languageButton.setText(' 文 A ') # spaces add padding
self.languageButton.setToolTip(self.tr('Switch language / 切换语言 / 言語 / 언어'))
self.languageButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.languageMenu = QMenu(self.languageButton)
# 创建互斥 ActionGroup,确保只有一个语言选项被勾选
self.languageActionGroup = QActionGroup(self.languageMenu)
self.languageActionGroup.setExclusive(True)
for code, label in i18n.LANGUAGES.items():
act = self.languageMenu.addAction(label)
act.setCheckable(True)
act.setChecked(code == self.language)
act.setData(code)
self.languageActionGroup.addAction(act)
act.triggered.connect(lambda checked=False, c=code: self.changeLanguage(c))
self.languageButton.setMenu(self.languageMenu)
self.gridLayout_6.addWidget(self.languageButton, 0, 5)
APP.messageFromOtherInstance.connect(self.handleMessage)
GUI.defaultOutputFolderButton.clicked.connect(self.selectDefaultOutputFolder)
@@ -1766,12 +1859,12 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if self.windowSize != '0x0':
x, y = self.windowSize.split('x')
MW.resize(int(x), int(y))
MW.setWindowTitle("Kindle Comic Converter " + __version__)
MW.setWindowTitle(i18n.tr_arg(self.tr("Kindle Comic Converter %1"), __version__))
MW.show()
MW.raise_()
class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
class KCCGUI_MetaEditor(QObject, KCC_ui_editor.Ui_editorDialog):
def _buildBulkFieldToolTip(self, fieldLabel, valuesByFile):
note = '<p><em>Note: Changing this field will overwrite all values in all selected files.</em></p>'
@@ -1837,7 +1930,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
if parser.format in ['RAR', 'RAR5']:
self.editorWidget.setEnabled(False)
self.okButton.setEnabled(False)
self.statusLabel.setText('CBR files in selection are read-only.')
self.statusLabel.setText(self.tr('CBR files in selection are read-only.'))
return
if self.bulkMode:
@@ -1845,7 +1938,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.parser = metadata.MetadataParser(firstFile)
self.editorWidget.setEnabled(True)
self.okButton.setEnabled(True)
self.statusLabel.setText(f'Editing {len(self.files)} files.')
self.statusLabel.setText(i18n.tr_arg(self.tr('Editing %1 files.'), len(self.files)))
# Show bulk volume checkbox
self.bulkVolumeCheck.setVisible(True)
@@ -1854,7 +1947,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
for field in (self.volumeLine, self.numberLine, self.titleLine):
field.setEnabled(False)
field.setText('')
field.setPlaceholderText('(multiple files)')
field.setPlaceholderText(self.tr('(multiple files)'))
field.setToolTip('')
# Load metadata for all files and show common values, or “(multiple values)” + tooltip.
@@ -1898,7 +1991,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.editorWidget.setEnabled(True)
self.okButton.setEnabled(True)
self.statusLabel.setText('Separate authors with a comma.')
self.statusLabel.setText(self.tr('Separate authors with a comma.'))
for field in (self.seriesLine, self.volumeLine, self.numberLine, self.titleLine):
field.setText(self.parser.data[field.objectName().capitalize()[:-4]])
@@ -1934,7 +2027,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
return
if not bulkData and volumes is None:
self.statusLabel.setText('No changes to apply.')
self.statusLabel.setText(self.tr('No changes to apply.'))
return
errors = []
@@ -1943,7 +2036,7 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.cancelButton.setEnabled(False)
for i, file in enumerate(self.files, 1):
self.statusLabel.setText(f'Processing {i}/{total}: {os.path.basename(file)}')
self.statusLabel.setText(i18n.tr_arg(self.tr('Processing %1/%2: %3'), i, total, os.path.basename(file)))
QApplication.processEvents()
try:
@@ -1964,18 +2057,19 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.cancelButton.setEnabled(True)
if errors:
GUI.showDialog("Some files failed to save:\n\n" + "\n".join(errors[:10]) +
(f"\n...and {len(errors) - 10} more" if len(errors) > 10 else ""), 'error')
self.statusLabel.setText('Errors occurred.')
more = i18n.tr_arg(self.tr("\n...and %1 more"), len(errors) - 10) if len(errors) > 10 else ''
GUI.showDialog(i18n.tr_arg(self.tr("Some files failed to save:\n\n%1%2"),
"\n".join(errors[:10]), more), 'error')
self.statusLabel.setText(self.tr('Errors occurred.'))
else:
self.statusLabel.setText(f'Successfully updated {total} files.')
self.statusLabel.setText(i18n.tr_arg(self.tr('Successfully updated %1 files.'), total))
self.ui.close()
else:
for field in (self.volumeLine, self.numberLine):
if field.text().isnumeric() or self.cleanData(field.text()) == '':
self.parser.data[field.objectName().capitalize()[:-4]] = self.cleanData(field.text())
else:
self.statusLabel.setText(field.objectName().capitalize()[:-4] + ' field must be a number.')
self.statusLabel.setText(i18n.tr_arg(self.tr('%1 field must be a number.'), field.objectName().capitalize()[:-4]))
break
else:
for field in (self.seriesLine, self.titleLine):
@@ -1991,8 +2085,8 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.parser.saveXML()
except Exception as err:
_, _, traceback = sys.exc_info()
GUI.showDialog("Failed to save metadata!\n\n%s\n\nTraceback:\n%s"
% (str(err), sanitizeTrace(traceback)), 'error')
GUI.showDialog(i18n.tr_arg(self.tr("Failed to save metadata!\n\n%1\n\nTraceback:\n%2"),
str(err), sanitizeTrace(traceback)), 'error')
self.ui.close()
def cleanData(self, s):
@@ -2050,12 +2144,24 @@ class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
self.volumeLine.setEnabled(checked)
if checked:
self.volumeLine.setText('')
self.volumeLine.setPlaceholderText('e.g., 5 or 1-10 or 1,3,5')
self.volumeLine.setPlaceholderText(self.tr('e.g., 5 or 1-10 or 1,3,5'))
else:
self.volumeLine.setText('')
self.volumeLine.setPlaceholderText('(multiple files)')
self.volumeLine.setPlaceholderText(self.tr('(multiple files)'))
def updateDynamicText(self):
"""语言切换后,重新应用动态设置的文案。"""
# 重新应用占位符文本
if self.bulkMode:
for field in (self.numberLine, self.titleLine):
if not field.isEnabled():
field.setPlaceholderText(self.tr('(multiple files)'))
self.volumeLine.setPlaceholderText(
self.tr('e.g., 5 or 1-10 or 1,3,5') if self.bulkVolumeCheck.isChecked()
else self.tr('(multiple files)'))
def __init__(self):
QObject.__init__(self)
self.ui = QDialog()
self.parser = None
self.files = []
File diff suppressed because it is too large Load Diff
+27 -22
View File
@@ -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])
+52
View File
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""KCC UI 语言切换辅助模块。"""
from PySide6.QtCore import QCoreApplication, QTranslator
# 语言代码 -> 菜单显示名(菜单按"各自语言"显示,不参与翻译)
LANGUAGES = {
'en': 'English',
'zh_CN': '中文(简体)',
'ja_JP': '日本語',
'ko_KR': '한국어',
}
DEFAULT_LANGUAGE = 'en'
# 模块级持有引用,防止 translator 被 GC 回收后失效
_translator = None
def apply_language(lang_code):
"""安装指定语言的 QTranslator,返回实际生效的 code。
语言文件打包在 qrc 资源 /i18n/ 前缀下。
"""
global _translator
app = QCoreApplication.instance()
if app is None:
# QApplication 尚未创建,无法安装翻译器;保持默认语言
return DEFAULT_LANGUAGE
if _translator is not None:
app.removeTranslator(_translator)
_translator = None
if lang_code not in LANGUAGES:
lang_code = DEFAULT_LANGUAGE
if lang_code != 'en':
t = QTranslator()
if t.load(f':/i18n/kcc_{lang_code}.qm'):
app.installTranslator(t)
_translator = t
else:
lang_code = DEFAULT_LANGUAGE
return lang_code
def tr_arg(translated_str, *args):
"""替换翻译字符串中的 %1, %2, %3 ... 占位符。
PySide6 的 tr() 返回 Python str 而非 QString,没有 .arg() 方法,
因此用此函数手动替换占位符。
"""
result = translated_str
for i, arg in enumerate(args, 1):
result = result.replace(f'%{i}', str(arg))
return result
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff