Ctrl K
ComunidadGuías…

img-to-pdf.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * img-to-pdf.mjs — Screenshot/image -> single-page PDF via Playwright
 *
 * Some ATS upload fields demand a PDF specifically and reject images
 * (screenshots of an offer, a portal error, a badge, etc). This wraps
 * the image in a minimal HTML page and renders it with the same
 * Playwright/Chromium engine generate-pdf.mjs already uses for CVs —
 * zero new npm dependencies.
 *
 * Deliberately standalone: it does NOT go through generate-pdf.mjs, so
 * it is never subject to that script's cv.md section-order validation
 * (which only makes sense for the CV HTML template).
 *
 * MVP scope: one image -> one PDF page. Multi-image/multi-page is an
 * explicit non-goal for v1 (see issue #1730).
 *
 * Usage:
 *   node img-to-pdf.mjs <image-path> <output-path> [--force]
 *   node img-to-pdf.mjs --self-test
 *
 * --force is required to overwrite an existing output file; without it
 * the script errors out rather than silently clobbering a PDF.
 */

import { resolve, dirname, extname } from 'path';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { isMainModule } from './lib/is-main-module.mjs';
import { browserLaunchOptions } from './lib/browser-launch.mjs';

const MIME_TYPES = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.bmp': 'image/bmp',
  '.svg': 'image/svg+xml',
};

/**
 * @param {string} imagePath
 * @returns {string|null} MIME type, or null if the extension is unsupported.
 */
export function detectMimeType(imagePath) {
  const ext = extname(imagePath).toLowerCase();
  return MIME_TYPES[ext] || null;
}

/**
 * Parse CLI args into { inputPath, outputPath, force, help, error }.
 * Pure and side-effect free so it's testable without touching the filesystem.
 *
 * @param {string[]} args
 */
export function parseArgs(args) {
  let inputPath = '';
  let outputPath = '';
  let force = false;
  let help = false;

  for (const arg of args) {
    if (arg === '--force') {
      force = true;
    } else if (arg === '--help' || arg === '-h') {
      help = true;
    } else if (!inputPath) {
      inputPath = arg;
    } else if (!outputPath) {
      outputPath = arg;
    }
  }

  if (help) return { inputPath, outputPath, force, help, error: null };
  if (!inputPath || !outputPath) {
    return { inputPath, outputPath, force, help, error: 'Missing <image-path> and/or <output-path>.' };
  }
  return { inputPath, outputPath, force, help, error: null };
}

function usage() {
  console.log('Usage: node img-to-pdf.mjs <image-path> <output-path> [--force]');
  console.log('');
  console.log('Converts a single screenshot/image into a single-page PDF (Playwright/Chromium),');
  console.log('for ATS upload fields that require a PDF specifically. Zero new dependencies —');
  console.log('reuses the playwright dependency already used by generate-pdf.mjs.');
  console.log('');
  console.log('  --force   overwrite <output-path> if it already exists');
  console.log('');
  console.log('MVP scope: one image in, one PDF page out. Multi-image/multi-page is not supported.');
}

/**
 * Render a single image file to a single-page PDF matching the image's own
 * pixel dimensions (at 96 CSS px/inch), so the output is neither cropped
 * nor padded with blank margins.
 *
 * @param {string} inputPath - Absolute path to the source image.
 * @param {string} outputPath - Absolute path to write the PDF to.
 * @param {{force?: boolean}} options - Whether an existing PDF may be overwritten.
 * @returns {Promise<{outputPath: string, size: number, width: number, height: number}>}
 */
export async function convertImageToPdf(inputPath, outputPath, { force = false } = {}) {
  const mimeType = detectMimeType(inputPath);
  if (!mimeType) {
    throw new Error(`Unsupported image type: ${extname(inputPath) || '(no extension)'}. Supported: ${Object.keys(MIME_TYPES).join(', ')}`);
  }

  const buffer = await readFile(inputPath);
  const base64 = buffer.toString('base64');
  const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
  * { margin: 0; padding: 0; }
  html, body { margin: 0; padding: 0; }
  img { display: block; }
</style>
</head>
<body>
<img id="career-ops-img" src="data:${mimeType};base64,${base64}">
</body>
</html>`;

  await mkdir(dirname(outputPath), { recursive: true });

  // After argv checks. A static import made --help and a missing path wait
  // on playwright, and crash when the package was not installed.
  const { chromium } = await import('playwright');
  const browser = await chromium.launch(browserLaunchOptions({ headless: true }));
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'load' });

    try {
      await page.waitForFunction(() => {
        const img = document.getElementById('career-ops-img');
        return !!img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
      }, undefined, { timeout: 10000 });
    } catch (err) {
      throw new Error(`Image failed to decode within 10s (unreadable or corrupt file?): ${inputPath}`);
    }

    const { width, height } = await page.evaluate(() => {
      const img = document.getElementById('career-ops-img');
      return { width: img.naturalWidth, height: img.naturalHeight };
    });

    // 96 CSS px per inch is the standard browser/Playwright conversion —
    // sizing the PDF page to the image's own dimensions means the image
    // fills the page exactly: no cropping, no blank margins.
    const widthIn = width / 96;
    const heightIn = height / 96;

    const pdfBuffer = await page.pdf({
      width: `${widthIn}in`,
      height: `${heightIn}in`,
      margin: { top: '0', right: '0', bottom: '0', left: '0' },
      printBackground: true,
    });

    await writeFile(outputPath, pdfBuffer, { flag: force ? 'w' : 'wx' });

    return { outputPath, size: pdfBuffer.length, width, height };
  } finally {
    await browser.close();
  }
}

function selfTest() {
  const assert = (cond, msg) => {
    if (!cond) { console.error(`SELF-TEST FAIL: ${msg}`); process.exit(1); }
  };

  // detectMimeType
  assert(detectMimeType('screenshot.png') === 'image/png', 'png detected');
  assert(detectMimeType('a/b/c.JPG') === 'image/jpeg', 'uppercase JPG detected');
  assert(detectMimeType('x.jpeg') === 'image/jpeg', 'jpeg detected');
  assert(detectMimeType('x.gif') === 'image/gif', 'gif detected');
  assert(detectMimeType('x.webp') === 'image/webp', 'webp detected');
  assert(detectMimeType('x.bmp') === 'image/bmp', 'bmp detected');
  assert(detectMimeType('x.svg') === 'image/svg+xml', 'svg detected');
  assert(detectMimeType('x.pdf') === null, 'unsupported extension -> null');
  assert(detectMimeType('noext') === null, 'no extension -> null');

  // parseArgs
  const a1 = parseArgs(['in.png', 'out.pdf']);
  assert(a1.inputPath === 'in.png' && a1.outputPath === 'out.pdf' && a1.force === false && a1.error === null, 'basic positional args');

  const a2 = parseArgs(['in.png', 'out.pdf', '--force']);
  assert(a2.force === true && a2.error === null, '--force flag parsed');

  const a3 = parseArgs(['--force', 'in.png', 'out.pdf']);
  assert(a3.inputPath === 'in.png' && a3.outputPath === 'out.pdf' && a3.force === true, '--force before positionals still parses correctly');

  const a4 = parseArgs(['in.png']);
  assert(a4.error !== null, 'missing output-path -> error');

  const a5 = parseArgs([]);
  assert(a5.error !== null, 'no args -> error');

  const a6 = parseArgs(['--help']);
  assert(a6.help === true && a6.error === null, '--help short-circuits missing-arg validation');

  console.log('img-to-pdf self-test OK (mime detection + arg parsing)');
}

async function main() {
  const args = process.argv.slice(2);

  if (args.includes('--self-test')) {
    selfTest();
    return;
  }

  const parsed = parseArgs(args);

  if (parsed.help) {
    usage();
    return;
  }

  if (parsed.error) {
    console.error(`❌ ${parsed.error}`);
    usage();
    process.exit(1);
  }

  const inputPath = resolve(parsed.inputPath);
  const outputPath = resolve(parsed.outputPath);

  if (!existsSync(inputPath)) {
    console.error(`❌ Image not found: ${inputPath}`);
    process.exit(1);
  }

  if (existsSync(outputPath) && !parsed.force) {
    console.error(`❌ Output already exists: ${outputPath}`);
    console.error('   Pass --force to overwrite it.');
    process.exit(1);
  }

  console.log(`🖼️  Input:  ${inputPath}`);
  console.log(`📁 Output: ${outputPath}`);

  try {
    const result = await convertImageToPdf(inputPath, outputPath, { force: parsed.force });
    console.log(`✅ PDF generated: ${result.outputPath}`);
    console.log(`📐 Page size: ${result.width}x${result.height}px`);
    console.log(`📦 Size: ${(result.size / 1024).toFixed(1)} KB`);
  } catch (err) {
    console.error(`❌ Conversion failed: ${err.message}`);
    process.exit(1);
  }
}

const isMain = isMainModule(import.meta.url);
if (isMain) {
  main();
}