Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ combine_pdf gem for that use:
Gamba.combine([label, other_label], page_size: [288, 432], orientation: :portrait)
```

Drawing on a page of an existing PDF, such as a letterhead or a pre-printed form, under every page or anywhere at
any size. It is written once, however many pages use it:

```ruby
Prawn::Document.new(background_pdf: "letterhead.pdf") do
pdf_page "terms.pdf", page: 2, width: 200
end
```

Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in
memory:

Expand Down Expand Up @@ -147,11 +156,12 @@ Any of it can be built to attack the server that renders it.
one another, and they draw within the budget of the SVG that embeds them.
- Fonts an SVG declares with `@font-face` disappear once it is drawn, and one that is not a font is left out. In
prawn-svg, an uploaded SVG declaring a "Helvetica" font changed the font of the application's own text.
- A damaged or malicious PNG, JPEG, TrueType or OpenType file either renders or raises an error Gamba names
(`Prawn::Errors::UnsupportedImageType`, `Prawn::Images::JPG::FormatError`, `Gamba::TrueType::Error`, or
`Prawn::Errors::CannotFit` when a damaged font makes a glyph wider than the page). A PNG
- A damaged or malicious PNG, JPEG, TrueType, OpenType or PDF file either renders or raises an error Gamba names
(`Prawn::Errors::UnsupportedImageType`, `Prawn::Images::JPG::FormatError`, `Gamba::TrueType::Error`,
`Prawn::Errors::MalformedPDF`, or `Prawn::Errors::CannotFit` when a damaged font makes a glyph wider than the
page). A PNG
inflates no further than the size its header gives. CI corrupts sample files on every run to check this.
- `Gamba.combine` stops at 64 levels of nested arrays and dictionaries and at 256 MB of decompressed data, and
- `Gamba.combine` and `pdf_page` stop at 64 levels of nested arrays and dictionaries and at 256 MB of decompressed data, and
ignores references that lead back to themselves, so a crafted PDF can't send it into a loop or fill
memory.
- Text with inline formatting (`inline_format: true`) is parsed in linear time, whatever the markup.
Expand Down
8 changes: 8 additions & 0 deletions docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ <h2 id="images-title">Images</h2>
<div class="prose">
<p>JPEG images are embedded as they are; PNG images in every variant Prawn reads, alpha included, are decoded. The same image placed twice in a document is embedded once.</p>
<p>A corrupt or hostile file raises <code>Prawn::Errors::UnsupportedImageType</code> or a named font error, never a crash inside a parser: PNG data inflates no further than its header announces. An input fuzzer checks it on flipped bytes, truncations and forged lengths.</p>
<h3 id="pdf-pages">Pages of other PDFs</h3>
<p>A page of an existing PDF, such as a designer’s letterhead or a pre-printed form, is drawn like an image: <code>pdf_page</code> takes a path, an IO or the bytes of a PDF, a <code>page</code> counted from 1, and the options <code>image</code> takes for position and size. <code>background_pdf</code> puts one under every page, at its own size from the top left corner; <code>start_new_page</code> takes another one, or <code>false</code> for none.</p>
<pre><code class="language-ruby"><span class="no">Prawn</span><span class="o">::</span><span class="no">Document</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">page_size: </span><span class="s2">"A4"</span><span class="p">,</span> <span class="ss">top_margin: </span><span class="mi">120</span><span class="p">,</span> <span class="ss">background_pdf: </span><span class="s2">"letterhead.pdf"</span><span class="p">)</span> <span class="k">do</span>
<span class="n">text</span> <span class="s2">"Invoice INV-2026-0001"</span>
<span class="n">pdf_page</span> <span class="s2">"terms.pdf"</span><span class="p">,</span> <span class="ss">page: </span><span class="mi">2</span><span class="p">,</span> <span class="ss">width: </span><span class="mi">200</span>
<span class="n">start_new_page</span><span class="p">(</span><span class="ss">background_pdf: </span><span class="kp">false</span><span class="p">)</span>
<span class="k">end</span></code></pre>
<p>The page appears as a viewer shows it, its crop box turned by its <code>/Rotate</code>. It is written once however many pages draw it, and pages of one file share their fonts and images. The reader is the one behind <code>Gamba.combine</code>, with its limits and errors; under <code>pdf_a: true</code>, a page whose fonts are not embedded raises <code>Prawn::Errors::PdfAViolation</code>. Gamba’s own API takes <code>background_pdf</code> and <code>background_pdf_page</code> as document options, and <code>pdf_page</code> as a box sized like an image.</p>
</div>
</section>

Expand Down
5 changes: 4 additions & 1 deletion lib/gamba.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class Font

module Gamba
autoload :Combine, ::File.expand_path("gamba/combine", __dir__)
autoload :Copier, ::File.expand_path("gamba/copier", __dir__)
autoload :ImportedPage, ::File.expand_path("gamba/imported_page", __dir__)
autoload :PageBoxes, ::File.expand_path("gamba/page_boxes", __dir__)
autoload :Document, ::File.expand_path("gamba/native/document", __dir__)
autoload :Layout, ::File.expand_path("gamba/native/layout", __dir__)

Expand All @@ -118,7 +121,7 @@ def self.combine(sources, page_size: nil, orientation: nil)
# Loads what Gamba otherwise loads on first use, SVG support included: call it before a server forks its
# workers, so they share that memory instead of each loading its own copy.
def self.eager_load!
[TrueType::File, TrueType::Subset, Prawn::Fonts::TTF, Combine, PDF::Core::Reader, Document]
[TrueType::File, TrueType::Subset, Prawn::Fonts::TTF, Combine, ImportedPage, PDF::Core::Reader, Document]
require "prawn-svg"
end
end
Expand Down
146 changes: 11 additions & 135 deletions lib/gamba/combine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,11 @@ module Gamba
# Each page keeps its resources and content streams as they were encoded; only the page dictionaries and the
# document structure are written anew. Outlines, forms and tagged structure of the sources are not carried over.
class Combine
include PageBoxes

BOXES = %i[MediaBox CropBox BleedBox TrimBox ArtBox].freeze
DEFAULT_MEDIA_BOX = [0.0, 0.0, 612.0, 792.0].freeze
# A quarter turn counterclockwise, the way combine_pdf turns a page to the requested orientation.
QUARTER_TURN = ->(width, height) { [0.0, 1.0, -1.0, 0.0, height, 0.0] }
ROTATIONS = {
90 => ->(width, height) { [0.0, -1.0, 1.0, 0.0, 0.0, width] },
180 => ->(width, height) { [-1.0, 0.0, 0.0, -1.0, width, height] },
270 => QUARTER_TURN
}.freeze
# References followed to tell whether two objects are the same: a chain deeper than this is copied unshared,
# instead of overflowing the stack on a file that links its objects into a list as long as it likes.
MAX_DESCRIBED_DEPTH = 32
QUARTER_TURN = ROTATIONS.fetch(270)

# page_size: a name from PDF::Core::PageGeometry::SIZES or [width, height] in points; each page is scaled to fit
# it, keeping its proportions, from the lower left corner. orientation: :portrait or :landscape; a page the other
Expand Down Expand Up @@ -64,122 +57,39 @@ def reference(data)
# Page references first, so a link from one page to another lands on the copy instead of dragging the old tree.
def copy_pages(reader)
@reader = reader
@copies = {}
@pending = []
@content_identifiers = {}
@description_depth = 0
@copier = Copier.new(reader, shared: @shared, identifiers: @identifiers) { reference(_1) }
pages = reader.pages.map do |source_reference, page|
copy = reference(nil)
if source_reference.is_a?(PDF::Core::Reader::Ref)
@copies[source_reference.number] = copy
@content_identifiers[source_reference.number] = nil
end
@copier.place(source_reference.number, copy) if source_reference.is_a?(PDF::Core::Reader::Ref)
[copy, page]
end
annotations_to_move = []
pages.each do |copy, page|
matrix, *sizes = transformation(page)
copy.data = copy_value(matrix ? page.except(:Parent, :B, *BOXES, :Rotate, :Contents) : page.except(:Parent, :B))
copy.data = @copier.copy(matrix ? page.except(:Parent, :B, *BOXES, :Rotate, :Contents) : page.except(:Parent, :B))
copy.data[:Parent] = @pages_reference
if matrix
reshape(copy.data, page, matrix, *sizes)
annotations_to_move << [copy.data[:Annots], matrix] if copy.data[:Annots]
end
@kids << copy
end
copy_pending
@copier.finish
moved = {}.compare_by_identity
annotations_to_move.each { |annotations, matrix| move_annotations(annotations, matrix, moved) }
reader.version
end

def copy_value(value)
case value
when PDF::Core::Reader::Ref
@copies[value.number] ||= begin
identifier = content_identifier(value.number)
identifier ? (@shared[identifier] ||= pending_copy(value.number)) : pending_copy(value.number)
end
when Hash then value.transform_values { copy_value(_1) }
when Array then value.map { copy_value(_1) }
else value
end
end

def pending_copy(number)
reference(nil).tap { @pending << [number, _1] }
end

# Objects with the same content, references included, are written once: the fonts, images and glyph procedures a
# carrier repeats on every label. Pages, and annotations (whose place is their own), are never shared.
def content_identifier(number)
return @content_identifiers[number] if @content_identifiers.key?(number)
return nil if @description_depth >= MAX_DESCRIBED_DEPTH

@content_identifiers[number] = nil # an object reached again while describing it stays unshared
@description_depth += 1
begin
value = @reader.object(number)
key = if value.is_a?(PDF::Core::Reader::Stream)
description = describe(value.dictionary.except(:Length))
[description, value.data] if description
elsif !(value.is_a?(Hash) && value.key?(:Rect))
describe(value)
end
ensure
@description_depth -= 1
end
@content_identifiers[number] = key && (@identifiers[key] ||= @identifiers.size)
end

def describe(value, description = +"")
case value
when PDF::Core::Reader::Ref
identifier = content_identifier(value.number)
identifier && description << "R" << identifier.to_s << " "
when Hash
description << "<<"
value.each { |key, entry| describe(entry, description << PDF::Core.escape_name(key.name) << " ") || (return nil) }
description << ">>"
when Array
description << "["
value.each { describe(_1, description) || (return nil) }
description << "]"
when PDF::Core::ByteString then description << "(" << value.bytesize.to_s << ":" << value << ")"
when PDF::Core::Name then description << PDF::Core.escape_name(value.value) << " "
when PDF::Core::Real then description << value.source << " "
else description << value.inspect << " "
end
end

def copy_pending
until @pending.empty?
number, copy = @pending.pop
value = @reader.object(number)
if value.is_a?(PDF::Core::Reader::Stream)
dictionary = value.dictionary.except(:Length)
# The writer packs streams without bytes as plain objects: keep one byte of data instead.
dictionary = dictionary.except(:Filter, :DecodeParms) if value.data.empty?
copy.data = copy_value(dictionary)
copy.stream = value.data.empty? ? +" " : +value.data
else
copy.data = copy_value(value)
end
end
end

# The matrix that draws the page's visible box at its new place, and the new page's size; nil when it stays put.
def transformation(page)
return nil unless @page_size || @orientation

media = box(page[:MediaBox]) || DEFAULT_MEDIA_BOX
crop = box(page[:CropBox])
visible = crop ? intersection(crop, media) : media
media = media_box(page)
visible = visible_box(page)
left, bottom, right, top = visible
width = right - left
height = top - bottom
rotation = number(page[:Rotate]).to_i % 360
rotation = 0 unless (rotation % 90).zero?
rotation = rotation(page)
shown_width, shown_height = (rotation % 180).zero? ? [width, height] : [height, width]
turn = @orientation && shown_width != shown_height && (shown_width > shown_height) != (@orientation == :landscape)
final_width, final_height = turn ? [shown_height, shown_width] : [shown_width, shown_height]
Expand Down Expand Up @@ -208,7 +118,7 @@ def reshape(dictionary, page, matrix, width, height, content_width, content_heig
def contents(value)
resolved = @reader.resolve(value)
value = resolved if resolved.is_a?(Array)
Array(value).filter_map { |content| copy_value(content) if content.is_a?(PDF::Core::Reader::Ref) }
Array(value).filter_map { |content| @copier.copy(content) if content.is_a?(PDF::Core::Reader::Ref) }
end

# Annotation rectangles follow the content they sit on.
Expand Down Expand Up @@ -260,47 +170,13 @@ def plain_matrix(values)
numbers.all? ? numbers : nil
end

def box(value)
values = @reader.resolve(value)
return nil unless values.is_a?(Array) && values.size == 4

numbers = values.map { number(_1) }
return nil if numbers.any?(&:nil?) || numbers[0] == numbers[2] || numbers[1] == numbers[3]

left, right = [numbers[0], numbers[2]].minmax
bottom, top = [numbers[1], numbers[3]].minmax
[left, bottom, right, top]
end

def plain_box(values)
return nil unless values.is_a?(Array) && values.size == 4

numbers = values.map { numeric(_1) }
numbers.any?(&:nil?) ? nil : numbers
end

def number(value)
numeric(@reader.resolve(value))
end

# A real too large for a Float reads as infinite, and would write NaN into the page's matrix: no number then.
def numeric(value)
number = (value.is_a?(PDF::Core::Real) || value.is_a?(Numeric)) ? value.to_f : nil
number if number&.finite?
end

def intersection(crop, media)
left, bottom = [crop[0], media[0]].max, [crop[1], media[1]].max
right, top = [crop[2], media[2]].min, [crop[3], media[3]].min
(right > left && top > bottom) ? [left, bottom, right, top] : media
end

def multiply(first, second)
a, b, c, d, e, f = first
a2, b2, c2, d2, e2, f2 = second
[a * a2 + b * c2, a * b2 + b * d2, c * a2 + d * c2, c * b2 + d * d2, e * a2 + f * c2 + e2, e * b2 + f * d2 + f2]
end

def apply(matrix, x, y)
a, b, c, d, e, f = matrix
[a * x + c * y + e, b * x + d * y + f]
Expand Down
107 changes: 107 additions & 0 deletions lib/gamba/copier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

module Gamba
# Copies objects of a PDF written elsewhere into one being written, through the references a block makes: each
# object once, those with the same content (references included) once in all, the rest when #finish is called.
class Copier
# References followed to tell whether two objects are the same: a chain deeper than this is copied unshared,
# instead of overflowing the stack on a file that links its objects into a list as long as it likes.
MAX_DESCRIBED_DEPTH = 32

# shared and identifiers carry what was written from earlier files, so that a font they all use is written once.
def initialize(reader, shared: {}, identifiers: {}, &reference)
@reader = reader
@shared = shared
@identifiers = identifiers
@reference = reference
@copies = {}
@pending = []
@content_identifiers = {}
@description_depth = 0
end

# An object of the source written by the caller as `copy`, so that a link to it lands on that copy; never shared.
def place(number, copy)
@copies[number] = copy
@content_identifiers[number] = nil
end

# The value with each reference to the source replaced by one to its copy, written by #finish.
def copy(value)
case value
when PDF::Core::Reader::Ref
@copies[value.number] ||= begin
identifier = content_identifier(value.number)
identifier ? (@shared[identifier] ||= pending_copy(value.number)) : pending_copy(value.number)
end
when Hash then value.transform_values { copy(_1) }
when Array then value.map { copy(_1) }
else value
end
end

def finish
until @pending.empty?
number, copy = @pending.pop
value = @reader.object(number)
if value.is_a?(PDF::Core::Reader::Stream)
dictionary = value.dictionary.except(:Length)
# The writer packs streams without bytes as plain objects: keep one byte of data instead.
dictionary = dictionary.except(:Filter, :DecodeParms) if value.data.empty?
copy.data = copy(dictionary)
copy.stream = value.data.empty? ? +" " : +value.data
else
copy.data = copy(value)
end
end
end

private

def pending_copy(number)
@reference.call(nil).tap { @pending << [number, _1] }
end

# Objects with the same content, references included, are written once: the fonts, images and glyph procedures a
# carrier repeats on every label. Pages, and annotations (whose place is their own), are never shared.
def content_identifier(number)
return @content_identifiers[number] if @content_identifiers.key?(number)
return nil if @description_depth >= MAX_DESCRIBED_DEPTH

@content_identifiers[number] = nil # an object reached again while describing it stays unshared
@description_depth += 1
begin
value = @reader.object(number)
key = if value.is_a?(PDF::Core::Reader::Stream)
description = describe(value.dictionary.except(:Length))
[description, value.data] if description
elsif !(value.is_a?(Hash) && value.key?(:Rect))
describe(value)
end
ensure
@description_depth -= 1
end
@content_identifiers[number] = key && (@identifiers[key] ||= @identifiers.size)
end

def describe(value, description = +"")
case value
when PDF::Core::Reader::Ref
identifier = content_identifier(value.number)
identifier && description << "R" << identifier.to_s << " "
when Hash
description << "<<"
value.each { |key, entry| describe(entry, description << PDF::Core.escape_name(key.name) << " ") || (return nil) }
description << ">>"
when Array
description << "["
value.each { describe(_1, description) || (return nil) }
description << "]"
when PDF::Core::ByteString then description << "(" << value.bytesize.to_s << ":" << value << ")"
when PDF::Core::Name then description << PDF::Core.escape_name(value.value) << " "
when PDF::Core::Real then description << value.source << " "
else description << value.inspect << " "
end
end
end
end
Loading
Loading