143 / Input, output and data

Render a PDF page as an image

A print-layout editor lets the user trace a pre-printed sheet — an invoice with its letterhead and boxes — by putting the sheet in the background and placing the fields on top. The print shop delivers the template as a PDF, so one page has to become an image on the server, shrunk and stored like any other picture. pdf_open reads the cross-reference table and the pages; doc.render_report(page, dpi, opts) draws one of them.

143-pdf-a-imagen.nxSource →
// Nyx by Example: dibujar una página de un PDF como imagen con std/pdf_render.
//
// El caso real: un editor de formatos de impresión deja «calcar» una hoja preimpresa (una
// factura con su membrete y sus casillas) poniendo la hoja de FONDO y ubicando los campos
// encima. La imprenta entrega el modelo en PDF, así que hay que convertir una página del PDF a
// imagen en el servidor, sin programas externos, para achicarla y guardarla como cualquier foto.
//
//   let doc: PdfDoc = pdf_open(bytes, pdf_open_limits())?     // lee la xref y las páginas
//   let img: Image = doc.render(1, 150, pdf_render_strict())?  // página 1 a 150 ppp
//
// Qué dibuja: trazos y rellenos con antialias, recortes, colores (gris, RGB, CMYK, Indexed,
// Separation, DeviceN, Lab), imágenes (JPEG, crudas, con máscara), formularios (Form XObjects)
// y texto con fuentes TrueType o CFF incrustadas, o con las 14 estándar sin incrustar (las
// dibuja con caras de métrica compatible). Qué NO: sombreados y patrones, Type 1 y Type 3,
// JBIG2/JPX/CCITT, transparencia salvo el alfa constante, PDFs cifrados.
//
// Dos modos. Estricto (pdf_render_strict, el de siempre): si la página usa algo que no se
// dibuja, Err(unsupported) diciendo qué; nunca sale una imagen con un pedazo de menos sin
// avisar. Best effort (pdf_render_best_effort + render_report): se dibuja lo demás y lo omitido
// vuelve en una lista, para decidir si alcanza (un degradé decorativo que falta no estorba
// para calcar casillas).
//
// Tiempos de esta receta entera (armar la hoja, dibujarla a 150 ppp, achicarla y pasarla a
// JPEG), aarch64, una CPU: nyx build ~0,5 s, nyx build --release ~0,17 s. Páginas reales con
// fuentes incrustadas e imágenes, a 100 ppp sin --release: de 0,1 a 2,6 s (las que más tardan
// son las de muchos glifos).
//
// La hoja se fabrica acá con std/pdf para que la receta no dependa de un archivo: en la vida
// real son los bytes que subió la persona.

import "std/pdf"
import "std/pdf_render"
import "std/image"
import "std/error"

// Una «hoja preimpresa»: membrete, casillas y una tabla vacía.
fn hoja_preimpresa() -> Result<String, Error> {
    var d: Pdf = pdf_new(PdfPageSize.Letter, 0.0, 0.0)
    d.text(20.0, 25.0, "IMPRENTA LA ESTRELLA, C.A.", PdfFont.HelveticaBold, 14.0)
    d.text(20.0, 31.0, "RIF J-12345678-9 · Av. Bolívar, Valencia", PdfFont.Helvetica, 9.0)
    d.text(150.0, 25.0, "FACTURA", PdfFont.TimesBold, 16.0)
    d.text(150.0, 31.0, "N.º de control 00-", PdfFont.TimesRoman, 9.0)
    d.line_width(0.4)
    d.rect(20.0, 40.0, 175.9, 22.0, false)
    d.text(23.0, 46.0, "Cliente:", PdfFont.Helvetica, 9.0)
    d.text(23.0, 53.0, "RIF/C.I.:", PdfFont.Helvetica, 9.0)
    d.text(23.0, 60.0, "Domicilio:", PdfFont.Helvetica, 9.0)
    d.gray(0.85)
    d.rect(20.0, 70.0, 175.9, 7.0, true)
    d.gray(0.0)
    d.text(23.0, 75.0, "Cantidad      Descripción", PdfFont.HelveticaBold, 9.0)
    d.text(160.0, 75.0, "Importe", PdfFont.HelveticaBold, 9.0)
    var y: float = 84.0
    while y < 200.0 {
        d.line(20.0, y, 195.9, y)
        y = y + 8.0
    }
    return d.bytes()
}

fn main() -> int {
    let rb: Result<String, Error> = hoja_preimpresa()
    if rb.is_err() {
        println("no se pudo armar la hoja")
        return 1
    }
    let bytes: String = rb.unwrap()

    // Abrir: valida la estructura y cuenta las páginas sin dibujar nada.
    let rd: Result<PdfDoc, Error> = pdf_open(bytes, pdf_open_limits())
    match rd {
        Result.Err(e) => {
            println("PDF inválido: " + error_to_string(e))
            return 1
        }
        Result.Ok(doc) => {
            println("páginas: " + int_to_string(doc.page_count()))
            let rs: Result<Array, Error> = doc.page_size(1)
            let tam: Array = rs.unwrap()
            let w: float = tam[0]
            let h: float = tam[1]
            println("página 1: " + float_to_string(w) + " x " + float_to_string(h) + " puntos")

            // Dibujar a 150 ppp con lo no soportado como lista, no como error.
            let rr: Result<PdfRendered, Error> = doc.render_report(1, 150, pdf_render_best_effort())
            match rr {
                Result.Err(e) => {
                    println("no se pudo dibujar: " + error_to_string(e))
                    return 1
                }
                Result.Ok(res) => {
                    let img: Image = res.image
                    println("imagen: " + int_to_string(img.width) + " x " + int_to_string(img.height) + " px")
                    let om: Array = res.omitted
                    if om.length() > 0 { println("omitido: " + om.join("; ")) }
                    // Como cualquier foto: achicar para el editor y guardar en JPEG.
                    let rc: Result<Image, Error> = image_resize_fit(img, 1000, 1000)
                    let chica: Image = rc.unwrap()
                    let rj: Result<String, Error> = image_encode_jpeg(chica, 85)
                    let jpg: String = rj.unwrap()
                    println("fondo para el editor: " + int_to_string(chica.width) + " x " + int_to_string(chica.height) + ", " + int_to_string(jpg.length()) + " bytes de JPEG")
                }
            }
        }
    }

    // Un archivo que no es un PDF da Err con el motivo, no un cuelgue.
    let malo: Result<Image, Error> = pdf_page_to_image("esto no es un PDF", 1, 150)
    match malo {
        Result.Ok(img) => { println("¿?") }
        Result.Err(e) => { println("rechazado: " + error_to_string(e)) }
    }
    return 0
}
Outputstdout
páginas: 1
página 1: 612.0 x 792.0 puntos
imagen: 1275 x 1650 px
fondo para el editor: 773 x 1000, 52416 bytes de JPEG
rechazado: invalid: pdf_render: no es un PDF (falta %PDF- al principio) (code 22)

How it works

The sheet is built here with std/pdf so the recipe does not depend on a file; in real life it is the bytes the user uploaded. pdf_open validates the structure and counts the pages without drawing anything, and doc.page_size(1) gives the size in points (612 × 792 is US Letter). At 150 dpi that page becomes a 1275 × 1650 Image, which std/image then treats like any photo: image_resize_fit shrinks it to fit in 1000 × 1000 and image_encode_jpeg encodes it.

There are two modes. Strict (pdf_render_strict()) returns Err(unsupported) naming what the page uses and the renderer does not draw, so an image never comes out with a piece missing and no warning. Best effort (pdf_render_best_effort() with render_report) draws the rest and returns what it left out in res.omitted, so the caller decides whether it is good enough — a missing decorative gradient does not get in the way of tracing boxes. Bytes that are not a PDF give an Err with the reason, not a hang. The header of the .nx lists what is drawn (paths, clipping, the usual color spaces, JPEG and raw images, embedded TrueType/CFF fonts and the 14 standard ones) and what is not (shadings and patterns, Type 1 and Type 3 fonts, JBIG2/JPX/CCITT, encrypted files).