From ba201037e018af0195bde7a3acfb10e5f64efb33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 20:45:57 +0000 Subject: [PATCH] Tables have named columns A native table took Prawn's array of arrays: the header was the first row, and a column was styled by its position, counted by hand and recounted when a column was added. Columns now have names. Rows are hashes, or objects answering those names; each column takes a title, a width, an alignment and text attributes, and the header row is made of the titles. header takes the header's style or false, stripes alternates row backgrounds, cell styles every cell, and each option is checked. Without columns, a table of hashes has those of its first row. The block still receives the Prawn::Table before it is laid out, for what the options do not cover, and Prawn's table of arrays stays at document.prawn.table. The table box moves to its own file. Assisted-by: Claude [Claude Code] --- README.md | 10 ++- docs/guide.html | 27 ++++--- lib/gamba/native/document.rb | 1 + lib/gamba/native/layout.rb | 68 +++-------------- lib/gamba/native/table.rb | 137 +++++++++++++++++++++++++++++++++++ test/native_bands_test.rb | 2 +- test/native_boxes_test.rb | 110 +++++++++++++++++++++++----- 7 files changed, 267 insertions(+), 88 deletions(-) create mode 100644 lib/gamba/native/table.rb 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) } }