118 / Input, output and data

PDF invoice

std/pdf writes a real PDF in pure Nyx, letter size here, with positions in MILLIMETRES from the top-left corner — the module does the conversion to points and the vertical flip PDF expects. This recipe builds a full invoice: an issuer header with a PNG logo, a buyer block, a line-item table made of text and line calls (the spec is explicit that a table is a recipe, not a function of the module, because its layout decisions belong to the caller), right-aligned totals with 16% tax, and a QR from std/barcode drawn module by module with rect.

118-pdf-invoice.nxSource →
// Nyx by Example: una factura en PDF, tamaño carta.
//
// El caso real: un ERP emite la factura de una venta como PDF de verdad, para
// imprimirla en carta (Providencia 00071 en Venezuela) o adjuntarla a un correo
// con std/smtp. std/pdf es Nyx puro: el mismo programa corre en el servidor y
// compilado a wasm32-wasi.
//
// Lo que muestra:
//   - posiciones en MILÍMETROS desde la esquina de ARRIBA a la izquierda (el
//     módulo hace la cuenta a puntos y el volteo de PDF);
//   - montos alineados a la derecha con pdf_text_width (x = borde - ancho);
//   - una tabla hecha con text + line (spec std-pdf D-5: la tabla es una
//     receta, no una función del módulo, porque sus decisiones son del programa);
//   - un logo PNG con transparencia (include_bytes: viaja dentro del binario);
//   - el QR de la factura, de std/barcode, dibujado módulo a módulo con rect;
//   - los errores: bytes() devuelve Err si algo no se pudo dibujar tal cual.
//
// Correr: nyx run -- factura.pdf   (sin argumento solo informa el tamaño)

import "std/pdf"
import "std/barcode"
import "std/error"

// Monto en céntimos -> "1.234,56" (separadores venezolanos). Se trabaja en
// enteros: los montos nunca pasan por float.
fn monto(centimos: int) -> String {
    let ent: int = centimos / 100
    let cts: int = centimos % 100
    var s: String = int_to_string(ent)
    var out: String = ""
    while s.length() > 3 {
        out = "." + s.substring(s.length() - 3, s.length()) + out
        s = s.substring(0, s.length() - 3)
    }
    out = s + out
    var c: String = int_to_string(cts)
    if cts < 10 { c = "0" + c }
    return out + "," + c
}

// Texto alineado a la derecha: termina exactamente en `derecha` mm.
fn texto_der(d: Pdf, derecha: float, y: float, s: String, f: PdfFont, pt: float) {
    d.text(derecha - pdf_text_width(s, f, pt), y, s, f, pt)
}

// El QR, uno oscuro = un rectángulo. Los módulos oscuros seguidos de una fila
// van en UN rectángulo: menos operadores, y ninguna rendija entre ellos.
fn dibujar_qr(d: Pdf, m: Array, x: float, y: float, modulo: float) {
    var r: int = 0
    while r < m.length() {
        let fila: Array = m[r]
        var c: int = 0
        while c < fila.length() {
            let v: int = fila[c]
            if v == 1 {
                var fin: int = c
                while fin + 1 < fila.length() {
                    let sig: int = fila[fin + 1]
                    if sig != 1 { break }
                    fin = fin + 1
                }
                let ancho: float = int_to_float(fin - c + 1) * modulo
                d.rect(x + int_to_float(c) * modulo, y + int_to_float(r) * modulo, ancho, modulo, true)
                c = fin + 1
            } else {
                c = c + 1
            }
        }
        r = r + 1
    }
}

fn factura() -> Result<String, Error> {
    let izq: float = 15.0
    let der: float = 200.9          // 215.9 mm de carta menos 15 de margen
    var d: Pdf = pdf_new(PdfPageSize.Letter, 0.0, 0.0)

    // ── Encabezado del emisor ──────────────────────────────────────────────
    // Alto 0: se calcula con la proporción del PNG (cuadrado, 22 x 22 mm).
    // `let` y no la llamada suelta: el `?` de una llamada que devuelve algo se
    // liga siempre (LLM.md §5.2).
    let logo: int = d.image_png(izq, 12.0, 22.0, 0.0, include_bytes("examples/by-example/assets/logo-tornillo.png"))?
    d.text(41.0, 18.0, "Ferretería El Tornillo, C.A.", PdfFont.HelveticaBold, 14.0)
    d.text(41.0, 24.0, "RIF J-29643190-6", PdfFont.Helvetica, 9.0)
    d.text(41.0, 28.5, "Av. Bolívar, Local 12, Maracay, estado Aragua", PdfFont.Helvetica, 9.0)
    d.text(41.0, 33.0, "Teléfono (0243) 555-0123 · ventas@eltornillo.com.ve", PdfFont.Helvetica, 9.0)
    texto_der(d, der, 18.0, "FACTURA", PdfFont.HelveticaBold, 16.0)
    texto_der(d, der, 24.0, "N° de factura: 00000123", PdfFont.Helvetica, 9.0)
    texto_der(d, der, 28.5, "N° de control: 00-00000123", PdfFont.Helvetica, 9.0)
    texto_der(d, der, 33.0, "Fecha de emisión: 24/09/2026", PdfFont.Helvetica, 9.0)
    d.line(izq, 38.0, der, 38.0)

    // ── Comprador ──────────────────────────────────────────────────────────
    // Un nombre que llega de la base de datos puede traer un carácter que las
    // fuentes estándar no tienen (un emoji, otro alfabeto): text() lo haría
    // fallar en bytes(). pdf_text_supported lo pregunta antes.
    var cliente: String = "Construcciones Señor de la Montaña, C.A."
    if not pdf_text_supported(cliente) { cliente = "(razón social con caracteres no imprimibles)" }
    d.text(izq, 45.0, "Razón social:", PdfFont.HelveticaBold, 9.0)
    d.text(45.0, 45.0, cliente, PdfFont.Helvetica, 9.0)
    d.text(izq, 50.0, "RIF:", PdfFont.HelveticaBold, 9.0)
    d.text(45.0, 50.0, "J-40123456-7", PdfFont.Helvetica, 9.0)
    d.text(izq, 55.0, "Domicilio fiscal:", PdfFont.HelveticaBold, 9.0)
    d.text(45.0, 55.0, "Calle Páez, Edif. Aurora, piso 3, Valencia", PdfFont.Helvetica, 9.0)

    // ── Renglones ──────────────────────────────────────────────────────────
    // Columnas: código y descripción a la izquierda; cantidad, precio y total
    // alineados a la derecha en su borde.
    let codigos: Array = ["T-0012", "C-2210", "P-0450", "H-1001"]
    let descripciones: Array = ["Tornillo autorroscante 8 x 1\" (caja de 100)", "Cemento gris tipo I, saco de 42,5 kg",
                                "Pintura de caucho blanca, galón", "Martillo de uña, mango de fibra"]
    let cantidades: Array = [3, 10, 2, 1]
    let precios: Array = [1250, 8990, 15475, 12000]    // céntimos
    let col_cant: float = 128.0
    let col_precio: float = 160.0
    var y: float = 66.0
    d.rect(izq, y - 5.0, der - izq, 7.0, false)
    d.text(izq + 2.0, y, "Código", PdfFont.HelveticaBold, 9.0)
    d.text(38.0, y, "Descripción", PdfFont.HelveticaBold, 9.0)
    texto_der(d, col_cant, y, "Cant.", PdfFont.HelveticaBold, 9.0)
    texto_der(d, col_precio, y, "Precio unit.", PdfFont.HelveticaBold, 9.0)
    texto_der(d, der - 2.0, y, "Total Bs.", PdfFont.HelveticaBold, 9.0)
    var base: int = 0
    var i: int = 0
    while i < codigos.length() {
        y = y + 7.0
        let cod: String = codigos[i]
        let desc: String = descripciones[i]
        let cant: int = cantidades[i]
        let precio: int = precios[i]
        let total: int = cant * precio
        base = base + total
        d.text(izq + 2.0, y, cod, PdfFont.Helvetica, 9.0)
        d.text(38.0, y, desc, PdfFont.Helvetica, 9.0)
        texto_der(d, col_cant, y, int_to_string(cant), PdfFont.Helvetica, 9.0)
        texto_der(d, col_precio, y, monto(precio), PdfFont.Helvetica, 9.0)
        texto_der(d, der - 2.0, y, monto(total), PdfFont.Helvetica, 9.0)
        d.line(izq, y + 2.0, der, y + 2.0)
        i = i + 1
    }

    // ── Totales ────────────────────────────────────────────────────────────
    // IVA al 16 %, redondeado al céntimo (mitad hacia arriba) en enteros.
    let iva: int = mul_div_round(base, 16, 100, RoundMode.HalfUp)
    y = y + 9.0
    texto_der(d, col_precio, y, "Base imponible (16 %):", PdfFont.Helvetica, 9.0)
    texto_der(d, der - 2.0, y, monto(base), PdfFont.Helvetica, 9.0)
    y = y + 5.0
    texto_der(d, col_precio, y, "IVA 16 %:", PdfFont.Helvetica, 9.0)
    texto_der(d, der - 2.0, y, monto(iva), PdfFont.Helvetica, 9.0)
    y = y + 7.0
    d.line(130.0, y - 4.5, der, y - 4.5)
    texto_der(d, col_precio, y, "TOTAL A PAGAR Bs.:", PdfFont.HelveticaBold, 11.0)
    texto_der(d, der - 2.0, y, monto(base + iva), PdfFont.HelveticaBold, 11.0)

    // ── QR con los datos de la factura ─────────────────────────────────────
    // 0,8 mm por módulo; la zona de silencio (4 módulos) queda en blanco.
    let datos: String = "FAC 00000123|CTRL 00-00000123|J-29643190-6|24/09/2026|" + monto(base + iva)
    let m: Array = qr_matrix(datos, QrEcc.M)?
    dibujar_qr(d, m, izq + 3.2, y + 12.0, 0.8)
    d.text(izq + 35.0, y + 20.0, "Escanee para verificar los datos de esta factura.", PdfFont.Helvetica, 8.0)
    d.text(izq + 35.0, y + 25.0, "Documento emitido por medios digitales. Sin tachaduras ni enmiendas.", PdfFont.TimesRoman, 8.0)

    return d.bytes()
}

fn main() -> int {
    match factura() {
        Result.Ok(pdf) => {
            let args: Array = get_args()
            if args.length() > 1 {
                let ruta: String = args[1]
                write_file(ruta, pdf)
                print("factura escrita en " + ruta + " (" + int_to_string(pdf.length()) + " bytes)")
            } else {
                print("factura: " + int_to_string(pdf.length()) + " bytes, empieza con " + pdf.substring(0, 8))
                print("para escribirla: nyx run -- factura.pdf")
            }
            return 0
        }
        Result.Err(e) => {
            print("no se pudo armar la factura: " + error_to_string(e))
            return 1
        }
    }
}
Outputstdout
factura: 26456 bytes, empieza con %PDF-1.4
para escribirla: nyx run -- factura.pdf

How it works

Every amount is worked in CENTS, as an int — never a float — and monto formats it with Venezuelan separators (. for thousands, , for the decimal pair). mul_div_round(base, 16, 100, RoundMode.HalfUp) computes the tax with the rounding rule spelled out, instead of trusting whatever a float multiplication happens to land on for a specific amount.

texto_der right-aligns a piece of text against a fixed edge by measuring it first with pdf_text_width(s, f, pt) and placing it at edge - width — the same primitive lines up every column of the table and every total without a single hardcoded x per row. pdf_text_supported(cliente) is asked BEFORE drawing a name that could carry a character outside the standard fonts' coverage (an emoji, another alphabet): text() would fail turning the document into bytes(), so the recipe substitutes a safe placeholder instead of letting the whole invoice fail on one field.

The logo comes from include_bytes(...), so the PNG travels inside the compiled binary — no file to ship alongside it, in native or in wasm32-wasi. The QR at the bottom is std/barcode's qr_matrix, walked row by row: consecutive dark modules in a row become ONE rect instead of one per module, so there is no seam between them and fewer drawing operations. d.bytes() is the single point that can fail — anything that could not be drawn exactly as asked comes back as an Err, not a silently wrong PDF.