12. Recetas

← Comandos del CLI · Índice

Casos completos, listos para adaptar. Cada uno resuelve un problema real de producción.


12.1 Imprenta: preflight de una revista en offset

Problema: el archivo llega del cliente y alguien tiene que revisar tinta, fuentes, imágenes y sangrado antes de mandarlo a plancha. Un error descubierto después cuesta la tirada entera.

perfiles/offset.pdfl:

profile "offset-revista" {

  const TAC_LIMITE = 300%      // límite de tinta del estucado
  const SANGRADO = 3mm         // exigencia de la imposición
  const DPI_MINIMO = 300

  check "Cobertura de tinta" tags: ["prepress"] {
    // El TAC exacto lee los colores declarados en el archivo; la estimación
    // por renderizado subestima el negro rico y deja pasar el exceso
    doc.pages.each { |page|
      tac = prepress::calculate_exact_tac(page.number)
      assert tac <= TAC_LIMITE,
        "página #{page.number}: #{tac}% de tinta (límite #{TAC_LIMITE}%)"
    }
  }

  check "Colores" tags: ["prepress"] {
    assert prepress::detect_color_mode() != "RGB",
      "documento en RGB — convertir a CMYK"

    spots = prepress::detect_spot_colors()
    assert spots.length == 0,
      "tinta especial no contratada: #{spots.join(", ")}"

    assert !prepress::detect_rich_black(),
      "negro rico detectado — en los textos usa 0/0/0/100"
  }

  check "Fuentes" tags: ["fuentes"] {
    sueltas = prepress::detect_text_substitution()
    assert sueltas.length == 0,
      "fuentes sin incrustar (el texto va a cambiar en el RIP): #{sueltas.join(", ")}"

    assert prepress::validate_font_size(6),
      "hay texto por debajo de 6 pt — ilegible impreso"
  }

  check "Filetes" tags: ["prepress"] {
    assert !prepress::detect_hairlines(0.25),
      "los filetes por debajo de 0,25 pt desaparecen en la impresión"
    assert !prepress::detect_hairlines_exact(),
      "hay un filete con grosor 0 — define un grosor real"
  }

  check "Imágenes" tags: ["imagenes"] {
    doc.images.each { |img|
      assert img.dpi >= DPI_MINIMO,
        "imagen en la página #{img.page_number}: #{round(img.dpi)} DPI (mínimo #{DPI_MINIMO})"
      assert img.color_space != "DeviceRGB",
        "imagen RGB en la página #{img.page_number}"
    }
  }

  check "Geometría" tags: ["prepress"] {
    assert prepress::validate_trim_box(),
      "sin TrimBox — la imposición no sabe dónde cortar"
    assert prepress::validate_bleed_box(),
      "sin BleedBox — no hay sangrado definido"
    assert prepress::check_page_geometry(SANGRADO),
      "sangrado menor de 3 mm en alguna página"
  }
}

Uso en el mostrador:

# Informe en HTML para devolver al cliente
pdfl run perfiles/offset.pdfl cliente.pdf --output html --output-file informe.html

Uso en carpeta vigilada: el operador deja el archivo en la carpeta y el informe aparece al lado.

pdfl watch entrada/ --script perfiles/offset.pdfl \
  --output-dir informes/ --report html

12.2 Editorial jurídica: el contrato antes de publicarlo

Problema: los contratos y las pólizas deben llevar cláusulas obligatorias, no pueden tener texto de borrador ni exponer datos personales, y el texto tiene que poder buscarse.

perfiles/juridico.pdfl:

profile "contrato-estandar" {

  check "Cláusulas obligatorias" tags: ["juridico"] {
    // Glosario mantenido por el departamento jurídico
    faltan = data::validate_against_reference("terminos/clausulas.txt")
    assert faltan.length == 0,
      "cláusulas ausentes: #{faltan.join("; ")}"
  }

  check "Nada de borradores" tags: ["juridico"] {
    assert text::forbid_text("BORRADOR"), "documento marcado como borrador"
    assert text::forbid_text("lorem ipsum"), "hay texto de relleno"
    assert text::forbid_match("X{3,}"), "campos sin rellenar (XXX)"
  }

  check "Datos personales" tags: ["compliance"] {
    // El CPF y el CNPJ solo entran en la lista si el dígito verificador es
    // válido, así que un número de ejemplo no da una falsa alarma
    hallazgos = text::detect_personal_data()
    assert hallazgos.length == 0,
      "datos personales en el documento: #{hallazgos.join("; ")}"
  }

  check "Numeración y rúbrica" tags: ["juridico"] {
    doc.pages.each { |page|
      pie = region(0, 0, page.width, 60, "pie")
      contenido = text::extract_from_region(page.number, pie).trim()
      assert contenido != "",
        "la página #{page.number} no tiene numeración ni rúbrica en el pie"
    }
  }

  check "Texto buscable" tags: ["accesibilidad"] {
    assert !text::detect_rasterized_text(),
      "hay páginas escaneadas — el texto no se puede buscar ni leer con lector de pantalla"
    assert text::detect_language() == "es",
      "el documento no está en español"
  }
}

Uso:

pdfl run perfiles/juridico.pdfl contrato.pdf --output pdf --output-file dictamen.pdf

12.3 Laboratorio: prospecto con código de lote

Problema: el prospecto debe llevar los textos que exige el organismo regulador, y el código de barras tiene que corresponder al producto correcto: cambiar el código entre productos es el error más caro del sector.

perfiles/prospecto.pdfl:

profile "prospecto-regulado" {

  check "Textos obligatorios" tags: ["regulador"] {
    faltan = data::validate_against_reference("bases/textos_regulador.txt")
    assert faltan.length == 0,
      "textos obligatorios ausentes: #{faltan.join("; ")}"
  }

  check "Legibilidad" tags: ["regulador"] {
    // El regulador exige un cuerpo mínimo en el prospecto
    assert prepress::validate_font_size(6),
      "hay texto por debajo de 6 pt"
  }

  check "Código de barras" tags: ["codes", "critico"] {
    assert codes::detect_barcodes(), "prospecto sin código de barras"

    codigo = codes::decode_barcode(1)
    assert codes::validate_barcode_checksum(1),
      "dígito verificador inválido: #{codigo}"

    // Este check atrapa el error más caro: el código de un producto
    // con el texto de otro
    assert codes::compare_barcode_with_text(),
      "el número del código no aparece en el texto del prospecto"
  }

  check "Producto homologado" tags: ["datos", "critico"] {
    codigo = codes::decode_barcode(1)
    producto = data::query_gtin(codigo)
    assert producto,
      "el GTIN #{codigo} no consta en la base de productos"

    // El nombre registrado tiene que aparecer impreso
    nombre = producto.get(2)
    assert text::require_text(nombre),
      "el nombre '#{nombre}' no aparece en el prospecto"
    print("producto comprobado:", nombre)
  }

  check "Posición del código" tags: ["layout"] {
    area = region(400, 20, 180, 90, "área del código")
    assert codes::validate_barcode_position(area),
      "código fuera del área reservada — riesgo de corte en el acabado"
  }
}

Uso con las bases:

PDFL_DATA_DIR=./bases pdfl run perfiles/prospecto.pdfl prospecto_v3.pdf

12.4 Aprobación: comparar con la versión aprobada

Problema: el cliente aprobó la v1; llega la v2 diciendo «solo cambié una palabra». Fiarse sale caro.

# Qué cambió de verdad, en HTML para que lo vea el cliente
pdfl compare aprobados/catalogo_v1.pdf recibidos/catalogo_v2.pdf \
  --normalize --ignore-dates \
  --output html --output-file diferencias.html

echo "exit: $?"   # 0 idénticos · 1 solo metadatos · 2 el contenido cambió

Para comprobar también el aspecto (y no solo el texto), un script:

perfiles/fidelidad.pdfl:

profile "fidelidad-visual" {

  const APROBADO = "aprobados/catalogo_v1.pdf"

  check "Páginas visualmente idénticas" tags: ["aprobacion"] {
    doc.pages.each { |page|
      ssim = visual::measure_ssim(page.number, APROBADO)
      assert ssim > 0.99,
        "la página #{page.number} cambió visualmente (SSIM #{ssim}, #{visual::pixel_diff(page.number, APROBADO)}% de los píxeles)"
    }
  }

  check "Ninguna imagen sustituida" tags: ["aprobacion"] {
    doc.pages.each { |page|
      assert !visual::detect_image_replacement(page.number, APROBADO),
        "página #{page.number}: imagen cambiada respecto a la aprobada"
    }
  }
}

12.5 CI/CD: validando un lote entero

Problema: todo archivo que entra en el repositorio tiene que pasar el preflight, sin que nadie ejecute nada a mano.

.github/workflows/preflight.yml:

name: Preflight de los PDF

on: [push, pull_request]

jobs:
  validar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Instalar pdfl
        env:
          GH_TOKEN: ${{ secrets.PDFLANG_TOKEN }}   # acceso de lectura al repositorio privado pdflang
        run: |
          gh release download --repo k074r0k1kuch1/pdflang \
            --pattern 'pdfl-linux-x64.tar.gz'
          tar xzf pdfl-linux-x64.tar.gz
          echo "$PWD/pdfl" >> $GITHUB_PATH

      - name: Comprobar los propios scripts
        run: |
          for f in perfiles/*.pdfl; do
            pdfl lint "$f"
            pdfl fmt "$f" --check
          done

      - name: Preflight de todos los PDF
        env:
          PDFL_LICENSE: ${{ secrets.PDFL_LICENSE }}   # licencia seatless: la máquina del CI no es fija
        run: |
          # --once procesa lo que hay en la carpeta y sale con el peor código
          pdfl watch archivos/ --script perfiles/offset.pdfl \
            --output-dir informes/ --once

      - name: Publicar los informes
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: informes
          path: informes/

Los pasos de lint y fmt no necesitan licencia: solo leen scripts. El de preflight sí la necesita, porque pdfl watch procesa PDF. PDFL_LICENSE guarda una licencia seatless, la que no mira el equipo, porque el runner del CI se destruye en cada ejecución y la huella cambia cada vez.

pdflang es un repositorio privado, así que descargar el binario exige autenticación. PDFLANG_TOKEN es un token de acceso personal con permiso de lectura sobre él, guardado como secreto en el repositorio que ejecuta este workflow. El GITHUB_TOKEN estándar no basta: solo da acceso al repositorio donde se ejecuta.

En shell puro, con control archivo por archivo:

#!/usr/bin/env bash
# valida_lote.sh — valida una carpeta y arma un resumen
set -uo pipefail

rechazados=0
for archivo in entrada/*.pdf; do
  nombre=$(basename "$archivo" .pdf)
  if pdfl run perfiles/offset.pdfl "$archivo" \
       --output json --output-file "informes/$nombre.json"; then
    echo "OK          $nombre"
  else
    echo "RECHAZADO   $nombre"
    rechazados=$((rechazados + 1))
  fi
done

echo "---"
echo "$rechazados archivo(s) rechazado(s)"
exit $((rechazados > 0))

12.6 Preparar el archivo de la editorial para la imprenta

Problema: el archivo viene sin cajas de producción, con comentarios de revisión y con capas que pueden reactivarse por error.

perfiles/preparar.pdfl:

// Valida antes de tocar nada: si la precondición falla, sale en el informe
check "Precondiciones" {
  require doc.page_count > 0
  assert !struct::check_encryption(),
    "archivo cifrado — pide la versión abierta"
}

// Cajas de producción que la editorial no definió
fix::set_trim_box(8.5, 8.5, 586.5, 833.5)
fix::set_bleed_box(0, 0, 595, 842)

// Limpieza
fix::remove_annotations()      // comentarios de revisión
fix::remove_attachments()      // adjuntos que solo pesan
fix::flatten_layers()          // las capas no pueden reactivarse
fix::remove_unused_resources() // sobras del archivo
pdfl fix editorial.pdf perfiles/preparar.pdfl --output imprenta.pdf --dry-run  # comprobar
pdfl fix editorial.pdf perfiles/preparar.pdfl --output imprenta.pdf            # aplicar
pdfl run perfiles/offset.pdfl imprenta.pdf                                     # validar

12.7 Distribuir los perfiles al equipo

Problema: cinco máquinas tienen que usar exactamente los mismos perfiles y las mismas bases, con la garantía de que nadie ha cambiado nada.

# En la máquina que mantiene los perfiles
pdfl pack perfiles/ --name perfil-imprenta --version 1.2.0
# genera perfil-imprenta.pdflpkg (scripts + bases + manifiesto con SHA-256)

# En las máquinas de producción
pdfl add perfil-imprenta.pdflpkg
# instala en ./pdfl_profiles/perfil-imprenta@1.2.0/ comprobando cada hash

pdfl run pdfl_profiles/perfil-imprenta@1.2.0/offset.pdfl archivo.pdf

Si el paquete se modificó por el camino, el add rechaza la instalación.


12.8 Investigando un archivo problemático

Secuencia práctica cuando algo va mal y no se sabe qué:

# 1. Panorama en segundos
pdfl inspect sospechoso.pdf

# 2. Un script exploratorio, solo con print()
cat > investigar.pdfl <<'EOF'
check "Radiografía" {
  print("TAC exacto:", prepress::calculate_exact_tac(), "%")
  print("TAC estimado:", prepress::calculate_tac(), "%")
  print("spots:", prepress::detect_spot_colors().join(", "))
  print("¿negro rico?", prepress::detect_rich_black())
  print("¿overprint ok?", prepress::validate_overprint_settings())
  print("fuentes sueltas:", prepress::detect_text_substitution().join(", "))

  doc.images.each { |img|
    print("imagen pág", img.page_number, ":", img.width, "x", img.height,
          "@", round(img.dpi), "DPI", img.color_space)
  }
}
EOF

pdfl run investigar.pdfl sospechoso.pdf > /dev/null
# el print() sale por stderr, así que el informe va a /dev/null
# y tú ves solo la investigación

← Comandos del CLI · Índice