""" Mô-đun Xuất - Hỗ trợ Word (DOCX), TXT, Markdown Bản quyền © 2026 Công ty TNHH Công nghệ An ninh mạng Huyễn Thành Tân Cương (Công nghệ Huyễn Thành) Tác giả: Huyễn Thành """ import os import re import logging import tempfile from typing import Tuple, Optional from datetime import datetime from pathlib import Path logger = logging.getLogger(__name__) from locales.i18n import t MODULE_ROOT = os.path.dirname(os.path.abspath(__file__)) EXPORT_DIR = os.path.join(MODULE_ROOT, "exports") os.makedirs(EXPORT_DIR, exist_ok=True) def _sanitize_filename(name: str, max_len: int = 120) -> str: """Làm sạch các ký tự không hợp lệ trong tên tệp và giới hạn độ dài""" if not name or not name.strip(): name = "novel" safe = re.sub(r'[<>:"/\\|?*]', '_', name).strip() if len(safe) > max_len: safe = safe[:max_len] return safe def _extract_chapters_from_markdown(text: str) -> list: """ Trích xuất thông tin chương từ văn bản tiểu thuyết định dạng Markdown Returns: [{"title": "...", "content": "..."}, ...] """ # Loại bỏ các thẻ HTML phụ trợ (details/summary/b/i) để parse Regex tiêu đề Markdown chính xác text = re.sub(r'?(details|summary|b|i|br|u|strong|em)[^>]*>', '', text) chapters = [] current_chapter = None content_lines = [] # Phát hiện tiêu đề chương tổng quát hơn, hỗ trợ '#', '##', '###' và các cấp độ khác, hỗ trợ các biến thể tiếng Trung và không gian header_re = re.compile(r'^(?:# {1,6}\s*)?(Chương\s*\d+\s*[\s\S]*|Chương\s*\d+[\s\S]*|Chương\s*\d+\s*[::\s\--–]?.*)$', re.IGNORECASE) for line in text.splitlines(): if not line: # Dòng trống có tác dụng ngăn cách đoạn văn nhưng không kết thúc chương if current_chapter: content_lines.append('') continue # Phát hiện tiêu đề chương if header_re.match(line.strip()): # Lưu chương trước if current_chapter: current_chapter['content'] = '\n'.join([l for l in content_lines]).strip() chapters.append(current_chapter) # Trích xuất văn bản tiêu đề title_match = re.search(r'(第\s*\d+\s*章[\s\S]*)', line) title = title_match.group(1).strip() if title_match else line.strip() current_chapter = {'title': title, 'content': ''} content_lines = [] continue # Bỏ qua tiêu đề cấp tệp if line.strip().startswith('# '): continue if current_chapter is None: # Nếu bạn chưa gặp tiêu đề chương, hãy đặt nội dung của chương đầu tiên current_chapter = {'title': t("exporter.first_chapter"), 'content': ''} content_lines = [line] else: content_lines.append(line) # lưu chương cuối if current_chapter: current_chapter['content'] = '\n'.join([l for l in content_lines]).strip() chapters.append(current_chapter) return chapters def export_to_txt(novel_text: str, title: str) -> Tuple[Optional[str], str]: """ Xuất ra định dạng TXT Args: novel_text: Văn bản tiểu thuyết (định dạng Markdown) title: Tiêu đề tiểu thuyết Returns: (Đường dẫn tệp, thông tin trạng thái) """ try: if not novel_text.strip(): return None, t("exporter.no_content") # Trích xuất chương chapters = _extract_chapters_from_markdown(novel_text) if not chapters: return None, t("exporter.no_chapters") # Tạo nội dung TXT txt_content = f"{title}\n\n" for chapter in chapters: txt_content += f"{chapter['title']}\n\n" txt_content += f"{chapter['content']}\n\n" txt_content += "-" * 80 + "\n\n" # Lưu tập tin (ghi nguyên tử) safe_title = _sanitize_filename(title) filename = f"{safe_title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" filepath = os.path.join(EXPORT_DIR, filename) try: with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False, dir=EXPORT_DIR) as tmp: tmp.write(txt_content) tmp_path = tmp.name os.replace(tmp_path, filepath) except Exception as e: logger.error(f"TXT write failed: {e}") return None, t("exporter.export_failed", error=str(e)) logger.info(f"TXT export success: {filename}") return filepath, t("exporter.export_success", filename=filename) except Exception as e: logger.error(f"TXT export failed: {e}") return None, t("exporter.export_failed", error=str(e)) def export_to_markdown(novel_text: str, title: str) -> Tuple[Optional[str], str]: """ Xuất ra định dạng Markdown Args: novel_text: Văn bản tiểu thuyết (định dạng Markdown) title: Tiêu đề tiểu thuyết Returns: (Đường dẫn tệp, thông tin trạng thái) """ try: if not novel_text.strip(): return None, t("exporter.no_content") # Thêm siêu dữ liệu md_content = f"# {title}\n\n" md_content += f"*{t('exporter.generated_at', datetime=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))}*\n\n" md_content += "---\n\n" md_content += novel_text # Lưu tập tin (ghi nguyên tử) safe_title = _sanitize_filename(title) filename = f"{safe_title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" filepath = os.path.join(EXPORT_DIR, filename) try: with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False, dir=EXPORT_DIR) as tmp: tmp.write(md_content) tmp_path = tmp.name os.replace(tmp_path, filepath) except Exception as e: logger.error(f"Markdown write failed: {e}") return None, t("exporter.export_failed", error=str(e)) logger.info(f"Markdown export success: {filename}") return filepath, t("exporter.export_success", filename=filename) except Exception as e: logger.error(f"Markdown export failed: {e}") return None, t("exporter.export_failed", error=str(e)) def export_to_docx(novel_text: str, title: str) -> Tuple[Optional[str], str]: """ Xuất ra định dạng Word (DOCX) - Dàn trang chuyên nghiệp Args: novel_text: Văn bản tiểu thuyết (định dạng Markdown) title: Tiêu đề tiểu thuyết Returns: (Đường dẫn tệp, thông tin trạng thái) """ try: from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn except ImportError: return None, t("exporter.missing_docx") try: if not novel_text.strip(): return None, t("exporter.no_content") # Trích xuất chương chapters = _extract_chapters_from_markdown(novel_text) if not chapters: return None, t("exporter.no_chapters") doc = Document() # Kiểu cấu hình style = doc.styles['Normal'] font = style.font font.name = t("exporter.body_font") font.size = Pt(12) # phông chữ tiếng trung rPr = style.element.get_or_add_rPr() rPr.find(qn('w:rFonts')).set(qn('w:eastAsia'), t("exporter.body_font")) # định dạng đoạn văn style.paragraph_format.first_line_indent = Pt(24) style.paragraph_format.space_after = Pt(0) style.paragraph_format.line_spacing = 1.5 # Thêm tên sách title_para = doc.add_paragraph(title) title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER title_run = title_para.runs[0] title_run.font.name = t("exporter.title_font") title_run.font.size = Pt(26) title_run.font.bold = True title_run.font.color.rgb = RGBColor(0, 0, 0) # Cài đặt phông chữ tiếng Trung title_rPr = title_run._element.get_or_add_rPr() title_rPr.find(qn('w:rFonts')).set(qn('w:eastAsia'), t("exporter.title_font")) # Thêm thông tin tác giả và ngày tháng info_para = doc.add_paragraph() info_para.alignment = WD_ALIGN_PARAGRAPH.CENTER info_run = info_para.add_run(t("exporter.generated_date", date=datetime.now().strftime('%Y-%m-%d'))) info_run.font.size = Pt(10) doc.add_paragraph() # Dòng trống # Thêm chương for chapter in chapters: # Tiêu đề chương chapter_title_para = doc.add_paragraph(chapter['title']) chapter_title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER for run in chapter_title_para.runs: run.font.name = t('exporter.title_font') run.font.size = Pt(16) run.font.bold = True run_rPr = run._element.get_or_add_rPr() run_rPr.find(qn('w:rFonts')).set(qn('w:eastAsia'), t("exporter.title_font")) doc.add_paragraph() # Dòng trống # Nội dung chương - thêm theo đoạn paragraphs = chapter['content'].split('\n\n') for para_text in paragraphs: if para_text.strip(): p = doc.add_paragraph(para_text.strip(), style='Normal') doc.add_paragraph() # Dòng trống giữa các chương # Lưu tập tin (ghi nguyên tử) safe_title = _sanitize_filename(title) filename = f"{safe_title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx" filepath = os.path.join(EXPORT_DIR, filename) try: tmp_fd, tmp_path = tempfile.mkstemp(suffix='.docx', dir=EXPORT_DIR) os.close(tmp_fd) doc.save(tmp_path) os.replace(tmp_path, filepath) except Exception as e: logger.error(f"DOCX write failed: {e}") return None, t("exporter.export_failed", error=str(e)) logger.info(f"DOCX export success: {filename}") return filepath, t("exporter.export_success", filename=filename) except Exception as e: logger.error(f"DOCX export failed: {e}") return None, t("exporter.export_failed", error=str(e)) def export_to_html(novel_text: str, title: str) -> Tuple[Optional[str], str]: """ Xuất ra định dạng HTML - Có thể đọc trên trình duyệt Args: novel_text: Văn bản tiểu thuyết (định dạng Markdown) title: Tiêu đề tiểu thuyết Returns: (Đường dẫn tệp, thông tin trạng thái) """ try: import markdown except ImportError: return None, t("exporter.missing_markdown") try: if not novel_text.strip(): return None, t("exporter.no_content") # Chuyển đổi Markdown sang HTML html_content = markdown.markdown(novel_text) # Được gói gọn dưới dạng tài liệu HTML hoàn chỉnh full_html = f"""
{t('exporter.generated_at', datetime=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))}