Juntando imágenes en un PDF optimizado con ghostscript [Node]

freishner

Capo
Se incorporó
16 Noviembre 2021
Mensajes
308
Hace unos días, me tocó ingresar a node, básicamente porque replicar los cálculos de JavaScript, fuera de JavaScript es una cuestión complicada, cuando se tiene que hilar fino fabricando textos criptográficos equivalentes entre lenguajes, pero esto es otro tema, ya que no aparece nada de los tokens acá ... así que, de antemano, pido disculpas porque mi node es bien rudimentario, al venir desde el ambito web en ES5.

No voy a entrar en detalles técnicos de transfondo. Node de por sí hace las cosas lo suficientemente sencillas.

Para sistemas basados en debian, podemos instalar ghostscript con el siguiente comando:
Código:
sudo apt-get install -y ghostscript

Tambien vamos a necesitar graphics magick
Código:
sudo apt install graphicsmagick

Versión de node que tengo instalada:
Código:
v18.16.0

Lecturas recomendadas

Introducción a ghostscript: link (inglés)
Cómo usar ghostscript: link (inglés)
Dispositivos de salida de alto nivel (ghostscript): link (inglés)
Cómo lanzar procesos hijos en nodejs: link (inglés)
Generar archivos PDF en nodejs con PDF Kit: link (inglés)
El repo de gm en node: link (inglés)
Unidades de medida en node pdfkit: link (inglés)
Medidas de papeles: link (inglés)
Compresión PNG vs JPG: link (inglés)

Juntando imágenes en un PDF con PDF Kit
JavaScript:
const fs = require('fs');
const path = require('path');
const gm = require('gm');
const PDFDocument = require('pdfkit');
const child_process = require('child_process');
const { promisify } = require('util');
const readdir = promisify(fs.readdir);

async function getImgSize(imgPath) {
    return await new Promise((resolve, reject) => {
        gm(imgPath).size((err, size) => {
            if (!err) { resolve(size); }
            else { reject(err); }
        });
    });
}

function getAFormatPaperSize(width, height) {
    let sizes = [
        { width: 2384, height: 3370, size: 'A0' },
        { width: 1684, height: 2384, size: 'A1' },
        { width: 1190, height: 1684, size: 'A2' },
        { width: 842, height: 1190, size: 'A3' },
        { width: 595, height: 842, size: 'A4' },
        { width: 420, height: 595, size: 'A5' },
        { width: 298, height: 420, size: 'A6' },
        { width: 210, height: 298, size: 'A7' },
        { width: 148, height: 210, size: 'A8' }
    ];

    for (size of sizes.reverse()) {
        if (width <= size.width || height <= size.height) {
            return size;
        }
    }
}

async function images2pdf(path, fileName) {
    console.log("Converting to PDF...");

    // Read all images
    let images = (await readdir(path + 'pages/')).map(name => `${path}pages/${name}`);

    // Create PDF doc
    let doc = new PDFDocument({ autoFirstPage: false });
    doc.pipe(fs.createWriteStream(`${fileName}`));

    // Add images
    for (let i = 0; i < images.length; i++) {
        let size = await getImgSize(images[i]);
        let resize = getAFormatPaperSize(size.width, size.height);

        doc.addPage({
            margins: {
                top:    0,
                bottom: 0,
                left:   0,
                right:  0
            },
            size: resize.size
        }).image(images[i], {
            fit: [resize.width, resize.height],
            align: 'center',
            valign: 'center'
        });
    }

    // Close file
    doc.end();
    console.log(`PDF file writed at: ${fileName}.pdf`);
}

El papel

Los recursos relacionados al papel son importantes de revisar, ya que ésto me dió su caxo, al querer que cada imágen quedara centrada y no apareciera cortada por el tamaño del documento, es por ello que el documento debe ser mas grande que la imágen en una de sus dos medidas (ancho o largo) (véase la función getAFormatPaperSize).

Para realizar la generación del documento solo hace falta llamar a la función images2pdf con la ruta de la carpeta en donde estan las imágenes y la ruta del archivo final en pdf. Para mi caso, la estructura del directorio es la siguiente:

Código:
- contenedor
-- archivos varios..
-- pages/
--- imágenes en PNG/JPG

Los formatos de imágenes son importantes, para dos factores, el peso del archivo resultante en PDF y porque pdfkit solo admite jpg y png.

Como sabrán, JPG no tiene transparencias, y el fondo predeterminado es el blanco, en cambio PNG si las tiene, por ende, éste último es mas pesado. Y aún así, luego de numerosas pruebas, JPG me resultó ser mas pesado, al menos en las imágenes con las que estaba tratando, que por ser cientos, que una pesara menos en JPG, no hacía ninguna diferencia.

Para mi caso, la función images2pdf la llamaría de la siguiente forma:
JavaScript:
images2pdf('ruta-alguna-parte/contenedor/', 'ruta-alguna-parte/contenedor/archivo.pdf');

Una vez generado el documento, podemos pasar a la siguiente parte que es opcional.

Optimizado del documento con ghostscript
JavaScript:
function optimizePDF(pdfPath) {
    try {
        if (fs.existsSync(pdfPath)) {
            console.log(`Compressing file: ${pdfPath}...`);
            let outFile = pdfPath.replace(path.basename(pdfPath), '') + path.basename(pdfPath).replace('.pdf', '') + '-compressed.pdf';
            let command = [
                `ghostscript`,
                '-sDEVICE=pdfwrite',
                // '-dPDFSETTINGS=/ebook', //150 dpi
                '-dCompatibilityLevel=1.4',
                // '-dPDFSETTINGS=/screen', //72dpi
                '-dDetectDuplicateImages=true',
                '-dDoThumbnails=false',
                '-dDownsampleColorImages=true',
                '-dColorImageResolution=72',
                '-dGrayImageResolution=72',
                '-dMonoImageResolution=72',
                '-dColorImageDownsampleThreshold=1.0',
                '-dGrayImageDownsampleThreshold=1.0',
                '-dMonoImageDownsampleThreshold=1.0',
                '-dNOPAUSE',
                '-dQUIET',
                '-dBATCH',
                `-sOutputFile="${outFile}"`,
                `${pdfPath}`
            ].join(' ');
            child_process.exec(command, (err, stdout, stderr) => {
                if (err) { console.error(err); }
                else {
                    console.log(stdout, stderr);
                    fs.rmSync(pdfPath);
                    fs.renameSync(outFile, pdfPath);
                }
            });
        }
    } catch (err) { console.log(err); }
}

El funcionamiento es sencillo, node crea un proceso hijo para llamar a la herramienta ghostscript, la que realiza ciertas optimizaciones al documento con la finalidad de reducir su tamaño. Pude lograr reducciones de mas de 50MB con ella.

No voy a adentrarme mucho en los comandos que se le suplen a ghostscript ya que los tienen en la lectura recomendada. Solo comentaré que lo mas significativo, es que no se repitan las mismas imágenes, y que la resolución quede en 72dpi, que es suficiente para leer en un dispositivo.

Finalmente, la función elimina el archivo original, y conserva el que tiene el postfijo -compressed renombrándolo como el archivo original.

En mi caso, la llamada a la función sería de la siguiente forma:

JavaScript:
optimizePDF('ruta-alguna-parte/contenedor/archivo.pdf');
 
Subir