8 个文件、2223 行 — Project.swift 里没有一个外部 SPM 依赖。这就是 BookExport 的构造:Lanternly 中把日记记录组装成 PDF 和 EPUB 3 的模块,不借助任何外部包 — 只有系统框架:CoreGraphics、Core Text、ImageIO、Foundation。这个选择是有意为之:在 Lanternly 里,导出从不上锁 — 任何时候都能把记录从应用中带走,Markdown、纯文本,如今还有书 — 日记不应该变成困住用户自己数据的陷阱。在所有格式中,书是最有实感的一种:PDF 或 EPUB 能把多年的记录变成一件实物 — 可以打印、送去印刷厂,或者直接在阅读器里打开 — 服务器上不会留下一个字节,因为整个生成过程都在设备上完成。
两种格式共用一条流水线:BookConfig 是一个实时的 @Observable 构建器(范围:某一年 / 整本日记 / 特定日志本,封面,标题);BookContentResolver 是解析器,只按时间顺序取出活跃记录(归档记录永远不会进入书中);接下来要么走 BookPDFRenderer,要么走 BookEPUBRenderer,一直到分享面板。
为什么不用 PDFKit#
在 Apple 平台上一提到"PDF",第一个问题就是:为什么不用 PDFKit 或 UIGraphicsPDFRenderer。我的回答很务实:Lanternly 是跨平台应用(iOS 和 macOS),而 UIGraphicsPDFRenderer 只存在于 UIKit。为两个框架维护两套书籍排版实现,不是一个两千行规模的模块该花预算的地方。
我往下走了一层,直接使用 CoreGraphics:
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data) else { return nil }
var box = CGRect(x: 0, y: 0, width: pageW, height: pageH)
guard let ctx = CGContext(consumer: consumer, mediaBox: &box, nil) else { return nil }
ctx.textMatrix = .identityCGContext(consumer:mediaBox:) 在 iOS 和 macOS 上是同一个 API。接下来用 ctx.beginPDFPage(nil) / ctx.endPDFPage() 这一对调用来开合页面,文本则由同样跨平台的 Core Text 绘制。一个渲染器、一套排版,页面逻辑内部没有任何 #if os(iOS)。
会自己翻页的文本#
书籍 PDF 中最有趣的工程问题不是画出一页,而是在只知道页面尺寸的前提下,把任意长度的文本排进任意数量的页面。用 NSLayoutManager 这是白送的;在裸的 Core Text 上,分页必须靠 CTFramesetter + CTFrameGetVisibleStringRange 这对组合手工搭建:
while start < total {
if !pageStarted { startPage(e) }
let availH = contentBottomY - cursorTop
if availH < 24 { endPage(); startPage(e); continue }
let rect = CGRect(x: contentX, y: pageH - (cursorTop + availH), width: contentW, height: availH)
let sub = attr.attributedSubstring(from: NSRange(location: start, length: total - start))
let fs = CTFramesetterCreateWithAttributedString(sub)
let path = CGPath(rect: rect, transform: nil)
let frame = CTFramesetterCreateFrame(fs, CFRange(location: 0, length: 0), path, nil)
ctx.textMatrix = .identity
ctx.setFillColor(ink)
CTFrameDraw(frame, ctx)
let visible = CTFrameGetVisibleStringRange(frame)
let consumed = visible.length
if consumed <= 0 { endPage(); startPage(e); continue }
...
if start + consumed >= total {
cursorTop += ceil(used.height)
start = total
} else {
start += consumed
endPage(); startPage(e)
}
}思路很简单:把带属性字符串的剩余部分放进可用高度的矩形里创建一个 CTFrame,绘制,然后问这个 frame 实际"装下"了多少字符 — CTFrameGetVisibleStringRange。如果没装完,剩余部分就成为下一页的输入;如果一个字符都没装下(consumed <= 0 — 比如可用高度连一行都放不下),这就是针对死循环的显式防护:关闭当前页,打开新页,在干净的页面上重试。没有这个检查,一旦几何条件凑得不巧,渲染就会永远卡死。
书籍排版,而非屏幕排版#
PDF 按接近 A5 的开本(419.53×595.28 pt)排版 — 这是印刷书籍的比例,不是 A4,也不是手机屏幕。两侧页边距 50 pt,正文 11.3 pt、行距 ×1.55,两端对齐并带连字符断词(hyphenationFactor = 1),首行缩进只从记录的第二段开始出现 — 这是书籍排版里区分"章节开头"与"思路延续"的手法:
let a = makeAttr(p, font: sans(11.3, .regular), color: ink,
alignment: .justified, firstIndent: i == 0 ? 0 : 15,
lineHeightMultiple: 1.55, hyphenate: true,
paragraphSpacing: 2)页眉像真正的印刷书一样交替排布 — recto/verso。偶数页页码在左侧,旁边是日志本名称和年份;奇数页页码在右侧,左边是月份:
let isVerso = pageNum % 2 == 0
let journal = (headerOverride ?? e.journal?.title ?? "Дневник").uppercased()
let header = isVerso ? "\(journal) · \(BookFmt.year(e.createdAt))"
: BookFmt.month(e.createdAt).uppercased()(回退值 "Дневник" 是应用内置的俄语产品字符串,意为"日记",按原样保留。)
封面是另一个故事:7 个预设("日落"和"日出"这样的渐变、带星空和弯月的"夜晚"、带遮罩层的"自定义照片"),全部由同一个函数 drawCover 绘制。它有两个调用方:UI 中构建器的实时预览和 PDF 的扉页。不是两份相似的实现,而是唯一的排版来源 — 哪天标题下那条细线的位置变了,预览和印出来的书也不可能各走各路。
书中的照片通过 CGImageSourceCreateThumbnailAtIndex 逐张解码并设有尺寸上限 — 封面 1600 px、记录的单张照片 1400 px、网格 1000 px。每次解码都包在 autoreleasepool 里,2–4 张照片的网格按两列排布。对于有数百条带附件记录的日记来说,这不是细节,而是渲染能否撑到最后、不出现内存尖峰的问题。
字体值得单独一提:标题用 Lora,注释文字用 Inter — 和应用界面里同一批文件,统一入口是 LanternlyTypeface。这里有意不使用 Dynamic Type — 这是页面几何固定的文档排版,而不是随用户设置伸缩的屏幕。
固定的页面几何随着 PDF 到此结束。EPUB 里根本没有这回事 — 那是一个把排版全权交给阅读器的、完全不同的格式,在压缩包层面有一套自己的协议。
手写 EPUB:mimetype 打头#
EPUB 3 也是一个 ZIP,但带有严格的协议,而它最常在第一个字节就被破坏。mimetype 文件必须是压缩包的第一个条目,不压缩、无 extra 字段:
zip.add("mimetype", Data("application/epub+zip".utf8)) // 第一个条目,store
zip.add("META-INF/container.xml", Data(containerXML.utf8))
zip.add("OEBPS/style.css", Data(styleCSS(fontFaceCSS(fonts)).utf8))
for f in fonts { zip.add("OEBPS/fonts/\(f.face.file).ttf", f.data) }接下来是一个最小但完整的容器:META-INF/container.xml 指向 OPF;content.opf 承载元数据(dc:identifier 采用 urn:uuid 形式,dcterms:modified 采用不带小数秒的严格 UTC 格式);带 epub:type="toc" 的 nav.xhtml 是 EPUB 3 的现代导航,旁边还有 toc.ncx — 留给仍按老规矩期待 NCX 的阅读器。
章节按月生成("LLLL yyyy",首字母大写):chap1.xhtml、chap2.xhtml 等等,外加独立的 cover.xhtml 和 cover.jpg — 封面用的正是 PDF 里那个 drawCover 函数,只是渲染一次输出成 JPEG。
Lora 和 Inter 字体通过 @font-face 内嵌 — 但不是应用自带的全部 11 个字重,而只有被 embedInEPUB 标志显式标记的 5 个(SIL OFL 许可证允许这么做,OFL.txt 文件就放在 bundle 里 TTF 旁边)。如果设备上找不到某个字体文件,对应的 @font-face 干脆不写,CSS 会回退到 font-family 声明中的系统 serif/sans。这样就保持了 epubcheck-safe:清单里永远不会出现指向不存在文件的引用。
图片上有类似的防护。如果照片因为某种原因解码失败,压缩包里仍会放入一个占位符 — 一张合法的 1×1 像素 JPEG:
private static func placeholderJPEG() -> Data {
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return Data() }
ctx.setFillColor(CGColor(colorSpace: space, components: [0.93, 0.90, 0.82, 1]) ?? CGColor(gray: 0.9, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
guard let img = ctx.makeImage() else { return Data() }
return jpegData(img, quality: 0.8) ?? Data()
}逻辑很简单:清单里的每一项都必须指向压缩包中真实存在的文件,否则验证器会把整本书拒之门外。画一个像素,远比因为几年日记中间一张损坏的照片而让整个导出失败要便宜。
同样的克制也延伸到了打包这一切的压缩包本身。
141 行的 ZIP#
模块里没有任何第三方 ZIP 库 — 只有 ZipWriter,141 行,通过 FileHandle 以流式方式直接写入文件。这是有意识的简化:压缩方法只有 STORE,没有 deflate。代码里的注释说得很直白:
// EPUB 是一个顺序有特殊要求的 ZIP:最前面是未压缩的 `mimetype`,随后是容器
// 和内容。以流式方式直接写入文件(offset 由我们自己计算),条目采用 STORE
// 方法(不压缩):对 EPUB 完全合法,照片本来就是 JPEG,而简单的字节流排除了
// 一整类错误,并保证通过 epubcheck。正常阅读时文本本身已经被格式压得不错,照片又本来就是 JPEG — 再来一层 deflate 几乎赢不到什么,反而带来一整类独有的 bug(Huffman 表、字典窗口、不可压缩数据的边界情况处理)。
STORE 意味着流式写入、不在内存中缓冲整个压缩包:add(_:_:) 把头部和数据直接写进文件,并记住偏移量供之后的中央目录使用,因此带着数百张照片的大部头日记永远不会整个驻留在内存里。
CRC-32 也是自研的,使用基于 IEEE 802.3 多项式的标准查找表,并配有针对经典参考向量的单元测试:
static func checksum(_ data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFF_FFFF
data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in
for byte in buf {
crc = table[Int((crc ^ UInt32(byte)) & 0xFF)] ^ (crc >> 8)
}
}
return crc ^ 0xFFFF_FFFF
}@Test("CRC-32 совпадает с эталоном zlib")
func crc32MatchesReference() {
// 经典测试向量:CRC32("123456789") == 0xCBF43926。
#expect(CRC32.checksum(Data("123456789".utf8)) == 0xCBF4_3926)
}而由于整个压缩包都用 STORE 写入,测试获得了 deflate 下不可能有的选项:直接对着成品 .epub 的原始字节做校验,无需解压。
// mimetype 排在最前(紧跟 30 字节的头部之后),未压缩、无 extra 字段:
// 文件名紧贴内容。
let mimeOffset = data.range(of: Data("mimetype".utf8))?.lowerBound
#expect(mimeOffset == 30)
#expect(contains(data, "mimetypeapplication/epub+zip"))偏移量 30 正好是 ZIP 本地文件头的长度(签名 + 版本 + 标志 + CRC + 尺寸 + 文件名长度),一旦它发生偏移,测试会先于 epubcheck 报错。同样的手法也用来检查目录 — 导航必须链接到记录锚点 #e0…#e3:
// 导航链接指向记录锚点 #e0…#e3。
for i in 0..<4 { #expect(contains(data, "#e\(i)\"")) }
#expect(contains(data, "epub:type=\"toc\""))— 以及验证归档记录即便在数据库里形式上存在,也绝不会进入书中:
let data = try await render(entries, journal: journal)
#expect(contains(data, "ВидимаяАктивнаяЗапись"))
#expect(!contains(data, "СекретАрхивнойЗаписи"))(测试中的俄语字面量分别意为"可见的活跃记录"和"归档记录的秘密"。)
PDF 一侧的检查更简单,但原则相同 — 不 mock 渲染器,而是完整跑一遍:合法的 %PDF- 签名、全部 7 个预设的封面渲染,以及在包含活跃与归档记录的真实 SwiftData 容器上只返回活跃记录的解析器。
EPUB 的最终检查在 Swift Testing 之外 — 用 epubcheck 跑一遍,它是该格式的官方验证器(需要安装 JDK)。CI 里的字节级测试能瞬间捕捉容器结构的回归;epubcheck 则是文件真正进入 Apple Books 或任何其他阅读器之前的最后一道关卡。
Lanternly 的书籍导出是 Lanternly+ 的付费功能:访问权限由单一闸门 BookExportAccess.unlocked(store) 检查,它查看的是 StoreManager.isPlus。UI 展示的是软性付费墙,但真正的闸门不只在那里 — 同样的 guard 也放在生成器内部,所以绕过界面并不能绕过检查。
我会带走的三个决定#
这里有三个决定值得带进任何类似的项目。第一:如果应用是跨平台的,而框架绑定平台(UIGraphicsPDFRenderer 仅限 UIKit),不要害怕直接下沉到 CoreGraphics/Core Text — 在那一层跨平台是白送的。第二:不用 NSLayoutManager 给任意长度文本分页,靠 CTFramesetter + CTFrameGetVisibleStringRange 这对组合就能解决,但必须配上零进度防护 — 否则一帧不巧的几何条件就会变成永恒的循环。第三:不是所有容器格式都需要外部库。141 行 STORE-only ZIP 加上带参考向量测试的自研 CRC-32 撑起的 EPUB,不是为省而省,而是有意识地放弃一整类压缩 bug,换来可以按文件原始字节做测试的可预测性。



