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, "