diff --git a/README.md b/README.md index 0e50b1d..d7e4f39 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/guide.html b/docs/guide.html index 3ee56c4..f751a5b 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -111,16 +111,23 @@
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 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
+ 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 title (its name, spelled out, unless told), a width, an align and the text attributes weight, italic, size and color. Without columns, a table of hashes has those of its first row.
+lines = [{sku: "A-1", description: "Gadget", quantity: 2, total: "20,00"}]
+
+document.table(lines,
+ columns: {
+ sku: {title: "SKU", width: 60},
+ description: {},
+ quantity: {title: "Qty", align: :right},
+ total: {align: :right, weight: :bold}
+ },
+ header: {background: "333333", color: "ffffff"},
+ stripes: %w[ffffff f6f6f6],
+ cell: {size: 9, padding: 4, border_color: "cccccc"})
+ 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; header: takes its style (align, weight, italic, size, color, background) or false to leave it out. stripes alternates row backgrounds, and cell styles every cell: padding, size, font, color, background, borders, border_width, border_color. For what these do not cover, such as a span or the style of one cell, the block receives the Prawn::Table before it is laid out; Prawn’s table of arrays itself stays at document.prawn.table.
+document.table(lines, columns: columns) do |table|
+ table.row(-1).borders = [:top]
+ table.cells[3, 2].background_color = "ffeeee"
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. SVG is not a box yet: draw it through document.prawn.
A header and a footer are declared once, before the body. Each block receives a stack to fill and the page, which answers number and total; it is run for every page when the document is rendered, once the pages are counted, in place of Prawn’s repeat and number_pages.
diff --git a/lib/gamba/native/document.rb b/lib/gamba/native/document.rb
index 5a0b84a..015abba 100644
--- a/lib/gamba/native/document.rb
+++ b/lib/gamba/native/document.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative "layout"
+require_relative "table"
require_relative "band"
module Gamba
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index 9c930bc..a9a10df 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/lib/gamba/native/table.rb b/lib/gamba/native/table.rb
new file mode 100644
index 0000000..c828acc
--- /dev/null
+++ b/lib/gamba/native/table.rb
@@ -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
diff --git a/test/native_bands_test.rb b/test/native_bands_test.rb
index 4ebcdf6..b321ce2 100644
--- a/test/native_bands_test.rb
+++ b/test/native_bands_test.rb
@@ -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
diff --git a/test/native_boxes_test.rb b/test/native_boxes_test.rb
index 22cf2ac..947aad2 100644
--- a/test/native_boxes_test.rb
+++ b/test/native_boxes_test.rb
@@ -7,42 +7,99 @@
# 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
+ LINES = Array.new(60) { |index| {sku: "SKU#{index}", description: "Gadget, model #{index}", quantity: 2} }.freeze
+ COLUMNS = {sku: {title: "SKU"}, description: {}, quantity: {align: :right}}.freeze
+ Line = Data.define(:sku, :quantity)
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}) }
+ native = Gamba.document { |document| document.table(LINES, columns: COLUMNS, cell: {size: 9}) }
+ prawn = Prawn::Document.new do
+ table([%w[SKU Description Quantity], *LINES.map(&:values)], header: true, width: bounds.width, cell_style: {size: 9}) do
+ column(2).align = :right
+ row(0).font_style = :bold
+ end
+ end
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
+ def test_columns_are_those_of_the_first_row_unless_named_and_titles_come_from_their_names
+ document = Gamba.document { |yielded| yielded.table([{first_name: "Alice", city: "Paris"}]) }
+
+ assert_equal "First nameCityAliceParis", glyphs(document.to_s).map(&:first).join
+ end
+
+ def test_rows_can_be_objects_answering_the_names_of_the_columns
+ document = Gamba.document { |yielded| yielded.table([Line.new(sku: "A-1", quantity: 2)], columns: {quantity: {title: "Qty"}, sku: {title: "SKU"}}) }
+
+ assert_equal "QtySKU2A-1", glyphs(document.to_s).map(&:first).join
+ end
+
+ def test_a_column_takes_its_width_alignment_and_text_attributes
+ document = Gamba.document do |yielded|
+ yielded.table([{name: "A", amount: "B"}], header: false, columns: {name: {width: 100, italic: true, size: 9}, amount: {width: 50, align: :right, weight: :bold}})
+ end
+ name, amount = glyphs(document.to_s)
+
+ assert_equal [["A", "Helvetica-Oblique", 9.0, 41.0], ["B", "Helvetica-Bold", 12.0]], [name.first(4), amount.first(3)]
+ assert_equal prawn_right_aligned_bold_x, amount[3]
+ end
+
+ def test_the_header_is_bold_unless_styled_and_can_be_left_out
+ fonts = lambda do |header|
+ document = Gamba.document { |yielded| yielded.table([{name: "A"}], header:) }
+ glyphs(document.to_s).map { [_1[0], _1[1], _1[2]] }.uniq
+ end
+
+ assert_equal [["N", "Helvetica-Bold", 12.0], ["a", "Helvetica-Bold", 12.0], ["m", "Helvetica-Bold", 12.0], ["e", "Helvetica-Bold", 12.0], ["A", "Helvetica", 12.0]], fonts.call(true)
+ assert_equal [["A", "Helvetica", 12.0]], fonts.call(false)
+ assert_equal ["Helvetica-BoldOblique", 9.0], fonts.call({italic: true, size: 9}).first.last(2)
+ end
+
+ def test_stripes_the_header_background_and_cell_options_reach_the_cells
document = Gamba.document do |yielded|
- yielded.table(ROWS.first(2)) do |table|
+ yielded.table(LINES.first(3), columns: COLUMNS, stripes: %w[ffffff eeeeee], header: {background: "333333", color: "ffffff"}, cell: {padding: 2, border_color: "ff0000"})
+ end
+ content = uncompressed(document.to_s)
+
+ assert_includes content, "0.2 0.2 0.2 scn"
+ assert_includes content, "0.93333 0.93333 0.93333 scn"
+ assert_includes content, "1.0 0.0 0.0 SCN"
+ assert_in_delta 38, glyphs(document.to_s).first[3], 0.01
+ end
+
+ def test_the_block_receives_the_prawn_table_before_it_is_laid_out
+ document = Gamba.document do |yielded|
+ yielded.table([{name: "A"}], header: false) do |table|
assert_instance_of Prawn::Table, table
- table.row(0).font_style = :bold
+ table.cells[0, 0].font_style = :italic
end
end
- assert_equal %w[Helvetica-Bold Helvetica], glyphs(document.to_s).map { _1[1] }.uniq
+ assert_equal ["Helvetica-Oblique"], glyphs(document.to_s).map { _1[1] }
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]) }
+ def test_a_table_without_a_header_or_a_row_draws_nothing_and_takes_no_room
+ cursor_after = nil
+ document = Gamba.document do |yielded|
+ yielded.stack(gap: 10) { |stack| stack.table([], columns: COLUMNS, header: false) }
+ cursor_after = yielded.prawn.cursor
+ end
- assert_equal [41.0, 141.0], glyphs(document.to_s).map { _1[3] }
+ assert_in_delta 720, cursor_after, 0.001
+ assert_empty glyphs(document.to_s)
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.table(LINES.first(2), columns: COLUMNS, header: false)
stack.text "After"
end
end
- table_height = Prawn::Document.new.make_table(ROWS.first(3)).height
+ table_height = Prawn::Document.new.make_table(LINES.first(2).map(&:values)).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]
@@ -54,7 +111,7 @@ def test_a_table_in_a_row_takes_its_share_and_sets_the_height_of_the_row
document = Gamba.document do |yielded|
yielded.row(gap: 20) do |row|
row.text "Left"
- row.table([%w[A B], %w[C D]], width: 200)
+ row.table([{first: "A", second: "B"}, {first: "C", second: "D"}], header: false, width: 200)
end
cursor_after = yielded.prawn.cursor
end
@@ -69,10 +126,9 @@ def test_a_row_holding_a_table_moves_whole_to_the_next_page
yielded.prawn.move_down 690
yielded.row do |row|
row.text "Left"
- row.table(ROWS.first(3))
+ row.table(LINES.first(3), columns: COLUMNS)
end
end
-
first_page, second_page = pages(document.to_s).map { |page| page.map(&:first).join }
assert_empty first_page
@@ -81,19 +137,31 @@ def test_a_row_holding_a_table_moves_whole_to_the_next_page
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
+ table_height = Prawn::Document.new.make_table(LINES.first(3).map(&:values), width: 540).height
document = Gamba.document do |yielded|
yielded.prawn.move_down 720 - table_height
- yielded.row { |row| row.table(ROWS.first(3)) }
+ yielded.row { |row| row.table(LINES.first(3), columns: COLUMNS, header: false) }
end
assert_equal [0, 3], lines_by_page(document.to_s)
end
+ def test_rows_as_arrays_missing_columns_and_unknown_options_raise
+ document = Gamba.document
+
+ assert_match(/\Acolumns are missing: name them, /, assert_raises(ArgumentError) { document.table([%w[A B]]) }.message)
+ assert_match(/\Aa row is an array: rows are hashes, /, assert_raises(ArgumentError) { document.table([%w[A B]], columns: COLUMNS) }.message)
+ assert_match(/\Aunknown option :titel: accepted options are :title, :width, /, assert_raises(ArgumentError) { document.table(LINES, columns: {sku: {titel: "SKU"}}) }.message)
+ assert_raises(ArgumentError) { document.table(LINES, cell: {paddin: 2}) }
+ assert_raises(ArgumentError) { document.table(LINES, header: {weigth: :bold}) }
+ assert_raises(ArgumentError) { document.table(LINES, columns: {}) }
+ assert_raises(ArgumentError) { document.table(LINES, columns: {sku: {weight: :heavy}}) }
+ 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_match(/\Aunknown option :headr: accepted options are :width, :grow, :columns, :header, :stripes, :cell\z/, assert_raises(ArgumentError) { document.table(LINES, 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) }
@@ -146,6 +214,12 @@ def test_an_image_placed_twice_is_embedded_once
private
+ # Where Prawn's own table draws the same cell: its right alignment is what the native table must match.
+ def prawn_right_aligned_bold_x
+ prawn = Prawn::Document.new { table([%w[A B]], column_widths: {0 => 100, 1 => 50}) { column(1).style(align: :right, font_style: :bold) } }
+ glyphs(prawn.render).last[3]
+ end
+
# 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) } }