//! PDF 输出后端 —— 基于 genpdf 0.2 生成 A5 尺寸 PDF。 use std::path::{Path, PathBuf}; use genpdf::elements::{Break, PageBreak, Paragraph, Text}; use genpdf::fonts::FontData; use genpdf::style::Style; use genpdf::{Alignment, Document, Element, Scale, Size}; use serde_json::Value; use tracing::{debug, info, warn}; use super::book_manager::BookManager; use super::finalize_utils::volume_title_map_for_chapters; use super::parser::ContentParser; use crate::base_system::book_paths; /// 查找第一个存在的路径。 fn find_first_existing(paths: &[PathBuf]) -> Option { for p in paths { if p.exists() { return Some(p.clone()); } } None } /// 计算字符的"视觉半宽":CJK / 全角字符 = 2,ASCII / 半角 = 1。 fn char_visual_width(c: char) -> usize { // CJK Unified Ideographs, CJK Ext-A/B, CJK Compatibility, Kangxi Radicals // Fullwidth forms, CJK Symbols, Kana, Hangul, etc. match c { '\u{1100}'..='\u{115F}' // Hangul Jamo | '\u{2E80}'..='\u{303E}' // CJK Radicals, Kangxi, CJK Symbols | '\u{3040}'..='\u{33BF}' // Hiragana, Katakana, CJK Compatibility | '\u{3400}'..='\u{4DBF}' // CJK Ext-A | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs | '\u{A000}'..='\u{A4CF}' // Yi | '\u{AC00}'..='\u{D7AF}' // Hangul Syllables | '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs | '\u{FE30}'..='\u{FE6F}' // CJK Compatibility Forms | '\u{FF01}'..='\u{FF60}' // Fullwidth Forms | '\u{FFE0}'..='\u{FFE6}' // Fullwidth Signs | '\u{20000}'..='\u{2FA1F}' // CJK Ext-B..F, Compatibility Supplement => 2, _ => 1, } } /// 标点禁则:这些字符不应出现在行首。 fn is_no_break_before(c: char) -> bool { matches!( c, ',' | '。' | '、' | ';' | ':' | '!' | '?' | ')' | '》' | '」' | '』' | '】' | '〉' | '〕' | '…' | '—' | '~' | '·' | '.' | ',' | '!' | '?' | ')' | ';' | ':' | '>' ) } /// 禁则标点溢出容差(半宽单位)。当累积宽度超出 max_half_widths 超过此值时, /// 即使遇到禁则标点也强制断行,避免 genpdf 因行宽溢出而丢弃整行。 const WRAP_OVERFLOW_TOLERANCE: usize = 6; /// genpdf 0.2 的文本换行在 CJK 下会出问题(内部只按 ASCII 空格分词), /// 因此按视觉宽度预分行:CJK 字符计 2 个半宽单位,ASCII 计 1 个。 /// `max_half_widths` 为一行允许的最大半宽单位数。 /// 同时遵守标点禁则:不在行首放置收尾标点,但溢出超过容差时强制断行。 fn pre_wrap_line(line: &str, max_half_widths: usize) -> Vec { if line.is_empty() { return vec![String::new()]; } let max_overflow = max_half_widths + WRAP_OVERFLOW_TOLERANCE; let mut segments: Vec = Vec::new(); let mut current = String::new(); let mut current_width: usize = 0; for c in line.chars() { let w = char_visual_width(c); let would_overflow = current_width + w > max_half_widths; let over_tolerance = current_width + w > max_overflow; // 超宽时换行,但禁则标点允许少量溢出;超出容差后强制断行 if would_overflow && !current.is_empty() && (!is_no_break_before(c) || over_tolerance) { segments.push(current); current = String::new(); current_width = 0; } current.push(c); current_width += w; } if !current.is_empty() { segments.push(current); } if segments.is_empty() { segments.push(String::new()); } segments } /// 生成 PDF 并写入 `path`。 pub(super) fn finalize_pdf( manager: &BookManager, chapters: &[Value], path: &Path, directory_raw: Option<&Value>, ) -> anyhow::Result<()> { // ── 字体加载 ────────────────────────────────────────────── let font_path = manager .config .resolve_pdf_font_path() .ok_or_else(|| anyhow::anyhow!("未找到可用的中文字体,请在配置中设置 pdf_font_path"))?; debug!(target: "finalize_pdf", font = %font_path.display(), "loading font"); let font_data = FontData::new(std::fs::read(&font_path)?, None) .map_err(|e| anyhow::anyhow!("加载字体失败: {e}"))?; let font_family = genpdf::fonts::FontFamily { regular: font_data.clone(), bold: font_data.clone(), italic: font_data.clone(), bold_italic: font_data, }; // ── 文档初始化 ──────────────────────────────────────────── let mut doc = Document::new(font_family); doc.set_title(&manager.book_name); // A5: 148 × 210 mm let page_size = Size::new(148, 210); doc.set_paper_size(page_size); // 页边距 let mut decorator = genpdf::SimplePageDecorator::new(); decorator.set_margins(genpdf::Margins::trbl(15, 15, 15, 15)); doc.set_page_decorator(decorator); doc.set_minimal_conformance(); doc.set_line_spacing(1.5); // ── 封面图 ───────────────────────────────────────────────── let cover_candidates: Vec = if let Some(base) = manager.config.get_status_folder_path() { book_paths::cover_file_candidates(&base, Some(&manager.book_name)) } else { vec![] }; if let Some(cover_path) = find_first_existing(&cover_candidates) { debug!(target: "finalize_pdf", cover = %cover_path.display(), "adding cover image"); match genpdf::elements::Image::from_path(&cover_path) { Ok(img) => { // 根据图片实际像素尺寸计算缩放,使封面适应 A5 内容区域 (118×180mm) let content_w_mm = 118.0_f64; let content_h_mm = 180.0_f64; let dpi = 300.0_f64; let mmpi = 25.4_f64; let scale = if let Ok(reader) = image::ImageReader::open(&cover_path) { if let Ok(dim) = reader.into_dimensions() { let img_w_mm = mmpi * dim.0 as f64 / dpi; let img_h_mm = mmpi * dim.1 as f64 / dpi; let s = (content_w_mm / img_w_mm).min(content_h_mm / img_h_mm); debug!(target: "finalize_pdf", px_w = dim.0, px_h = dim.1, scale = s, "cover scale"); s } else { 2.0 } } else { 2.0 }; let img = img .with_alignment(Alignment::Center) .with_scale(Scale::new(scale, scale)); doc.push(img); // 封面后直接分页(genpdf 不会自动分页) doc.push(PageBreak::new()); } Err(e) => { warn!(target: "finalize_pdf", error = ?e, "cover image load failed, skipping"); } } } // ── 书籍信息页 ──────────────────────────────────────────── // A5 内容区 118mm,10pt 中文字约 3.53mm → 33 字 ≈ 66 半宽单位 const MAX_HW_BODY: usize = 66; const MAX_HW_TITLE: usize = 40; let title_style = Style::new().with_font_size(18); let meta_style = Style::new().with_font_size(11); for seg in pre_wrap_line(&manager.book_name, MAX_HW_TITLE) { doc.push( Paragraph::new(seg) .aligned(Alignment::Center) .styled(title_style), ); } doc.push(Break::new(1)); if !manager.author.trim().is_empty() { doc.push(Paragraph::new(format!("作者:{}", manager.author)).styled(meta_style)); } let status_text = match manager.finished { Some(true) => "完结", Some(false) => "连载", None => "未知", }; doc.push(Paragraph::new(format!("状态:{status_text}")).styled(meta_style)); if let Some(category) = manager.category.as_deref() && !category.trim().is_empty() { doc.push(Paragraph::new(format!("分类:{}", category.trim())).styled(meta_style)); } if !manager.tags.trim().is_empty() { doc.push(Paragraph::new(format!("标签:{}", manager.tags)).styled(meta_style)); } if !manager.description.trim().is_empty() { doc.push(Break::new(1)); doc.push(Paragraph::new("简介:").styled(meta_style)); // 先按换行符拆分,再对每小段做视觉宽度预分行 for sub_line in manager.description.trim().lines() { let sub_line = sub_line.trim(); if sub_line.is_empty() { doc.push(Break::new(0.3)); } else { for seg in pre_wrap_line(sub_line, MAX_HW_BODY) { doc.push(Text::new(seg).styled(meta_style)); } } } } doc.push(PageBreak::new()); // ── 分卷 / 章节 ────────────────────────────────────────── let volume_map = volume_title_map_for_chapters(chapters, directory_raw); let volume_style = Style::new().with_font_size(16); let chapter_title_style = Style::new().with_font_size(14); let body_style = Style::new().with_font_size(10); let mut last_volume: Option = None; for ch in chapters { let chapter_id = ch.get("id").and_then(|v| v.as_str()).unwrap_or(""); let title = ch.get("title").and_then(|v| v.as_str()).unwrap_or("章节"); let content = ch.get("content").and_then(|v| v.as_str()).unwrap_or(""); let content = ContentParser::clean_plain(content, title); // 分卷标题 if let Some(vol) = volume_map.get(chapter_id) { let vol = vol.trim(); if !vol.is_empty() && last_volume.as_deref() != Some(vol) { doc.push(Break::new(1)); doc.push( Paragraph::new(vol) .aligned(Alignment::Center) .styled(volume_style), ); doc.push(Break::new(1)); last_volume = Some(vol.to_string()); } } // 章节标题 for seg in pre_wrap_line(title, MAX_HW_TITLE) { doc.push(Paragraph::new(seg).styled(chapter_title_style)); } doc.push(Break::new(0.5)); // 正文:每段首行加两个全角空格缩进 // 使用 Text 而非 Paragraph:genpdf 的 Paragraph 按空格分词换行, // CJK 无空格文本在 wrap::Wrapper 中会被视为一个不可分割的"单词", // 当单词宽度超过页面内容区时会直接丢弃而不渲染,导致吞行。 // Text 元素直接单行渲染,即使略超边距也仅溢出而非丢失内容。 for line in content.lines() { let line = line.trim(); if line.is_empty() { doc.push(Break::new(0.3)); } else { let indented = format!("\u{3000}\u{3000}{line}"); let segs = pre_wrap_line(&indented, MAX_HW_BODY); for seg in segs { doc.push(Text::new(seg).styled(body_style)); } } } doc.push(PageBreak::new()); } // ── 渲染输出 ────────────────────────────────────────────── doc.render_to_file(path) .map_err(|e| anyhow::anyhow!("PDF 渲染失败: {e}"))?; info!(target: "finalize_pdf", path = %path.display(), "PDF generated"); Ok(()) }