From 1059725ed3cd3de182515a78a83cd32edc0287f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 17:13:15 +0000 Subject: [PATCH] Stacks and rows: layout described, measured, then placed With Prawn's API the caller places everything: bounding_box with widths worked out by hand, float to come back up, a height check before a block that must not be split. Gamba's own API gains two containers. A stack puts its children one under the other and breaks pages between them; a row puts them side by side, each as wide as told or sharing what is left by its grow. Both take a gap, padding, a background and a border, and every box is measured before anything is drawn. A row, a stack kept together and a decorated stack move whole to the next page; one higher than a page raises CannotFit naming the box. Boxes are placed in absolute coordinates rather than through nested bounding boxes, which resume mid-page after a page break. A stack of texts draws what the same Prawn text calls draw, pages included, and the lines wrapped to measure a text are drawn without wrapping again. Assisted-by: Claude [Claude Code] --- README.md | 15 +- docs/guide.html | 18 ++ lib/gamba.rb | 1 + lib/gamba/native/document.rb | 66 ++---- lib/gamba/native/layout.rb | 377 +++++++++++++++++++++++++++++++++++ test/contract/gamba.yml | 8 + test/native_contract_test.rb | 2 +- test/native_layout_test.rb | 235 ++++++++++++++++++++++ 8 files changed, 672 insertions(+), 50 deletions(-) create mode 100644 lib/gamba/native/layout.rb create mode 100644 test/native_layout_test.rb diff --git a/README.md b/README.md index d81cad1..cf911ea 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,20 @@ end document.write("invoice.pdf") ``` +Layout is described instead of computed: a `stack` puts boxes one under the other and breaks pages between them, a +`row` puts them side by side, and each is measured before anything is drawn. + +```ruby +document.row(gap: 24, padding: 12, background: "f5f5f5") do |row| + row.stack(gap: 4) { |stack| stack.text "Alice Martin", style: :title; stack.text "1 Example Street" } + row.text "Due on 1 December 2026", width: 150, align: :right +end +``` + Code written for Prawn adopts it one block at a time, `gamba { |document| document.text "Total", style: :title }`, -and `document.prawn` gives a native document all of Prawn's API. Text, styles and components are there today; -layout comes next ([the plan](https://github.com/levups/gamba/issues/69)). +and `document.prawn` gives a native document all of Prawn's API. Text, styles, components, stacks and rows are there +today; tables, images, headers and footers as boxes 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 06aaf9b..9632fcf 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -93,6 +93,24 @@

Gamba’s own API

end document.write("invoice.pdf") # or an IO; document.to_s gives the bytes

A style names text attributes (font, size, weight, italic, color, align, leading); the :default style applies to every text, and what a call passes wins over its style. draw takes any object that answers call(document), so a document is made of small classes you can test one by one. Fonts registered here use their ligatures and kerning: there is no Prawn page to match. Pass features: [] to register_font to turn them off.

+

Layout is described, not computed. A stack puts its children one under the other, a row side by side; each box is measured before anything is drawn, so a row is as high as its tallest child and what follows starts below it, without a bounding_box, a float or a move_down.

+
document.row(gap: 24, padding: 12, background: "f5f5f5", border_color: "cccccc") do |row|
+  row.stack(gap: 4) do |stack|
+    stack.text "Alice Martin", style: :title
+    stack.text "1 Example Street, 75001 Paris"
+  end
+  row.text "Due on 1 December 2026", width: 150, align: :right
+end
+
+document.stack(gap: 8) do |stack|
+  paragraphs.each { |paragraph| stack.text paragraph }
+  stack.stack(keep_together: true, padding: [6, 10], border_width: 1) do |total|
+    total.text "Total", weight: :bold
+    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. Tables, images, headers and footers are not boxes yet: draw them through document.prawn between two stacks.

You don’t have to rewrite anything to start. In code written for Prawn, gamba opens a native block at the cursor, and leaves the cursor below what it drew:

Prawn::Document.generate("report.pdf") do
   text "Existing code, unchanged"
diff --git a/lib/gamba.rb b/lib/gamba.rb
index 588f38d..4264562 100644
--- a/lib/gamba.rb
+++ b/lib/gamba.rb
@@ -99,6 +99,7 @@ class Font
 module Gamba
   autoload :Combine, ::File.expand_path("gamba/combine", __dir__)
   autoload :Document, ::File.expand_path("gamba/native/document", __dir__)
+  autoload :Layout, ::File.expand_path("gamba/native/layout", __dir__)
 
   # A document written with Gamba's own API: Gamba.document(page_size: "A4") { |document| document.text "Hello" }.
   # See Gamba::Document::OPTIONS for the options; Prawn's API stays reachable through Gamba::Document#prawn.
diff --git a/lib/gamba/native/document.rb b/lib/gamba/native/document.rb
index 913b9c1..0d2fd28 100644
--- a/lib/gamba/native/document.rb
+++ b/lib/gamba/native/document.rb
@@ -1,24 +1,27 @@
 # frozen_string_literal: true
 
+require_relative "layout"
+
 module Gamba
   # Gamba's own API, over a Prawn::Document that stays reachable through #prawn. Blocks receive this object,
   # options are keywords, and an unknown one raises instead of being ignored.
   class Document
+    include Layout::Builder
+
     OPTIONS = %i[
       page_size page_layout margin left_margin right_margin top_margin bottom_margin background info print_scaling
       pdf_a lang
     ].freeze
-    TEXT_ATTRIBUTES = %i[font size weight italic color align leading].freeze
-    FONT_STYLES = %i[normal bold italic bold_italic].freeze
+    TEXT_ATTRIBUTES = Layout::Styles::TEXT_ATTRIBUTES
     # No Prawn page to match here, so fonts that have ligatures and GPOS kerning use them.
     DEFAULT_FONT_FEATURES = %i[liga kern].freeze
-    DEFAULT_STYLE = :default
 
     attr_reader :prawn
 
     def initialize(prawn = nil, **options)
-      @styles = {}
-      @prawn = prawn || new_prawn_document(options)
+      Layout::Styles.check_options(options, OPTIONS)
+      @prawn = prawn || Prawn::Document.new(options)
+      @styles = Layout::Styles.new(@prawn, DEFAULT_FONT_FEATURES)
     end
 
     def register_font(family, normal:, bold: nil, italic: nil, bold_italic: nil, features: DEFAULT_FONT_FEATURES)
@@ -29,30 +32,21 @@ def register_font(family, normal:, bold: nil, italic: nil, bold_italic: nil, fea
 
     # Text attributes under a name: text "…", style: name. The :default style applies to every text.
     def style(name, **attributes)
-      check_options(attributes, TEXT_ATTRIBUTES)
-      @styles[name] = attributes.freeze
+      @styles.define(name, attributes)
       self
     end
 
     def text(string, style: nil, **attributes)
-      check_options(attributes, TEXT_ATTRIBUTES)
-      attributes = {**@styles.fetch(DEFAULT_STYLE, {}), **named_style(style), **attributes}
-      font = attributes.delete(:font)
-      options = prawn_text_options(attributes)
+      @styles.text_arguments(style, attributes) => {font:, font_options:, color:, text_options:}
+      text_options[:color] = color if color
       if font
-        prawn.font(font, prawn.font_families.key?(font) ? {} : {features: DEFAULT_FONT_FEATURES}) { prawn.text(string, options) }
+        prawn.font(font, font_options) { prawn.text(string, text_options) }
       else
-        prawn.text(string, options)
+        prawn.text(string, text_options)
       end
       self
     end
 
-    # Draws anything that answers call(document): a document is made of small objects, each tested alone.
-    def draw(component)
-      component.call(self)
-      self
-    end
-
     def to_s
       prawn.render
     end
@@ -68,34 +62,12 @@ def write(destination)
 
     private
 
-    def new_prawn_document(options)
-      check_options(options, OPTIONS)
-      Prawn::Document.new(options)
-    end
-
-    def named_style(name)
-      return {} unless name
-
-      @styles.fetch(name) do
-        raise ArgumentError, "unknown style #{name.inspect}: defined styles are #{@styles.keys.map(&:inspect).join(", ")}"
-      end
-    end
-
-    def prawn_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)]
-      attributes[:style] = font_style unless font_style == :normal
-      attributes.compact
-    end
-
-    def check_options(given, accepted)
-      unknown = given.keys - accepted
-      return if unknown.empty?
-
-      raise ArgumentError, "unknown option#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")}: " \
-        "accepted options are #{accepted.map(&:inspect).join(", ")}"
+    # A stack or a row of the document is placed as soon as its block ends, from the cursor, which it leaves below.
+    def add(container)
+      cursor = Layout::Cursor.new(prawn)
+      container.flow(prawn, cursor, prawn.bounds.absolute_left, prawn.bounds.width)
+      prawn.y = cursor.y
+      self
     end
   end
 end
diff --git a/lib/gamba/native/layout.rb b/lib/gamba/native/layout.rb
new file mode 100644
index 0000000..8438c80
--- /dev/null
+++ b/lib/gamba/native/layout.rb
@@ -0,0 +1,377 @@
+# frozen_string_literal: true
+
+module Gamba
+  # Boxes described first, measured, then placed: a stack puts its children one under the other and breaks pages
+  # between them, a row puts them side by side on one page.
+  module Layout
+    FIT_TOLERANCE_POINTS = 0.001
+    MEASURING_HEIGHT_POINTS = 100_000_000
+
+    # The top of the next box on the current page, in absolute coordinates.
+    class Cursor
+      attr_accessor :y
+
+      def initialize(prawn)
+        @prawn = prawn
+        @y = prawn.y
+        @page_top = prawn.bounds.absolute_top
+      end
+
+      def remaining
+        bounds = @prawn.bounds
+        y - (bounds.stretchy? ? @prawn.margin_box.absolute_bottom : bounds.absolute_bottom)
+      end
+
+      def fits?(height)
+        height <= remaining + FIT_TOLERANCE_POINTS
+      end
+
+      def at_page_top?
+        y == @page_top
+      end
+
+      def next_page
+        @prawn.bounds.move_past_bottom
+        @page_top = @y = @prawn.y
+      end
+    end
+
+    class Node
+      OPTIONS = %i[width grow].freeze
+
+      attr_reader :width, :grow
+
+      def initialize(width: nil, grow: nil)
+        raise ArgumentError, "width and grow exclude each other: a box is as wide as told, or shares what is left" if width && grow
+
+        @width = width
+        @grow = grow
+      end
+
+      # A box is measured when it is placed and again by the boxes around it: once per width is enough.
+      def height(prawn, width)
+        (@height_by_width ||= {})[width] ||= measure(prawn, width)
+      end
+
+      # A box that cannot be split goes to the next page when the rest of this one is too short for it.
+      def flow(prawn, cursor, left, width)
+        height = height(prawn, width)
+        cursor.next_page unless cursor.fits?(height) || cursor.at_page_top?
+        unless cursor.fits?(height)
+          raise Prawn::Errors::CannotFit, "#{description} is #{height.round(1)} pt high, the page has #{cursor.remaining.round(1)} pt: it cannot be split"
+        end
+
+        paint(prawn, left, cursor.y, width)
+        cursor.y -= height
+      end
+
+      private
+
+      def relative(prawn, left, top)
+        [left - prawn.bounds.absolute_left, top - prawn.bounds.absolute_bottom]
+      end
+    end
+
+    class Text < Node
+      def initialize(string, font:, font_options:, color:, text_options:, **)
+        super(**)
+        @fragments = [color ? {text: string, color:} : {text: string}]
+        @font = font
+        @font_options = font_options
+        @text_options = text_options
+      end
+
+      # The lines wrapped to measure the text are drawn as they are, when Prawn's box can draw them again.
+      def paint(prawn, left, top, width)
+        at = relative(prawn, left, top)
+        with_font(prawn) do
+          if @measured_width == width && @measured_box.layout_reusable?
+            @measured_box.render_laid_out(at)
+          else
+            box(prawn, @fragments, at, width, MEASURING_HEIGHT_POINTS).render
+          end
+        end
+      end
+
+      # Lines fill the page, and the rest goes on to the next ones.
+      def flow(prawn, cursor, left, width)
+        with_font(prawn) do
+          fragments = @fragments
+          loop do
+            box = box(prawn, fragments, relative(prawn, left, cursor.y), width, cursor.remaining)
+            unprinted = box.render
+            cursor.y -= advance(box) unless box.nothing_printed?
+            break if unprinted.empty?
+            raise Prawn::Errors::CannotFit, "#{description} has a line higher than the page" if box.nothing_printed? && cursor.at_page_top?
+
+            fragments = unprinted
+            cursor.next_page
+          end
+        end
+      end
+
+      private
+
+      def measure(prawn, width)
+        with_font(prawn) do
+          @measured_width = width
+          @measured_box = box(prawn, @fragments, [0, 0], width, MEASURING_HEIGHT_POINTS)
+          @measured_box.render(dry_run: true, keep_layout: true)
+          advance(@measured_box)
+        end
+      end
+
+      def description
+        "the text #{@fragments.first[:text][0, 30].inspect}"
+      end
+
+      # Prawn's font answers the font, not what its block returns.
+      def with_font(prawn)
+        return yield unless @font
+
+        returned = nil
+        prawn.font(@font, @font_options) { returned = yield }
+        returned
+      end
+
+      def box(prawn, fragments, at, width, height)
+        Prawn::Text::Formatted::Box.new(fragments, {document: prawn, at:, width:, height:, **@text_options})
+      end
+
+      # What Prawn's text moves the cursor by: the lines, and the gap that separates the last one from what follows.
+      def advance(box)
+        box.height + box.line_gap + box.leading
+      end
+    end
+
+    # What a document and its containers share: the calls that add a box.
+    module Builder
+      def stack(**options)
+        container = Stack.new(@styles, **options)
+        yield container
+        add(container)
+      end
+
+      def row(**options)
+        container = Row.new(@styles, **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)
+        self
+      end
+    end
+
+    class Container < Node
+      include Builder
+
+      OPTIONS = (Node::OPTIONS + %i[gap padding background border_width border_color]).freeze
+      DEFAULT_BORDER_COLOR = "000000"
+
+      def initialize(styles, gap: 0, padding: 0, background: nil, border_width: nil, border_color: nil, **)
+        super(**)
+        @styles = styles
+        @gap = gap
+        @padding_top, @padding_right, @padding_bottom, @padding_left = edges(padding)
+        @background = background
+        @border_width = border_width || (0.5 if border_color)
+        @border_color = border_color || DEFAULT_BORDER_COLOR
+        @children = []
+      end
+
+      def text(string, style: nil, width: nil, grow: nil, **attributes)
+        add(Text.new(string, width:, grow:, **@styles.text_arguments(style, attributes)))
+      end
+
+      private
+
+      def add(node)
+        @children << node
+        self
+      end
+
+      def decorated?
+        @background || @border_width
+      end
+
+      def decorate(prawn, left, top, width, height)
+        return unless decorated?
+
+        prawn.save_graphics_state do
+          corner = relative(prawn, left, top)
+          if @background
+            prawn.fill_color(@background)
+            prawn.fill_rectangle(corner, width, height)
+          end
+          if @border_width
+            prawn.line_width(@border_width)
+            prawn.stroke_color(@border_color)
+            prawn.stroke_rectangle(corner, width, height)
+          end
+        end
+      end
+
+      def inner_width(width)
+        width - @padding_left - @padding_right
+      end
+
+      # CSS's order: one value for the four edges, [vertical, horizontal], or [top, right, bottom, left].
+      def edges(padding)
+        case padding
+        in Numeric then [padding] * 4
+        in [Numeric => vertical, Numeric => horizontal] then [vertical, horizontal, vertical, horizontal]
+        in [Numeric, Numeric, Numeric, Numeric] then padding
+        else raise ArgumentError, "padding is #{padding.inspect}: give one number, [vertical, horizontal] or [top, right, bottom, left]"
+        end
+      end
+    end
+
+    class Stack < Container
+      OPTIONS = (Container::OPTIONS + %i[keep_together]).freeze
+
+      def initialize(styles, **options)
+        Styles.check_options(options, OPTIONS)
+        @keep_together = options.delete(:keep_together)
+        super
+      end
+
+      def paint(prawn, left, top, width)
+        decorate(prawn, left, top, width, height(prawn, width))
+        child_top = top - @padding_top
+        @children.each do |child|
+          child_width = child_width(child, width)
+          child.paint(prawn, left + @padding_left, child_top, child_width)
+          child_top -= child.height(prawn, child_width) + @gap
+        end
+      end
+
+      # A plain stack breaks pages between its children; one kept together, or with a background or a border to
+      # draw around the whole of it, moves as one box.
+      def flow(prawn, cursor, left, width)
+        return super if @keep_together || decorated?
+
+        cursor.y -= @padding_top
+        @children.each_with_index do |child, index|
+          cursor.y -= @gap unless index.zero? || cursor.at_page_top?
+          child.flow(prawn, cursor, left + @padding_left, child_width(child, width))
+        end
+        cursor.y -= @padding_bottom
+      end
+
+      private
+
+      def measure(prawn, width)
+        @padding_top + @padding_bottom + (@gap * [@children.size - 1, 0].max) +
+          @children.sum { |child| child.height(prawn, child_width(child, width)) }
+      end
+
+      def description
+        "a stack kept together"
+      end
+
+      def child_width(child, width)
+        raise ArgumentError, "grow shares the width of a row: it means nothing in a stack" if child.grow
+
+        [child.width || inner_width(width), inner_width(width)].min
+      end
+    end
+
+    class Row < Container
+      def initialize(styles, **options)
+        Styles.check_options(options, OPTIONS)
+        super
+      end
+
+      def paint(prawn, left, top, width)
+        decorate(prawn, left, top, width, height(prawn, width))
+        child_left = left + @padding_left
+        @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
+        @children.each_with_index do |child, index|
+          height = child.height(prawn, widths[index])
+          tallest = height if height > tallest
+        end
+        @padding_top + @padding_bottom + tallest
+      end
+
+      def description
+        "a row"
+      end
+
+      # Children told their width take it; the others share what is left by their grow, 1 unless told.
+      def child_widths(width)
+        shared = inner_width(width) - (@gap * [@children.size - 1, 0].max) - @children.sum { |child| child.width || 0 }
+        total_grow = @children.sum { |child| child.width ? 0 : child.grow || 1 }
+        raise Prawn::Errors::CannotFit, "a row's children are #{-shared.round(1)} pt wider than the row" if shared.negative?
+
+        @children.map { |child| child.width || (shared * (child.grow || 1) / total_grow.to_f) }
+      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].freeze
+      FONT_STYLES = %i[normal bold italic bold_italic].freeze
+      DEFAULT_STYLE = :default
+
+      def self.check_options(given, accepted)
+        unknown = given.keys - accepted
+        return if unknown.empty?
+
+        raise ArgumentError, "unknown option#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")}: " \
+          "accepted options are #{accepted.map(&:inspect).join(", ")}"
+      end
+
+      def initialize(prawn, default_font_features)
+        @prawn = prawn
+        @default_font_features = default_font_features
+        @attributes_by_name = {}
+      end
+
+      def define(name, attributes)
+        self.class.check_options(attributes, TEXT_ATTRIBUTES)
+        @attributes_by_name[name] = attributes.freeze
+      end
+
+      # The font to set around the text, if any, and the options of the Prawn call.
+      def text_arguments(style, attributes)
+        self.class.check_options(attributes, TEXT_ATTRIBUTES)
+        attributes = {**@attributes_by_name.fetch(DEFAULT_STYLE, {}), **named(style), **attributes}
+        font = attributes.delete(:font)
+        font_options = (font.nil? || @prawn.font_families.key?(font)) ? {} : {features: @default_font_features}
+        {font:, font_options:, color: attributes.delete(:color), text_options: text_options(attributes)}
+      end
+
+      private
+
+      def named(name)
+        return {} unless name
+
+        @attributes_by_name.fetch(name) do
+          raise ArgumentError, "unknown style #{name.inspect}: defined styles are #{@attributes_by_name.keys.map(&:inspect).join(", ")}"
+        end
+      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)]
+        attributes[:style] = font_style unless font_style == :normal
+        attributes.compact
+      end
+    end
+  end
+end
diff --git a/test/contract/gamba.yml b/test/contract/gamba.yml
index a8e045d..7513bbb 100644
--- a/test/contract/gamba.yml
+++ b/test/contract/gamba.yml
@@ -8,10 +8,18 @@ Gamba:
 Gamba::Document:
   "#draw": [req component]
   "#prawn": []
+  "#row": [keyrest options]
+  "#stack": [keyrest options]
   "#register_font": [req family, keyreq normal, key bold, key italic, key bold_italic, key features]
   "#style": [req name, keyrest attributes]
   "#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.
+Gamba::Layout::Container:
+  "#draw": [req component]
+  "#row": [keyrest options]
+  "#stack": [keyrest options]
+  "#text": [req string, key style, key width, key grow, keyrest attributes]
 Prawn::Document:
   "#gamba": []
diff --git a/test/native_contract_test.rb b/test/native_contract_test.rb
index ed7bac8..6269bdd 100644
--- a/test/native_contract_test.rb
+++ b/test/native_contract_test.rb
@@ -21,7 +21,7 @@ class NativeContractTest < Minitest::Test
   def test_every_public_method_is_in_the_contract
     WHOLLY_LISTED.each do |class_name, kind|
       owner = Object.const_get(class_name)
-      public_names = (kind == :singleton) ? owner.singleton_methods(false).map { ".#{_1}" } : owner.public_instance_methods(false).map { "##{_1}" }
+      public_names = (kind == :singleton) ? owner.singleton_methods(false).map { ".#{_1}" } : (owner.public_instance_methods - Object.public_instance_methods).map { "##{_1}" }
 
       assert_empty public_names - API.fetch(class_name).keys, "list each one in test/contract/gamba.yml"
     end
diff --git a/test/native_layout_test.rb b/test/native_layout_test.rb
new file mode 100644
index 0000000..695db1b
--- /dev/null
+++ b/test/native_layout_test.rb
@@ -0,0 +1,235 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "open3"
+require "prawn"
+
+# Stacks and rows of Gamba's own API: boxes measured, then placed, with pages broken between a stack's children.
+class NativeLayoutTest < Minitest::Test
+  PARAGRAPHS = Array.new(30) { |index| "Paragraph #{index} #{"word " * 60}".strip }
+
+  def test_a_stack_of_texts_draws_what_prawn_text_calls_draw_across_pages
+    native = Gamba.document { |document| document.stack { |stack| PARAGRAPHS.each { |paragraph| stack.text paragraph, size: 10 } } }
+    prawn = Prawn::Document.new { PARAGRAPHS.each { |paragraph| text paragraph, size: 10 } }
+
+    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_a_stack_separates_its_children_by_its_gap_and_insets_them_by_its_padding
+    y_after = nil
+    document = Gamba.document(margin: 50) do |yielded|
+      yielded.stack(gap: 10, padding: [5, 20]) do |stack|
+        stack.text "A"
+        stack.text "B"
+      end
+      y_after = yielded.prawn.y
+    end
+    first, second = glyphs(document.to_s)
+    line_advance = Prawn::Document.new.height_of("A")
+
+    assert_in_delta 70, first[3], 0.01
+    assert_in_delta line_advance + 10, second[4] - first[4], 0.01
+    assert_in_delta 792 - 50 - 5 - (2 * line_advance) - 10 - 5, y_after, 0.01
+  end
+
+  def test_a_row_gives_children_their_width_and_shares_the_rest_by_grow
+    document = Gamba.document(margin: 50, page_size: [600, 400]) do |yielded|
+      yielded.row(gap: 10) do |row|
+        row.text "A", width: 100
+        row.text "B"
+        row.text "C", grow: 2
+      end
+    end
+
+    assert_equal [50.0, 160.0, 296.67], glyphs(document.to_s).map { _1[3] }
+  end
+
+  def test_a_row_is_as_high_as_its_tallest_child
+    document = Gamba.document do |yielded|
+      yielded.row do |row|
+        row.text "short"
+        row.stack { |stack| 4.times { stack.text "line" } }
+      end
+      yielded.text "after"
+    end
+    after = glyphs(document.to_s).select { _1.first == "a" }.last
+    first_line = glyphs(document.to_s).first
+
+    assert_in_delta 4 * Prawn::Document.new.height_of("line"), after[4] - first_line[4], 0.01
+  end
+
+  def test_a_stack_kept_together_moves_whole_to_the_next_page
+    kept, split = [true, false].map do |keep_together|
+      Gamba.document do |document|
+        document.prawn.move_down 660
+        document.stack(keep_together:) { |stack| 6.times { |index| stack.text "line #{index}" } }
+      end
+    end
+
+    assert_equal [0, 6], lines_by_page(kept.to_s)
+    assert_equal [4, 2], lines_by_page(split.to_s)
+  end
+
+  def test_a_row_moves_whole_to_the_next_page
+    document = Gamba.document do |yielded|
+      yielded.prawn.move_down 700
+      yielded.row { |row| 2.times { row.text "word " * 30 } }
+    end
+
+    assert_equal [0, 4], lines_by_page(document.to_s)
+  end
+
+  def test_the_gap_is_dropped_at_the_top_of_a_page
+    document = Gamba.document(margin: 50) do |yielded|
+      yielded.prawn.move_down 680
+      yielded.stack(gap: 40) { |stack| 2.times { stack.text "line" } }
+    end
+    first_page, second_page = pages(document.to_s)
+    top_of_a_page = pages(Gamba.document(margin: 50) { |yielded| yielded.text "line" }.to_s).first.first[4]
+
+    assert_equal 4, first_page.size
+    assert_in_delta top_of_a_page, second_page.first[4], 0.01
+  end
+
+  def test_a_background_and_a_border_are_drawn_around_the_whole_box
+    plain = Gamba.document { |document| document.stack(padding: 10) { |stack| stack.text "A" } }
+    decorated = Gamba.document { |document| document.stack(padding: 10, background: "eeeeee", border_color: "ff0000", border_width: 2) { |stack| stack.text "A" } }
+    rectangle = /^36\.0 722\.128 540\.0 33\.872 re$/
+
+    assert_equal 0, uncompressed(plain.to_s).scan(rectangle).size
+    assert_equal 2, uncompressed(decorated.to_s).scan(rectangle).size
+    assert_equal glyphs(plain.to_s), glyphs(decorated.to_s)
+  end
+
+  def test_a_decorated_stack_moves_whole_to_the_next_page
+    document = Gamba.document do |yielded|
+      yielded.prawn.move_down 660
+      yielded.stack(background: "eeeeee") { |stack| 6.times { |index| stack.text "line #{index}" } }
+    end
+
+    assert_equal [0, 6], lines_by_page(document.to_s)
+  end
+
+  def test_a_box_that_cannot_be_split_and_is_higher_than_a_page_raises
+    error = assert_raises(Prawn::Errors::CannotFit) do
+      Gamba.document { |document| document.row { |row| row.text "word " * 5000 } }
+    end
+
+    assert_match(/\Aa row is [\d.]+ pt high, the page has 720\.0 pt: it cannot be split\z/, error.message)
+  end
+
+  def test_children_wider_than_their_row_raise
+    error = assert_raises(Prawn::Errors::CannotFit) do
+      Gamba.document { |document| document.row(gap: 10) { |row| 2.times { row.text "A", width: 300 } } }
+    end
+
+    assert_equal "a row's children are 70.0 pt wider than the row", error.message
+  end
+
+  def test_unknown_or_contradictory_options_raise
+    document = Gamba.document
+
+    assert_match(/\Aunknown option :gab: accepted options are :width, :grow, :gap, /, assert_raises(ArgumentError) { document.stack(gab: 2) { nil } }.message)
+    assert_raises(ArgumentError) { document.row(keep_together: true) { nil } }
+    assert_raises(ArgumentError) { document.stack(padding: [1, 2, 3]) { nil } }
+    assert_raises(ArgumentError) { document.row { |row| row.text "A", width: 10, grow: 1 } }
+    assert_raises(ArgumentError) { document.stack { |stack| stack.text "A", grow: 1 } }
+    assert_raises(ArgumentError) { document.stack { |stack| stack.text "A", colour: "ff0000" } }
+    assert_raises(ArgumentError) { document.stack { |stack| stack.text "A", style: :missing } }
+  end
+
+  def test_styles_apply_inside_boxes
+    document = Gamba.document do |yielded|
+      yielded.style :default, font: "Courier", size: 10
+      yielded.style :title, size: 18, weight: :bold
+      yielded.row do |row|
+        row.text "A", style: :title
+        row.stack { |stack| stack.text "B", italic: true }
+      end
+    end
+
+    assert_equal [["A", "Courier-Bold", 18.0], ["B", "Courier-Oblique", 10.0]], glyphs(document.to_s).map { _1.first(3) }
+  end
+
+  def test_a_component_drawn_in_a_box_receives_the_box
+    address = ->(builder) { builder.text "1 Example Street" }
+    document = Gamba.document do |yielded|
+      yielded.row do |row|
+        row.text "Alice"
+        row.draw address
+      end
+    end
+
+    assert_equal 2, glyphs(document.to_s).map { _1[3] }.uniq.count { |x| [36.0, 306.0].include?(x) }
+    assert_equal 1, lines_by_page(document.to_s).first
+  end
+
+  def test_a_box_in_prawn_code_starts_at_the_cursor_within_the_bounds_and_leaves_the_cursor_below
+    cursor_after = nil
+    pdf = Prawn::Document.new do
+      text "Before"
+      bounding_box([100, cursor], width: 200) do
+        gamba { |document| document.row { |row| 2.times { row.text "cell" } } }
+      end
+      cursor_after = cursor
+      text "After"
+    end
+    before, *cells, after = glyphs(pdf.render).select { ["B", "c", "A"].include?(_1.first) }
+    line_advance = pdf.height_of("cell")
+
+    assert_equal [136.0, 236.0], cells.map { _1[3] }
+    assert_in_delta 720 - (2 * line_advance), cursor_after, 0.01
+    assert_in_delta 2 * line_advance, after[4] - before[4], 0.01
+  end
+
+  def test_a_laid_out_document_is_a_valid_pdf_rendered_the_same_twice
+    render = lambda do
+      Gamba.document do |document|
+        document.row(gap: 12, padding: 8, background: "eeeeee", border_color: "999999") do |row|
+          row.stack(gap: 4) { |stack| PARAGRAPHS.first(3).each { |paragraph| stack.text paragraph } }
+          row.text "Due 2026-12-01", width: 120, align: :right
+        end
+        document.stack(gap: 6) { |stack| PARAGRAPHS.each { |paragraph| stack.text paragraph } }
+      end.to_s
+    end
+    pdf = render.call
+
+    assert_equal pdf, render.call
+    Tempfile.create(["layout", ".pdf"]) do |file|
+      file.binmode
+      file.write(pdf)
+      file.flush
+
+      assert_nil Oracle.invalidity(file.path)
+    end
+  end
+
+  private
+
+  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)
+    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(["layout", ".pdf"]) do |file|
+      file.binmode
+      file.write(pdf)
+      file.flush
+      yield file.path
+    end
+  end
+end