File size: 12,390 Bytes
bc32e7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | //! 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<PathBuf> {
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<String> {
if line.is_empty() {
return vec![String::new()];
}
let max_overflow = max_half_widths + WRAP_OVERFLOW_TOLERANCE;
let mut segments: Vec<String> = 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<PathBuf> = 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<String> = 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(())
}
|