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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
13 changes: 11 additions & 2 deletions docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,17 @@ <h2 id="native-title">Gamba’s own API</h2>
<span class="n">total</span><span class="p">.</span><span class="nf">text</span> <span class="s2">"1 234,00 €"</span>
<span class="k">end</span>
<span class="k">end</span></code></pre>
<p>In a row, a child given a <code>width</code> takes it and the others share what is left, in proportion to their <code>grow</code> (1 unless told). Both containers take <code>gap</code>, <code>padding</code> (one number, <code>[vertical, horizontal]</code> or <code>[top, right, bottom, left]</code>), <code>background</code>, <code>border_width</code> and <code>border_color</code>.</p>
<p>A stack breaks pages between its children, and a text between its lines. A row, a stack with <code>keep_together: true</code> 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 <code>Prawn::Errors::CannotFit</code>, saying which box and by how much. </p>
<p>In a row, a child given a <code>width</code> takes it and the others share what is left, in proportion to their <code>grow</code> (1 unless told). A <code>grid</code> puts its cells side by side, <code>columns</code> 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; <code>gap</code> is one number or <code>[between lines, between columns]</code>:</p>
<pre><code class="language-ruby"><span class="n">document</span><span class="p">.</span><span class="nf">grid</span><span class="p">(</span><span class="ss">columns: </span><span class="mi">3</span><span class="p">,</span> <span class="ss">gap: </span><span class="p">[</span><span class="mi">16</span><span class="p">,</span> <span class="mi">12</span><span class="p">])</span> <span class="k">do</span> <span class="o">|</span><span class="n">grid</span><span class="o">|</span>
<span class="n">products</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">product</span><span class="o">|</span>
<span class="n">grid</span><span class="p">.</span><span class="nf">stack</span><span class="p">(</span><span class="ss">padding: </span><span class="mi">8</span><span class="p">,</span> <span class="ss">border_color: </span><span class="s2">"cccccc"</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">card</span><span class="o">|</span>
<span class="n">card</span><span class="p">.</span><span class="nf">text</span> <span class="n">product</span><span class="p">.</span><span class="nf">name</span><span class="p">,</span> <span class="ss">weight: :bold</span>
<span class="n">card</span><span class="p">.</span><span class="nf">text</span> <span class="n">product</span><span class="p">.</span><span class="nf">price</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="k">end</span></code></pre>
<p>Every container takes <code>gap</code>, <code>padding</code> (one number, <code>[vertical, horizontal]</code> or <code>[top, right, bottom, left]</code>), <code>background</code>, <code>border_width</code> and <code>border_color</code>.</p>
<p>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 <code>keep_together: true</code>, 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 <code>Prawn::Errors::CannotFit</code>, saying which box and by how much. </p>
<p>Pages break where a reader expects them. A paragraph never leaves fewer than <code>orphans</code> lines at the bottom of a page, nor fewer than <code>widows</code> 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.</p>
<p>A box with <code>keep_with_next: true</code> 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:</p>
<pre><code class="language-ruby"><span class="n">document</span><span class="p">.</span><span class="nf">style</span> <span class="ss">:heading</span><span class="p">,</span> <span class="ss">size: </span><span class="mi">16</span><span class="p">,</span> <span class="ss">weight: :bold</span><span class="p">,</span> <span class="ss">keep_with_next: </span><span class="kp">true</span>
Expand Down
112 changes: 106 additions & 6 deletions lib/gamba/native/layout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion test/contract/gamba.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
144 changes: 144 additions & 0 deletions test/native_grid_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading