12. Recetas
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
lintyfmtno necesitan licencia: solo leen scripts. El de preflight sí la necesita, porquepdfl watchprocesa PDF.PDFL_LICENSEguarda una licenciaseatless, la que no mira el equipo, porque el runner del CI se destruye en cada ejecución y la huella cambia cada vez.
pdflanges un repositorio privado, así que descargar el binario exige autenticación.PDFLANG_TOKENes un token de acceso personal con permiso de lectura sobre él, guardado como secreto en el repositorio que ejecuta este workflow. ElGITHUB_TOKENestá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