8 файлов, 2223 строки — и ни одной внешней SPM-зависимости в Project.swift. Так устроен BookExport, модуль Lanternly, который собирает из дневниковых записей PDF и EPUB 3 без единого пакета извне — только системные CoreGraphics, Core Text, ImageIO, Foundation. Решение осознанное: экспорт в Lanternly всегда без замков, запись можно забрать из приложения в любой момент — Markdown, обычный текст, теперь и книга, — а дневник не должен становиться ловушкой для собственных данных пользователя. Из всех форматов книга — самый осязаемый: PDF или EPUB превращает годы записей в вещь, которую можно напечатать, отправить в типографию или просто открыть в читалке — без единого байта на сервере, потому что вся сборка идёт на устройстве.
Пайплайн один для обоих форматов: BookConfig — живой @Observable-конструктор (объём — год / весь дневник / конкретный журнал, обложка, заголовок), BookContentResolver — резолвер, который берёт только активные записи в хронологическом порядке (архив в книгу не попадает никогда), и дальше — либо BookPDFRenderer, либо BookEPUBRenderer, до листа «Поделиться».
Почему не PDFKit#
Первый вопрос, который встаёт при слове «PDF» на Apple-платформах — почему не 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:) — общий API что для iOS, что для macOS. Дальше страницы открываются и закрываются парой 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 из остатка атрибутированной строки в прямоугольник доступной высоты, рисуем, а затем спрашиваем у фрейма, сколько символов реально «влезло» — 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-формате без долей секунды), nav.xhtml с epub:type="toc" — современная навигация EPUB 3, и рядом toc.ncx — для читалок, которые всё ещё ждут NCX по-старому.
Главы формируются по месяцам («LLLL yyyy», с заглавной буквы): chap1.xhtml, chap2.xhtml и так далее, плюс отдельные cover.xhtml и cover.jpg — обложка та же самая функция drawCover, что и в PDF, просто отрендеренная один раз в JPEG.
Шрифты Lora и Inter встраиваются через @font-face — но не все 11 начертаний, которые есть в приложении, а только 5, явно помеченных флагом embedInEPUB (лицензия SIL OFL это разрешает, файлы OFL.txt лежат рядом с TTF в бандле). Если конкретного файла шрифта на устройстве не нашлось — @font-face для него просто не пишется, и CSS откатывается на системные serif/sans из декларации font-family. Так и остаётся epubcheck-safe: в манифесте не появляется ссылка на файл, которого нет.
Похожая защита — на изображениях. Если декод фото по какой-то причине не удался, в архив всё равно кладётся заглушка — валидный JPEG 1×1 пиксель:
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()
}Логика простая: каждый элемент манифеста должен указывать на реально существующий файл в архиве, иначе валидатор откажет книге целиком. Дешевле нарисовать пиксель, чем уронить экспорт из-за одного повреждённого снимка посреди дневника за несколько лет.
Та же экономность распространяется и на сам архив, который всё это упаковывает.
ZIP на 141 строку#
Никакой сторонней ZIP-библиотеки в модуле нет — есть ZipWriter, 141 строка, потоковая запись прямо в файл через FileHandle. Осознанное упрощение: метод сжатия — только STORE, без deflate. Комментарий в коде говорит об этом прямо:
// EPUB — это ZIP с особым порядком: первым идёт несжатый `mimetype`, затем
// контейнер и контент. Пишем потоково в файл (offset считаем сами), записи —
// методом STORE (без сжатия): для EPUB это валидно, фото и так в JPEG, а простой
// поток исключает целый класс ошибок и гарантированно проходит epubcheck.Текст уже сжимается неплохо самим форматом при обычном чтении, а фото и так лежат в JPEG — вторичное deflate-сжатие почти ничего не выигрывает, зато добавляет отдельный класс багов (таблицы Хаффмана, окна словаря, обработка edge-case для несжимаемых данных).
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-сторона проверяется проще, но по тому же принципу — не мокать рендер, а гонять его целиком: валидная сигнатура %PDF-, рендер обложки для всех 7 пресетов, и резолвер, который на реальном SwiftData-контейнере с активными и архивными записями отдаёт только активные.
Финальная проверка EPUB, которая живёт за пределами Swift Testing — прогон через epubcheck, официальный валидатор формата (требует установленный JDK). Байтовые тесты в CI ловят регресс в структуре контейнера мгновенно; epubcheck — контрольная точка перед тем, как файл реально уйдёт в Apple Books или любую другую читалку.
Экспорт в книгу в Lanternly — премиум-фича Lanternly+: доступ проверяется одним гейтом BookExportAccess.unlocked(store), который смотрит на StoreManager.isPlus. UI показывает мягкий пейволл, но genuine-гейт живёт не только там — тот же guard стоит и внутри самого генератора, так что обход экрана не даёт обойти проверку.
Три решения, которые я унесу с собой#
Три решения здесь стоило бы унести в любой похожий проект. Первое: если приложение кросс-платформенное, а фреймворк платформозависимый (UIGraphicsPDFRenderer — только UIKit), не бояться спуститься на уровень CoreGraphics/Core Text напрямую — там кросс-платформенность бесплатная. Второе: пагинация текста произвольной длины без NSLayoutManager решается парой CTFramesetter + CTFrameGetVisibleStringRange, но обязательно с защитой от нулевого прогресса — иначе один неудачный кадр геометрии превращается в вечный цикл. Третье: не всякий формат-контейнер требует внешней библиотеки. EPUB на 141 строке STORE-only ZIP плюс собственная CRC-32 с тестом на эталонный вектор — не экономия ради экономии, а сознательный отказ от целого класса багов сжатия в обмен на предсказуемость, которую можно тестировать по сырым байтам файла.



