diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index f23f689..ab7e757 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(bundle exec rake test:*)",
- "Bash(bundle exec rake:*)"
+ "Bash(bundle exec rake:*)",
+ "WebSearch",
+ "Bash(unzip:*)"
],
"deny": [],
"ask": []
diff --git a/README.md b/README.md
index 43c83b6..ebb0880 100644
--- a/README.md
+++ b/README.md
@@ -332,6 +332,75 @@ doc.table(style: :borderless) do
end
```
+#### Column Sizing
+
+Control table column widths with layout modes and explicit sizing.
+
+**Auto-layout** - columns adjust to fit content:
+
+```ruby
+doc.table(layout: :auto) do
+ doc.tr do
+ doc.td "Short"
+ doc.td "This column expands to fit longer content"
+ end
+end
+```
+
+**Fixed column widths** - specify widths for all columns:
+
+```ruby
+# Inches
+doc.table(columns: %w[2in 3in 1.5in]) do
+ doc.tr do
+ doc.td "2 inches"
+ doc.td "3 inches"
+ doc.td "1.5 inches"
+ end
+end
+
+# Centimeters
+doc.table(columns: %w[5cm 10cm]) do
+ doc.tr { doc.td "5cm"; doc.td "10cm" }
+end
+
+# Percentages
+doc.table(columns: %w[25% 50% 25%]) do
+ doc.tr { doc.td "Quarter"; doc.td "Half"; doc.td "Quarter" }
+end
+```
+
+**Per-cell widths** - set width on individual cells:
+
+```ruby
+doc.table do
+ doc.tr do
+ doc.td("Narrow", width: "1in")
+ doc.td("Wide", width: "4in")
+ end
+end
+```
+
+**Combined layout and columns:**
+
+```ruby
+doc.table(layout: :fixed, columns: %w[2in 2in 2in]) do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ doc.td "C"
+ end
+end
+```
+
+**Width formats:**
+
+| Format | Example | Description |
+|--------|---------|-------------|
+| Inches | `"2in"` | Fixed width in inches |
+| Centimeters | `"5cm"` | Fixed width in centimeters |
+| Percentage | `"50%"` | Percentage of table width |
+
### Images
Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
@@ -526,10 +595,10 @@ end
| `li(text)` | List item with text |
| `li { }` | List item with block content |
| `li(text) { }` | List item with text and nested content |
-| `table(style:) { }` | Table with optional style |
+| `table(style:, layout:, columns:) { }` | Table with optional style, layout (`:auto`/`:fixed`), and column widths |
| `tr { }` | Table row |
-| `td(text)` | Table cell with text |
-| `td { }` | Table cell with block content |
+| `td(text, width:)` | Table cell with text and optional width |
+| `td(width:) { }` | Table cell with block content and optional width |
| `image(path, width:, height:)` | Insert image (PNG/JPEG). Dimensions: `"2in"`, `"5cm"`, `"100px"`, or integer pixels |
## Development
diff --git a/examples/full_demo.rb b/examples/full_demo.rb
index 6367e9e..fc0216f 100644
--- a/examples/full_demo.rb
+++ b/examples/full_demo.rb
@@ -343,6 +343,48 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
end
end
+ doc.h3 "Table Column Sizing"
+
+ doc.p "Auto-layout table (columns fit content):"
+ doc.table(layout: :auto) do
+ doc.tr do
+ doc.td "Short"
+ doc.td "This column has much longer content that will expand"
+ end
+ end
+
+ doc.p "Fixed column widths in inches:"
+ doc.table(columns: %w[2in 3in 1.5in]) do
+ doc.tr do
+ doc.td { doc.b { doc.text "2 inches" } }
+ doc.td { doc.b { doc.text "3 inches" } }
+ doc.td { doc.b { doc.text "1.5 inches" } }
+ end
+ doc.tr do
+ doc.td "Column A"
+ doc.td "Column B"
+ doc.td "Column C"
+ end
+ end
+
+ doc.p "Percentage-based columns:"
+ doc.table(columns: %w[25% 50% 25%]) do
+ doc.tr do
+ doc.td "25%"
+ doc.td "50%"
+ doc.td "25%"
+ end
+ end
+
+ doc.p "Per-cell width control:"
+ doc.table do
+ doc.tr do
+ doc.td("Narrow", width: "1in")
+ doc.td("Wide column", width: "4in")
+ doc.td("Medium", width: "2in")
+ end
+ end
+
# ============================================================================
# 10. Images
# ============================================================================
diff --git a/lib/notare.rb b/lib/notare.rb
index 46fd7aa..99f6bfe 100644
--- a/lib/notare.rb
+++ b/lib/notare.rb
@@ -17,6 +17,7 @@ require_relative "notare/nodes/table_cell"
require_relative "notare/image_dimensions"
require_relative "notare/style"
require_relative "notare/table_style"
+require_relative "notare/width_parser"
require_relative "notare/xml/content_types"
require_relative "notare/xml/relationships"
require_relative "notare/xml/document_xml"
diff --git a/lib/notare/builder.rb b/lib/notare/builder.rb
index 1a618e0..44d385f 100644
--- a/lib/notare/builder.rb
+++ b/lib/notare/builder.rb
@@ -100,8 +100,8 @@ module Notare
@current_list.add_item(item)
end
- def table(style: nil, &block)
- tbl = Nodes::Table.new(style: resolve_table_style(style))
+ def table(style: nil, layout: nil, columns: nil, &block)
+ tbl = Nodes::Table.new(style: resolve_table_style(style), layout: layout, columns: columns)
previous_table = @current_table
@current_table = tbl
block.call
@@ -118,8 +118,8 @@ module Notare
@current_table.add_row(row)
end
- def td(text = nil, &block)
- cell = Nodes::TableCell.new
+ def td(text = nil, width: nil, &block)
+ cell = Nodes::TableCell.new(width: width)
if block
with_target(cell, &block)
elsif text
diff --git a/lib/notare/document.rb b/lib/notare/document.rb
index 4922ac9..e1ed5b9 100644
--- a/lib/notare/document.rb
+++ b/lib/notare/document.rb
@@ -88,13 +88,13 @@ module Notare
def next_image_rid
# rId1 = styles.xml (always present)
# rId2 = numbering.xml (if lists present)
- # rId3+ = images, then hyperlinks
+ # rId3+ = images and hyperlinks share the same ID space
base = @has_lists ? 3 : 2
- "rId#{base + @images.size}"
+ "rId#{base + @images.size + @hyperlinks.size}"
end
def next_hyperlink_rid
- # Hyperlinks come after images
+ # Images and hyperlinks share the same ID space
base = @has_lists ? 3 : 2
"rId#{base + @images.size + @hyperlinks.size}"
end
diff --git a/lib/notare/nodes/table.rb b/lib/notare/nodes/table.rb
index 4d6bd90..1fee0a9 100644
--- a/lib/notare/nodes/table.rb
+++ b/lib/notare/nodes/table.rb
@@ -3,12 +3,14 @@
module Notare
module Nodes
class Table < Base
- attr_reader :rows, :style
+ attr_reader :rows, :style, :layout, :columns
- def initialize(style: nil)
+ def initialize(style: nil, layout: nil, columns: nil)
super()
@rows = []
@style = style
+ @layout = layout
+ @columns = columns
end
def add_row(row)
diff --git a/lib/notare/nodes/table_cell.rb b/lib/notare/nodes/table_cell.rb
index c7e3137..a754820 100644
--- a/lib/notare/nodes/table_cell.rb
+++ b/lib/notare/nodes/table_cell.rb
@@ -3,11 +3,12 @@
module Notare
module Nodes
class TableCell < Base
- attr_reader :runs
+ attr_reader :runs, :width
- def initialize
- super
+ def initialize(width: nil)
+ super()
@runs = []
+ @width = width
end
def add_run(run)
diff --git a/lib/notare/width_parser.rb b/lib/notare/width_parser.rb
new file mode 100644
index 0000000..e5afad3
--- /dev/null
+++ b/lib/notare/width_parser.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Notare
+ module WidthParser
+ TWIPS_PER_INCH = 1440
+ TWIPS_PER_CM = 567
+ PCT_MULTIPLIER = 50
+
+ ParsedWidth = Struct.new(:value, :type, keyword_init: true)
+
+ def self.parse(value)
+ case value
+ when :auto, nil
+ ParsedWidth.new(value: 0, type: "auto")
+ when Integer
+ ParsedWidth.new(value: value, type: "dxa")
+ when /\A(\d+(?:\.\d+)?)\s*in\z/i
+ twips = (::Regexp.last_match(1).to_f * TWIPS_PER_INCH).to_i
+ ParsedWidth.new(value: twips, type: "dxa")
+ when /\A(\d+(?:\.\d+)?)\s*cm\z/i
+ twips = (::Regexp.last_match(1).to_f * TWIPS_PER_CM).to_i
+ ParsedWidth.new(value: twips, type: "dxa")
+ when /\A(\d+(?:\.\d+)?)\s*%\z/
+ pct = (::Regexp.last_match(1).to_f * PCT_MULTIPLIER).to_i
+ ParsedWidth.new(value: pct, type: "pct")
+ else
+ raise ArgumentError, "Invalid width: #{value}. Use '2in', '5cm', '50%', :auto, or integer twips."
+ end
+ end
+ end
+end
diff --git a/lib/notare/xml/document_xml.rb b/lib/notare/xml/document_xml.rb
index d97e3d3..5101f2b 100644
--- a/lib/notare/xml/document_xml.rb
+++ b/lib/notare/xml/document_xml.rb
@@ -163,12 +163,12 @@ module Notare
end
def render_table(xml, table)
- column_count = table.rows.first&.cells&.size || 1
- col_width = 5000 / column_count
+ column_widths = compute_column_widths(table)
xml["w"].tbl do
xml["w"].tblPr do
- xml["w"].tblW("w:w" => "5000", "w:type" => "pct")
+ render_table_width(xml, column_widths)
+ render_table_layout(xml, table.layout)
if table.style
xml["w"].tblStyle("w:val" => table.style.style_id)
else
@@ -179,25 +179,89 @@ module Notare
end
end
end
- xml["w"].tblGrid do
- column_count.times do
- xml["w"].gridCol("w:w" => col_width.to_s)
- end
+ render_table_grid(xml, column_widths)
+ table.rows.each { |row| render_table_row(xml, row, column_widths) }
+ end
+ end
+
+ def compute_column_widths(table)
+ if table.columns
+ table.columns.map { |c| WidthParser.parse(c) }
+ elsif table.layout == :auto
+ first_row = table.rows.first
+ cell_count = first_row&.cells&.size || 1
+ Array.new(cell_count) { WidthParser::ParsedWidth.new(value: 0, type: "auto") }
+ else
+ infer_widths_from_first_row(table)
+ end
+ end
+
+ def infer_widths_from_first_row(table)
+ first_row = table.rows.first
+ return [WidthParser::ParsedWidth.new(value: 5000, type: "pct")] unless first_row
+
+ cells = first_row.cells
+ has_explicit_widths = cells.any?(&:width)
+
+ if has_explicit_widths
+ cells.map do |cell|
+ cell.width ? WidthParser.parse(cell.width) : WidthParser::ParsedWidth.new(value: 0, type: "auto")
end
- table.rows.each { |row| render_table_row(xml, row, col_width) }
+ else
+ col_width = 5000 / cells.size
+ cells.map { WidthParser::ParsedWidth.new(value: col_width, type: "pct") }
end
end
- def render_table_row(xml, row, col_width)
+ def render_table_width(xml, column_widths)
+ if column_widths.all? { |w| w.type == "pct" }
+ total = column_widths.sum(&:value)
+ xml["w"].tblW("w:w" => total.to_s, "w:type" => "pct")
+ elsif column_widths.all? { |w| w.type == "dxa" }
+ total = column_widths.sum(&:value)
+ xml["w"].tblW("w:w" => total.to_s, "w:type" => "dxa")
+ else
+ xml["w"].tblW("w:w" => "0", "w:type" => "auto")
+ end
+ end
+
+ def render_table_layout(xml, layout)
+ return unless layout
+
+ layout_type = layout == :auto ? "autofit" : "fixed"
+ xml["w"].tblLayout("w:type" => layout_type)
+ end
+
+ def render_table_grid(xml, column_widths)
+ xml["w"].tblGrid do
+ column_widths.each do |width|
+ grid_width = case width.type
+ when "pct" then pct_to_approximate_dxa(width.value)
+ when "auto" then 1440 # Default 1 inch for auto columns
+ else width.value
+ end
+ xml["w"].gridCol("w:w" => grid_width.to_s)
+ end
+ end
+ end
+
+ # Convert percentage (in fiftieths) to approximate twips
+ # Assumes 6.5 inch content width = 9360 twips
+ def pct_to_approximate_dxa(pct_value)
+ (pct_value * 9360 / 5000.0).to_i
+ end
+
+ def render_table_row(xml, row, column_widths)
xml["w"].tr do
- row.cells.each { |cell| render_table_cell(xml, cell, col_width) }
+ row.cells.each_with_index { |cell, idx| render_table_cell(xml, cell, column_widths[idx]) }
end
end
- def render_table_cell(xml, cell, col_width)
+ def render_table_cell(xml, cell, column_width)
+ width = column_width || WidthParser::ParsedWidth.new(value: 0, type: "auto")
xml["w"].tc do
xml["w"].tcPr do
- xml["w"].tcW("w:w" => col_width.to_s, "w:type" => "pct")
+ xml["w"].tcW("w:w" => width.value.to_s, "w:type" => width.type)
end
xml["w"].p do
cell.runs.each { |run| render_run(xml, run) }
diff --git a/lib/notare/xml/styles_xml.rb b/lib/notare/xml/styles_xml.rb
index 9eb4e0e..5cb57dc 100644
--- a/lib/notare/xml/styles_xml.rb
+++ b/lib/notare/xml/styles_xml.rb
@@ -32,6 +32,8 @@ module Notare
render_style(xml, style)
end
+ render_table_normal_style(xml) if @table_styles.any?
+
@table_styles.each_value do |style|
render_table_style(xml, style)
end
@@ -57,8 +59,12 @@ module Notare
xml["w"].pPr do
xml["w"].jc("w:val" => ALIGNMENT_MAP[style.align]) if style.align
xml["w"].ind("w:left" => style.indent.to_s) if style.indent
- xml["w"].spacing("w:before" => style.spacing_before.to_s) if style.spacing_before
- xml["w"].spacing("w:after" => style.spacing_after.to_s) if style.spacing_after
+ if style.spacing_before || style.spacing_after
+ spacing_attrs = {}
+ spacing_attrs["w:before"] = style.spacing_before.to_s if style.spacing_before
+ spacing_attrs["w:after"] = style.spacing_after.to_s if style.spacing_after
+ xml["w"].spacing(spacing_attrs)
+ end
end
end
@@ -75,9 +81,24 @@ module Notare
end
end
+ def render_table_normal_style(xml)
+ xml["w"].style("w:type" => "table", "w:default" => "1", "w:styleId" => "TableNormal") do
+ xml["w"].name("w:val" => "Normal Table")
+ xml["w"].tblPr do
+ xml["w"].tblCellMar do
+ xml["w"].top("w:w" => "0", "w:type" => "dxa")
+ xml["w"].left("w:w" => "108", "w:type" => "dxa")
+ xml["w"].bottom("w:w" => "0", "w:type" => "dxa")
+ xml["w"].right("w:w" => "108", "w:type" => "dxa")
+ end
+ end
+ end
+ end
+
def render_table_style(xml, style)
xml["w"].style("w:type" => "table", "w:styleId" => style.style_id) do
xml["w"].name("w:val" => style.display_name)
+ xml["w"].basedOn("w:val" => "TableNormal")
xml["w"].tblPr do
render_table_borders(xml, style.borders) if style.borders
diff --git a/test/table_test.rb b/test/table_test.rb
index e7fdb66..af49763 100644
--- a/test/table_test.rb
+++ b/test/table_test.rb
@@ -134,4 +134,170 @@ class TableTest < Minitest::Test
assert_includes xml, "R0C0"
assert_includes xml, "R4C4"
end
+
+ def test_table_with_auto_layout
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(layout: :auto) do
+ doc.tr do
+ doc.td "Short"
+ doc.td "Much longer content here"
+ end
+ end
+ end
+
+ assert_includes xml, ''
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_table_with_fixed_layout
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(layout: :fixed) do
+ doc.tr do
+ doc.td "Cell 1"
+ doc.td "Cell 2"
+ end
+ end
+ end
+
+ assert_includes xml, ''
+ end
+
+ def test_table_with_explicit_column_widths_in_inches
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(columns: %w[2in 3in]) do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ end
+ end
+ end
+
+ # 2 inches = 2880 twips, 3 inches = 4320 twips
+ assert_includes xml, ''
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_table_with_percentage_columns
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(columns: ["25%", "75%"]) do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ end
+ end
+ end
+
+ # 25% = 1250 (fiftieths), 75% = 3750 (fiftieths)
+ assert_includes xml, ''
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_table_with_centimeter_columns
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(columns: ["2.54cm", "5cm"]) do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ end
+ end
+ end
+
+ # 2.54cm = ~1440 twips (1 inch), 5cm = ~2835 twips
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_cell_with_explicit_width
+ xml = create_doc_and_read_xml do |doc|
+ doc.table do
+ doc.tr do
+ doc.td("Narrow", width: "1in")
+ doc.td("Wide", width: "4in")
+ end
+ end
+ end
+
+ # 1 inch = 1440 twips, 4 inches = 5760 twips
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_cell_width_with_block
+ xml = create_doc_and_read_xml do |doc|
+ doc.table do
+ doc.tr do
+ doc.td(width: "2in") { doc.b { doc.text "Bold content" } }
+ end
+ end
+ end
+
+ assert_includes xml, ''
+ assert_includes xml, "Bold content"
+ assert_includes xml, ""
+ end
+
+ def test_mixed_cell_widths_explicit_and_auto
+ xml = create_doc_and_read_xml do |doc|
+ doc.table do
+ doc.tr do
+ doc.td("Fixed", width: "2in")
+ doc.td "Auto"
+ end
+ end
+ end
+
+ # First cell has explicit width, second gets auto
+ assert_includes xml, ''
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_table_columns_override_cell_widths
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(columns: %w[3in 3in]) do
+ doc.tr do
+ doc.td("A", width: "1in") # This width is ignored
+ doc.td "B"
+ end
+ end
+ end
+
+ # Table-level columns take precedence
+ assert_includes xml, ''
+ refute_includes xml, ''
+ end
+
+ def test_table_layout_with_columns
+ xml = create_doc_and_read_xml do |doc|
+ doc.table(layout: :fixed, columns: %w[2in 2in 2in]) do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ doc.td "C"
+ end
+ end
+ end
+
+ assert_includes xml, ''
+ assert_includes xml, ''
+ end
+
+ def test_default_behavior_unchanged
+ xml = create_doc_and_read_xml do |doc|
+ doc.table do
+ doc.tr do
+ doc.td "A"
+ doc.td "B"
+ end
+ end
+ end
+
+ # Default: equal percentage widths (5000 / 2 = 2500 per cell)
+ assert_includes xml, ''
+ assert_includes xml, ''
+ refute_includes xml, "