diff --git a/README.md b/README.md index 37aae83..e49fd69 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ document.table(lines, columns: {sku: {title: "SKU", width: 60}, description: {}, Pages break where a reader expects them: a paragraph leaves no single line at the bottom or the top of a page (`widows` and `orphans`, 2 unless told), and a heading with `keep_with_next: true` moves with what follows it. -Text, styles, components, stacks, rows, tables, images, headers, footers and these page breaks are there today; grids -come next ([the plan](https://github.com/levups/gamba/issues/69)). +Text, styles, components, stacks, rows, grids, tables, images, headers, footers and these page breaks are there today; +boxes with a background split across pages 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 fa68d4f..549d24a 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -109,8 +109,17 @@
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.
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). A grid puts its cells side by side, columns to a line, all as wide, the last line’s included; a line is as high as its tallest cell, and a cell with a background or a border is drawn as high as its line; gap is one number or [between lines, between columns]:
document.grid(columns: 3, gap: [16, 12]) do |grid|
+ products.each do |product|
+ grid.stack(padding: 8, border_color: "cccccc") do |card|
+ card.text product.name, weight: :bold
+ card.text product.price
+ end
+ end
+end
+ Every container takes 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 grid breaks them between its lines. A row, a line of a grid, and a stack or a grid with keep_together: true, 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.
Pages break where a reader expects them. A paragraph never leaves fewer than orphans lines at the bottom of a page, nor fewer than widows at the top of the next: 2 each unless told, 1 to break anywhere. A paragraph too short to keep both moves whole to the next page; one that starts a page fills it whatever its rules.
A box with keep_with_next: true goes to the next page together with the start of the box after it: the first lines of a paragraph, the header and first row of a table, all of a box that cannot be split. Headings are the usual case, so the three can sit in a style:
document.style :heading, size: 16, weight: :bold, keep_with_next: true
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index ebb9d5e..305743a 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -296,6 +296,13 @@ def row(**options)
add(container)
end
+ # Cells side by side, `columns` to a line, all as wide; a line is as high as its tallest cell.
+ def grid(columns:, **options)
+ container = Grid.new(@styles, columns:, **options)
+ yield container
+ add(container)
+ end
+
# Draws anything that answers call(builder): a document is made of small objects, each tested alone.
def draw(component)
component.call(self)
@@ -376,8 +383,9 @@ def initialize(styles, **options)
super
end
- def paint(prawn, left, top, width)
- decorate(prawn, left, top, width, height(prawn, width))
+ # In a line of a grid, the background and the border reach down to the bottom of the line.
+ def paint(prawn, left, top, width, stretched_height = nil)
+ decorate(prawn, left, top, width, stretched_height || height(prawn, width))
child_top = top - @padding_top
@children.each do |child|
child_width = child_width(child, width)
@@ -453,17 +461,21 @@ def initialize(styles, **options)
super
end
- def paint(prawn, left, top, width)
- decorate(prawn, left, top, width, height(prawn, width))
+ def paint(prawn, left, top, width, stretched_height = nil)
+ decorate(prawn, left, top, width, stretched_height || height(prawn, width))
child_left = left + @padding_left
+ paint_children(prawn, child_left, top, width)
+ end
+
+ private
+
+ def paint_children(prawn, child_left, top, width)
@children.zip(child_widths(width)) do |child, child_width|
child.paint(prawn, child_left, top - @padding_top, child_width)
child_left += child_width + @gap
end
end
- private
-
def measure(prawn, width)
widths = child_widths(width)
tallest = 0
@@ -488,6 +500,94 @@ def child_widths(width)
end
end
+ # Cells in lines of `columns`: a stack of rows whose cells are all as wide, the last line's included. Pages
+ # break between lines, never inside one. gap is one number, or [between lines, between columns].
+ class Grid < Stack
+ OPTIONS = (Stack::OPTIONS + %i[columns]).freeze
+
+ def initialize(styles, columns:, gap: 0, **options)
+ unless columns.is_a?(Integer) && columns.positive?
+ raise ArgumentError, "columns is #{columns.inspect}: give the number of cells on a line, 1 or more"
+ end
+
+ Styles.check_options(options, OPTIONS)
+ line_gap, @column_gap = grid_gaps(gap)
+ @columns = columns
+ super(styles, gap: line_gap, **options)
+ end
+
+ private
+
+ def add(node)
+ if node.width || node.grow
+ raise ArgumentError, "width and grow mean nothing in a grid: its columns share its width equally"
+ end
+
+ @children << Line.new(@styles, @columns, @column_gap) if @children.empty? || @children.last.full?
+ @children.last.push(node)
+ self
+ end
+
+ def description
+ "a grid kept together"
+ end
+
+ def grid_gaps(gap)
+ case gap
+ in Numeric then [gap, gap]
+ in [Numeric => line_gap, Numeric => column_gap] then [line_gap, column_gap]
+ else raise ArgumentError, "gap is #{gap.inspect}: give one number, or [between lines, between columns]"
+ end
+ end
+
+ # A line of a grid: a row whose cells take a column each, however many it holds.
+ class Line < Row
+ def initialize(styles, columns, column_gap)
+ super(styles, gap: column_gap)
+ @columns = columns
+ end
+
+ def push(node)
+ @children << node
+ end
+
+ def full?
+ @children.size == @columns
+ end
+
+ # Kept with the next line when one of its cells is, a heading in a cell for instance.
+ def keep_with_next
+ @children.any?(&:keep_with_next)
+ end
+
+ private
+
+ # Cells with a background or a border are drawn as high as the line, like those of a CSS grid.
+ def paint_children(prawn, child_left, top, width)
+ line_height = height(prawn, width)
+ @children.zip(child_widths(width)) do |child, child_width|
+ if child.is_a?(Container)
+ child.paint(prawn, child_left, top, child_width, line_height)
+ else
+ child.paint(prawn, child_left, top, child_width)
+ end
+ child_left += child_width + @gap
+ end
+ end
+
+ def description
+ "a line of a grid"
+ end
+
+ def child_widths(width)
+ gaps = @gap * (@columns - 1)
+ raise Prawn::Errors::CannotFit, "a grid's gaps are #{(gaps - width).round(1)} pt wider than the grid" if gaps > width
+
+ Array.new(@children.size, (width - gaps) / @columns.to_f)
+ end
+ end
+ end
+
# Named text attributes, and what they come to for Prawn once a call's own are added.
class Styles
TEXT_ATTRIBUTES = %i[font size weight italic color align leading widows orphans keep_with_next].freeze
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 2da7d48..53eef23 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -12,6 +12,7 @@ Gamba::Document:
"#row": [keyrest options]
"#stack": [keyrest options]
"#footer": [key gap, block block]
+ "#grid": [keyreq columns, keyrest options]
"#header": [key gap, block block]
"#image": [req source, keyrest options]
"#register_font": [req family, keyreq normal, key bold, key italic, key bold_italic, key features]
@@ -20,9 +21,10 @@ Gamba::Document:
"#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.
+# What a stack, a row and a grid answer inside their block; the rest of the class is how they are measured and placed.
Gamba::Layout::Container:
"#draw": [req component]
+ "#grid": [keyreq columns, keyrest options]
"#image": [req source, keyrest options]
"#row": [keyrest options]
"#stack": [keyrest options]
diff --git a/test/native_grid_test.rb b/test/native_grid_test.rb
new file mode 100644
index 0000000..aeb860d
--- /dev/null
+++ b/test/native_grid_test.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+
+# Grids of Gamba's own API: cells in lines of a given number of equal columns, pages broken between lines.
+class NativeGridTest < Minitest::Test
+ def test_cells_take_equal_columns_the_last_line_included_and_lines_are_separated_by_the_line_gap
+ document = Gamba.document(margin: 50, page_size: [600, 400]) do |yielded|
+ yielded.grid(columns: 3, gap: [20, 10]) { |grid| %w[A B C D].each { |letter| grid.text letter } }
+ end
+ a, b, c, d = glyphs(document.to_s)
+
+ assert_equal [50.0, 220.0, 390.0, 50.0], [a, b, c, d].map { _1[3] }
+ assert_equal [a[4]] * 3, [a, b, c].map { _1[4] }
+ assert_in_delta line_advance + 20, d[4] - a[4], 0.01
+ end
+
+ def test_one_gap_separates_both_lines_and_columns
+ document = Gamba.document(margin: 50, page_size: [600, 400]) do |yielded|
+ yielded.grid(columns: 2, gap: 10) { |grid| %w[A B C].each { |letter| grid.text letter } }
+ end
+ a, b, c = glyphs(document.to_s)
+
+ assert_equal [50.0, 305.0, 50.0], [a, b, c].map { _1[3] }
+ assert_in_delta line_advance + 10, c[4] - a[4], 0.01
+ end
+
+ def test_a_line_is_as_high_as_its_tallest_cell
+ document = Gamba.document do |yielded|
+ yielded.grid(columns: 2) do |grid|
+ grid.text "short"
+ grid.stack { |stack| 4.times { stack.text "line" } }
+ grid.text "next"
+ end
+ end
+ first, next_line = glyphs(document.to_s).values_at(0, -4)
+
+ assert_equal "n", next_line.first
+ assert_in_delta 4 * line_advance, next_line[4] - first[4], 0.01
+ end
+
+ def test_cells_with_a_background_or_a_border_are_drawn_as_high_as_their_line
+ document = Gamba.document do |yielded|
+ yielded.grid(columns: 3) do |grid|
+ grid.stack(border_color: "cccccc") { |card| card.text "short" }
+ grid.stack(background: "eeeeee") { |card| 3.times { card.text "tall" } }
+ grid.text "plain"
+ end
+ end
+ heights = uncompressed(document.to_s).scan(/^[\d.]+ [\d.]+ [\d.]+ ([\d.]+) re$/).flatten.map(&:to_f)
+
+ assert_equal 2, heights.size
+ assert_in_delta 3 * line_advance, heights.first, 0.01
+ assert_equal heights.first, heights.last
+ end
+
+ def test_pages_break_between_lines_never_inside_one
+ document = Gamba.document do |yielded|
+ yielded.prawn.move_down 700
+ yielded.grid(columns: 2) { |grid| 4.times { |index| grid.text "cell #{index}" } }
+ end
+
+ assert_equal [1, 1], lines_by_page(document.to_s)
+ end
+
+ def test_a_line_with_a_cell_kept_with_the_next_goes_to_the_next_page_with_it
+ document = Gamba.document do |yielded|
+ yielded.prawn.move_down 700
+ yielded.grid(columns: 2) do |grid|
+ grid.text "heading", keep_with_next: true
+ grid.text "aside"
+ 2.times { |index| grid.text "cell #{index}" }
+ end
+ end
+
+ assert_equal [0, 2], lines_by_page(document.to_s)
+ end
+
+ def test_a_decorated_grid_moves_whole_to_the_next_page
+ document = Gamba.document do |yielded|
+ yielded.prawn.move_down 700
+ yielded.grid(columns: 2, background: "eeeeee") { |grid| 4.times { |index| grid.text "cell #{index}" } }
+ end
+
+ assert_equal [0, 2], lines_by_page(document.to_s)
+ end
+
+ def test_a_grid_is_a_box_like_the_others
+ document = Gamba.document(margin: 50, page_size: [600, 400]) do |yielded|
+ yielded.row(gap: 20) do |row|
+ row.text "L", width: 100
+ row.grid(columns: 2, padding: 10) { |grid| %w[A B].each { |letter| grid.text letter } }
+ end
+ end
+
+ assert_equal [50.0, 180.0, 360.0], glyphs(document.to_s).map { _1[3] }.uniq
+ end
+
+ def test_wrong_columns_gaps_and_cell_widths_raise
+ document = Gamba.document
+
+ assert_raises(ArgumentError) { document.grid { nil } }
+ assert_match(/\Acolumns is 0: /, assert_raises(ArgumentError) { document.grid(columns: 0) { nil } }.message)
+ assert_raises(ArgumentError) { document.grid(columns: "2") { nil } }
+ assert_raises(ArgumentError) { document.grid(columns: 2, gap: [1, 2, 3]) { nil } }
+ assert_match(/\Aunknown option :rows: accepted options are :width, :grow, .*:keep_together, :columns\z/, assert_raises(ArgumentError) { document.grid(columns: 2, rows: 2) { nil } }.message)
+ assert_raises(ArgumentError) { document.grid(columns: 2) { |grid| grid.text "A", width: 10 } }
+ assert_raises(ArgumentError) { document.grid(columns: 2) { |grid| grid.text "A", grow: 1 } }
+ assert_equal "a grid's gaps are 60.0 pt wider than the grid", assert_raises(Prawn::Errors::CannotFit) { document.grid(columns: 4, gap: 200) { |grid| grid.text "A" } }.message
+ end
+
+ private
+
+ def line_advance
+ Prawn::Document.new.height_of("A")
+ 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(["grid", ".pdf"]) do |file|
+ file.binmode
+ file.write(pdf)
+ file.flush
+ yield file.path
+ end
+ end
+end