diff --git a/.claude/settings.local.json b/.claude/settings.local.json index aad0fcb..f23f689 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(bundle exec rake test:*)" + "Bash(bundle exec rake test:*)", + "Bash(bundle exec rake:*)" ], "deny": [], "ask": [] diff --git a/README.md b/README.md index 65ba637..43c83b6 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,77 @@ Notare::Document.create("output.docx") do |doc| end ``` +#### Table Styles + +Define reusable table styles with borders, shading, cell margins, and alignment: + +```ruby +Notare::Document.create("output.docx") do |doc| + # Define a custom table style + doc.define_table_style :fancy, + borders: { style: "double", color: "0066CC", size: 6 }, + shading: "E6F2FF", + cell_margins: 100, + align: :center + + # Apply the style to a table + doc.table(style: :fancy) do + doc.tr do + doc.td "Product" + doc.td "Price" + end + doc.tr do + doc.td "Widget" + doc.td "$10.00" + end + end +end +``` + +#### Table Style Properties + +| Property | Description | Example | +|----------|-------------|---------| +| `borders` | Border configuration | `{ style: "single", color: "000000", size: 4 }` | +| `shading` | Background color (hex) | `"EEEEEE"` | +| `cell_margins` | Cell padding (twips) | `100` or `{ top: 50, bottom: 50, left: 100, right: 100 }` | +| `align` | Table alignment | `:left`, `:center`, `:right` | + +**Border styles:** `single`, `double`, `dotted`, `dashed`, `triple`, `none` + +**Border configuration options:** + +```ruby +# All borders the same +borders: { style: "single", color: "000000", size: 4 } + +# Per-edge borders +borders: { + top: { style: "double", color: "FF0000", size: 8 }, + bottom: { style: "single", color: "000000", size: 4 }, + left: { style: "none" }, + right: { style: "none" }, + insideH: { style: "dotted", color: "CCCCCC", size: 2 }, + insideV: { style: "dotted", color: "CCCCCC", size: 2 } +} + +# No borders +borders: :none +``` + +#### Built-in Table Styles + +| Style | Description | +|-------|-------------| +| `:grid` | Standard black single-line borders | +| `:borderless` | No borders | + +```ruby +doc.table(style: :borderless) do + doc.tr { doc.td "No borders here" } +end +``` + ### Images Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats. @@ -449,12 +520,13 @@ end | `link(url, text)` | Hyperlink with custom text | | `link(url) { }` | Hyperlink with block content | | `define_style(name, **props)` | Define a custom style | +| `define_table_style(name, **props)` | Define a custom table style | | `ul { }` | Bullet list (can be nested) | | `ol { }` | Numbered list (can be nested) | | `li(text)` | List item with text | | `li { }` | List item with block content | | `li(text) { }` | List item with text and nested content | -| `table { }` | Table | +| `table(style:) { }` | Table with optional style | | `tr { }` | Table row | | `td(text)` | Table cell with text | | `td { }` | Table cell with block content | diff --git a/examples/full_demo.rb b/examples/full_demo.rb index af2ffb0..6367e9e 100644 --- a/examples/full_demo.rb +++ b/examples/full_demo.rb @@ -18,6 +18,25 @@ Notare::Document.create(OUTPUT_FILE) do |doc| doc.define_style :deleted_text, strike: true, color: "999999" doc.define_style :important, highlight: "yellow", bold: true + # ============================================================================ + # Custom Table Styles + # ============================================================================ + doc.define_table_style :fancy_table, + borders: { style: "double", color: "0066CC", size: 6 }, + shading: "E6F2FF", + cell_margins: 80, + align: :center + + doc.define_table_style :minimal_table, + borders: { + top: { style: "single", color: "CCCCCC", size: 4 }, + bottom: { style: "single", color: "CCCCCC", size: 4 }, + left: { style: "none" }, + right: { style: "none" }, + insideH: { style: "dotted", color: "DDDDDD", size: 2 }, + insideV: { style: "none" } + } + # ============================================================================ # Title and Introduction # ============================================================================ @@ -218,6 +237,8 @@ Notare::Document.create(OUTPUT_FILE) do |doc| # 9. Tables # ============================================================================ doc.h2 "9. Tables" + + doc.h3 "Default Table" doc.table do doc.tr do doc.td { doc.b { doc.text "Feature" } } @@ -276,6 +297,52 @@ Notare::Document.create(OUTPUT_FILE) do |doc| end end + doc.h3 "Styled Tables" + + doc.p "Fancy table with double borders and shading:" + doc.table(style: :fancy_table) do + doc.tr do + doc.td { doc.b { doc.text "Product" } } + doc.td { doc.b { doc.text "Price" } } + doc.td { doc.b { doc.text "Quantity" } } + end + doc.tr do + doc.td "Widget A" + doc.td "$10.00" + doc.td "100" + end + doc.tr do + doc.td "Widget B" + doc.td "$15.00" + doc.td "50" + end + end + + doc.p "Minimal table with horizontal lines only:" + doc.table(style: :minimal_table) do + doc.tr do + doc.td { doc.b { doc.text "Name" } } + doc.td { doc.b { doc.text "Role" } } + end + doc.tr do + doc.td "Alice" + doc.td "Developer" + end + doc.tr do + doc.td "Bob" + doc.td "Designer" + end + end + + doc.p "Borderless table (built-in style):" + doc.table(style: :borderless) do + doc.tr do + doc.td "No" + doc.td "borders" + doc.td "here" + end + end + # ============================================================================ # 10. Images # ============================================================================ diff --git a/lib/notare.rb b/lib/notare.rb index 264c67e..46fd7aa 100644 --- a/lib/notare.rb +++ b/lib/notare.rb @@ -16,6 +16,7 @@ require_relative "notare/nodes/table_row" require_relative "notare/nodes/table_cell" require_relative "notare/image_dimensions" require_relative "notare/style" +require_relative "notare/table_style" 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 7c7479f..1a618e0 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(&block) - tbl = Nodes::Table.new + def table(style: nil, &block) + tbl = Nodes::Table.new(style: resolve_table_style(style)) previous_table = @current_table @current_table = tbl block.call @@ -198,5 +198,12 @@ module Notare style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}") end + + def resolve_table_style(style_or_name) + return nil if style_or_name.nil? + return style_or_name if style_or_name.is_a?(TableStyle) + + table_style(style_or_name) || raise(ArgumentError, "Unknown table style: #{style_or_name}") + end end end diff --git a/lib/notare/document.rb b/lib/notare/document.rb index 7cac14a..4922ac9 100644 --- a/lib/notare/document.rb +++ b/lib/notare/document.rb @@ -4,7 +4,7 @@ module Notare class Document include Builder - attr_reader :nodes, :styles, :hyperlinks + attr_reader :nodes, :styles, :table_styles, :hyperlinks def self.create(path, &block) doc = new @@ -25,7 +25,9 @@ module Notare @images = {} @hyperlinks = [] @styles = {} + @table_styles = {} register_built_in_styles + register_built_in_table_styles end def define_style(name, **properties) @@ -36,6 +38,14 @@ module Notare @styles[name] end + def define_table_style(name, **properties) + @table_styles[name] = TableStyle.new(name, **properties) + end + + def table_style(name) + @table_styles[name] + end + def save(path) Package.new(self).save(path) end @@ -104,5 +114,13 @@ module Notare define_style :quote, italic: true, color: "666666", indent: 720 define_style :code, font: "Courier New", size: 10 end + + def register_built_in_table_styles + define_table_style :grid, + borders: { style: "single", color: "000000", size: 4 } + + define_table_style :borderless, + borders: :none + end end end diff --git a/lib/notare/nodes/table.rb b/lib/notare/nodes/table.rb index 8676cf4..4d6bd90 100644 --- a/lib/notare/nodes/table.rb +++ b/lib/notare/nodes/table.rb @@ -3,11 +3,12 @@ module Notare module Nodes class Table < Base - attr_reader :rows + attr_reader :rows, :style - def initialize - super + def initialize(style: nil) + super() @rows = [] + @style = style end def add_row(row) diff --git a/lib/notare/package.rb b/lib/notare/package.rb index 85509fd..8b7ba1b 100644 --- a/lib/notare/package.rb +++ b/lib/notare/package.rb @@ -59,7 +59,7 @@ module Notare end def styles_xml - Xml::StylesXml.new(@document.styles).to_xml + Xml::StylesXml.new(@document.styles, @document.table_styles).to_xml end def numbering_xml diff --git a/lib/notare/table_style.rb b/lib/notare/table_style.rb new file mode 100644 index 0000000..efe03d7 --- /dev/null +++ b/lib/notare/table_style.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Notare + class TableStyle + attr_reader :name, :borders, :shading, :cell_margins, :align + + BORDER_STYLES = %w[single double dotted dashed triple none nil].freeze + BORDER_POSITIONS = %i[top bottom left right insideH insideV].freeze + ALIGNMENTS = %i[left center right].freeze + + def initialize(name, borders: nil, shading: nil, cell_margins: nil, align: nil) + @name = name + @borders = normalize_borders(borders) + @shading = normalize_color(shading) + @cell_margins = normalize_cell_margins(cell_margins) + @align = validate_align(align) + end + + def style_id + name.to_s.split("_").map(&:capitalize).join + end + + def display_name + name.to_s.split("_").map(&:capitalize).join(" ") + end + + private + + def normalize_borders(borders) + return nil if borders.nil? + return :none if borders == :none + + # Check if it's a per-edge configuration + if borders.keys.any? { |k| BORDER_POSITIONS.include?(k) } + borders.transform_values { |v| normalize_single_border(v) } + else + # Single border config applied to all edges + normalize_single_border(borders) + end + end + + def normalize_single_border(border) + return :none if border == :none || border[:style] == "none" + + style = border[:style] || "single" + unless BORDER_STYLES.include?(style) + raise ArgumentError, "Invalid border style: #{style}. Use #{BORDER_STYLES.join(", ")}" + end + + { + style: style, + color: normalize_color(border[:color]) || "000000", + size: border[:size] || 4 + } + end + + def normalize_color(color) + return nil if color.nil? + + hex = color.to_s.sub(/^#/, "").upcase + return hex if hex.match?(/\A[0-9A-F]{6}\z/) + + raise ArgumentError, "Invalid color: #{color}. Use 6-digit hex (e.g., 'FF0000')" + end + + def normalize_cell_margins(margins) + return nil if margins.nil? + + if margins.is_a?(Hash) + margins.slice(:top, :bottom, :left, :right) + else + margins.to_i + end + end + + def validate_align(align) + return nil if align.nil? + return align if ALIGNMENTS.include?(align) + + raise ArgumentError, "Invalid alignment: #{align}. Use #{ALIGNMENTS.join(", ")}" + end + end +end diff --git a/lib/notare/xml/document_xml.rb b/lib/notare/xml/document_xml.rb index f83f50a..d97e3d3 100644 --- a/lib/notare/xml/document_xml.rb +++ b/lib/notare/xml/document_xml.rb @@ -169,9 +169,13 @@ module Notare xml["w"].tbl do xml["w"].tblPr do xml["w"].tblW("w:w" => "5000", "w:type" => "pct") - xml["w"].tblBorders do - %w[top left bottom right insideH insideV].each do |border| - xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000") + if table.style + xml["w"].tblStyle("w:val" => table.style.style_id) + else + xml["w"].tblBorders do + %w[top left bottom right insideH insideV].each do |border| + xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000") + end end end end diff --git a/lib/notare/xml/styles_xml.rb b/lib/notare/xml/styles_xml.rb index d6e6f08..9eb4e0e 100644 --- a/lib/notare/xml/styles_xml.rb +++ b/lib/notare/xml/styles_xml.rb @@ -12,8 +12,15 @@ module Notare justify: "both" }.freeze - def initialize(styles) + TABLE_ALIGNMENT_MAP = { + left: "left", + center: "center", + right: "right" + }.freeze + + def initialize(styles, table_styles = {}) @styles = styles + @table_styles = table_styles end def to_xml @@ -24,6 +31,10 @@ module Notare @styles.each_value do |style| render_style(xml, style) end + + @table_styles.each_value do |style| + render_table_style(xml, style) + end end end builder.to_xml @@ -63,6 +74,59 @@ module Notare xml["w"].highlight("w:val" => style.highlight) if style.highlight 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"].tblPr do + render_table_borders(xml, style.borders) if style.borders + render_table_shading(xml, style.shading) if style.shading + render_table_cell_margins(xml, style.cell_margins) if style.cell_margins + xml["w"].jc("w:val" => TABLE_ALIGNMENT_MAP[style.align]) if style.align + end + end + end + + def render_table_borders(xml, borders) + xml["w"].tblBorders do + %i[top left bottom right insideH insideV].each do |pos| + border = borders == :none ? :none : (borders[pos] || borders) + render_single_border(xml, pos, border) + end + end + end + + def render_single_border(xml, position, border) + if border == :none + xml["w"].send(position, "w:val" => "nil") + else + xml["w"].send(position, + "w:val" => border[:style], + "w:sz" => border[:size].to_s, + "w:space" => "0", + "w:color" => border[:color]) + end + end + + def render_table_shading(xml, color) + xml["w"].shd("w:val" => "clear", "w:color" => "auto", "w:fill" => color) + end + + def render_table_cell_margins(xml, margins) + xml["w"].tblCellMar do + if margins.is_a?(Hash) + xml["w"].top("w:w" => margins[:top].to_s, "w:type" => "dxa") if margins[:top] + xml["w"].left("w:w" => margins[:left].to_s, "w:type" => "dxa") if margins[:left] + xml["w"].bottom("w:w" => margins[:bottom].to_s, "w:type" => "dxa") if margins[:bottom] + xml["w"].right("w:w" => margins[:right].to_s, "w:type" => "dxa") if margins[:right] + else + %i[top left bottom right].each do |side| + xml["w"].send(side, "w:w" => margins.to_s, "w:type" => "dxa") + end + end + end + end end end end diff --git a/test/table_style_test.rb b/test/table_style_test.rb new file mode 100644 index 0000000..f08a2a8 --- /dev/null +++ b/test/table_style_test.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "test_helper" + +class TableStyleTest < Minitest::Test + include NotareTestHelpers + + # --- TableStyle class tests --- + + def test_table_style_id_generation + style = Notare::TableStyle.new(:my_table_style) + assert_equal "MyTableStyle", style.style_id + end + + def test_table_style_display_name + style = Notare::TableStyle.new(:my_table_style) + assert_equal "My Table Style", style.display_name + end + + def test_invalid_border_style_raises_error + assert_raises(ArgumentError) do + Notare::TableStyle.new(:bad, borders: { style: "invalid" }) + end + end + + def test_invalid_color_raises_error + assert_raises(ArgumentError) do + Notare::TableStyle.new(:bad, shading: "invalid") + end + end + + def test_color_normalizes_hash_prefix + style = Notare::TableStyle.new(:test, shading: "#ff0000") + assert_equal "FF0000", style.shading + end + + def test_invalid_alignment_raises_error + assert_raises(ArgumentError) do + Notare::TableStyle.new(:bad, align: :invalid) + end + end + + # --- Document registration tests --- + + def test_define_table_style + xml_files = create_doc_and_read_all_xml do |doc| + doc.define_table_style :custom, borders: { style: "double", color: "FF0000", size: 8 } + doc.table(style: :custom) do + doc.tr { doc.td "Test" } + end + end + + styles_xml = xml_files["word/styles.xml"] + assert_includes styles_xml, 'w:styleId="Custom"' + assert_includes styles_xml, 'w:type="table"' + assert_includes styles_xml, 'w:val="double"' + assert_includes styles_xml, 'w:color="FF0000"' + end + + def test_unknown_table_style_raises_error + assert_raises(ArgumentError) do + Tempfile.create(["test", ".docx"]) do |file| + Notare::Document.create(file.path) do |doc| + doc.table(style: :nonexistent) { doc.tr { doc.td "Test" } } + end + end + end + end + + # --- Style application tests --- + + def test_table_with_style_reference + xml = create_doc_and_read_xml do |doc| + doc.define_table_style :bordered, borders: { style: "single", color: "000000", size: 4 } + doc.table(style: :bordered) do + doc.tr { doc.td "Cell" } + end + end + + assert_includes xml, '" + refute_includes xml, "" + assert_includes styles_xml, 'w:w="100"' + end + + def test_table_style_with_alignment + xml_files = create_doc_and_read_all_xml do |doc| + doc.define_table_style :centered, align: :center + doc.table(style: :centered) do + doc.tr { doc.td "Cell" } + end + end + + styles_xml = xml_files["word/styles.xml"] + assert_includes styles_xml, '" + end + + def test_per_edge_borders + xml_files = create_doc_and_read_all_xml do |doc| + doc.define_table_style :mixed_borders, + borders: { + top: { style: "double", color: "FF0000", size: 8 }, + bottom: { style: "single", color: "000000", size: 4 }, + left: { style: "none" }, + right: { style: "none" }, + insideH: { style: "dotted", color: "CCCCCC", size: 2 }, + insideV: { style: "dotted", color: "CCCCCC", size: 2 } + } + doc.table(style: :mixed_borders) do + doc.tr { doc.td "Cell" } + end + end + + styles_xml = xml_files["word/styles.xml"] + assert_includes styles_xml, '" + assert_match(/]*w:w="100"/, styles_xml) + assert_match(/]*w:w="200"/, styles_xml) + end +end