import
tkinter as tk
from
tkinter
import
filedialog, messagebox, scrolledtext, ttk
import
os
import
threading
import
ebooklib
from
ebooklib
import
epub
from
bs4
import
BeautifulSoup
import
html
import
sys
import
traceback
import
tempfile
import
io
from
contextlib
import
redirect_stdout, redirect_stderr
from
docx
import
Document as DocxDocument
try
:
import
docx2pdf
except
ImportError:
try
:
root_check
=
tk.Tk()
root_check.withdraw()
messagebox.showerror(
"依赖错误"
,
"缺少 'docx2pdf' 库。\n请运行 'pip install docx2pdf' 进行安装。\n\n注意: 此库还需要系统安装 Microsoft Word (Windows) 或 LibreOffice (Linux/macOS) 才能将 DOCX 转换为 PDF。"
)
root_check.destroy()
except
tk.TclError:
print
(
"ERROR: Missing 'docx2pdf' library. Please run 'pip install docx2pdf'."
,
file
=
sys.stderr)
sys.exit(
1
)
from
tkinterdnd2
import
DND_FILES, TkinterDnD
SUPPORTED_INPUT_FORMATS
=
[(
"EPUB 文件"
,
"*.epub"
), (
"所有文件"
,
"*.*"
)]
SUPPORTED_OUTPUT_FORMATS
=
[
"TXT"
,
"DOCX"
,
"PDF"
]
def
resource_path(relative_path):
try
: base_path
=
sys._MEIPASS
except
Exception: base_path
=
os.path.abspath(
"."
)
return
os.path.join(base_path, relative_path)
def
extract_text_from_html(html_content):
try
:
soup
=
BeautifulSoup(html_content,
'lxml'
)
for
script_or_style
in
soup([
"script"
,
"style"
]): script_or_style.decompose()
text
=
soup.get_text(separator
=
' '
, strip
=
True
)
text
=
html.unescape(text)
return
text
except
Exception as e:
print
(f
"Error extracting text: {e}"
);
return
""
def
get_epub_items_in_order(book):
items_in_order
=
[]
try
:
all_html_items
=
list
(book.get_items_of_type(ebooklib.ITEM_DOCUMENT))
if
not
all_html_items: all_html_items
=
list
(book.get_items_of_type(ebooklib.ITEM_XHTML))
if
not
all_html_items:
print
(
"Warning: No processable content found."
);
return
[]
items_map
=
{item.
id
: item
for
item
in
all_html_items}
spine_ids
=
[item_id
for
item_id, _
in
book.spine]
ordered_items
=
[]; seen_ids
=
set
()
for
item_id
in
spine_ids:
if
item_id
in
items_map: item
=
items_map[item_id]; ordered_items.append(item); seen_ids.add(item_id)
other_items
=
[item
for
item_id, item
in
items_map.items()
if
item_id
not
in
seen_ids]
items_in_order
=
ordered_items
+
other_items
except
Exception as e:
print
(f
"Warning: Cannot determine EPUB order: {e}. Falling back..."
);
items_in_order
=
list
(book.get_items_of_type(ebooklib.ITEM_DOCUMENT))
if
not
items_in_order: items_in_order
=
list
(book.get_items_of_type(ebooklib.ITEM_XHTML))
return
items_in_order
def
convert_epub_to_txt_internal(epub_path, txt_path, status_callback):
try
:
status_callback(f
" 正在读取 EPUB: {os.path.basename(epub_path)}..."
)
book
=
epub.read_epub(epub_path)
full_text
=
[]
items_to_process
=
get_epub_items_in_order(book)
if
not
items_to_process: status_callback(
"警告: EPUB 中无文档项目。生成空 TXT。"
);
open
(txt_path,
'w'
, encoding
=
'utf-8'
).close();
return
status_callback(f
" 找到 {len(items_to_process)} 个 EPUB 内容项,正在提取文本..."
)
for
i, item
in
enumerate
(items_to_process):
content
=
item.get_body_content();
if
not
content:
continue
try
:
decoded_content
=
""
try
: decoded_content
=
content.decode(
'utf-8'
)
except
UnicodeDecodeError:
status_callback(f
" 警告: {item.get_name()} UTF-8 解码失败,尝试忽略错误..."
);
try
: decoded_content
=
content.decode(
'utf-8'
, errors
=
'ignore'
)
except
Exception as decode_err: status_callback(f
" 严重警告: {item.get_name()} 解码彻底失败: {decode_err}"
);
continue
text
=
extract_text_from_html(decoded_content)
if
text: full_text.append(text); full_text.append(
"\n\n"
)
except
Exception as e: status_callback(f
"警告: 处理项目 {item.get_name()} 时出错: {e}"
);
continue
status_callback(
" 正在写入 TXT 文件..."
);
with
open
(txt_path,
'w'
, encoding
=
'utf-8'
) as f: f.write("".join(full_text))
status_callback(
" TXT 文件写入完成。"
)
except
epub.EpubException as e:
raise
epub.EpubException(f
"读取 EPUB 失败: {e}"
)
except
FileNotFoundError:
raise
FileNotFoundError(f
"输入 EPUB 未找到: {epub_path}"
)
except
Exception as e:
raise
Exception(f
"转换 EPUB->TXT 时未知错误: {e}\n{traceback.format_exc()}"
)
def
convert_epub_to_docx_internal(epub_path, docx_path, status_callback):
try
:
status_callback(f
" 正在读取 EPUB: {os.path.basename(epub_path)}..."
)
book
=
epub.read_epub(epub_path); doc
=
DocxDocument()
items_to_process
=
get_epub_items_in_order(book)
if
not
items_to_process: status_callback(
"警告: EPUB 中无文档项目。生成空 DOCX。"
); doc.save(docx_path);
return
status_callback(f
" 找到 {len(items_to_process)} 个 EPUB 内容项,正在提取内容到 DOCX..."
)
for
i, item
in
enumerate
(items_to_process):
content
=
item.get_body_content()
if
not
content:
continue
try
:
decoded_content
=
""
try
: decoded_content
=
content.decode(
'utf-8'
)
except
UnicodeDecodeError:
status_callback(f
" 警告: {item.get_name()} UTF-8 解码失败,尝试忽略错误..."
);
try
: decoded_content
=
content.decode(
'utf-8'
, errors
=
'ignore'
)
except
Exception as decode_err: status_callback(f
" 严重警告: {item.get_name()} 解码彻底失败: {decode_err}"
);
continue
text
=
extract_text_from_html(decoded_content)
if
text:
paragraphs
=
text.split(
'\n'
)
for
para_text
in
paragraphs:
if
para_text.strip(): doc.add_paragraph(para_text.strip())
doc.add_paragraph("")
except
Exception as e: status_callback(f
"警告: 处理项目 {item.get_name()} 时出错: {e}"
);
continue
status_callback(
" 正在保存 DOCX 文件..."
); doc.save(docx_path); status_callback(
" DOCX 文件保存完成。"
)
except
epub.EpubException as e:
raise
epub.EpubException(f
"读取 EPUB 失败: {e}"
)
except
FileNotFoundError:
raise
FileNotFoundError(f
"输入 EPUB 未找到: {epub_path}"
)
except
Exception as e:
raise
Exception(f
"转换 EPUB->DOCX 时未知错误: {e}\n{traceback.format_exc()}"
)
def
convert_docx_to_pdf_internal(docx_path, pdf_path, status_callback):
try
:
status_callback(f
" 正在将 DOCX 转换为 PDF: {os.path.basename(docx_path)} -> {os.path.basename(pdf_path)}..."
)
with io.StringIO() as buf_out, io.StringIO() as buf_err:
with redirect_stdout(buf_out), redirect_stderr(buf_err):
docx2pdf.convert(docx_path, pdf_path)
suppressed_stdout
=
buf_out.getvalue().strip()
suppressed_stderr
=
buf_err.getvalue().strip()
if
suppressed_stdout: status_callback(f
" (docx2pdf stdout: {suppressed_stdout})"
)
if
suppressed_stderr: status_callback(f
" (docx2pdf stderr: {suppressed_stderr})"
)
status_callback(
" PDF 文件转换完成。"
)
return
True
except
FileNotFoundError:
status_callback(f
"错误: 输入 DOCX 文件未找到: {docx_path}"
)
raise
FileNotFoundError(f
"输入 DOCX 文件未找到: {docx_path}"
)
except
Exception as e:
error_msg
=
f
"错误: 使用 docx2pdf 将 DOCX 转换为 PDF 时失败: {e}"
if
'suppressed_stderr'
in
locals
()
and
suppressed_stderr:
error_msg
+
=
f
"\n (底层错误信息可能为: {suppressed_stderr})"
status_callback(error_msg)
status_callback(
" 请确保已安装 Microsoft Word (Windows) 或 LibreOffice (Linux/macOS),并且它们可以正常工作。"
)
raise
RuntimeError(f
"DOCX 到 PDF 转换失败: {e}"
)
def
convert_file(input_path, output_folder, output_format, status_callback):
intermediate_docx_path
=
None
try
:
input_filename
=
os.path.basename(input_path); base_name
=
os.path.splitext(input_filename)[
0
]
input_ext
=
os.path.splitext(input_filename)[
1
].lower(); output_ext
=
f
".{output_format.lower()}"
if
input_ext !
=
".epub"
: status_callback(f
"跳过: 只支持 EPUB 输入 - {input_filename}"
);
return
False
if
not
os.path.isdir(output_folder):
try
: os.makedirs(output_folder, exist_ok
=
True
); status_callback(f
" 创建了输出文件夹: {output_folder}"
)
except
Exception as e: status_callback(f
"错误: 输出文件夹无效且无法创建: {output_folder} ({e})"
);
return
False
final_output_path
=
os.path.join(output_folder, f
"{base_name}{output_ext}"
)
status_callback(f
"开始转换: {input_filename} -> {output_format}..."
)
if
output_format
=
=
"PDF"
:
status_callback(
" 执行两步转换 (EPUB -> DOCX -> PDF)"
)
intermediate_docx_filename
=
f
"{base_name}_intermediate_for_pdf.docx"
intermediate_docx_path
=
os.path.join(output_folder, intermediate_docx_filename)
status_callback(f
" 步骤 1: 正在转换 EPUB 到临时 DOCX ({intermediate_docx_filename})..."
)
try
: convert_epub_to_docx_internal(input_path, intermediate_docx_path, status_callback); status_callback(
" 步骤 1: 临时 DOCX 文件创建成功。"
)
except
Exception as e: status_callback(f
"错误: 步骤 1 (EPUB -> DOCX) 失败: {e}"
);
return
False
status_callback(f
" 步骤 2: 正在转换临时 DOCX 到 PDF..."
)
try
: convert_docx_to_pdf_internal(intermediate_docx_path, final_output_path, status_callback); status_callback(
" 步骤 2: PDF 文件创建成功。"
); status_callback(f
"成功完成 (PDF): {os.path.basename(final_output_path)}"
);
return
True
except
Exception as e: status_callback(f
"错误: 步骤 2 (DOCX -> PDF) 失败: {e}"
);
return
False
elif
output_format
=
=
"TXT"
: convert_epub_to_txt_internal(input_path, final_output_path, status_callback); status_callback(f
"成功完成 (TXT): {os.path.basename(final_output_path)}"
);
return
True
elif
output_format
=
=
"DOCX"
: convert_epub_to_docx_internal(input_path, final_output_path, status_callback); status_callback(f
"成功完成 (DOCX): {os.path.basename(final_output_path)}"
);
return
True
else
: status_callback(f
"跳过: 不支持的目标输出格式 '{output_format}'"
);
return
False
except
Exception as e:
status_callback(f
"严重错误: 转换 {os.path.basename(input_path)} -> {output_format} 时发生意外失败 - {str(e)}"
)
print
(f
"--- Conversion Error Traceback ({os.path.basename(input_path)}) ---"
); traceback.print_exc();
print
(
"--- End Traceback ---"
)
return
False
finally
:
if
intermediate_docx_path
and
os.path.exists(intermediate_docx_path):
try
: status_callback(f
" 清理: 正在删除临时 DOCX 文件 ({os.path.basename(intermediate_docx_path)})..."
); os.remove(intermediate_docx_path); status_callback(
" 清理: 临时文件删除成功。"
)
except
Exception as e: status_callback(f
"警告: 无法删除临时 DOCX 文件 {intermediate_docx_path}: {e}"
)
class
FileConverterApp:
def
__init__(
self
, root):
self
.root
=
root
self
.root.title(
"EPUB 转换器 (转 TXT/DOCX/PDF)"
)
self
.root.geometry(
"700x650"
)
self
.input_files
=
[]
self
.input_folder
=
""
self
.output_folder
=
""
self
.selected_output_format
=
tk.StringVar()
self
.default_output_folder
=
self
._get_default_output_dir()
self
.conversion_thread
=
None
self
.shutdown_event
=
threading.Event()
self
.is_closing
=
False
input_frame
=
tk.LabelFrame(root, text
=
"输入文件 (可拖拽 EPUB 文件/文件夹)"
, padx
=
10
, pady
=
10
); input_frame.pack(padx
=
10
, pady
=
10
, fill
=
"x"
)
self
.btn_select_files
=
tk.Button(input_frame, text
=
"选择 EPUB 文件"
, command
=
self
.select_files);
self
.btn_select_files.grid(row
=
0
, column
=
0
, padx
=
5
, pady
=
5
)
self
.btn_select_folder
=
tk.Button(input_frame, text
=
"选择 EPUB 文件夹"
, command
=
self
.select_folder);
self
.btn_select_folder.grid(row
=
0
, column
=
1
, padx
=
5
, pady
=
5
)
self
.lbl_input_selection
=
tk.Label(input_frame, text
=
"未选择 EPUB 文件或文件夹"
, wraplength
=
650
, justify
=
"left"
, height
=
3
, anchor
=
"nw"
);
self
.lbl_input_selection.grid(row
=
1
, column
=
0
, columnspan
=
2
, padx
=
5
, pady
=
5
, sticky
=
"we"
)
output_frame
=
tk.LabelFrame(root, text
=
"输出设置"
, padx
=
10
, pady
=
10
); output_frame.pack(padx
=
10
, pady
=
5
, fill
=
"x"
)
self
.btn_select_output
=
tk.Button(output_frame, text
=
"选择输出文件夹"
, command
=
self
.select_output_folder);
self
.btn_select_output.grid(row
=
0
, column
=
0
, padx
=
5
, pady
=
5
, sticky
=
'w'
)
self
.output_folder
=
self
.default_output_folder;
self
.lbl_output_folder
=
tk.Label(output_frame, text
=
f
"默认: {self.output_folder}"
, wraplength
=
450
, justify
=
"left"
);
self
.lbl_output_folder.grid(row
=
0
, column
=
1
, padx
=
5
, pady
=
5
, sticky
=
'we'
)
lbl_output_format
=
tk.Label(output_frame, text
=
"选择输出格式:"
); lbl_output_format.grid(row
=
1
, column
=
0
, padx
=
5
, pady
=
5
, sticky
=
'w'
)
self
.combo_output_format
=
ttk.Combobox(output_frame, textvariable
=
self
.selected_output_format, values
=
SUPPORTED_OUTPUT_FORMATS, state
=
"readonly"
);
self
.combo_output_format.grid(row
=
1
, column
=
1
, padx
=
5
, pady
=
5
, sticky
=
'we'
)
if
SUPPORTED_OUTPUT_FORMATS:
self
.combo_output_format.
set
(SUPPORTED_OUTPUT_FORMATS[
0
])
self
.selected_output_format.trace_add(
"write"
,
self
.check_ready_to_convert)
output_frame.columnconfigure(
1
, weight
=
1
)
action_frame
=
tk.Frame(root, padx
=
10
, pady
=
5
); action_frame.pack(fill
=
"x"
)
self
.btn_convert
=
tk.Button(action_frame, text
=
"开始转换"
, command
=
self
.start_conversion_thread, state
=
"disabled"
);
self
.btn_convert.pack(side
=
"left"
, padx
=
5
)
self
.btn_clear
=
tk.Button(action_frame, text
=
"清空选择"
, command
=
self
.clear_selections);
self
.btn_clear.pack(side
=
"left"
, padx
=
5
)
log_frame
=
tk.LabelFrame(root, text
=
"状态和日志"
, padx
=
10
, pady
=
10
); log_frame.pack(padx
=
10
, pady
=
10
, fill
=
"both"
, expand
=
True
)
self
.log_area
=
scrolledtext.ScrolledText(log_frame, wrap
=
tk.WORD, height
=
10
, state
=
"disabled"
, bd
=
0
, relief
=
"sunken"
);
self
.log_area.pack(fill
=
"both"
, expand
=
True
)
self
.root.drop_target_register(DND_FILES)
self
.root.dnd_bind(
'<<Drop>>'
,
self
.handle_drop)
self
.update_log(f
"默认输出文件夹设置为: {self.default_output_folder}"
);
self
.update_log(
"支持的输入格式: EPUB"
);
self
.update_log(f
"支持的输出格式: {', '.join(SUPPORTED_OUTPUT_FORMATS)}"
);
self
.update_log(
"将 EPUB 文件或包含 EPUB 文件的文件夹拖拽到窗口,或使用按钮选择。"
);
self
.update_log(
"注意: 转换为 PDF 需要系统安装 Microsoft Word (Windows) 或 LibreOffice (Linux/macOS)。"
)
self
.check_ready_to_convert()
self
.root.protocol(
"WM_DELETE_WINDOW"
,
self
.on_closing)
def
on_closing(
self
):
if
self
.is_closing:
return
self
.is_closing
=
True
if
self
.conversion_thread
and
self
.conversion_thread.is_alive():
self
.update_log(
"正在关闭窗口... 如果转换正在进行,可能需要稍等片刻。"
)
messagebox.showinfo(
"关闭"
,
"正在尝试关闭应用程序。\n如果转换正在进行,后台任务可能仍在运行,请稍候。"
)
else
:
self
.update_log(
"正在关闭窗口..."
)
try
:
if
self
.root
and
self
.root.winfo_exists():
self
.root.destroy()
except
tk.TclError:
pass
def
_get_default_output_dir(
self
):
try
:
if
getattr
(sys,
'frozen'
,
False
): app_path
=
os.path.dirname(sys.executable)
else
: app_path
=
os.path.dirname(os.path.abspath(__file__))
return
app_path
except
NameError:
return
os.getcwd()
except
Exception:
return
os.getcwd()
def
update_log(
self
, message):
if
self
.is_closing
or
not
hasattr
(
self
,
'root'
)
or
not
self
.root.winfo_exists():
print
(f
"Log (GUI closing/closed): {message}"
)
return
def
_update():
if
hasattr
(
self
,
'log_area'
)
and
self
.log_area.winfo_exists():
try
:
self
.log_area.config(state
=
"normal"
)
self
.log_area.insert(tk.END,
str
(message)
+
"\n"
)
self
.log_area.see(tk.END)
self
.log_area.config(state
=
"disabled"
)
except
tk.TclError:
print
(f
"Log (TclError on update): {message}"
)
else
:
print
(f
"Log (GUI closing/closed): {message}"
)
if
hasattr
(
self
,
'root'
)
and
self
.root.winfo_exists():
self
.root.after(
0
, _update)
else
:
print
(f
"Log (GUI closing/closed): {message}"
)
def
_parse_dnd_data(
self
, data_string):
import
re; paths
=
re.findall(r
'\{[^{}]*\}|\S+'
, data_string); cleaned_paths
=
[p.strip(
'{}'
).strip()
for
p
in
paths];
return
[p
for
p
in
cleaned_paths
if
p
and
os.path.exists(p)]
def
handle_drop(
self
, event):
if
self
.is_closing:
return
try
:
dropped_items
=
self
._parse_dnd_data(event.data);
if
not
dropped_items:
self
.update_log(
"拖拽操作未识别有效路径。"
);
return
added_files
=
[]; folder
=
None
; valid_ext
=
(
".epub"
,)
for
item
in
dropped_items:
if
os.path.isdir(item): folder
=
item; added_files
=
[];
self
.update_log(f
"拖拽文件夹: {folder} (处理 EPUB)"
);
break
elif
os.path.isfile(item):
if
item.lower().endswith(valid_ext):
if
item
not
in
self
.input_files: added_files.append(item)
else
:
self
.update_log(f
"忽略拖拽 (非 EPUB): {os.path.basename(item)}"
)
else
:
self
.update_log(f
"忽略拖拽 (非文件/文件夹): {os.path.basename(item)}"
)
if
folder:
self
.input_folder
=
folder;
self
.input_files
=
[];
self
.lbl_input_selection.config(text
=
f
"已选文件夹: {folder}"
)
elif
added_files:
if
self
.input_folder:
self
.input_folder
=
"";
self
.input_files
=
[]
self
.input_files.extend(added_files)
names
=
[os.path.basename(f)
for
f
in
self
.input_files]; n
=
len
(
self
.input_files); max_disp
=
5
disp_txt
=
f
"已选 {n} 个 EPUB:\n"
+
"\n"
.join(names[:max_disp])
+
(
"\n..."
if
n > max_disp
else
"")
self
.lbl_input_selection.config(text
=
disp_txt);
self
.update_log(f
"拖拽添加 {len(added_files)} 个 EPUB。总计 {n} 个。"
)
elif
not
folder
and
not
added_files:
self
.update_log(
"拖拽内容无有效 EPUB。"
)
self
.check_ready_to_convert()
except
Exception as e:
self
.update_log(f
"处理拖拽出错: {e}"
); traceback.print_exc()
def
select_files(
self
):
if
self
.is_closing:
return
try
:
files
=
filedialog.askopenfilenames(title
=
"选择 EPUB 输入文件"
, filetypes
=
SUPPORTED_INPUT_FORMATS)
if
files:
epub_files
=
[f
for
f
in
files
if
f.lower().endswith(
'.epub'
)]; ignored
=
len
(files)
-
len
(epub_files)
if
ignored >
0
:
self
.update_log(f
"注意: 忽略了 {ignored} 个非 EPUB 文件。"
)
if
epub_files:
self
.input_files
=
list
(epub_files);
self
.input_folder
=
""
names
=
[os.path.basename(f)
for
f
in
self
.input_files]; n
=
len
(
self
.input_files); max_disp
=
5
disp_txt
=
f
"已选 {n} 个 EPUB:\n"
+
"\n"
.join(names[:max_disp])
+
(
"\n..."
if
n > max_disp
else
"")
self
.lbl_input_selection.config(text
=
disp_txt);
self
.update_log(f
"已选 {n} 个 EPUB 文件。"
)
self
.check_ready_to_convert()
elif
not
self
.input_files:
self
.lbl_input_selection.config(text
=
"未选择 EPUB"
);
self
.update_log(
"未选有效 EPUB。"
);
self
.check_ready_to_convert()
except
Exception as e:
self
.update_log(f
"选择文件出错: {e}"
); traceback.print_exc()
def
select_folder(
self
):
if
self
.is_closing:
return
try
:
folder
=
filedialog.askdirectory(title
=
"选择包含 EPUB 文件的文件夹"
)
if
folder:
self
.input_folder
=
folder;
self
.input_files
=
[];
self
.lbl_input_selection.config(text
=
f
"已选文件夹: {folder}"
);
self
.update_log(f
"已选文件夹: {folder} (处理 EPUB)"
);
self
.check_ready_to_convert()
except
Exception as e:
self
.update_log(f
"选择文件夹出错: {e}"
); traceback.print_exc()
def
select_output_folder(
self
):
if
self
.is_closing:
return
try
:
init_dir
=
self
.output_folder
if
os.path.isdir(
self
.output_folder)
else
self
.default_output_folder
folder
=
filedialog.askdirectory(title
=
"选择保存转换后文件的文件夹"
, initialdir
=
init_dir)
if
folder:
self
.output_folder
=
folder;
self
.lbl_output_folder.config(text
=
f
"{folder}"
);
self
.update_log(f
"输出文件夹设为: {folder}"
);
self
.check_ready_to_convert()
except
Exception as e:
self
.update_log(f
"选择输出文件夹出错: {e}"
); traceback.print_exc()
def
clear_selections(
self
):
if
self
.is_closing:
return
self
.input_files
=
[];
self
.input_folder
=
"";
self
.output_folder
=
self
.default_output_folder
self
.lbl_input_selection.config(text
=
"未选择 EPUB 文件或文件夹"
);
self
.lbl_output_folder.config(text
=
f
"默认: {self.output_folder}"
)
try
:
if
hasattr
(
self
,
'log_area'
)
and
self
.log_area.winfo_exists():
self
.log_area.config(state
=
"normal"
);
self
.log_area.delete(
1.0
, tk.END);
self
.log_area.config(state
=
"disabled"
)
except
tk.TclError:
pass
if
hasattr
(
self
,
'btn_convert'
):
self
.btn_convert.config(state
=
"disabled"
)
self
.update_log(
"选择已清除。输出已重置为默认。"
)
def
check_ready_to_convert(
self
,
*
args):
if
self
.is_closing:
return
try
:
output_ok
=
self
.output_folder
and
os.path.isdir(
self
.output_folder); input_ok
=
bool
(
self
.input_files
or
self
.input_folder); format_ok
=
bool
(
self
.selected_output_format.get())
can_convert
=
input_ok
and
output_ok
and
format_ok
if
hasattr
(
self
,
'btn_convert'
):
controls_active
=
hasattr
(
self
,
'btn_select_files'
)
and
self
.btn_select_files[
'state'
]
=
=
'normal'
self
.btn_convert.config(state
=
"normal"
if
can_convert
and
controls_active
else
"disabled"
)
except
Exception:
pass
def
start_conversion_thread(
self
):
if
self
.is_closing:
return
output_format
=
self
.selected_output_format.get()
if
not
(
self
.input_files
or
self
.input_folder): messagebox.showerror(
"错误"
,
"请先选择 EPUB 输入。"
);
return
if
not
self
.output_folder
or
not
os.path.isdir(
self
.output_folder):
try
: os.makedirs(
self
.output_folder, exist_ok
=
True
);
self
.update_log(f
"创建输出文件夹: {self.output_folder}"
)
except
Exception as e: messagebox.showerror(
"错误"
, f
"输出文件夹无效且无法创建: {e}"
);
return
if
not
output_format: messagebox.showerror(
"错误"
,
"请先选择输出格式。"
);
return
self
.toggle_controls(enabled
=
False
);
self
.update_log(f
"开始转换 EPUB 到 {output_format}..."
)
thread_in_files
=
list
(
self
.input_files); thread_in_folder
=
self
.input_folder; thread_out_folder
=
self
.output_folder
self
.conversion_thread
=
threading.Thread(target
=
self
.run_conversion, args
=
(output_format, thread_in_files, thread_in_folder, thread_out_folder), daemon
=
True
)
self
.conversion_thread.start()
def
toggle_controls(
self
, enabled
=
True
):
if
self
.is_closing:
return
state
=
"normal"
if
enabled
else
"disabled"
try
:
if
hasattr
(
self
,
'btn_select_files'
):
self
.btn_select_files.config(state
=
state)
if
hasattr
(
self
,
'btn_select_folder'
):
self
.btn_select_folder.config(state
=
state)
if
hasattr
(
self
,
'btn_select_output'
):
self
.btn_select_output.config(state
=
state)
if
hasattr
(
self
,
'btn_clear'
):
self
.btn_clear.config(state
=
state)
if
hasattr
(
self
,
'combo_output_format'
):
self
.combo_output_format.config(state
=
"readonly"
if
enabled
else
"disabled"
)
if
hasattr
(
self
,
'btn_convert'
):
if
not
enabled:
self
.btn_convert.config(state
=
"disabled"
)
else
:
self
.check_ready_to_convert()
except
tk.TclError:
self
.update_log(
"警告: 尝试配置已销毁的控件。"
)
def
run_conversion(
self
, output_format, input_files, input_folder, output_folder):
files_to_process
=
[]; valid_extensions
=
(
".epub"
,)
if
input_files: files_to_process
=
[f
for
f
in
input_files
if
f.lower().endswith(valid_extensions)]
elif
input_folder:
try
:
self
.update_log(f
"扫描文件夹: {input_folder}"
); count
=
0
for
entry
in
os.scandir(input_folder):
if
entry.is_file()
and
entry.name.lower().endswith(valid_extensions): files_to_process.append(entry.path); count
+
=
1
self
.update_log(f
"找到 {count} 个 EPUB。"
)
except
FileNotFoundError:
self
.update_log(f
"错误: 输入文件夹未找到 - {input_folder}"
);
self
.conversion_finished(success
=
False
, message
=
"输入文件夹未找到。"
);
return
except
Exception as e:
self
.update_log(f
"错误: 扫描文件夹出错 - {e}"
);
self
.conversion_finished(success
=
False
, message
=
"扫描文件夹出错。"
);
return
if
not
files_to_process:
self
.update_log(
"错误: 未找到有效 EPUB 转换。"
);
self
.conversion_finished(success
=
False
, message
=
"未找到 EPUB 转换。"
);
return
total_files
=
len
(files_to_process); success_count
=
0
; fail_count
=
0
for
i, file_path
in
enumerate
(files_to_process):
if
not
os.path.exists(file_path):
self
.update_log(f
"--- [{i+1}/{total_files}] 跳过: 文件不存在 {os.path.basename(file_path)} ---"
); fail_count
+
=
1
;
continue
self
.update_log(f
"--- [{i+1}/{total_files}] ---"
)
result
=
convert_file(file_path, output_folder, output_format,
self
.update_log)
if
result
is
True
: success_count
+
=
1
else
: fail_count
+
=
1
summary
=
f
"转换完成。\n总计 EPUB: {total_files}, 成功: {success_count}, 失败/跳过: {fail_count}"
self
.update_log(
"--- "
+
summary.replace(
'\n'
,
' '
)
+
" ---"
); overall_success
=
(fail_count
=
=
0
and
success_count >
0
)
self
.conversion_finished(success
=
overall_success, message
=
summary)
def
conversion_finished(
self
, success
=
True
, message
=
"转换完成"
):
if
self
.is_closing
or
not
hasattr
(
self
,
'root'
)
or
not
self
.root.winfo_exists():
print
(f
"Conversion finished but GUI closed. Message: {message}"
)
return
def
_update_gui():
if
hasattr
(
self
,
'root'
)
and
self
.root.winfo_exists()
and
not
self
.is_closing:
self
.toggle_controls(enabled
=
True
)
if
success: messagebox.showinfo(
"完成"
, message)
elif
"未找到"
in
message
or
"扫描"
in
message: messagebox.showwarning(
"提示"
, message)
else
: messagebox.showwarning(
"完成"
, message
+
"\n部分文件可能转换失败或跳过,请查看日志。"
)
else
:
print
(f
"Conversion finished but GUI closed before final update. Message: {message}"
)
if
hasattr
(
self
,
'root'
)
and
self
.root.winfo_exists():
self
.root.after(
10
, _update_gui)
if
__name__
=
=
"__main__"
:
try
:
root
=
TkinterDnD.Tk()
app
=
FileConverterApp(root)
root.mainloop()
except
Exception as main_err:
print
(f
"FATAL ERROR DURING STARTUP OR MAIN LOOP: {main_err}"
,
file
=
sys.stderr)
traceback.print_exc(
file
=
sys.stderr)
try
:
root_err
=
tk.Tk(); root_err.withdraw()
messagebox.showerror(
"程序严重错误"
, f
"应用程序遇到严重错误无法继续:\n{main_err}\n详细信息请查看控制台或日志。"
)
root_err.destroy()
except
Exception:
pass
sys.exit(
1
)