8 files, 2,223 lines — and not a single external SPM dependency in Project.swift. That's the shape of BookExport, the Lanternly module that builds PDF and EPUB 3 from diary entries without a single outside package — only the system frameworks: CoreGraphics, Core Text, ImageIO, Foundation. The choice is deliberate: in Lanternly, export is always lock-free — an entry can be taken out of the app at any moment, as Markdown, plain text, and now a book — and a diary must never become a trap for the user's own data. Of all the formats, a book is the most tangible: a PDF or EPUB turns years of entries into an object you can print, send to a print shop, or simply open in an e-reader — without a single byte on a server, because the whole build happens on-device.
The pipeline is the same for both formats: BookConfig — a live @Observable builder (scope: one year / the whole diary / a specific journal, cover, title), BookContentResolver — a resolver that takes only active entries in chronological order (archived entries never make it into the book), and then either BookPDFRenderer or BookEPUBRenderer, all the way to the Share sheet.
Why not PDFKit#
The first question that comes up at the word "PDF" on Apple platforms is why not PDFKit or UIGraphicsPDFRenderer. My answer is pragmatic: Lanternly is a cross-platform app (iOS and macOS), and UIGraphicsPDFRenderer lives only in UIKit. Maintaining two book-layout implementations for two frameworks is not what the budget of a two-thousand-line module should be spent on.
I dropped one level down, to CoreGraphics directly:
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:) is a shared API on both iOS and macOS. From there, pages are opened and closed with the ctx.beginPDFPage(nil) / ctx.endPDFPage() pair, and the text is drawn with Core Text — cross-platform as well. One renderer, one layout, no #if os(iOS) inside the page logic.
Text that turns its own pages#
The most interesting engineering problem in a book PDF isn't drawing a single page — it's flowing arbitrarily long text across an arbitrary number of pages, knowing only their size. With NSLayoutManager this would come for free; on bare Core Text, pagination has to be assembled by hand with the CTFramesetter + CTFrameGetVisibleStringRange pair:
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)
}
}The idea is simple: create a CTFrame from the remainder of the attributed string into a rectangle of the available height, draw it, then ask the frame how many characters actually fit — CTFrameGetVisibleStringRange. If not everything fit, the remainder becomes the input for the next page; if nothing fit at all (consumed <= 0 — say, the available height isn't enough for even a single line), that's an explicit guard against an infinite loop: the page is closed, a new one is opened, and the attempt is retried on a clean slate. Without this check, an unlucky combination of geometry would hang the render for good.
Book typography, not screen typography#
The PDF is built at a format close to A5 (419.53×595.28 pt) — a printed-book proportion, not A4 and not a phone screen. Margins are 50 pt on the sides, body text is 11.3 pt with ×1.55 line spacing, justified with hyphenation (hyphenationFactor = 1), and the first-line indent appears only from an entry's second paragraph onward — a typographic device that, in books, distinguishes the start of a section from a continuing thought:
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)Running heads alternate like in a real printed book — recto/verso. On an even page the number sits on the left, next to the journal name and year; on an odd page the number is on the right, with the month to its left:
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()(The "Дневник" fallback is the app's Russian product string for "Diary" and ships as-is.)
The cover is a story of its own: 7 presets (gradients like "Sunset" and "Dawn," a "Night" with a starfield and a crescent moon, and "Custom Photo" with a scrim), and all of it is drawn by a single drawCover function. It has two callers: the live builder preview in the UI and the PDF title page. Not two similar implementations but a single source of layout — if the position of the rule under the title changes tomorrow, the preview and the printed book can't drift apart on their own.
Photos in the book are decoded one at a time via CGImageSourceCreateThumbnailAtIndex with size caps — 1600 px for the cover, 1400 px for an entry's single photo, 1000 px for the grid. Every decode is wrapped in an autoreleasepool, and a grid of 2–4 photos is laid out in two columns. With a diary of hundreds of entries with attachments, this isn't a detail — it's the question of whether the render survives to the end without a memory spike.
Fonts deserve a separate mention: headings are Lora, captions are Inter — the same files as in the app's interface, with LanternlyTypeface as the single point of truth. Dynamic Type is deliberately not used here — this is document layout with fixed page geometry, not a screen that adapts to the user's settings.
With PDF, this is where the fixed page geometry ends. EPUB has none at all — an entirely different format that hands layout over to the reader, with its own protocol at the archive level.
EPUB by hand: mimetype first#
EPUB 3 is also a ZIP, but with a strict protocol that most often gets broken in the very first byte. The mimetype file must be the archive's first entry, uncompressed and with no extra field:
zip.add("mimetype", Data("application/epub+zip".utf8)) // first, 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) }Then comes a minimal but complete container: META-INF/container.xml points to the OPF, content.opf carries the metadata (dc:identifier as a urn:uuid, dcterms:modified in the strict UTC format without fractional seconds), nav.xhtml with epub:type="toc" — the modern EPUB 3 navigation — and toc.ncx next to it, for readers that still expect NCX the old way.
Chapters are formed by month ("LLLL yyyy", capitalized): chap1.xhtml, chap2.xhtml, and so on, plus separate cover.xhtml and cover.jpg — the cover is the very same drawCover function as in the PDF, just rendered once into a JPEG.
The Lora and Inter fonts are embedded via @font-face — but not all 11 weights the app ships, only the 5 explicitly marked with the embedInEPUB flag (the SIL OFL license allows it; the OFL.txt files sit next to the TTFs in the bundle). If a particular font file isn't found on the device, its @font-face simply isn't written, and the CSS falls back to the system serif/sans from the font-family declaration. That keeps things epubcheck-safe: the manifest never gains a reference to a file that doesn't exist.
A similar guard protects images. If a photo decode fails for whatever reason, a placeholder still goes into the archive — a valid 1×1-pixel 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()
}The logic is simple: every manifest item must point to a file that actually exists in the archive, otherwise the validator rejects the whole book. Drawing a pixel is cheaper than sinking the export over one corrupted photo somewhere in a multi-year diary.
The same frugality extends to the archive that packs all of this up.
A 141-line ZIP#
There is no third-party ZIP library in the module — there is ZipWriter, 141 lines, streaming straight to a file through FileHandle. A deliberate simplification: the only compression method is STORE, no deflate. A comment in the code says it outright:
// EPUB is a ZIP with a special ordering: the uncompressed `mimetype` comes first,
// then the container and the content. We stream straight to the file (tracking the
// offset ourselves); entries use the STORE method (no compression): valid for EPUB,
// the photos are JPEG anyway, and a plain stream rules out a whole class of bugs
// and reliably passes epubcheck.Text already compresses decently at the format level during normal reading, and the photos are stored as JPEG anyway — a second deflate pass wins almost nothing while adding a class of bugs of its own (Huffman tables, dictionary windows, edge-case handling for incompressible data).
STORE means streaming writes without buffering the whole archive in memory: add(_:_:) writes the header and the data straight to the file and remembers the offset for the later central directory, so a large diary with hundreds of photos never sits in RAM in full.
The CRC-32 is home-grown too, with the standard table over the IEEE 802.3 polynomial, and with a unit test against the canonical reference vector:
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() {
// The classic vector: CRC32("123456789") == 0xCBF43926.
#expect(CRC32.checksum(Data("123456789".utf8)) == 0xCBF4_3926)
}And since the entire archive is written with STORE, the tests get an option that deflate would have taken away: verifying the finished .epub directly against the raw bytes of the file, without unzipping.
// mimetype comes FIRST (right after the 30-byte header), uncompressed, no extra field:
// the name sits flush against the content.
let mimeOffset = data.range(of: Data("mimetype".utf8))?.lowerBound
#expect(mimeOffset == 30)
#expect(contains(data, "mimetypeapplication/epub+zip"))Offset 30 is exactly the length of a ZIP local file header (signature + versions + flags + CRC + sizes + name length), and if it ever shifts, the test fails before epubcheck would. The same technique checks the table of contents — navigation must link to the entry anchors #e0…#e3:
// Navigation links to the entry anchors #e0…#e3.
for i in 0..<4 { #expect(contains(data, "#e\(i)\"")) }
#expect(contains(data, "epub:type=\"toc\""))— and that archived entries never make it into the book, even if they formally exist in the database:
let data = try await render(entries, journal: journal)
#expect(contains(data, "ВидимаяАктивнаяЗапись"))
#expect(!contains(data, "СекретАрхивнойЗаписи"))(The Russian test literals read "VisibleActiveEntry" and "ArchivedEntrySecret.")
The PDF side is checked more simply but on the same principle — don't mock the renderer, run it whole: a valid %PDF- signature, cover rendering for all 7 presets, and a resolver that, on a real SwiftData container with active and archived entries, returns only the active ones.
The final EPUB check lives outside Swift Testing — a run through epubcheck, the format's official validator (it requires a JDK installed). The byte-level tests in CI catch a container-structure regression instantly; epubcheck is the checkpoint before the file actually goes out to Apple Books or any other reader.
Book export in Lanternly is a Lanternly+ premium feature: access is checked by a single gate, BookExportAccess.unlocked(store), which looks at StoreManager.isPlus. The UI shows a soft paywall, but the genuine gate doesn't live only there — the same guard sits inside the generator itself, so bypassing the screen doesn't bypass the check.
Three Decisions I'm Taking With Me#
Three decisions here are worth carrying into any similar project. First: if the app is cross-platform and the framework is platform-bound (UIGraphicsPDFRenderer is UIKit-only), don't be afraid to drop down to CoreGraphics/Core Text directly — cross-platform support is free there. Second: paginating text of arbitrary length without NSLayoutManager is solved by the CTFramesetter + CTFrameGetVisibleStringRange pair, but always with a zero-progress guard — otherwise one unlucky frame of geometry turns into an eternal loop. Third: not every container format demands an external library. EPUB on a 141-line STORE-only ZIP plus your own CRC-32 with a reference-vector test isn't thrift for thrift's sake — it's a conscious trade of a whole class of compression bugs for predictability you can test against the raw bytes of the file.



