diff --git a/README.md b/README.md index cf911ea..b566622 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ end ``` Code written for Prawn adopts it one block at a time, `gamba { |document| document.text "Total", style: :title }`, -and `document.prawn` gives a native document all of Prawn's API. Text, styles, components, stacks and rows are there -today; tables, images, headers and footers as boxes come next +and `document.prawn` gives a native document all of Prawn's API. Text, styles, components, stacks, rows, tables and +images are there today; headers, footers and grids come next ([the plan](https://github.com/levups/gamba/issues/69)). Ligatures and kerning from OpenType fonts, per font: diff --git a/docs/guide.html b/docs/guide.html index 9632fcf..6ded0cb 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -110,7 +110,19 @@

Gamba’s own API

end end

In a row, a child given a width takes it and the others share what is left, in proportion to their grow (1 unless told). Both containers take gap, padding (one number, [vertical, horizontal] or [top, right, bottom, left]), background, border_width and border_color.

-

A stack breaks pages between its children, and a text between its lines. A row, a stack with keep_together: true and a stack with a background or a border move whole to the next page when the rest of this one is too short; one that is higher than a page raises Prawn::Errors::CannotFit, saying which box and by how much. Tables, images, headers and footers are not boxes yet: draw them through document.prawn between two stacks.

+

A stack breaks pages between its children, and a text between its lines. A row, a stack with keep_together: true and a stack with a background or a border move whole to the next page when the rest of this one is too short; one that is higher than a page raises Prawn::Errors::CannotFit, saying which box and by how much.

+

Tables and images are boxes too, on the document, in a stack or in a row. A table takes the width of its box, unless column_widths gives every column its own, and in a stack it runs over pages with its header repeated. Its options are Prawn’s header, column_widths, cell_style and row_colors, and its block receives the Prawn::Table before it is laid out, to style rows, columns and cells as Prawn’s manual shows.

+
document.stack(gap: 12) do |stack|
+  stack.table([%w[SKU Description Qty Total], *lines], header: true, cell_style: {size: 9, padding: 4}) do |table|
+    table.row(0).font_style = :bold
+    table.columns(2..3).align = :right
+  end
+  stack.row do |row|
+    row.image "signature.png", height: 40
+    row.table([["Total", "1 234,00 €"]], width: 200)
+  end
+end
+

An image keeps its own size unless told a width or a height, is never wider than its box, and keeps its proportions; align puts it on the left, the centre or the right of its box. The same file placed twice is embedded once. Headers, footers and SVG are not boxes yet: draw them through document.prawn.

You don’t have to rewrite anything to start. In code written for Prawn, gamba opens a native block at the cursor, and leaves the cursor below what it drew:

Prawn::Document.generate("report.pdf") do
   text "Existing code, unchanged"
diff --git a/lib/gamba/native/document.rb b/lib/gamba/native/document.rb
index 0d2fd28..8b15aae 100644
--- a/lib/gamba/native/document.rb
+++ b/lib/gamba/native/document.rb
@@ -62,10 +62,12 @@ def write(destination)
 
     private
 
-    # A stack or a row of the document is placed as soon as its block ends, from the cursor, which it leaves below.
-    def add(container)
+    # A box of the document is placed as soon as it is described, from the cursor, which it leaves below.
+    def add(box)
+      raise ArgumentError, "grow shares the width of a row: it means nothing on the document" if box.grow
+
       cursor = Layout::Cursor.new(prawn)
-      container.flow(prawn, cursor, prawn.bounds.absolute_left, prawn.bounds.width)
+      box.flow(prawn, cursor, prawn.bounds.absolute_left, [box.width || prawn.bounds.width, prawn.bounds.width].min)
       prawn.y = cursor.y
       self
     end
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index 8438c80..9c930bc 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -4,7 +4,7 @@ module Gamba
   # Boxes described first, measured, then placed: a stack puts its children one under the other and breaks pages
   # between them, a row puts them side by side on one page.
   module Layout
-    FIT_TOLERANCE_POINTS = 0.001
+    FIT_TOLERANCE_POINTS = Prawn::FLOAT_PRECISION
     MEASURING_HEIGHT_POINTS = 100_000_000
 
     # The top of the next box on the current page, in absolute coordinates.
@@ -144,8 +144,113 @@ def advance(box)
       end
     end
 
+    # A Prawn table as a box: as wide as its box, broken across pages with its header when a stack flows it.
+    class Table < Node
+      OPTIONS = (Node::OPTIONS + %i[header column_widths cell_style row_colors]).freeze
+      # Prawn starts a new page unless a row is strictly shorter than what is left of the page: a table that
+      # fills the page to the point would be cut. Measured a hair higher, it fits with room to spare or moves.
+      PAGE_BREAK_SLACK_POINTS = 0.001
+
+      def initialize(rows, styling, width: nil, grow: nil, **table_options)
+        Styles.check_options(table_options, OPTIONS)
+        super(width:, grow:)
+        @rows = rows
+        @styling = styling
+        @table_options = table_options.compact
+      end
+
+      def paint(prawn, left, top, width)
+        table = table(prawn, width)
+        prawn.y = top
+        within(prawn, left, width) { table.draw }
+      end
+
+      def flow(prawn, cursor, left, width)
+        paint(prawn, left, cursor.y, width)
+        cursor.y = prawn.y
+      end
+
+      private
+
+      def measure(prawn, width)
+        table(prawn, width).height + PAGE_BREAK_SLACK_POINTS
+      end
+
+      def description
+        "a table"
+      end
+
+      # Columns given all their widths keep them; otherwise the table takes the width of its box.
+      def table(prawn, width)
+        return @table if @table_width == width
+
+        options = @table_options[:column_widths].is_a?(Array) ? @table_options : {width:, **@table_options}
+        @table_width = width
+        @table = within(prawn, prawn.bounds.absolute_left, width) { prawn.make_table(@rows, options) { |table| @styling&.call(table) } }
+      end
+
+      # Prawn's table reads its left edge and the width it may take from the bounds.
+      def within(prawn, left, width, &)
+        bounds = prawn.bounds
+        prawn.indent(left - bounds.absolute_left, bounds.absolute_right - left - width, &)
+      end
+    end
+
+    # An image as a box: at its own size unless told a width or a height, never wider than its box, never distorted.
+    class Image < Node
+      OPTIONS = (Node::OPTIONS + %i[height align]).freeze
+      ALIGNMENTS = %i[left center right].freeze
+
+      def initialize(source, height: nil, align: :left, **)
+        raise ArgumentError, "align is #{align.inspect}: accepted alignments are #{ALIGNMENTS.map(&:inspect).join(", ")}" unless ALIGNMENTS.include?(align)
+
+        super(**)
+        @source = source
+        @height = height
+        @align = align
+      end
+
+      def paint(prawn, left, top, width)
+        image_width, image_height = size(prawn, width)
+        offset = {left: 0, center: (width - image_width) / 2.0, right: width - image_width}.fetch(@align)
+        prawn.embed_image(*image(prawn), at: relative(prawn, left + offset, top), width: image_width, height: image_height)
+      end
+
+      private
+
+      def measure(prawn, width)
+        size(prawn, width).last
+      end
+
+      def description
+        "an image"
+      end
+
+      def image(prawn)
+        @image ||= prawn.build_image_object(@source)
+      end
+
+      def size(prawn, available_width)
+        info = image(prawn).last
+        ratio = info.width / info.height.to_f
+        image_width = @height ? @height * ratio : (width || info.width)
+        image_width = [image_width, available_width].min
+        [image_width, image_width / ratio]
+      end
+    end
+
     # What a document and its containers share: the calls that add a box.
     module Builder
+      # The block receives the Prawn::Table before it is laid out, to style rows, columns and cells.
+      def table(rows, **options, &styling)
+        add(Table.new(rows, styling, **options))
+      end
+
+      def image(source, **options)
+        Styles.check_options(options, Image::OPTIONS)
+        add(Image.new(source, **options))
+      end
+
       def stack(**options)
         container = Stack.new(@styles, **options)
         yield container
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 7513bbb..247d2f5 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -10,16 +10,20 @@ Gamba::Document:
   "#prawn": []
   "#row": [keyrest options]
   "#stack": [keyrest options]
+  "#image": [req source, 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]
   "#text": [req string, key style, keyrest attributes]
   "#to_s": []
   "#write": [req destination]
 # What a stack and a row answer inside their block; the rest of the class is how they are measured and placed.
 Gamba::Layout::Container:
   "#draw": [req component]
+  "#image": [req source, keyrest options]
   "#row": [keyrest options]
   "#stack": [keyrest options]
+  "#table": [req rows, keyrest options, block styling]
   "#text": [req string, key style, key width, key grow, keyrest attributes]
 Prawn::Document:
   "#gamba": []
diff --git a/test/native_boxes_test.rb b/test/native_boxes_test.rb
new file mode 100644
index 0000000..22cf2ac
--- /dev/null
+++ b/test/native_boxes_test.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+
+# Tables and images as boxes of Gamba's own API: measured like the others, placed in stacks and rows.
+class NativeBoxesTest < Minitest::Test
+  IMAGE = File.join(Oracle::ROOT, "corpus/cache/prawn/data/images/dice.png") # 320 by 240 points
+  ROWS = [%w[SKU Description Quantity], *Array.new(60) { |index| ["SKU#{index}", "Gadget, model #{index}", "2"] }].freeze
+
+  def test_a_table_draws_what_prawn_draws_at_the_width_of_its_box_across_pages
+    native = Gamba.document { |document| document.table(ROWS, header: true, cell_style: {size: 9}) }
+    prawn = Prawn::Document.new { table(ROWS, header: true, width: bounds.width, cell_style: {size: 9}) }
+
+    assert_operator native.prawn.page_count, :>, 1
+    assert_equal pages(prawn.render), pages(native.to_s)
+    assert_in_delta prawn.cursor, native.prawn.cursor, 0.001
+  end
+
+  def test_the_block_styles_the_prawn_table_before_it_is_laid_out
+    document = Gamba.document do |yielded|
+      yielded.table(ROWS.first(2)) do |table|
+        assert_instance_of Prawn::Table, table
+        table.row(0).font_style = :bold
+      end
+    end
+
+    assert_equal %w[Helvetica-Bold Helvetica], glyphs(document.to_s).map { _1[1] }.uniq
+  end
+
+  def test_a_table_given_all_its_column_widths_keeps_them
+    document = Gamba.document { |yielded| yielded.table([%w[A B]], column_widths: [100, 50]) }
+
+    assert_equal [41.0, 141.0], glyphs(document.to_s).map { _1[3] }
+  end
+
+  def test_what_follows_a_table_in_a_stack_starts_below_it_after_the_gap
+    document = Gamba.document do |yielded|
+      yielded.stack(gap: 10) do |stack|
+        stack.table(ROWS.first(3))
+        stack.text "After"
+      end
+    end
+    table_height = Prawn::Document.new.make_table(ROWS.first(3)).height
+    after = glyphs(document.to_s).find { _1.first == "A" }
+    top_of_a_page = glyphs(Gamba.document { |yielded| yielded.text "After" }.to_s).first[4]
+
+    assert_in_delta top_of_a_page + table_height + 10, after[4], 0.01
+  end
+
+  def test_a_table_in_a_row_takes_its_share_and_sets_the_height_of_the_row
+    cursor_after = nil
+    document = Gamba.document do |yielded|
+      yielded.row(gap: 20) do |row|
+        row.text "Left"
+        row.table([%w[A B], %w[C D]], width: 200)
+      end
+      cursor_after = yielded.prawn.cursor
+    end
+    table_height = Prawn::Document.new.make_table([%w[A B], %w[C D]]).height
+
+    assert_in_delta 36 + 540 - 200 + 5, glyphs(document.to_s).find { _1.first == "A" }[3], 0.01
+    assert_in_delta 720 - table_height, cursor_after, 0.01
+  end
+
+  def test_a_row_holding_a_table_moves_whole_to_the_next_page
+    document = Gamba.document do |yielded|
+      yielded.prawn.move_down 690
+      yielded.row do |row|
+        row.text "Left"
+        row.table(ROWS.first(3))
+      end
+    end
+
+    first_page, second_page = pages(document.to_s).map { |page| page.map(&:first).join }
+
+    assert_empty first_page
+    assert_includes second_page, "Left"
+    assert_includes second_page, "SKU1"
+  end
+
+  def test_a_table_that_fills_the_page_to_the_point_is_not_cut
+    table_height = Prawn::Document.new.make_table(ROWS.first(3), width: 540).height
+    document = Gamba.document do |yielded|
+      yielded.prawn.move_down 720 - table_height
+      yielded.row { |row| row.table(ROWS.first(3)) }
+    end
+
+    assert_equal [0, 3], lines_by_page(document.to_s)
+  end
+
+  def test_unknown_table_and_image_options_raise
+    document = Gamba.document
+
+    assert_match(/\Aunknown option :headr: accepted options are :width, :grow, :header, /, assert_raises(ArgumentError) { document.table(ROWS, headr: true) }.message)
+    assert_raises(ArgumentError) { document.image(IMAGE, hight: 40) }
+    assert_raises(ArgumentError) { document.image(IMAGE, align: :middle) }
+    assert_raises(ArgumentError) { document.image(IMAGE, grow: 1) }
+    assert_raises(ArgumentError) { document.image("missing.png") }
+  end
+
+  def test_an_image_keeps_its_size_unless_told_a_width_or_a_height_and_its_proportions_always
+    skip CORPUS_MISSING_MESSAGE unless File.exist?(IMAGE)
+
+    assert_equal [[320.0, 240.0, 36.0, 516.0]], placements(Gamba.document { |document| document.image IMAGE })
+    assert_equal [[120.0, 90.0, 36.0, 666.0]], placements(Gamba.document { |document| document.image IMAGE, width: 120 })
+    assert_equal [[80.0, 60.0, 36.0, 696.0]], placements(Gamba.document { |document| document.image IMAGE, height: 60 })
+  end
+
+  def test_an_image_is_never_wider_than_its_box_and_is_aligned_in_it
+    skip CORPUS_MISSING_MESSAGE unless File.exist?(IMAGE)
+    document = Gamba.document(page_size: [372, 400]) do |yielded|
+      yielded.row(gap: 0) do |row|
+        row.image IMAGE
+        row.image IMAGE, height: 30, align: :center
+        row.image IMAGE, height: 30, align: :right
+      end
+    end
+
+    assert_equal [[100.0, 75.0, 36.0, 289.0], [40.0, 30.0, 166.0, 334.0], [40.0, 30.0, 296.0, 334.0]], placements(document)
+  end
+
+  def test_what_follows_an_image_starts_below_it_and_an_image_too_high_for_the_page_moves_to_the_next
+    skip CORPUS_MISSING_MESSAGE unless File.exist?(IMAGE)
+    cursors = []
+    document = Gamba.document do |yielded|
+      yielded.image IMAGE, height: 100
+      cursors << yielded.prawn.cursor
+      yielded.prawn.move_down 600
+      yielded.image IMAGE, height: 100
+      cursors << yielded.prawn.cursor
+    end
+
+    assert_equal [620.0, 620.0], cursors
+    assert_equal 2, document.prawn.page_count
+  end
+
+  def test_an_image_placed_twice_is_embedded_once
+    skip CORPUS_MISSING_MESSAGE unless File.exist?(IMAGE)
+    document = Gamba.document { |yielded| yielded.row { |row| 2.times { row.image IMAGE, height: 40 } } }
+
+    assert_equal 2, placements(document).size
+    assert_equal 1, uncompressed(document.to_s).scan(%r{/Subtype /Image}).size - uncompressed(document.to_s).scan(%r{/SMask \d}).size
+  end
+
+  private
+
+  # Each image drawn: [width, height, left, bottom], from the matrix its `Do` operator is drawn with.
+  def placements(document)
+    uncompressed(document.to_s).scan(/^([\d.]+) 0\.0 0\.0 ([\d.]+) ([\d.]+) ([\d.]+) cm\n\/I\d+ Do$/).map { |numbers| numbers.map { _1.to_f.round(2) } }
+  end
+
+  def lines_by_page(pdf)
+    pages(pdf).map { |page| page.map(&:last).uniq.size }
+  end
+
+  def glyphs(pdf)
+    pages(pdf).flatten(1)
+  end
+
+  def pages(pdf)
+    with_file(pdf) { |path| Oracle.text_layout(path).map(&:glyphs) }
+  end
+
+  def uncompressed(pdf)
+    with_file(pdf) { |path| Open3.capture2("qpdf", "--qdf", "--object-streams=disable", path, "-").first.b }
+  end
+
+  def with_file(pdf)
+    Tempfile.create(["boxes", ".pdf"]) do |file|
+      file.binmode
+      file.write(pdf)
+      file.flush
+      yield file.path
+    end
+  end
+end