<-- Home |--rust |--rfe

Rfe 011:工程师(粗)搞点PDF目录小工具好快乐

简介

前言

simplebookmarker 是一个用 Rust 编写的 PDF 书签命令行工具,发布在 simplebookmarker - crates.io: Rust Package Registry,源代码托管在 https://github.com/qchen-fdii-cardc/simplebookmarker。它的命令名是 sbm,主要功能是读取 PDF 文件和缩进式文本目录,并把文本目录转换为 PDF 阅读器可识别的书签。

1cargo install simplebookmarker

本文结合当前代码实现,说明 sbm 的需求分析、数据结构、信息流、命令行参数设计、PDF 书签读写方式,以及其中涉及的 Rust 程序设计知识。重点放在两个库上:clap 负责把命令行参数解析成可靠的内部结构,lopdf 负责读取和写入 PDF outline。

给 PDF 补书签是一个典型的小型自动化任务。用户关心的是文本格式是否容易维护、原 PDF 是否安全、已有书签是否会被保留,以及命令失败时能否知道问题出在哪里。程序实现则需要把这些使用场景转化为清晰的模式:默认写出新文件,--in-place 替换原文件,--dry-run 只检查不写入,--export 导出现有书签,--from-zero 从零重建书签。

需求

sbm 的核心需求可以用一句话概括:把一个人容易维护的文本目录,转换成 PDF 里的书签。文本格式刻意设计得很轻:每一行以页码开头,后面是标题,标题前的 - 可有可无;层级由缩进表示,一个 tab 或四个空格算一级。

11-Introduction
2    3-Background
3        5-History
410 First chapter

这套格式有几个好处。它能直接从目录页手工整理出来,不需要 JSON、YAML 之类的结构化标点;它在普通编辑器里很容易批量修改;它还能自然表达父子书签。程序这边要做的,是把这种宽松格式翻译成更严格的数据结构。

后来需求慢慢长出来,工具也就不再只是“追加书签”。现在它需要支持几种工作方式:默认读取 book.pdfbook.txt,输出 book_bm.pdf--in-place 直接替换原 PDF;--dry-run 只检查和报告,不写文件;--export 把 PDF 中已有书签导出成同样的文本格式;--from-zero 则表示放弃已有书签,只按文本重新生成。

这些选项背后的判断很实际。给 PDF 写书签有风险,特别是 in-place 修改原文件时,所以默认输出新文件;真要覆盖原文件,就用临时文件加原子替换。已有书签也不能一刀切:普通输出时保留并追加比较稳妥,in-place 时默认更新同页书签更像是在“修订原文件”,而 --from-zero 给了用户一个彻底重建目录的入口。

设计

数据结构

整个程序最核心的数据结构其实只有几块。它们管理的信息也很克制:用户在命令行上说了什么,最后该读写哪些文件,文本里哪些行能变成书签,哪些行为什么没有被采用,以及写 PDF 时应如何对待旧书签。Cli 负责收集原始意图,PathsExportPaths 负责把意图落成路径,Entry 是一条已经合法化的书签,ParseReport 则把解析过程留下来给 dry-run 看。

下面是当前代码里的主体结构,删掉了一些属性细节,但保留了设计味道:

 1#[derive(Debug, Parser)]
 2#[command(version, about)]
 3struct Cli {
 4    name: Option<String>,
 5    input: Option<PathBuf>,
 6    bookmarks: Option<PathBuf>,
 7    output: Option<PathBuf>,
 8    in_place: bool,
 9    dry_run: bool,
10    export: Option<Option<PathBuf>>,
11    from_zero: bool,
12    on_existing: Option<ExistingPolicy>,
13}
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
16enum ExistingPolicy {
17    #[default]
18    Create,
19    Update,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23struct Entry {
24    page: u32,
25    title: String,
26    depth: usize,
27}
28
29#[derive(Debug, PartialEq, Eq)]
30struct ParseReport {
31    entries: Vec<Entry>,
32    blank_lines: usize,
33    malformed_lines: Vec<usize>,
34    out_of_range_lines: Vec<(usize, u32)>,
35}

这里最值得注意的是 Entry。PDF 书签本身有对象编号、目标页对象、颜色、展开状态等很多细节,但程序内部没有一开始就背上这些东西。它先把目录统一收敛为“页码、标题、深度”这三个字段。页码是人类看 PDF 时认的页码,标题是阅读器侧栏里显示的文字,深度是缩进层级。等真正写入 PDF 时,再把页码映射成 lopdf 需要的 ObjectId。这种做法让中间层保持干净:解析文本时不用懂 PDF 对象,写 PDF 时也不用再操心空行和坏缩进。

ParseReport 是后来长出来的结构。早期程序只需要 Vec<Entry>,坏行直接忽略即可。加入 --dry-run 后,用户需要知道“哪些行合法,哪些行没被采用,原因大概是什么”。于是解析函数不再只吐出结果,而是把过程里的信息一并带出来。

 1fn analyze_entries(contents: &str, max_page: u32) -> ParseReport {
 2    let mut report = ParseReport {
 3        entries: Vec::new(),
 4        blank_lines: 0,
 5        malformed_lines: Vec::new(),
 6        out_of_range_lines: Vec::new(),
 7    };
 8
 9    for (line_index, line) in contents.lines().enumerate() {
10        let line_number = line_index + 1;
11        if line.trim().is_empty() {
12            report.blank_lines += 1;
13            continue;
14        }
15
16        match parse_line(line) {
17            Some(entry) if entry.page <= max_page => report.entries.push(entry),
18            Some(entry) => report.out_of_range_lines.push((line_number, entry.page)),
19            None => report.malformed_lines.push(line_number),
20        }
21    }
22
23    report
24}

这段代码体现了 Rust 里很常见的写法:把“不一定成功”的解析表达成 Option<Entry>,再用 match 把不同情况分开。合法的 entry 进入 entries,页码超出 PDF 页数的 entry 进入 out_of_range_lines,完全无法解析的行进入 malformed_lines。dry-run 不需要重新解析一遍,它只打印这份报告。

流程

整体信息流可以画成这样:

flowchart TD
    A[命令行参数] --> B[Cli]
    B --> C{模式判断}
    C -->|--export| D[加载 PDF]
    D --> E[读取现有 TOC]
    E --> F[格式化为文本]
    F --> G[写出 .txt]

    C -->|普通 / dry-run| H[解析输入/书签/输出路径]
    H --> I[加载 PDF]
    I --> J[读取页码映射]
    I --> K[读取现有书签]
    H --> L[读取书签文本]
    L --> M[analyze_entries]
    M --> N{--dry-run?}
    N -->|是| O[打印解析报告]
    N -->|否| P{--from-zero?}
    P -->|是| Q[丢弃 existing]
    P -->|否| R[保留 existing]
    Q --> S[add_bookmarks]
    R --> S
    J --> S
    S --> T[build_outline]
    T --> U[save_document]

这张图里有一个关键分叉:--export 是独立模式,它只需要输入 PDF 和输出文本,不需要读取 bookmark 文本,也不应该关心 --dry-run--from-zero 这些写 PDF 的选项。普通模式和 dry-run 模式则共享大部分前置工作:路径解析、PDF 加载、页数检查、文本解析。区别只在最后一步,dry-run 停在报告阶段,普通模式继续写 PDF。

实现

clap:让命令行参数先有规矩

sbm 使用 clap 的 derive 写法。也就是说,我们先写一个 Rust 结构体,再通过属性告诉 clap:哪个字段是位置参数,哪个字段是开关,哪个字段可以取值,哪些选项互相冲突。

 1use clap::{Parser, ValueEnum};
 2
 3#[derive(Debug, Parser)]
 4#[command(version, about)]
 5struct Cli {
 6    /// Base name used to find NAME.pdf and NAME.txt
 7    name: Option<String>,
 8
 9    /// Input PDF file (overrides NAME.pdf)
10    #[arg(short, long, value_name = "PDF")]
11    input: Option<PathBuf>,
12
13    /// Replace the input PDF instead of creating a new file
14    #[arg(long, visible_alias = "inplace", conflicts_with = "output")]
15    in_place: bool,
16}

这段已经能说明 clap 的风格。name: Option<String> 没有 #[arg],就是一个可选的位置参数,所以 sbm book 里的 book 会落到这里。input 上的 short, long 表示同时支持 -i--inputin_place 是布尔开关,出现就是 true;它还声明了 conflicts_with = "output",所以用户不能同时写 --in-place --output result.pdf。这种冲突最好交给 clap 处理,因为它能统一生成错误信息和 help,不需要业务代码里到处手写判断。

--on-existing 用到了枚举参数。代码里给枚举派生了 ValueEnum,clap 就知道 createupdate 是合法值。

1#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
2enum ExistingPolicy {
3    #[default]
4    Create,
5    Update,
6}

不过当前实现没有直接给 on_existing 一个 clap 默认值,而是写成 Option<ExistingPolicy>

1#[arg(long, value_enum)]
2on_existing: Option<ExistingPolicy>,

原因在于默认值不是固定的。普通模式下默认 Create,因为输出到新 PDF 时保守追加比较合理;in-place 模式下默认 Update,因为用户更像是在修正原文件。这种“默认值取决于另一个参数”的逻辑,放进一个小方法里更清楚:

1fn existing_policy(&self) -> ExistingPolicy {
2    self.on_existing.unwrap_or(if self.in_place {
3        ExistingPolicy::Update
4    } else {
5        ExistingPolicy::Create
6    })
7}

另一个有趣的参数是 --export。它有三种状态:没写 --export,表示不是导出模式;写了 --export 但没给文件名,表示导出到和输入 PDF 同名的 .txt;写了 --export out.txt,表示导出到指定路径。普通的 Option<PathBuf> 只能表达两种状态,于是这里用了 Option<Option<PathBuf>>

1#[arg(
2    long,
3    value_name = "TEXT",
4    num_args = 0..=1,
5    default_missing_value = DEFAULT_EXPORT_PATH
6)]
7export: Option<Option<PathBuf>>,

外层 Option 表示这个选项有没有出现,内层 Option<PathBuf> 表示出现后有没有跟一个值。因为 clap 的 default_missing_value 需要一个非空字符串,代码里用了一个内部哨兵值:

1const DEFAULT_EXPORT_PATH: &str = "__sbm_default_export_path__";

随后在路径解析时把它转成真正的默认路径:

 1fn export_paths(&self) -> Result<ExportPaths, String> {
 2    let input = self.input_path()?;
 3    let output = match &self.export {
 4        Some(Some(path)) if path == Path::new(DEFAULT_EXPORT_PATH) => text_path_for_pdf(&input),
 5        Some(Some(path)) => path.clone(),
 6        Some(None) => text_path_for_pdf(&input),
 7        None => return Err("provide --export to export bookmarks".to_string()),
 8    };
 9
10    Ok(ExportPaths { input, output })
11}

这里看起来绕了一点,但换来的是命令行体验自然:sbm book --exportsbm --input source.pdf --export 都能工作,后者会输出 source.txt

最后看 --from-zero。它表示放弃已有书签,重新生成目录。既然已有书签不参与,--on-existing 就没意义;它也不是导出模式的一部分。因此它直接在 clap 层声明冲突:

1/// Discard all existing PDF bookmarks before adding new ones
2#[arg(long, conflicts_with_all = ["export", "on_existing"])]
3from_zero: bool,

这就是 clap 设计参数组合时的一个要点:能在参数层说清楚的规则,不要拖到业务层才发现。业务代码应该处理“怎么做”,参数解析器负责拦住“这样说不通”。

文本解析:把宽松格式变成稳定结构

目录文本允许 tab,也允许四个空格;允许 1-title,也允许 1 title。这种宽松格式对用户友好,但程序内部必须有明确规则。缩进解析由 indentation 负责:

 1fn indentation(line: &str) -> Option<(usize, &str)> {
 2    let prefix_len = line
 3        .char_indices()
 4        .find_map(|(index, character)| (!matches!(character, ' ' | '\t')).then_some(index))
 5        .unwrap_or(line.len());
 6    let prefix = &line[..prefix_len];
 7    let mut depth = 0;
 8    let mut spaces = 0;
 9
10    for character in prefix.chars() {
11        match character {
12            '\t' if spaces == 0 => depth += 1,
13            ' ' => {
14                spaces += 1;
15                if spaces == 4 {
16                    depth += 1;
17                    spaces = 0;
18                }
19            }
20            _ => return None,
21        }
22    }
23
24    (spaces == 0).then_some((depth, &line[prefix_len..]))
25}

它返回的是 Option<(usize, &str)>:成功时给出缩进层级和去掉缩进后的内容,失败时返回 None。失败的情况包括缩进里混入了不合规则的字符,或者空格数不是四的倍数。注意这里没有分配新的字符串,返回的 &str 仍然借用原始行,这就是 Rust 切片好用的地方。

单行解析接着处理页码和标题:

 1fn parse_line(line: &str) -> Option<Entry> {
 2    let (depth, content) = indentation(line)?;
 3    let digit_count = content.bytes().take_while(u8::is_ascii_digit).count();
 4    if digit_count == 0 {
 5        return None;
 6    }
 7
 8    let page = content[..digit_count].parse().ok()?;
 9    let rest = content[digit_count..].trim_start();
10    let title = rest.strip_prefix('-').unwrap_or(rest).trim();
11    if page == 0 || title.is_empty() {
12        return None;
13    }
14
15    Some(Entry {
16        page,
17        title: title.to_string(),
18        depth,
19    })
20}

这里有两个典型的 Rust 写法。第一,indentation(line)? 里的问号可以用在 Option 上;如果缩进非法,整个函数立刻返回 None。第二,parse().ok()?Result 转成 Option,解析失败同样短路返回。代码没有把错误分成十几类,因为对这个工具来说,dry-run 只需要告诉用户“这一行不合法”,没必要把解析器写成编译器。

lopdf:和 PDF 书签打交道

PDF 内部不是一份简单的文本文件,而是一组对象、引用和字典。lopdf 的好处是,它既能让我们碰到底层对象,也提供了一些高层方法处理目录。sbm 用到的主要类型是 DocumentBookmarkObjectObjectId

加载 PDF 很直接:

1let mut document = Document::load(&paths.input)?;
2let pages = document.get_pages();
3let max_page = pages.keys().copied().max().unwrap_or(0);

get_pages() 返回的是页码到 PDF 页对象 ID 的映射。这个映射非常关键,因为文本里的 page: u32 只是人的页码,而 Bookmark::new 需要的是 PDF 里的页面对象引用。

读取已有书签用 get_toc()

 1fn existing_entries(document: &Document) -> lopdf::Result<Vec<Entry>> {
 2    match document.get_toc() {
 3        Ok(toc) => Ok(toc
 4            .toc
 5            .into_iter()
 6            .map(|item| Entry {
 7                page: item.page as u32,
 8                title: item.title,
 9                depth: item.level.saturating_sub(1),
10            })
11            .collect()),
12        Err(lopdf::Error::NoOutline) => Ok(Vec::new()),
13        Err(error) => Err(error),
14    }
15}

lopdf 里的 TOC level 是从 1 开始的,而 Entry.depth 是从 0 开始的,所以这里用了 saturating_sub(1)。如果 PDF 没有 outline,lopdf 会返回 NoOutline,对 sbm 来说这不是错误,而是“已有书签为空”。其他错误才继续往外抛。

写书签分两步。第一步是把一批 Entry 追加到 Document 里,并维护父子关系:

 1fn append_entries(
 2    document: &mut Document,
 3    entries: &[Entry],
 4    pages: &BTreeMap<u32, ObjectId>,
 5) -> Vec<Option<u32>> {
 6    let mut parents: Vec<u32> = Vec::new();
 7    let mut bookmark_ids = Vec::with_capacity(entries.len());
 8
 9    for entry in entries {
10        let Some(&page_id) = pages.get(&entry.page) else {
11            bookmark_ids.push(None);
12            continue;
13        };
14        let depth = entry.depth.min(parents.len());
15        parents.truncate(depth);
16        let parent = depth.checked_sub(1).map(|index| parents[index]);
17        let bookmark = Bookmark::new(entry.title.clone(), [0.0, 0.0, 0.0], 0, page_id);
18        let bookmark_id = document.add_bookmark(bookmark, parent);
19        parents.push(bookmark_id);
20        bookmark_ids.push(Some(bookmark_id));
21    }
22
23    bookmark_ids
24}

parents 是一个小栈。处理到 depth 0 的书签时,它没有父节点;处理到 depth 1 时,它的父节点是最近的 depth 0;如果缩进跳级,比如一上来就是 depth 3,代码用 entry.depth.min(parents.len()) 把它挂到当前可用的最深层级下面。这样做比直接报错更宽容,也符合 README 里的说明。

第二步是构建 outline 并挂到 PDF catalog 上:

1if let Some(outline_id) = document.build_outline() {
2    document
3        .catalog_mut()?
4        .set("Outlines", Object::Reference(outline_id));
5}

这行 set("Outlines", Object::Reference(outline_id)) 是 PDF 阅读器能看到书签的关键。add_bookmark 先把书签登记到 lopdf 的文档结构里,build_outline 生成 outline 对象,最后 catalog 的 Outlines 指向这个对象。--from-zero 的实现也借用了这个机制:它不把旧书签传入 add_bookmarks,于是新 catalog 只指向新生成的目录。旧 outline 不再参与阅读器可见的目录结构。

已有书签的 create/update 策略在 add_bookmarks 里完成。update 模式先按页码寻找还没被使用过的旧书签,找到就改标题;找不到就创建新的。create 模式则不匹配,直接保留旧书签并追加新书签。

 1if policy == ExistingPolicy::Update {
 2    let mut used = vec![false; existing.len()];
 3    for (entry_index, entry) in entries.iter().enumerate() {
 4        if let Some(existing_index) = existing
 5            .iter()
 6            .enumerate()
 7            .position(|(index, existing_entry)| !used[index] && existing_entry.page == entry.page)
 8        {
 9            existing[existing_index].title = entry.title.clone();
10            used[existing_index] = true;
11            matches[entry_index] = Some(existing_index);
12        }
13    }
14}

保存 PDF 时还有一个细节:in-place 不能直接往原文件上写,写到一半崩了就麻烦了。当前实现先在同目录创建临时文件,写入、同步,然后替换原文件:

1let target = fs::canonicalize(input)?;
2let parent = target.parent().unwrap_or_else(|| Path::new("."));
3let permissions = fs::metadata(&target)?.permissions();
4let mut temporary = NamedTempFile::new_in(parent)?;
5temporary.as_file().set_permissions(permissions)?;
6document.save_to(temporary.as_file_mut())?;
7temporary.as_file_mut().sync_all()?;
8temporary.persist(target)?;

这就是 tempfile 包的用处。它让“安全覆盖文件”这件事少了很多边角问题。

主流程:模式分叉要早,公共逻辑要共享

run() 是整个命令的调度台:

 1fn run() -> Result<(), Box<dyn Error>> {
 2    let cli = Cli::parse();
 3    if cli.export.is_some() {
 4        let paths = cli.export_paths()?;
 5        return export_bookmarks(&paths.input, &paths.output);
 6    }
 7
 8    let policy = cli.existing_policy();
 9    let dry_run = cli.dry_run;
10    let from_zero = cli.from_zero;
11    let paths = cli.paths()?;
12    let mut document = Document::load(&paths.input)?;
13    let pages = document.get_pages();
14    let max_page = pages.keys().copied().max().unwrap_or(0);
15    let existing = existing_entries(&document)?;
16    let contents = fs::read_to_string(&paths.bookmarks)?;
17    let report = analyze_entries(&contents, max_page);
18    if dry_run {
19        print_dry_run_report(&paths, max_page, existing.len(), &report, policy, from_zero);
20        return Ok(());
21    }
22
23    let entries = report.entries;
24    let existing = if from_zero { Vec::new() } else { existing };
25    add_bookmarks(&mut document, existing, entries, &pages, policy)?;
26    save_document(&mut document, &paths.input, &paths.output)?;
27    println!("Wrote {}", paths.output.display());
28    Ok(())
29}

这段流程有两个值得学的地方。第一,导出模式尽早返回。它和写 PDF 的流程没有太多共同点,硬把它塞进后面的逻辑只会让分支变乱。第二,dry-run 尽量复用真实流程的前半段。它真的加载 PDF,真的读取页数,真的解析文本,所以报告才可信;它只是在写文件前停下。

函数返回 Result<(), Box<dyn Error>>,让不同来源的错误都能用 ? 往上传。main() 只负责把错误打印出来,并设置退出码:

1fn main() {
2    if let Err(error) = run() {
3        eprintln!("error: {error}");
4        std::process::exit(1);
5    }
6}

这是 Rust 命令行程序很常见的骨架:业务逻辑放在 run(),入口函数保持短小。

语言特性

所有权与借用

这个项目虽然小,但已经覆盖了不少 Rust 日常开发的基本功。所有权和借用首先会出现:Document::load 得到的文档后续要修改,所以是 let mut document;写书签的函数接收 &mut Document,读取已有书签只需要 &Document。这比口头说“这里会改、那里不会改”更硬,编译器会帮你守住边界。

小结一下,Rust 让“是否修改数据”成为函数签名的一部分。&Document 表示只读借用,&mut Document 表示独占的可变借用。对于 PDF 这种状态较多的对象,这种约束很有价值,因为它减少了无意间修改共享状态的机会。

Option、Result 与错误传播

OptionResult 是第二个重点。文本行可能解析失败,所以 parse_line 返回 Option<Entry>;加载 PDF、读文件、保存文件可能失败,所以这些函数返回 Result。问号运算符 ? 让错误传播很轻:不是不处理错误,而是把错误交给更外层统一处理。

小结一下,Option 适合表达“有没有”,Result 适合表达“成没成,以及失败原因是什么”。在 sbm 里,单行解析失败时只需要知道没有得到 Entry,所以用 Option;文件和 PDF 操作失败需要把错误交给用户,所以用 Result

枚举表示有限状态

枚举是第三个重点。ExistingPolicy 比字符串稳得多,编译器能保证只出现 CreateUpdate 两种情况。以后如果再加第三种策略,相关 match 或判断也更容易被发现。

小结一下,只要一组状态是有限且明确的,就应优先考虑枚举。命令行参数解析成枚举后,业务代码面对的是类型,而不是散落在各处的字符串比较。

迭代器与普通循环

迭代器在代码里也不少见:

1let digit_count = content.bytes().take_while(u8::is_ascii_digit).count();

这一行从字符串开头数 ASCII 数字,写法简洁,也避免了手写下标循环。另一个例子是把 lopdf 的 TOC 项转换成内部 Entry

1Ok(toc
2    .toc
3    .into_iter()
4    .map(|item| Entry {
5        page: item.page as u32,
6        title: item.title,
7        depth: item.level.saturating_sub(1),
8    })
9    .collect())

into_iter() 消耗原集合,map 做转换,collect 收集成 Vec<Entry>。这套写法在 Rust 里非常常见。

小结一下,Rust 迭代器适合表达“从一批数据变成另一批数据”。当代码是过滤、转换、收集时,迭代器通常比手写循环更容易看出意图;当逻辑需要维护额外状态,比如 append_entries 里的父节点栈,普通 for 循环反而更清楚。

测试组织

最后是测试。这个项目的测试不是只测“函数能跑”,而是把几个重要承诺钉住:路径默认值、导出路径、in-place 默认 update、from-zero 的冲突规则、解析报告、PDF outline 的层级结构。对命令行工具来说,参数组合就是公共接口,值得写测试。

小结一下,Rust 项目里的单元测试可以直接放在同一个文件的 #[cfg(test)] 模块中。这样既能测试公开行为,也能测试一些不需要暴露给外部的内部函数。对小型命令行工具来说,这种测试组织方式成本低,反馈快。

总结

sbm 的设计并不追求花哨。它把用户输入收敛成小而稳定的 Entry,用 ParseReport 给 dry-run 足够透明的反馈,用 clap 把命令行组合的规矩设置清楚,再把 lopdf 的 PDF 对象操作包在几个小函数里。这样的代码读起来像一条路:参数进来,路径定下来,文本变成条目,条目变成书签,最后写回文件。

这也是 Rust 很适合写命令行工具的地方。类型系统不会替你决定产品行为,但它会迫使你把状态说清楚;错误处理不会替你设计体验,但它会让失败路径不至于散在角落。写到最后,程序虽然只有一个 main.rs,但边界是清楚的:clap 管“用户怎么说”,解析器管“文本怎么变成数据”,lopdf 管“数据怎么进 PDF”,而 run() 只负责把这些步骤排成一个可靠的流程。


文章标签

|-->rust |-->pdf |-->bookmark


GitHub