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 @@

Gamba’s own API

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 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) } }