8 archivos, 2223 líneas — y ni una sola dependencia SPM externa en Project.swift. Así está construido BookExport, el módulo de Lanternly que genera PDF y EPUB 3 a partir de las entradas del diario sin un solo paquete de fuera — solo frameworks del sistema: CoreGraphics, Core Text, ImageIO, Foundation. La decisión es consciente: en Lanternly la exportación nunca tiene candados — una entrada se puede sacar de la app en cualquier momento, como Markdown, texto plano y ahora también un libro — y un diario no debe convertirse en una trampa para los propios datos del usuario. De todos los formatos, el libro es el más tangible: un PDF o un EPUB convierte años de entradas en un objeto que se puede imprimir, enviar a una imprenta o simplemente abrir en un lector electrónico — sin un solo byte en un servidor, porque todo el ensamblado ocurre en el dispositivo.
El pipeline es el mismo para ambos formatos: BookConfig — un constructor vivo @Observable (alcance: un año / todo el diario / un journal concreto, portada, título), BookContentResolver — un resolver que toma solo las entradas activas en orden cronológico (las archivadas jamás entran en el libro), y después — o bien BookPDFRenderer, o bien BookEPUBRenderer, hasta llegar a la hoja de Compartir.
Por qué no PDFKit#
La primera pregunta que surge al decir «PDF» en las plataformas de Apple es por qué no usar PDFKit o UIGraphicsPDFRenderer. Mi respuesta es pragmática: Lanternly es una app multiplataforma (iOS y macOS), y UIGraphicsPDFRenderer vive solo en UIKit. Mantener dos implementaciones de la maquetación del libro para dos frameworks no es en lo que conviene gastar el presupuesto de un módulo de dos mil líneas.
Bajé un nivel, a CoreGraphics directamente:
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:) es una API común tanto para iOS como para macOS. A partir de ahí, las páginas se abren y se cierran con el par ctx.beginPDFPage(nil) / ctx.endPDFPage(), y el texto se dibuja con Core Text — también multiplataforma. Un solo renderer, una sola maquetación, sin #if os(iOS) dentro de la lógica de páginas.
Texto que pasa sus propias páginas#
El problema de ingeniería más interesante de un PDF de libro no es dibujar una página, sino repartir un texto arbitrariamente largo en un número arbitrario de páginas conociendo solo su tamaño. Con NSLayoutManager esto saldría gratis; sobre Core Text a pelo, la paginación hay que montarla a mano con el par 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)
}
}La idea es simple: se crea un CTFrame a partir del resto de la cadena atribuida dentro de un rectángulo con la altura disponible, se dibuja, y luego se le pregunta al frame cuántos caracteres «cupieron» realmente — CTFrameGetVisibleStringRange. Si no cupo todo, el resto se convierte en la entrada de la página siguiente; si no cupo absolutamente nada (consumed <= 0 — por ejemplo, la altura disponible no alcanza ni para una línea), eso es una protección explícita contra el bucle infinito: la página se cierra, se abre una nueva y el intento se repite en limpio. Sin esa comprobación, con una combinación desafortunada de geometría el renderizado se quedaría colgado para siempre.
Tipografía de libro, no de pantalla#
El PDF se compone en un formato cercano al A5 (419.53×595.28 pt) — una proporción de libro impreso, no A4 ni la pantalla de un teléfono. Márgenes de 50 pt a los lados, cuerpo de texto de 11.3 pt con interlineado ×1.55, justificado con guiones (hyphenationFactor = 1), y la sangría de primera línea aparece solo a partir del segundo párrafo de la entrada — un recurso tipográfico que en los libros distingue el inicio de una sección de la continuación de un pensamiento:
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)Los encabezados de página se alternan como en un libro impreso de verdad — recto/verso. En la página par el número va a la izquierda, junto al nombre del journal y el año; en la impar, el número a la derecha y, a su izquierda, el mes:
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()(El literal "Дневник" es la cadena rusa del producto para «Diario» y se conserva tal cual.)
La portada es otra historia: 7 presets (degradados como «Atardecer» y «Amanecer», una «Noche» con campo de estrellas y luna creciente, y «Foto propia» con scrim), y todo eso lo dibuja una única función drawCover. Tiene dos llamadores: la vista previa en vivo del constructor en la UI y la página de título del PDF. No dos implementaciones parecidas, sino una única fuente de maquetación — si mañana cambia la posición de la línea bajo el título, la vista previa y el libro impreso no podrán divergir por su cuenta.
Las fotos del libro se decodifican de una en una mediante CGImageSourceCreateThumbnailAtIndex con topes de tamaño — 1600 px para la portada, 1400 px para la foto única de una entrada, 1000 px para la cuadrícula. Cada decodificación va envuelta en un autoreleasepool, y la cuadrícula de 2–4 fotos se distribuye en dos columnas. Con un diario de cientos de entradas con adjuntos, esto no es un detalle: es la cuestión de si el renderizado llega al final sin un pico de memoria.
Mención aparte para las fuentes: los títulos van en Lora, los rótulos en Inter — los mismos archivos que en la interfaz de la app, con LanternlyTypeface como punto único. Dynamic Type aquí no se usa a propósito: esto es maquetación de documento con geometría de página fija, no una pantalla que se adapta a los ajustes del usuario.
Con el PDF, aquí termina la geometría fija de página. El EPUB no la tiene en absoluto — la maquetación se la cede al lector un formato completamente distinto, con su propio protocolo a nivel de archivo comprimido.
EPUB a mano: mimetype primero#
EPUB 3 también es un ZIP, pero con un protocolo estricto que suele romperse justo en el primer byte. El archivo mimetype debe ser la primera entrada del archivo comprimido, sin compresión y sin campo extra:
zip.add("mimetype", Data("application/epub+zip".utf8)) // primero, 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) }Después viene un contenedor mínimo pero completo: META-INF/container.xml apunta al OPF, content.opf lleva los metadatos (dc:identifier como urn:uuid, dcterms:modified en el formato UTC estricto sin fracciones de segundo), nav.xhtml con epub:type="toc" — la navegación moderna de EPUB 3 — y al lado toc.ncx, para los lectores que aún esperan NCX a la antigua.
Los capítulos se forman por meses («LLLL yyyy», con mayúscula inicial): chap1.xhtml, chap2.xhtml, etcétera, más cover.xhtml y cover.jpg aparte — la portada es la misma función drawCover que en el PDF, solo que renderizada una vez a JPEG.
Las fuentes Lora e Inter se incrustan mediante @font-face — pero no los 11 estilos que trae la app, sino solo los 5 marcados explícitamente con el flag embedInEPUB (la licencia SIL OFL lo permite; los archivos OFL.txt están junto a los TTF en el bundle). Si un archivo de fuente concreto no se encuentra en el dispositivo, su @font-face simplemente no se escribe, y el CSS recurre a los serif/sans del sistema desde la declaración font-family. Así se mantiene epubcheck-safe: en el manifiesto nunca aparece una referencia a un archivo que no existe.
Una protección similar cubre las imágenes. Si por alguna razón falla la decodificación de una foto, al archivo se añade de todos modos un sustituto — un JPEG válido de 1×1 píxel:
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()
}La lógica es simple: cada elemento del manifiesto debe apuntar a un archivo que exista de verdad dentro del contenedor; de lo contrario, el validador rechaza el libro entero. Es más barato dibujar un píxel que hundir la exportación por una sola foto dañada en medio de un diario de varios años.
La misma frugalidad se extiende también al propio archivo comprimido que lo empaqueta todo.
Un ZIP de 141 líneas#
En el módulo no hay ninguna biblioteca ZIP de terceros — hay un ZipWriter, 141 líneas, escritura en streaming directa al archivo mediante FileHandle. Una simplificación consciente: el único método de compresión es STORE, sin deflate. Un comentario en el código lo dice sin rodeos:
// Un EPUB es un ZIP con un orden especial: primero va el `mimetype` sin comprimir,
// luego el contenedor y el contenido. Escribimos en streaming directo al archivo
// (calculamos el offset nosotros mismos); las entradas usan el método STORE (sin
// compresión): para EPUB es válido, las fotos ya están en JPEG, y un flujo simple
// elimina toda una clase de errores y pasa epubcheck de forma garantizada.El texto ya se comprime bastante bien por el propio formato en la lectura habitual, y las fotos de todos modos están en JPEG — una segunda compresión deflate apenas gana nada y en cambio añade su propia clase de bugs (tablas de Huffman, ventanas de diccionario, casos límite con datos incomprimibles).
STORE es escritura en streaming sin almacenar todo el archivo en memoria: add(_:_:) escribe la cabecera y los datos directamente al archivo y recuerda el offset para el directorio central posterior, de modo que un diario grande con cientos de fotografías nunca reside completo en la RAM.
El CRC-32 también es propio, con la tabla estándar sobre el polinomio IEEE 802.3, y con un test unitario contra el vector de referencia canónico:
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() {
// El vector clásico: CRC32("123456789") == 0xCBF43926.
#expect(CRC32.checksum(Data("123456789".utf8)) == 0xCBF4_3926)
}Y como todo el archivo se escribe con STORE, los tests obtienen una opción que con deflate no existiría: comprobar el .epub terminado directamente sobre los bytes crudos del archivo, sin descomprimir.
// mimetype va PRIMERO (justo tras la cabecera de 30 bytes), sin comprimir, sin campo
// extra: el nombre queda pegado al contenido.
let mimeOffset = data.range(of: Data("mimetype".utf8))?.lowerBound
#expect(mimeOffset == 30)
#expect(contains(data, "mimetypeapplication/epub+zip"))El offset 30 es exactamente la longitud de la cabecera local de una entrada ZIP (firma + versiones + flags + CRC + tamaños + longitud del nombre), y si algún día se desplaza, el test fallará antes que epubcheck. Con la misma técnica se comprueba el índice — la navegación debe enlazar a las anclas de las entradas #e0…#e3:
// La navegación enlaza a las anclas de las entradas #e0…#e3.
for i in 0..<4 { #expect(contains(data, "#e\(i)\"")) }
#expect(contains(data, "epub:type=\"toc\""))— y también que las entradas archivadas no entran en el libro, aunque existan formalmente en la base de datos:
let data = try await render(entries, journal: journal)
#expect(contains(data, "ВидимаяАктивнаяЗапись"))
#expect(!contains(data, "СекретАрхивнойЗаписи"))(Los literales rusos del test significan «EntradaActivaVisible» y «SecretoDeEntradaArchivada».)
El lado PDF se comprueba de forma más sencilla, pero con el mismo principio — no mockear el renderer, sino ejecutarlo entero: firma %PDF- válida, renderizado de la portada para los 7 presets, y un resolver que, sobre un contenedor SwiftData real con entradas activas y archivadas, devuelve solo las activas.
La comprobación final del EPUB, que vive fuera de Swift Testing, es una pasada por epubcheck, el validador oficial del formato (requiere un JDK instalado). Los tests a nivel de bytes en CI atrapan al instante una regresión en la estructura del contenedor; epubcheck es el punto de control antes de que el archivo salga de verdad hacia Apple Books o cualquier otro lector.
La exportación a libro en Lanternly es una función premium de Lanternly+: el acceso se comprueba con un único gate, BookExportAccess.unlocked(store), que consulta StoreManager.isPlus. La UI muestra un paywall suave, pero el gate genuino no vive solo ahí — el mismo guard está dentro del propio generador, así que saltarse la pantalla no permite saltarse la comprobación.
Tres decisiones que me llevo conmigo#
Tres decisiones de aquí merecerían viajar a cualquier proyecto parecido. Primera: si la app es multiplataforma y el framework depende de la plataforma (UIGraphicsPDFRenderer es solo de UIKit), no hay que tener miedo de bajar al nivel de CoreGraphics/Core Text directamente — ahí la multiplataforma sale gratis. Segunda: la paginación de texto de longitud arbitraria sin NSLayoutManager se resuelve con el par CTFramesetter + CTFrameGetVisibleStringRange, pero siempre con protección contra el progreso cero — de lo contrario, un solo cuadro desafortunado de geometría se convierte en un bucle eterno. Tercera: no todo formato contenedor exige una biblioteca externa. Un EPUB sobre un ZIP STORE-only de 141 líneas más un CRC-32 propio con test contra el vector de referencia no es ahorro por ahorrar, sino la renuncia consciente a toda una clase de bugs de compresión a cambio de una predictibilidad que se puede testear sobre los bytes crudos del archivo.



