diff --git a/README.md b/README.md
index e49fd69..d343576 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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.
diff --git a/docs/guide.html b/docs/guide.html
index 549d24a..7b3770b 100644
--- a/docs/guide.html
+++ b/docs/guide.html
@@ -222,6 +222,14 @@
Images
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.
A corrupt or hostile file raises Prawn::Errors::UnsupportedImageType 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.
+
Pages of other PDFs
+
A page of an existing PDF, such as a designer’s letterhead or a pre-printed form, is drawn like an image: pdf_page takes a path, an IO or the bytes of a PDF, a page counted from 1, and the options image takes for position and size. background_pdf puts one under every page, at its own size from the top left corner; start_new_page takes another one, or false for none.
+
Prawn::Document.new(page_size: "A4", top_margin: 120, background_pdf: "letterhead.pdf") do
+ text "Invoice INV-2026-0001"
+ pdf_page "terms.pdf", page: 2, width: 200
+ start_new_page(background_pdf: false)
+end
+
The page appears as a viewer shows it, its crop box turned by its /Rotate. 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 Gamba.combine, with its limits and errors; under pdf_a: true, a page whose fonts are not embedded raises Prawn::Errors::PdfAViolation. Gamba’s own API takes background_pdf and background_pdf_page as document options, and pdf_page as a box sized like an image.
diff --git a/lib/gamba.rb b/lib/gamba.rb
index 4264562..53392f0 100644
--- a/lib/gamba.rb
+++ b/lib/gamba.rb
@@ -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__)
@@ -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
diff --git a/lib/gamba/combine.rb b/lib/gamba/combine.rb
index 9caf199..c2fc10d 100644
--- a/lib/gamba/combine.rb
+++ b/lib/gamba/combine.rb
@@ -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
@@ -64,22 +57,16 @@ 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)
@@ -87,99 +74,22 @@ def copy_pages(reader)
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]
@@ -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.
@@ -260,18 +170,6 @@ 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
@@ -279,28 +177,6 @@ def plain_box(values)
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]
diff --git a/lib/gamba/copier.rb b/lib/gamba/copier.rb
new file mode 100644
index 0000000..2c77064
--- /dev/null
+++ b/lib/gamba/copier.rb
@@ -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
diff --git a/lib/gamba/document.rb b/lib/gamba/document.rb
index 11b538c..de4be65 100644
--- a/lib/gamba/document.rb
+++ b/lib/gamba/document.rb
@@ -17,7 +17,7 @@ class Document
VALID_OPTIONS = %i[
page_size page_layout margin left_margin right_margin top_margin bottom_margin skip_page_creation compress
- background info text_formatter print_scaling pdf_a lang
+ background info text_formatter print_scaling pdf_a lang background_pdf background_pdf_page
].freeze
attr_accessor :margin_box, :page_number, :text_formatter
@@ -46,6 +46,8 @@ def initialize(options = {}, &block)
@pdf_a = options.delete(:pdf_a) == true
@background = options[:background]
@background_scale = options[:background_scale] || 1
+ @background_pdf = options[:background_pdf]
+ @background_pdf_page = options[:background_pdf_page] || 1
@font_size = 12
@bounding_box = nil
@margin_box = nil
@@ -85,6 +87,7 @@ def start_new_page(options = {})
insert_page(page, @page_number)
@page_number += 1
canvas { image(@background, scale: @background_scale, at: bounds.top_left) } if @background
+ draw_background_pdf(options)
@y = @bounding_box.absolute_top
float { @on_page_create&.call(self) }
header_blend_mode = page.stack.last&.blend_mode
@@ -240,6 +243,13 @@ def repeaters
private
+ # A page of another PDF under each page, at its own size from the top left corner: the document's, unless
+ # start_new_page names another (background_pdf:, background_pdf_page:) or none (background_pdf: false).
+ def draw_background_pdf(options)
+ source, number = options.key?(:background_pdf) ? [options[:background_pdf], options[:background_pdf_page] || 1] : [@background_pdf, @background_pdf_page]
+ canvas { pdf_page(source, page: number, at: bounds.top_left) } if source
+ end
+
# A gradient set as the fill or stroke stays it on the new page, in place of the colour Prawn writes.
def use_graphic_settings(override_settings = false)
write_fill_paint if graphic_state.fill_pattern || current_fill_color != "000000" || override_settings
diff --git a/lib/gamba/images.rb b/lib/gamba/images.rb
index d61e407..3680dc4 100644
--- a/lib/gamba/images.rb
+++ b/lib/gamba/images.rb
@@ -52,6 +52,24 @@ def build_image_object(file)
[entry[:obj], entry[:info]]
end
+ # A page of another PDF, placed and sized as an image is, written once however many times it is drawn: a
+ # letterhead, a form to fill in. source: a path, an IO, or the bytes of a PDF; page: from 1.
+ def pdf_page(source, options = {})
+ options = options.dup
+ number = options.delete(:page) || 1
+ Prawn.verify_options(%i[at position vposition height width scale fit], options)
+ pdf_object, info = build_pdf_page_object(source, number)
+ embed_image(pdf_object, info, options)
+ info
+ end
+
+ def build_pdf_page_object(source, number = 1)
+ bytes = Gamba::ImportedPage.bytes(source)
+ imported = (@imported_pdfs ||= {})[Digest::SHA1.hexdigest(bytes)] ||= {source: Gamba::ImportedPage.source(self, bytes), pages: {}}
+ page = imported[:pages][number] ||= Gamba::ImportedPage.new(self, imported[:source], number)
+ [page.form, page]
+ end
+
def embed_image(pdf_object, info, options)
width, height = info.calc_image_dimensions(options)
if options[:at]
@@ -62,7 +80,8 @@ def embed_image(pdf_object, info, options)
end
label = "I#{@image_counter = (@image_counter || 0) + 1}"
page.xobjects[label] = pdf_object
- add_content("\nq\n#{PDF::Core.real_params([width, 0, 0, height, x, y - height])} cm\n/#{label} Do\nQ")
+ matrix = info.respond_to?(:placement) ? info.placement(width, height, x, y - height) : [width, 0, 0, height, x, y - height]
+ add_content("\nq\n#{PDF::Core.real_params(matrix)} cm\n/#{label} Do\nQ")
end
private
diff --git a/lib/gamba/imported_page.rb b/lib/gamba/imported_page.rb
new file mode 100644
index 0000000..061ac38
--- /dev/null
+++ b/lib/gamba/imported_page.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+module Gamba
+ # A page of a PDF written elsewhere, drawn like an image: a form XObject showing what a viewer shows of it, its
+ # crop box turned by its /Rotate. Placed many times, it is written once; pages of one file share their resources.
+ class ImportedPage < Prawn::Images::Image
+ include PageBoxes
+
+ # A file read once per document, its objects copied into it as its pages are used.
+ Source = Struct.new(:reader, :copier, :pages)
+ # Fonts whose glyphs need no font file: their procedures are in the PDF.
+ FONT_PROGRAMS = %i[FontFile FontFile2 FontFile3].freeze
+ TYPE3 = PDF::Core::Name.new(value: "Type3")
+ FORM = PDF::Core::Name.new(value: "Form")
+
+ attr_reader :width, :height, :form
+
+ # source: a path, an IO, or the bytes of a PDF.
+ def self.bytes(source)
+ if source.respond_to?(:read)
+ source.rewind if source.respond_to?(:rewind)
+ source.read.b
+ elsif source.is_a?(String) && source.b.start_with?("%PDF-")
+ source.b
+ else
+ path = Pathname.new(source)
+ raise ArgumentError, "#{path} not found" unless path.file?
+
+ path.binread
+ end
+ end
+
+ def self.source(document, bytes)
+ reader = PDF::Core::Reader.new(bytes)
+ document.min_version(reader.version)
+ Source.new(reader, Copier.new(reader) { document.ref!(_1) }, reader.pages)
+ end
+
+ def initialize(document, source, number)
+ super()
+ @reader = source.reader
+ raise Prawn::Errors::MalformedPDF, "This PDF has no pages" if source.pages.empty?
+ unless number.is_a?(Integer) && number.between?(1, source.pages.size)
+ raise ArgumentError, "page is #{number.inspect}: this PDF has pages 1 to #{source.pages.size}"
+ end
+
+ page = source.pages[number - 1].last
+ check_fonts_embedded(page[:Resources], {}) if document.pdf_a?
+ @form = document.ref!(form_dictionary(page, source.copier))
+ @form.stream, filters = content(page[:Contents], source.copier)
+ @form.data.merge!(filters)
+ source.copier.finish
+ end
+
+ # What draws the page in a box of this width and height, from its lower left corner.
+ def placement(width, height, x, y)
+ [width / @width.to_f, 0, 0, height / @height.to_f, x, y]
+ end
+
+ private
+
+ # The form maps the visible box, turned, onto a box from the origin as wide and high as the page is shown.
+ def form_dictionary(page, copier)
+ left, bottom, right, top = visible_box(page)
+ width = right - left
+ height = top - bottom
+ turn = rotation(page)
+ @width, @height = (turn % 180).zero? ? [width, height] : [height, width]
+ matrix = [1.0, 0.0, 0.0, 1.0, -left, -bottom]
+ matrix = multiply(matrix, ROTATIONS.fetch(turn).call(width, height)) unless turn.zero?
+ dictionary = {Type: :XObject, Subtype: :Form, BBox: [left, bottom, right, top], Matrix: matrix, Resources: copier.copy(page[:Resources] || {})}
+ dictionary[:Group] = copier.copy(page[:Group]) if page[:Group]
+ dictionary
+ end
+
+ # A form holds one stream: a page's only content stream keeps its bytes and filters, several are decoded and
+ # joined, as their division may fall between an operator and its operands.
+ def content(value, copier)
+ streams = Array(@reader.resolve(value)).map { @reader.resolve(_1) }.grep(PDF::Core::Reader::Stream)
+ return [+" ", {}] if streams.empty?
+ return [+streams.first.data, copier.copy(streams.first.dictionary.slice(:Filter, :DecodeParms))] if streams.one?
+
+ [streams.map { @reader.decoded(_1) }.join("\n"), {}]
+ end
+
+ # PDF/A wants every font's program in the file: a page that leaves one to the viewer raises.
+ def check_fonts_embedded(resources, visited)
+ resources = @reader.resolve(resources)
+ return unless resources.is_a?(Hash)
+
+ fonts = @reader.resolve(resources[:Font])
+ fonts.each_value { |font| check_font_embedded(@reader.resolve(font)) } if fonts.is_a?(Hash)
+ xobjects = @reader.resolve(resources[:XObject])
+ return unless xobjects.is_a?(Hash)
+
+ xobjects.each_value do |reference|
+ next if !reference.is_a?(PDF::Core::Reader::Ref) || visited[reference.number]
+
+ visited[reference.number] = true
+ xobject = @reader.resolve(reference)
+ check_fonts_embedded(xobject.dictionary[:Resources], visited) if xobject.is_a?(PDF::Core::Reader::Stream) && xobject.dictionary[:Subtype] == FORM
+ end
+ end
+
+ def check_font_embedded(font)
+ return unless font.is_a?(Hash) && font[:Subtype] != TYPE3
+
+ descendant = @reader.resolve(@reader.resolve(font[:DescendantFonts]).then { _1.is_a?(Array) ? _1.first : nil })
+ font = descendant if descendant.is_a?(Hash)
+ descriptor = @reader.resolve(font[:FontDescriptor])
+ return if descriptor.is_a?(Hash) && FONT_PROGRAMS.any? { descriptor[_1] }
+
+ name = @reader.resolve(font[:BaseFont])
+ raise Prawn::Errors::PdfAViolation, "PDF/A embeds every font: the page uses #{name.respond_to?(:value) ? name.value : "a font"} without its font file"
+ end
+ end
+end
diff --git a/lib/gamba/native/document.rb b/lib/gamba/native/document.rb
index 14b303f..c2be8cc 100644
--- a/lib/gamba/native/document.rb
+++ b/lib/gamba/native/document.rb
@@ -12,7 +12,7 @@ class Document
OPTIONS = %i[
page_size page_layout margin left_margin right_margin top_margin bottom_margin background info print_scaling
- pdf_a lang
+ pdf_a lang background_pdf background_pdf_page
].freeze
TEXT_ATTRIBUTES = Layout::Styles::TEXT_ATTRIBUTES
# No Prawn page to match here, so fonts that have ligatures and GPOS kerning use them.
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index 305743a..5ee85a3 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -271,6 +271,24 @@ def size(prawn, available_width)
end
end
+ # A page of another PDF as a box, sized and aligned as an image is.
+ class PdfPage < Image
+ def initialize(source, page: 1, **)
+ super(source, **)
+ @page = page
+ end
+
+ private
+
+ def description
+ "a PDF page"
+ end
+
+ def image(prawn)
+ @image ||= prawn.build_pdf_page_object(@source, @page)
+ end
+ end
+
# What a document and its containers share: the calls that add a box.
module Builder
# Rows are hashes, or objects answering the columns' names. The block receives the Prawn::Table before it
@@ -284,6 +302,12 @@ def image(source, **options)
add(Image.new(source, **options))
end
+ # A page of another PDF (a path, an IO or its bytes), from 1, as a box sized like an image.
+ def pdf_page(source, page: 1, **options)
+ Styles.check_options(options, Image::OPTIONS)
+ add(PdfPage.new(source, page:, **options))
+ end
+
def stack(**options)
container = Stack.new(@styles, **options)
yield container
diff --git a/lib/gamba/page_boxes.rb b/lib/gamba/page_boxes.rb
new file mode 100644
index 0000000..8b9914b
--- /dev/null
+++ b/lib/gamba/page_boxes.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Gamba
+ # The boxes and the turn of a page read from a PDF written elsewhere, through @reader, whatever the file holds:
+ # a value that is not a usable box or number reads as missing.
+ module PageBoxes
+ DEFAULT_MEDIA_BOX = [0.0, 0.0, 612.0, 792.0].freeze
+ # What draws a page's content turned as a viewer shows it under /Rotate, in a box from the origin.
+ 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 => ->(width, height) { [0.0, 1.0, -1.0, 0.0, height, 0.0] }
+ }.freeze
+
+ private
+
+ def media_box(page)
+ box(page[:MediaBox]) || DEFAULT_MEDIA_BOX
+ end
+
+ # What a viewer shows: the crop box within the media box.
+ def visible_box(page)
+ media = media_box(page)
+ crop = box(page[:CropBox])
+ crop ? intersection(crop, media) : media
+ end
+
+ # 0, 90, 180 or 270: a turn that is not a quarter one reads as none.
+ def rotation(page)
+ rotation = number(page[:Rotate]).to_i % 360
+ (rotation % 90).zero? ? rotation : 0
+ 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 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
+ end
+end
diff --git a/lib/gamba/pdf/reader.rb b/lib/gamba/pdf/reader.rb
index afbc8f1..955b25f 100644
--- a/lib/gamba/pdf/reader.rb
+++ b/lib/gamba/pdf/reader.rb
@@ -129,6 +129,12 @@ def pages
pages
end
+ # A stream's bytes decoded, Flate being the only filter read, as for object streams: a page's content in
+ # several streams is joined this way.
+ def decoded(stream)
+ decode(stream)
+ end
+
private
# Objects read so far were read as stored: forget them, so they are read again and decrypted.
@@ -393,14 +399,14 @@ def object_stream(stream_number)
end
end
- # Object and cross-reference streams only: Flate, optionally with a PNG predictor (§7.4.4.4). Every filter's
+ # Flate, optionally with a PNG predictor (§7.4.4.4). Every filter's
# output counts against what the file may decode in all.
def decode(stream)
filters = [resolve(stream.dictionary[:Filter])].flatten
filters.compact!
parameters = [resolve(stream.dictionary[:DecodeParms])].flatten
filters.each_with_index.reduce(stream.data) do |data, (filter, position)|
- raise Prawn::Errors::MalformedPDF, "Unsupported filter #{filter.inspect} on an object stream" unless filter == FLATE
+ raise Prawn::Errors::MalformedPDF, "Unsupported filter #{filter.inspect}: only Flate is read" unless filter == FLATE
inflated = inflate(data)
@decoded_bytes_left -= inflated.bytesize
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 53eef23..2315661 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -15,6 +15,7 @@ Gamba::Document:
"#grid": [keyreq columns, keyrest options]
"#header": [key gap, block block]
"#image": [req source, keyrest options]
+ "#pdf_page": [req source, key page, keyrest options]
"#register_font": [req family, keyreq normal, key bold, key italic, key bold_italic, key features]
"#style": [req name, keyrest attributes]
"#table": [req rows, keyrest options, block styling]
@@ -26,6 +27,7 @@ Gamba::Layout::Container:
"#draw": [req component]
"#grid": [keyreq columns, keyrest options]
"#image": [req source, keyrest options]
+ "#pdf_page": [req source, key page, keyrest options]
"#row": [keyrest options]
"#stack": [keyrest options]
"#table": [req rows, keyrest options, block styling]
diff --git a/test/input_fuzz_test.rb b/test/input_fuzz_test.rb
index f6423db..8bae440 100644
--- a/test/input_fuzz_test.rb
+++ b/test/input_fuzz_test.rb
@@ -5,7 +5,7 @@
require "prawn"
require "timeout"
-# Images and fonts may come from users: corrupted ones must render or fail with an error Gamba names, never crash
+# Images, fonts and PDF pages may come from users: corrupted ones must render or fail with an error Gamba names, never crash
# inside a parser, hang or exhaust memory. INPUT_FUZZ_SEED and INPUT_FUZZ_CASES choose them.
class InputFuzzTest < Minitest::Test
SEED = Integer(ENV.fetch("INPUT_FUZZ_SEED", "20261102"))
@@ -14,11 +14,11 @@ class InputFuzzTest < Minitest::Test
# CannotFit: a corrupted advance width can make a glyph wider than the page, as Prawn reports it.
EXPECTED_ERRORS = [
Prawn::Errors::UnsupportedImageType, Prawn::Images::JPG::FormatError, Gamba::TrueType::Error,
- Prawn::Errors::IncompatibleStringEncoding, Prawn::Errors::CannotFit
+ Prawn::Errors::IncompatibleStringEncoding, Prawn::Errors::CannotFit, Prawn::Errors::MalformedPDF, Prawn::Errors::EncryptedPDF
].freeze
DATA_DIRECTORY = File.join(Oracle::ROOT, "corpus/cache/prawn/data")
- def test_corrupted_images_and_fonts_fail_cleanly
+ def test_corrupted_images_fonts_and_pdfs_fail_cleanly
skip CORPUS_MISSING_MESSAGE unless File.directory?(DATA_DIRECTORY)
samples = Dir["#{DATA_DIRECTORY}/images/*.{png,jpg}"] + Dir["#{DATA_DIRECTORY}/fonts/*.{ttf,otf}"]
@@ -26,6 +26,7 @@ def test_corrupted_images_and_fonts_fail_cleanly
picker = Random.new(SEED)
failures = []
Dir.mktmpdir do |directory|
+ samples += pdf_samples(directory)
CASES.times do |index|
sample = samples.sample(random: picker)
path = File.join(directory, "case_#{index}#{File.extname(sample)}")
@@ -39,11 +40,26 @@ def test_corrupted_images_and_fonts_fail_cleanly
private
+ # PDFs Gamba wrote, as pages to draw on: text in a TrueType subset, an image, compressed or not.
+ def pdf_samples(directory)
+ [true, false].map do |compress|
+ path = File.join(directory, "sample_#{compress}.pdf")
+ Prawn::Document.new(compress:) do
+ font "#{DATA_DIRECTORY}/fonts/DejaVuSans.ttf"
+ text "Letterhead, café"
+ image "#{DATA_DIRECTORY}/images/dice.png", width: 50
+ end.render_file(path)
+ path
+ end
+ end
+
def unclean_failure(path)
Timeout.timeout(SECONDS_PER_CASE) do
Prawn::Document.new do
if path.end_with?(".png", ".jpg")
image path, width: 100
+ elsif path.end_with?(".pdf")
+ pdf_page path, width: 100
else
font path
text "Avocado, café €"
diff --git a/test/pdf_page_test.rb b/test/pdf_page_test.rb
new file mode 100644
index 0000000..396a8cb
--- /dev/null
+++ b/test/pdf_page_test.rb
@@ -0,0 +1,190 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+require "stringio"
+
+# A page of another PDF drawn like an image, or under every page: what a viewer shows of it, written once.
+class PdfPageTest < Minitest::Test
+ A4 = [595.28, 841.89].freeze
+ FONTS = File.join(Oracle::ROOT, "corpus/cache/prawn/data/fonts")
+
+ def test_a_page_drawn_at_its_size_from_the_corner_gives_the_pixels_of_the_page_alone
+ source = letterhead
+ drawn = Prawn::Document.new(page_size: "A4", margin: 0) { pdf_page source, at: [0, A4[1]] }.render
+
+ assert_same_pixels source, drawn
+ end
+
+ def test_a_rotated_or_cropped_page_appears_as_a_viewer_shows_it
+ skip "qpdf is not installed" unless system("qpdf", "--version", out: File::NULL)
+
+ rotated = qpdf(letterhead, %w[--rotate=+90])
+ assert_same_pixels rotated, Prawn::Document.new(page_size: A4.reverse, margin: 0) { pdf_page rotated, at: [0, A4[0]] }.render
+
+ # Poppler draws a page's media box: the page as shown is the same one with its media box cut to the crop box,
+ # whole pixels wide at the oracle's resolution so that no edge is antialiased.
+ content = {4 => stream("0 0 1 rg 0 0 612 792 re f BT /F1 24 Tf 72 700 Td (alice) Tj ET")}
+ cropped = classic_pdf("4 0 R", content, page: "/CropBox [50 650 338 746] /Rotate 270")
+ shown = classic_pdf("4 0 R", content, page: "/Rotate 270", media_box: "50 650 338 746")
+ assert_same_pixels shown, Prawn::Document.new(page_size: [96, 288], margin: 0) { pdf_page cropped, at: [0, 288] }.render
+ end
+
+ def test_a_page_is_placed_and_sized_as_an_image_is
+ source = letterhead
+ info = nil
+ document = Prawn::Document.new do
+ info = pdf_page source, width: 297.64, position: :center
+ end
+
+ assert_equal [595.28, 841.89, 297.64, 420.945], [info.width, info.height, info.scaled_width, info.scaled_height]
+ assert_in_delta 720 - 420.945, document.cursor, 0.001
+ assert_includes uncompressed(document.render), "0.5 0.0 0.0 0.5 157.18 335.055 cm"
+ end
+
+ def test_a_page_used_on_every_page_is_written_once_and_pages_of_a_file_share_their_resources
+ source = letterhead(pages: 2)
+ many = Prawn::Document.new(background_pdf: source) do
+ 49.times { start_new_page }
+ pdf_page source, page: 2, width: 100
+ end.render
+ objects = uncompressed(many)
+
+ assert_equal 2, objects.scan("/Subtype /Form").size
+ assert_equal 1, objects.scan("/BaseFont /Helvetica").size
+ end
+
+ def test_the_document_background_is_under_every_page_unless_a_page_names_another_or_none
+ source = letterhead(pages: 2)
+ document = Prawn::Document.new(background_pdf: source, background_pdf_page: 2) do
+ text "first"
+ start_new_page(background_pdf: false)
+ text "second"
+ start_new_page(background_pdf: StringIO.new(source))
+ start_new_page
+ end
+
+ assert_equal [["Page 2"], [], ["Page 1"], ["Page 2"]], text_lines(document.render).map { _1 - %w[first second] }
+ end
+
+ def test_several_content_streams_are_joined_wherever_they_were_divided
+ divided = classic_pdf("[4 0 R 6 0 R]", {4 => stream("BT /F1 24 Tf 72 700"), 6 => stream("Td (alice) Tj ET")})
+ whole = classic_pdf("4 0 R", {4 => stream("BT /F1 24 Tf 72 700 Td (alice) Tj ET")})
+
+ assert_same_pixels whole, Prawn::Document.new(page_size: "LETTER", margin: 0) { pdf_page divided, at: [0, 792] }.render
+ end
+
+ def test_sources_that_are_not_a_page_of_a_readable_pdf_raise
+ source = letterhead
+
+ assert_equal "page is 2: this PDF has pages 1 to 1", assert_raises(ArgumentError) { Prawn::Document.new.pdf_page(source, page: 2) }.message
+ assert_raises(ArgumentError) { Prawn::Document.new.pdf_page(source, page: "1") }
+ assert_raises(ArgumentError) { Prawn::Document.new.pdf_page("missing.pdf") }
+ assert_raises(Prawn::Errors::MalformedPDF) { Prawn::Document.new.pdf_page(StringIO.new("not a PDF")) }
+ if system("qpdf", "--version", out: File::NULL)
+ assert_raises(Prawn::Errors::EncryptedPDF) { Prawn::Document.new.pdf_page(qpdf(source, %w[--encrypt alice owner 256 --])) }
+ end
+ end
+
+ def test_under_pdf_a_a_page_whose_fonts_are_not_embedded_raises
+ error = assert_raises(Prawn::Errors::PdfAViolation) { Prawn::Document.new(pdf_a: true).pdf_page(letterhead) }
+ assert_equal "PDF/A embeds every font: the page uses Helvetica without its font file", error.message
+
+ skip CORPUS_MISSING_MESSAGE unless File.directory?(FONTS)
+
+ embedded = Prawn::Document.new do
+ font "#{FONTS}/DejaVuSans.ttf"
+ text "Example SAS"
+ end.render
+ assert_kind_of Gamba::ImportedPage, Prawn::Document.new(pdf_a: true).pdf_page(embedded)
+ end
+
+ def test_the_native_api_takes_a_background_and_draws_a_page_as_a_box
+ source = letterhead(pages: 2)
+ document = Gamba.document(background_pdf: source) do |yielded|
+ yielded.row do |row|
+ row.pdf_page source, page: 2, width: 100
+ row.text "beside"
+ end
+ yielded.text "after"
+ end
+ first_page = text_lines(document.to_s).first
+
+ assert_equal ["Page 1", "Page 2", "beside", "after"], first_page
+ end
+
+ private
+
+ # An A4 letterhead in Helvetica, compressed as Prawn writes it, its pages named.
+ def letterhead(pages: 1)
+ Prawn::Document.new(page_size: "A4", margin: 0, compress: true) do
+ pages.times do |index|
+ start_new_page unless index.zero?
+ fill_color "1f4e79"
+ fill_rectangle [0, A4[1]], A4[0], 80
+ fill_color "ffffff"
+ text_box "Page #{index + 1}", at: [40, A4[1] - 25], size: 24
+ end
+ end.render
+ end
+
+ def assert_same_pixels(expected, actual)
+ Dir.mktmpdir do |directory|
+ paths = {expected:, actual:}.to_h { |name, pdf| [name, File.join(directory, "#{name}.pdf").tap { File.binwrite(_1, pdf) }] }
+
+ assert_nil Oracle.invalidity(paths[:actual])
+ assert_empty Oracle.rendered_pixel_differences(paths[:expected], paths[:actual])
+ end
+ end
+
+ # The words of each page, by line, top to bottom.
+ def text_lines(pdf)
+ with_file(pdf) do |path|
+ Oracle.text_layout(path).map do |page|
+ page.glyphs.group_by(&:last).sort.map { |_y, glyphs| glyphs.map(&:first).join }
+ end
+ end
+ end
+
+ def uncompressed(pdf)
+ with_file(pdf) { |path| Open3.capture2("qpdf", "--qdf", "--object-streams=disable", path, "-").first.b }
+ end
+
+ def qpdf(pdf, arguments)
+ Dir.mktmpdir do |directory|
+ File.binwrite(File.join(directory, "in.pdf"), pdf)
+ assert system("qpdf", *arguments, File.join(directory, "in.pdf"), File.join(directory, "out.pdf")), "qpdf #{arguments.join(" ")}"
+ File.binread(File.join(directory, "out.pdf"))
+ end
+ end
+
+ def with_file(pdf)
+ Tempfile.create(["pdf_page", ".pdf"]) do |file|
+ file.binmode
+ file.write(pdf)
+ file.flush
+ yield file.path
+ end
+ end
+
+ def stream(content)
+ "<< /Length #{content.bytesize} >>\nstream\n#{content}\nendstream"
+ end
+
+ # A PDF 1.4 file with an xref table: catalog 1, pages 2, a page 3 in Helvetica (5), its contents.
+ def classic_pdf(contents, objects, page: "", media_box: "0 0 612 792")
+ bodies = {
+ 1 => "<< /Type /Catalog /Pages 2 0 R >>",
+ 2 => "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ 3 => "<< /Type /Page /Parent 2 0 R /MediaBox [#{media_box}] #{page} /Contents #{contents} /Resources << /Font << /F1 5 0 R >> >> >>",
+ 5 => "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
+ }.merge(objects).sort.to_h
+ output = +"%PDF-1.4\n"
+ offsets = bodies.to_h { |number, body| [number, output.bytesize].tap { output << "#{number} 0 obj\n#{body}\nendobj\n" } }
+ xref = output.bytesize
+ output << "xref\n0 #{bodies.keys.max + 1}\n0000000000 65535 f \n"
+ (1..bodies.keys.max).each { |number| output << (offsets[number] ? "#{format("%010d", offsets[number])} 00000 n \n" : "0000000000 65535 f \n") }
+ output << "trailer\n<< /Size #{bodies.keys.max + 1} /Root 1 0 R >>\nstartxref\n#{xref}\n%%EOF\n"
+ end
+end