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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,14 @@ their page:
document.footer(gap: 12) { |footer, page| footer.text "Page #{page.number} of #{page.total}", align: :center }
```

Text, styles, components, stacks, rows, tables, images, headers and footers are there today; tables with named
columns and grids come next ([the plan](https://github.com/levups/gamba/issues/69)).
Tables have named columns, and rows that are hashes or objects, so nothing is counted by position:

```ruby
document.table(lines, columns: {sku: {title: "SKU", width: 60}, description: {}, total: {align: :right, weight: :bold}})
```

Text, styles, components, stacks, rows, tables, images, headers and footers are there today; grids and finer page
breaks come next ([the plan](https://github.com/levups/gamba/issues/69)).

Ligatures and kerning from OpenType fonts, per font:

Expand Down
27 changes: 17 additions & 10 deletions docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,23 @@ <h2 id="native-title">Gamba’s own API</h2>
<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>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 <code>column_widths</code> gives every column its own, and in a stack it runs over pages with its header repeated. Its options are Prawn’s <code>header</code>, <code>column_widths</code>, <code>cell_style</code> and <code>row_colors</code>, and its block receives the <code>Prawn::Table</code> before it is laid out, to style rows, columns and cells as Prawn’s manual shows.</p>
<pre><code class="language-ruby"><span class="n">document</span><span class="p">.</span><span class="nf">stack</span><span class="p">(</span><span class="ss">gap: </span><span class="mi">12</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">stack</span><span class="o">|</span>
<span class="n">stack</span><span class="p">.</span><span class="nf">table</span><span class="p">([</span><span class="sx">%w[SKU Description Qty Total]</span><span class="p">,</span> <span class="o">*</span><span class="n">lines</span><span class="p">],</span> <span class="ss">header: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">cell_style: </span><span class="p">{</span><span class="ss">size: </span><span class="mi">9</span><span class="p">,</span> <span class="ss">padding: </span><span class="mi">4</span><span class="p">})</span> <span class="k">do</span> <span class="o">|</span><span class="n">table</span><span class="o">|</span>
<span class="n">table</span><span class="p">.</span><span class="nf">row</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nf">font_style</span> <span class="o">=</span> <span class="ss">:bold</span>
<span class="n">table</span><span class="p">.</span><span class="nf">columns</span><span class="p">(</span><span class="mi">2</span><span class="o">..</span><span class="mi">3</span><span class="p">).</span><span class="nf">align</span> <span class="o">=</span> <span class="ss">:right</span>
<span class="k">end</span>
<span class="n">stack</span><span class="p">.</span><span class="nf">row</span> <span class="k">do</span> <span class="o">|</span><span class="n">row</span><span class="o">|</span>
<span class="n">row</span><span class="p">.</span><span class="nf">image</span> <span class="s2">"signature.png"</span><span class="p">,</span> <span class="ss">height: </span><span class="mi">40</span>
<span class="n">row</span><span class="p">.</span><span class="nf">table</span><span class="p">([[</span><span class="s2">"Total"</span><span class="p">,</span> <span class="s2">"1 234,00 €"</span><span class="p">]],</span> <span class="ss">width: </span><span class="mi">200</span><span class="p">)</span>
<span class="k">end</span>
<p>Tables and images are boxes too, on the document, in a stack or in a row. A table has named columns: its rows are hashes, or objects answering the columns’ names, so nothing is counted by position. Each column takes a <code>title</code> (its name, spelled out, unless told), a <code>width</code>, an <code>align</code> and the text attributes <code>weight</code>, <code>italic</code>, <code>size</code> and <code>color</code>. Without <code>columns</code>, a table of hashes has those of its first row.</p>
<pre><code class="language-ruby"><span class="n">lines</span> <span class="o">=</span> <span class="p">[{</span><span class="ss">sku: </span><span class="s2">"A-1"</span><span class="p">,</span> <span class="ss">description: </span><span class="s2">"Gadget"</span><span class="p">,</span> <span class="ss">quantity: </span><span class="mi">2</span><span class="p">,</span> <span class="ss">total: </span><span class="s2">"20,00"</span><span class="p">}]</span>

<span class="n">document</span><span class="p">.</span><span class="nf">table</span><span class="p">(</span><span class="n">lines</span><span class="p">,</span>
<span class="ss">columns: </span><span class="p">{</span>
<span class="ss">sku: </span><span class="p">{</span><span class="ss">title: </span><span class="s2">"SKU"</span><span class="p">,</span> <span class="ss">width: </span><span class="mi">60</span><span class="p">},</span>
<span class="ss">description: </span><span class="p">{},</span>
<span class="ss">quantity: </span><span class="p">{</span><span class="ss">title: </span><span class="s2">"Qty"</span><span class="p">,</span> <span class="ss">align: :right</span><span class="p">},</span>
<span class="ss">total: </span><span class="p">{</span><span class="ss">align: :right</span><span class="p">,</span> <span class="ss">weight: :bold</span><span class="p">}</span>
<span class="p">},</span>
<span class="ss">header: </span><span class="p">{</span><span class="ss">background: </span><span class="s2">"333333"</span><span class="p">,</span> <span class="ss">color: </span><span class="s2">"ffffff"</span><span class="p">},</span>
<span class="ss">stripes: </span><span class="sx">%w[ffffff f6f6f6]</span><span class="p">,</span>
<span class="ss">cell: </span><span class="p">{</span><span class="ss">size: </span><span class="mi">9</span><span class="p">,</span> <span class="ss">padding: </span><span class="mi">4</span><span class="p">,</span> <span class="ss">border_color: </span><span class="s2">"cccccc"</span><span class="p">})</span></code></pre>
<p>A table takes the width of its box, unless every column has its own, and in a stack it runs over pages with its header repeated. The header row is made of the titles, in bold; <code>header:</code> takes its style (<code>align</code>, <code>weight</code>, <code>italic</code>, <code>size</code>, <code>color</code>, <code>background</code>) or <code>false</code> to leave it out. <code>stripes</code> alternates row backgrounds, and <code>cell</code> styles every cell: <code>padding</code>, <code>size</code>, <code>font</code>, <code>color</code>, <code>background</code>, <code>borders</code>, <code>border_width</code>, <code>border_color</code>. For what these do not cover, such as a span or the style of one cell, the block receives the <code>Prawn::Table</code> before it is laid out; Prawn’s table of arrays itself stays at <code>document.prawn.table</code>.</p>
<pre><code class="language-ruby"><span class="n">document</span><span class="p">.</span><span class="nf">table</span><span class="p">(</span><span class="n">lines</span><span class="p">,</span> <span class="ss">columns: </span><span class="n">columns</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">table</span><span class="o">|</span>
<span class="n">table</span><span class="p">.</span><span class="nf">row</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">).</span><span class="nf">borders</span> <span class="o">=</span> <span class="p">[</span><span class="ss">:top</span><span class="p">]</span>
<span class="n">table</span><span class="p">.</span><span class="nf">cells</span><span class="p">[</span><span class="mi">3</span><span class="p">,</span> <span class="mi">2</span><span class="p">].</span><span class="nf">background_color</span> <span class="o">=</span> <span class="s2">"ffeeee"</span>
<span class="k">end</span></code></pre>
<p>An image keeps its own size unless told a <code>width</code> or a <code>height</code>, is never wider than its box, and keeps its proportions; <code>align</code> puts it on the left, the centre or the right of its box. The same file placed twice is embedded once. SVG is not a box yet: draw it through <code>document.prawn</code>.</p>
<p>A header and a footer are declared once, before the body. Each block receives a stack to fill and the page, which answers <code>number</code> and <code>total</code>; it is run for every page when the document is rendered, once the pages are counted, in place of Prawn’s <code>repeat</code> and <code>number_pages</code>.</p>
Expand Down
1 change: 1 addition & 0 deletions lib/gamba/native/document.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "layout"
require_relative "table"
require_relative "band"

module Gamba
Expand Down
68 changes: 11 additions & 57 deletions lib/gamba/native/layout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,58 +144,6 @@ 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
Expand Down Expand Up @@ -241,7 +189,8 @@ def size(prawn, available_width)

# 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.
# Rows are hashes, or objects answering the columns' names. The block receives the Prawn::Table before it
# is laid out, for what the options do not cover: spans, the style of one cell.
def table(rows, **options, &styling)
add(Table.new(rows, styling, **options))
end
Expand Down Expand Up @@ -439,6 +388,14 @@ def self.check_options(given, accepted)
"accepted options are #{accepted.map(&:inspect).join(", ")}"
end

# Prawn's name for a weight and a slant.
def self.font_style(weight, italic)
weight ||= :normal
raise ArgumentError, "weight is #{weight.inspect}: accepted weights are :normal, :bold" unless %i[normal bold].include?(weight)

FONT_STYLES[((weight == :bold) ? 1 : 0) + (italic ? 2 : 0)]
end

def initialize(prawn, default_font_features)
@prawn = prawn
@default_font_features = default_font_features
Expand Down Expand Up @@ -470,10 +427,7 @@ def named(name)
end

def text_options(attributes)
weight = attributes.delete(:weight) || :normal
raise ArgumentError, "weight is #{weight.inspect}: accepted weights are :normal, :bold" unless %i[normal bold].include?(weight)

font_style = FONT_STYLES[((weight == :bold) ? 1 : 0) + (attributes.delete(:italic) ? 2 : 0)]
font_style = self.class.font_style(attributes.delete(:weight), attributes.delete(:italic))
attributes[:style] = font_style unless font_style == :normal
attributes.compact
end
Expand Down
137 changes: 137 additions & 0 deletions lib/gamba/native/table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

module Gamba
module Layout
# A table of named columns as a box: as wide as its box, broken across pages with its header when a stack
# flows it. Prawn's table lays it out and draws it.
class Table < Node
OPTIONS = (Node::OPTIONS + %i[columns header stripes cell]).freeze
COLUMN_OPTIONS = %i[title width align weight italic size color].freeze
HEADER_OPTIONS = %i[align weight italic size color background].freeze
CELL_OPTIONS = %i[padding size font color background borders border_width border_color].freeze
DEFAULT_HEADER = {weight: :bold}.freeze
# A native name on the left, the name of Prawn's cell on the right.
PRAWN_CELL_NAMES = {color: :text_color, background: :background_color}.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, **options)
Styles.check_options(options, OPTIONS)
super(width: options[:width], grow: options[:grow])
@rows = rows
@styling = styling
@columns = checked_columns(options[:columns] || inferred_columns)
@header = checked_header(options.fetch(:header, true))
@stripes = options[:stripes]
@cell = options.fetch(:cell, {})
Styles.check_options(@cell, CELL_OPTIONS)
end

def paint(prawn, left, top, width)
return if data.empty?

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 unless data.empty?
end

private

def measure(prawn, width)
data.empty? ? 0 : table(prawn, width).height + PAGE_BREAK_SLACK_POINTS
end

def description
"a table"
end

# Without columns, a table of hashes has those of its first row.
def inferred_columns
first = @rows.first
unless first.is_a?(Hash)
raise ArgumentError, 'columns are missing: name them, columns: {total: {title: "Total", align: :right}}, or give rows as hashes'
end

first.keys.to_h { |name| [name, {}] }
end

def checked_columns(columns)
columns.each_value { |options| Styles.check_options(options, COLUMN_OPTIONS) }
raise ArgumentError, "a table needs at least one column" if columns.empty?

columns
end

def checked_header(header)
return header ? DEFAULT_HEADER : nil unless header.is_a?(Hash)

Styles.check_options(header, HEADER_OPTIONS)
{**DEFAULT_HEADER, **header}
end

def data
@data ||= begin
body = @rows.map { |row| @columns.each_key.map { |name| value(row, name) } }
@header ? [@columns.map { |name, options| options.fetch(:title) { name.to_s.tr("_", " ").capitalize } }, *body] : body
end
end

def value(row, name)
case row
when Hash then row[name]
when Array then raise ArgumentError, "a row is an array: rows are hashes, or objects answering the columns' names (Prawn's table of arrays is document.prawn.table)"
else row.public_send(name)
end
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

@table_width = width
@table = within(prawn, prawn.bounds.absolute_left, width) do
prawn.make_table(data, prawn_options(width)) do |table|
style_columns(table)
style(table.row(0), @header) if @header
@styling&.call(table)
end
end
end

def prawn_options(width)
widths = @columns.each_value.map { |options| options[:width] }
options = {header: !@header.nil?, cell_style: prawn_cell_style(@cell)}
options[:row_colors] = @stripes if @stripes
options[:column_widths] = widths.each_with_index.to_h { |column_width, index| [index, column_width] }.compact
options[:width] = width unless widths.all?
options
end

def style_columns(table)
@columns.each_value.with_index { |options, index| style(table.column(index), options.except(:title, :width)) }
end

def style(cells, options)
style = prawn_cell_style(options.except(:weight, :italic))
style[:font_style] = Styles.font_style(options[:weight], options[:italic]) if options[:weight] || options[:italic]
cells.style(style)
end

def prawn_cell_style(options)
options.transform_keys { |name| PRAWN_CELL_NAMES.fetch(name, name) }
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
end
end
2 changes: 1 addition & 1 deletion test/native_bands_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_a_header_starts_on_the_page_it_is_declared_on

def test_a_table_in_a_footer_stays_on_its_page
document = Gamba.document do |yielded|
yielded.footer { |footer, page| footer.table([["Page", page.number.to_s]]) }
yielded.footer { |footer, page| footer.table([{label: "Page", number: page.number}], header: false) }
yielded.text "Body"
end

Expand Down
Loading
Loading