8 ファイル、2223 行 — そして Project.swift に外部 SPM 依存はひとつもない。これが BookExport、日記の記録から外部パッケージなしで PDF と EPUB 3 を組み立てる Lanternly のモジュールの姿だ。あるのはシステムフレームワークだけ:CoreGraphics、Core Text、ImageIO、Foundation。この選択は意図的なものだ:Lanternly のエクスポートには鍵がかかっていない — 記録はいつでもアプリの外へ持ち出せる。Markdown、プレーンテキスト、そして今回からは本も — 日記がユーザー自身のデータを閉じ込める罠になってはならないからだ。あらゆる形式の中で、本はもっとも手触りのあるものだ:PDF や EPUB は何年分もの記録を、印刷したり、印刷所に送ったり、そのままリーダーで開いたりできる「モノ」に変える — しかもサーバーには 1 バイトも送らない。組み立てはすべて端末上で行われるからだ。
パイプラインは両フォーマット共通だ。BookConfig はライブな @Observable ビルダー(範囲は 1 年分/日記全体/特定のジャーナル、表紙、タイトル)、BookContentResolver はアクティブな記録だけを時系列で取り出すリゾルバ(アーカイブ済みの記録は決して本に入らない)、その先は BookPDFRenderer か BookEPUBRenderer のどちらかを経て、共有シートまで進む。
なぜ PDFKit ではないのか#
Apple プラットフォームで「PDF」と言えば真っ先に浮かぶ疑問は、なぜ PDFKit や UIGraphicsPDFRenderer を使わないのか、だろう。私の答えは実利的だ:Lanternly はクロスプラットフォームのアプリ(iOS と macOS)であり、UIGraphicsPDFRenderer は UIKit にしか存在しない。二つのフレームワークのために本の組版実装を二つ抱えるのは、2000 行規模のモジュールの予算を割くべき対象ではない。
私は一段下へ降りて、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 におけるもっとも面白いエンジニアリング課題は、1 ページを描くことではなく、ページサイズだけを頼りに、任意の長さのテキストを任意のページ数へ流し込むことだ。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 を作って描画し、そのフレームに「実際に何文字入ったか」を尋ねる — それが CTFrameGetVisibleStringRange だ。全部入り切らなければ、残りが次ページの入力になる。まったく何も入らなかった場合(consumed <= 0 — たとえば使用可能な高さが 1 行分にも満たないとき)は、無限ループに対する明示的なガードが働く:ページを閉じ、新しいページを開き、まっさらな状態で再試行する。このチェックがなければ、ジオメトリの不運な組み合わせひとつでレンダリングは完全に固まってしまう。
画面ではなく、本のタイポグラフィ#
PDF は A5 に近い判型(419.53×595.28 pt)で組まれる — 印刷書籍のプロポーションであって、A4 でもスマートフォンの画面でもない。左右の余白は 50 pt、本文は 11.3 pt で行送りは ×1.55、ハイフネーション付きの両端揃え(hyphenationFactor = 1)、そして字下げは記録の 2 段落目からのみ付く — 本の世界で、セクションの始まりと思考の続きを区別する組版上の手法だ:
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 でサイズ上限付きで 1 枚ずつデコードされる — 表紙は 1600 px、記録の単独写真は 1400 px、グリッドは 1000 px。各デコードは autoreleasepool で包まれ、2〜4 枚のグリッドは 2 カラムに配置される。添付付きの記録が数百件ある日記では、これは些事ではない — メモリの急騰なしにレンダリングが最後まで生き延びられるかどうかの問題だ。
フォントについても触れておきたい:見出しは Lora、キャプションは Inter — アプリの UI と同じファイルで、LanternlyTypeface という単一の窓口を通る。Dynamic Type はここでは意図的に使っていない — これはページのジオメトリが固定された文書の組版であって、ユーザー設定に合わせて伸縮する画面ではないからだ。
固定されたページジオメトリの話は、PDF とともにここで終わる。EPUB にはそれがそもそも存在しない — 組版をリーダーに委ねる、アーカイブレベルに独自のプロトコルを持つ、まったく別のフォーマットだ。
EPUB を手作りする:mimetype が先頭#
EPUB 3 もまた ZIP だが、厳格なプロトコルがあり、それが破られるのは往々にして最初の 1 バイトだ。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 の現代的ナビゲーションで、その隣には昔ながらの NCX を待つリーダーのための toc.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 はバンドル内で 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()
}ロジックは単純だ:マニフェストの各項目はアーカイブ内に実在するファイルを指していなければならず、さもなければバリデータは本そのものを丸ごと却下する。数年分の日記の途中にある 1 枚の壊れた写真のためにエクスポートを落とすより、1 ピクセルを描くほうがはるかに安上がりだ。
この倹約の精神は、すべてを詰め込むアーカイブそのものにも及んでいる。
141 行の ZIP#
このモジュールにサードパーティの ZIP ライブラリはない — あるのは ZipWriter、141 行、FileHandle によるファイルへの直接ストリーム書き込みだ。意識的な簡略化として、圧縮方式は STORE のみで deflate はない。コード内のコメントがそれを率直に語っている:
// EPUB は特別な順序を持つ ZIP:先頭に無圧縮の `mimetype`、続いてコンテナと
// コンテンツが並ぶ。ファイルへ直接ストリーム書き込みし(offset は自前で計算)、
// エントリは STORE メソッド(無圧縮)で書く。EPUB としては有効で、写真はもともと
// JPEG。単純なストリームはエラーの一群を丸ごと排除し、epubcheck を確実に通る。テキストは通常の閲覧時にフォーマット自体でそこそこ圧縮されるし、写真はもともと JPEG だ — 二重の deflate 圧縮はほとんど何も稼げない一方で、独自のバグの一群(ハフマンテーブル、辞書ウィンドウ、圧縮不能データのエッジケース処理)を持ち込む。
STORE はアーカイブ全体をメモリにバッファせずに書くストリーム方式だ:add(_:_:) はヘッダとデータをその場でファイルへ書き込み、後で作るセントラルディレクトリのためにオフセットを覚えておく。だから写真数百枚の大きな日記が丸ごと RAM に載ることはない。
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 側の検証はもっとシンプルだが原則は同じだ — レンダラーをモックせず、丸ごと走らせる:有効な %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 は、節約のための節約ではない — 圧縮バグの一群を意識的に手放し、その代わりにファイルの生バイトでテストできる予測可能性を手に入れる、という取引なのだ。



