Class: Pdf::Utility::CliRasterizer

Inherits:
Object
  • Object
show all
Defined in:
app/services/pdf/utility/cli_rasterizer.rb

Overview

Service object: rasterize a single PDF page to PNG via the pdftoppm (poppler)
CLI in a short-lived subprocess.

Why not libvips: Rails 8.1.3.1 (CVE-2026-66066, GHSA-xr9x-r78c-5hrm) calls
+Vips.block_untrusted(true)+ process-wide, and pdfload is an untrusted loader,
so every in-process vips PDF load fails with "not a known file format".
Unblocking around user-supplied PDFs is the exact threat class the CVE patch
targets, so every PDF raster path isolates the parse in a subprocess instead
— poppler's attack surface is contained to a short-lived, rlimit-capped child
(AppSignal #1983, #6487, #6491, and #6492).

The caller owns the returned Tempfile (same convention as PageRasterizer).

Defined Under Namespace

Classes: RasterizationError

Constant Summary collapse

MAX_OUTPUT_PIXELS =

A 32-megapixel RGB page is roughly 96 MiB once decoded. This accommodates
11×17-inch pages at 400 DPI while rejecting poster-sized or forged PNG
dimensions before libvips sees them.

32_000_000
MAX_OUTPUT_BYTES =

Bound the on-disk artifact independently of its claimed dimensions.

128 * 1024 * 1024
PNG_SIGNATURE =

Poppler is required to emit PNG; verify that before reading its IHDR.

"\x89PNG\r\n\x1A\n".b

Class Method Summary collapse

Class Method Details

.page_count(pdf_path) ⇒ Integer

Count pages through pdfinfo in the same bounded subprocess used for
rasterization. This keeps PDF parsing outside the Rails process.

Parameters:

  • pdf_path (String)

    local path to a PDF file

Returns:

  • (Integer)

    positive page count

Raises:

  • (ArgumentError)

    when +pdf_path+ is nil or missing

  • (RasterizationError)

    when metadata cannot be read



38
39
40
41
42
43
44
45
46
# File 'app/services/pdf/utility/cli_rasterizer.rb', line 38

def page_count(pdf_path)
  validate_path!(pdf_path)

  stdout, stderr, status = run_command(['pdfinfo', pdf_path])
  pages = stdout[/^Pages:\s+(\d+)$/i, 1].to_i
  return pages if status.success? && pages.positive?

  raise RasterizationError, "pdfinfo failed (exit #{status.exitstatus || 'unknown'}): #{stderr.presence || stdout}".strip
end

.render_page(pdf_path, page: 0, dpi: 150) ⇒ Tempfile

Render +page+ (zero-based) of +pdf_path+ to a PNG Tempfile.

Parameters:

  • pdf_path (String)

    local path to a PDF file

  • page (Integer) (defaults to: 0)

    zero-based page index (pdftoppm is 1-based; translated here)

  • dpi (Integer) (defaults to: 150)

    render resolution

Returns:

  • (Tempfile)

    PNG of the rendered page. Caller owns cleanup.

Raises:

  • (ArgumentError)

    when +pdf_path+ is nil or missing

  • (RasterizationError)

    when the page cannot be rendered



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'app/services/pdf/utility/cli_rasterizer.rb', line 56

def render_page(pdf_path, page: 0, dpi: 150)
  validate_path!(pdf_path)

  Dir.mktmpdir('pdf_raster') do |dir|
    output_prefix = File.join(dir, 'page')
    # -singlefile writes exactly one file (<prefix>.png, no -N suffix);
    # pdftoppm pages are 1-based, hence page + 1.
    cli_page = (page.to_i + 1).to_s
    cmd = ['pdftoppm', '-png', '-r', dpi.to_i.to_s, '-f', cli_page, '-l', cli_page, '-singlefile', pdf_path, output_prefix]

    stdout, stderr, status = run_command(cmd)
    output = "#{output_prefix}.png"
    raise RasterizationError, "pdftoppm failed (exit #{status.exitstatus || 'unknown'}): #{stderr.presence || stdout}".strip unless status.success? && File.exist?(output)

    validate_output!(output)
    png = Tempfile.new(['pdf_page_', '.png'])
    png.binmode
    File.open(output, 'rb') { |source| IO.copy_stream(source, png) }
    png.flush
    png.rewind
    png
  end
end