From 953d5764e3786802335e079aa312fe469bb0a3df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 08:48:18 +0000 Subject: [PATCH] Draw a page of an existing PDF like an image, or under every page pdf_page draws a page of another PDF (a path, an IO or its bytes) with the options image takes, and background_pdf puts one under every page, start_new_page naming another or none. The page is a form XObject showing its crop box turned by its /Rotate, written once however many pages draw it; pages of one file share their resources. Under pdf_a, a page whose fonts are not embedded raises. Gamba's own API takes background_pdf as a document option and pdf_page as a box. The object copying of Gamba.combine moves to Gamba::Copier and its page boxes to Gamba::PageBoxes, both shared with the import. The input fuzzer now corrupts PDFs too. Assisted-by: Claude [Claude Code] --- README.md | 18 +++- docs/guide.html | 8 ++ lib/gamba.rb | 5 +- lib/gamba/combine.rb | 146 ++------------------------- lib/gamba/copier.rb | 107 ++++++++++++++++++++ lib/gamba/document.rb | 12 ++- lib/gamba/images.rb | 21 +++- lib/gamba/imported_page.rb | 117 +++++++++++++++++++++ lib/gamba/native/document.rb | 2 +- lib/gamba/native/layout.rb | 24 +++++ lib/gamba/page_boxes.rb | 68 +++++++++++++ lib/gamba/pdf/reader.rb | 10 +- test/contract/gamba.yml | 2 + test/input_fuzz_test.rb | 22 +++- test/pdf_page_test.rb | 190 +++++++++++++++++++++++++++++++++++ 15 files changed, 604 insertions(+), 148 deletions(-) create mode 100644 lib/gamba/copier.rb create mode 100644 lib/gamba/imported_page.rb create mode 100644 lib/gamba/page_boxes.rb create mode 100644 test/pdf_page_test.rb 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