From 0a9469f2715337e37921efd482c8ca2f20e2d345 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 08:25:17 +0000 Subject: [PATCH 1/2] Lay out cells in a grid of equal columns document.grid(columns: 3, gap: [16, 12]) puts its cells side by side, columns to a line, all as wide, the last line's included. A line is as high as its tallest cell and moves whole to the next page; pages break between lines. Cell widths and grow raise, a cell kept with the next keeps its line with the next one, and a grid with a background, a border or keep_together moves whole like a stack. Assisted-by: Claude [Claude Code] --- README.md | 4 +- docs/guide.html | 13 +++- lib/gamba/native/layout.rb | 82 +++++++++++++++++++++++++ test/contract/gamba.yml | 4 +- test/native_grid_test.rb | 120 +++++++++++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 test/native_grid_test.rb diff --git a/README.md b/README.md index 37aae83..e49fd69 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/guide.html b/docs/guide.html index fa68d4f..9819128 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -109,8 +109,17 @@

Gamba’s own API

total.text "1 234,00 €" end 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.

+

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). A grid puts its cells side by side, columns to a line, all as wide, the last line’s included; a line is as high as its tallest cell, and its gap is one number or [between lines, between columns]:

+
document.grid(columns: 3, gap: [16, 12]) do |grid|
+  products.each do |product|
+    grid.stack(padding: 8, border_color: "cccccc") do |card|
+      card.text product.name, weight: :bold
+      card.text product.price
+    end
+  end
+end
+

Every container takes 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 grid breaks them between its lines. A row, a line of a grid, and a stack or a grid with keep_together: true, 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.

Pages break where a reader expects them. A paragraph never leaves fewer than orphans lines at the bottom of a page, nor fewer than widows 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.

A box with keep_with_next: true 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:

document.style :heading, size: 16, weight: :bold, keep_with_next: true
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index ebb9d5e..b9041e8 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -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)
@@ -488,6 +495,81 @@ 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
+
+        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
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index 2da7d48..53eef23 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -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]
@@ -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]
diff --git a/test/native_grid_test.rb b/test/native_grid_test.rb
new file mode 100644
index 0000000..8a452f3
--- /dev/null
+++ b/test/native_grid_test.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+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_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)
+    Tempfile.create(["grid", ".pdf"]) do |file|
+      file.binmode
+      file.write(pdf)
+      file.flush
+      Oracle.text_layout(file.path).map(&:glyphs)
+    end
+  end
+end

From 825b65ea8e32fa4c764af86ee5fe594bdb359823 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Fri, 13 Nov 2026 08:28:00 +0000
Subject: [PATCH 2/2] Draw a grid's decorated cells as high as their line

A cell with a background or a border reaches down to the bottom of its line, like a CSS grid's, so cards side by side line up.

Assisted-by: Claude [Claude Code]
---
 docs/guide.html            |  2 +-
 lib/gamba/native/layout.rb | 30 ++++++++++++++++++++++++------
 test/native_grid_test.rb   | 26 +++++++++++++++++++++++++-
 3 files changed, 50 insertions(+), 8 deletions(-)

diff --git a/docs/guide.html b/docs/guide.html
index 9819128..549d24a 100644
--- a/docs/guide.html
+++ b/docs/guide.html
@@ -109,7 +109,7 @@ 

Gamba’s own API

total.text "1 234,00 €" end 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). A grid puts its cells side by side, columns to a line, all as wide, the last line’s included; a line is as high as its tallest cell, and its gap is one number or [between lines, between columns]:

+

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). A grid puts its cells side by side, columns 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; gap is one number or [between lines, between columns]:

document.grid(columns: 3, gap: [16, 12]) do |grid|
   products.each do |product|
     grid.stack(padding: 8, border_color: "cccccc") do |card|
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
index b9041e8..305743a 100644
--- a/lib/gamba/native/layout.rb
+++ b/lib/gamba/native/layout.rb
@@ -383,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)
@@ -460,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
@@ -557,6 +562,19 @@ def keep_with_next
 
         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
diff --git a/test/native_grid_test.rb b/test/native_grid_test.rb
index 8a452f3..aeb860d 100644
--- a/test/native_grid_test.rb
+++ b/test/native_grid_test.rb
@@ -1,6 +1,7 @@
 # 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.
@@ -40,6 +41,21 @@ def test_a_line_is_as_high_as_its_tallest_cell
     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
@@ -110,11 +126,19 @@ def glyphs(pdf)
   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
-      Oracle.text_layout(file.path).map(&:glyphs)
+      yield file.path
     end
   end
 end