Compare commits
5 Commits
52d715a6de
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
| 843466549a | |||
| 00cabb6dfb | |||
| ac916d980b | |||
| 9a70d91fd5 | |||
| 67a60c8c6e |
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(bundle exec rake test:*)"
|
"Bash(bundle exec rake test:*)",
|
||||||
|
"Bash(bundle exec rake:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(unzip:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
162
README.md
162
README.md
@@ -105,6 +105,21 @@ Notare includes built-in styles and supports custom style definitions.
|
|||||||
|
|
||||||
#### Built-in Styles
|
#### Built-in Styles
|
||||||
|
|
||||||
|
| Style | Properties |
|
||||||
|
|-------|------------|
|
||||||
|
| `:title` | 26pt, bold, centered |
|
||||||
|
| `:subtitle` | 15pt, italic, gray (#666666) |
|
||||||
|
| `:quote` | italic, gray (#666666), indented |
|
||||||
|
| `:code` | Courier New, 10pt |
|
||||||
|
| `:heading1` | 24pt, bold |
|
||||||
|
| `:heading2` | 18pt, bold |
|
||||||
|
| `:heading3` | 14pt, bold |
|
||||||
|
| `:heading4` | 12pt, bold |
|
||||||
|
| `:heading5` | 11pt, bold, italic |
|
||||||
|
| `:heading6` | 10pt, bold, italic |
|
||||||
|
|
||||||
|
Note: `h1` through `h6` methods use the corresponding heading styles automatically.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Notare::Document.create("output.docx") do |doc|
|
Notare::Document.create("output.docx") do |doc|
|
||||||
doc.p "This is a title", style: :title
|
doc.p "This is a title", style: :title
|
||||||
@@ -246,6 +261,146 @@ Notare::Document.create("output.docx") do |doc|
|
|||||||
end
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
|
Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
|
||||||
@@ -434,15 +589,16 @@ end
|
|||||||
| `link(url, text)` | Hyperlink with custom text |
|
| `link(url, text)` | Hyperlink with custom text |
|
||||||
| `link(url) { }` | Hyperlink with block content |
|
| `link(url) { }` | Hyperlink with block content |
|
||||||
| `define_style(name, **props)` | Define a custom style |
|
| `define_style(name, **props)` | Define a custom style |
|
||||||
|
| `define_table_style(name, **props)` | Define a custom table style |
|
||||||
| `ul { }` | Bullet list (can be nested) |
|
| `ul { }` | Bullet list (can be nested) |
|
||||||
| `ol { }` | Numbered list (can be nested) |
|
| `ol { }` | Numbered list (can be nested) |
|
||||||
| `li(text)` | List item with text |
|
| `li(text)` | List item with text |
|
||||||
| `li { }` | List item with block content |
|
| `li { }` | List item with block content |
|
||||||
| `li(text) { }` | List item with text and nested content |
|
| `li(text) { }` | List item with text and nested content |
|
||||||
| `table { }` | Table |
|
| `table(style:, layout:, columns:) { }` | Table with optional style, layout (`:auto`/`:fixed`), and column widths |
|
||||||
| `tr { }` | Table row |
|
| `tr { }` | Table row |
|
||||||
| `td(text)` | Table cell with text |
|
| `td(text, width:)` | Table cell with text and optional width |
|
||||||
| `td { }` | Table cell with block content |
|
| `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 |
|
| `image(path, width:, height:)` | Insert image (PNG/JPEG). Dimensions: `"2in"`, `"5cm"`, `"100px"`, or integer pixels |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
|
|||||||
doc.define_style :deleted_text, strike: true, color: "999999"
|
doc.define_style :deleted_text, strike: true, color: "999999"
|
||||||
doc.define_style :important, highlight: "yellow", bold: true
|
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
|
# Title and Introduction
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -218,6 +237,8 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
|
|||||||
# 9. Tables
|
# 9. Tables
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
doc.h2 "9. Tables"
|
doc.h2 "9. Tables"
|
||||||
|
|
||||||
|
doc.h3 "Default Table"
|
||||||
doc.table do
|
doc.table do
|
||||||
doc.tr do
|
doc.tr do
|
||||||
doc.td { doc.b { doc.text "Feature" } }
|
doc.td { doc.b { doc.text "Feature" } }
|
||||||
@@ -276,6 +297,94 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
|
|||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
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
|
# 10. Images
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ require_relative "notare/nodes/table_row"
|
|||||||
require_relative "notare/nodes/table_cell"
|
require_relative "notare/nodes/table_cell"
|
||||||
require_relative "notare/image_dimensions"
|
require_relative "notare/image_dimensions"
|
||||||
require_relative "notare/style"
|
require_relative "notare/style"
|
||||||
|
require_relative "notare/table_style"
|
||||||
|
require_relative "notare/width_parser"
|
||||||
require_relative "notare/xml/content_types"
|
require_relative "notare/xml/content_types"
|
||||||
require_relative "notare/xml/relationships"
|
require_relative "notare/xml/relationships"
|
||||||
require_relative "notare/xml/document_xml"
|
require_relative "notare/xml/document_xml"
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ module Notare
|
|||||||
@current_list.add_item(item)
|
@current_list.add_item(item)
|
||||||
end
|
end
|
||||||
|
|
||||||
def table(&block)
|
def table(style: nil, layout: nil, columns: nil, &block)
|
||||||
tbl = Nodes::Table.new
|
tbl = Nodes::Table.new(style: resolve_table_style(style), layout: layout, columns: columns)
|
||||||
previous_table = @current_table
|
previous_table = @current_table
|
||||||
@current_table = tbl
|
@current_table = tbl
|
||||||
block.call
|
block.call
|
||||||
@@ -118,8 +118,8 @@ module Notare
|
|||||||
@current_table.add_row(row)
|
@current_table.add_row(row)
|
||||||
end
|
end
|
||||||
|
|
||||||
def td(text = nil, &block)
|
def td(text = nil, width: nil, &block)
|
||||||
cell = Nodes::TableCell.new
|
cell = Nodes::TableCell.new(width: width)
|
||||||
if block
|
if block
|
||||||
with_target(cell, &block)
|
with_target(cell, &block)
|
||||||
elsif text
|
elsif text
|
||||||
@@ -198,5 +198,12 @@ module Notare
|
|||||||
|
|
||||||
style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}")
|
style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}")
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Notare
|
|||||||
class Document
|
class Document
|
||||||
include Builder
|
include Builder
|
||||||
|
|
||||||
attr_reader :nodes, :styles, :hyperlinks
|
attr_reader :nodes, :styles, :table_styles, :hyperlinks
|
||||||
|
|
||||||
def self.create(path, &block)
|
def self.create(path, &block)
|
||||||
doc = new
|
doc = new
|
||||||
@@ -25,7 +25,9 @@ module Notare
|
|||||||
@images = {}
|
@images = {}
|
||||||
@hyperlinks = []
|
@hyperlinks = []
|
||||||
@styles = {}
|
@styles = {}
|
||||||
|
@table_styles = {}
|
||||||
register_built_in_styles
|
register_built_in_styles
|
||||||
|
register_built_in_table_styles
|
||||||
end
|
end
|
||||||
|
|
||||||
def define_style(name, **properties)
|
def define_style(name, **properties)
|
||||||
@@ -36,6 +38,14 @@ module Notare
|
|||||||
@styles[name]
|
@styles[name]
|
||||||
end
|
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)
|
def save(path)
|
||||||
Package.new(self).save(path)
|
Package.new(self).save(path)
|
||||||
end
|
end
|
||||||
@@ -78,13 +88,13 @@ module Notare
|
|||||||
def next_image_rid
|
def next_image_rid
|
||||||
# rId1 = styles.xml (always present)
|
# rId1 = styles.xml (always present)
|
||||||
# rId2 = numbering.xml (if lists 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
|
base = @has_lists ? 3 : 2
|
||||||
"rId#{base + @images.size}"
|
"rId#{base + @images.size + @hyperlinks.size}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_hyperlink_rid
|
def next_hyperlink_rid
|
||||||
# Hyperlinks come after images
|
# Images and hyperlinks share the same ID space
|
||||||
base = @has_lists ? 3 : 2
|
base = @has_lists ? 3 : 2
|
||||||
"rId#{base + @images.size + @hyperlinks.size}"
|
"rId#{base + @images.size + @hyperlinks.size}"
|
||||||
end
|
end
|
||||||
@@ -104,5 +114,13 @@ module Notare
|
|||||||
define_style :quote, italic: true, color: "666666", indent: 720
|
define_style :quote, italic: true, color: "666666", indent: 720
|
||||||
define_style :code, font: "Courier New", size: 10
|
define_style :code, font: "Courier New", size: 10
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
module Notare
|
module Notare
|
||||||
module Nodes
|
module Nodes
|
||||||
class Table < Base
|
class Table < Base
|
||||||
attr_reader :rows
|
attr_reader :rows, :style, :layout, :columns
|
||||||
|
|
||||||
def initialize
|
def initialize(style: nil, layout: nil, columns: nil)
|
||||||
super
|
super()
|
||||||
@rows = []
|
@rows = []
|
||||||
|
@style = style
|
||||||
|
@layout = layout
|
||||||
|
@columns = columns
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_row(row)
|
def add_row(row)
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
module Notare
|
module Notare
|
||||||
module Nodes
|
module Nodes
|
||||||
class TableCell < Base
|
class TableCell < Base
|
||||||
attr_reader :runs
|
attr_reader :runs, :width
|
||||||
|
|
||||||
def initialize
|
def initialize(width: nil)
|
||||||
super
|
super()
|
||||||
@runs = []
|
@runs = []
|
||||||
|
@width = width
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_run(run)
|
def add_run(run)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ module Notare
|
|||||||
end
|
end
|
||||||
|
|
||||||
def styles_xml
|
def styles_xml
|
||||||
Xml::StylesXml.new(@document.styles).to_xml
|
Xml::StylesXml.new(@document.styles, @document.table_styles).to_xml
|
||||||
end
|
end
|
||||||
|
|
||||||
def numbering_xml
|
def numbering_xml
|
||||||
|
|||||||
83
lib/notare/table_style.rb
Normal file
83
lib/notare/table_style.rb
Normal file
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Notare
|
module Notare
|
||||||
VERSION = "0.0.2"
|
VERSION = "0.0.4"
|
||||||
end
|
end
|
||||||
|
|||||||
31
lib/notare/width_parser.rb
Normal file
31
lib/notare/width_parser.rb
Normal file
@@ -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
|
||||||
@@ -163,37 +163,105 @@ module Notare
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_table(xml, table)
|
def render_table(xml, table)
|
||||||
column_count = table.rows.first&.cells&.size || 1
|
column_widths = compute_column_widths(table)
|
||||||
col_width = 5000 / column_count
|
|
||||||
|
|
||||||
xml["w"].tbl do
|
xml["w"].tbl do
|
||||||
xml["w"].tblPr do
|
xml["w"].tblPr do
|
||||||
xml["w"].tblW("w:w" => "5000", "w:type" => "pct")
|
render_table_width(xml, column_widths)
|
||||||
xml["w"].tblBorders do
|
render_table_layout(xml, table.layout)
|
||||||
%w[top left bottom right insideH insideV].each do |border|
|
if table.style
|
||||||
xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000")
|
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
|
end
|
||||||
end
|
end
|
||||||
xml["w"].tblGrid do
|
render_table_grid(xml, column_widths)
|
||||||
column_count.times do
|
table.rows.each { |row| render_table_row(xml, row, column_widths) }
|
||||||
xml["w"].gridCol("w:w" => col_width.to_s)
|
end
|
||||||
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
|
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
|
||||||
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
|
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
|
||||||
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"].tc do
|
||||||
xml["w"].tcPr 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
|
end
|
||||||
xml["w"].p do
|
xml["w"].p do
|
||||||
cell.runs.each { |run| render_run(xml, run) }
|
cell.runs.each { |run| render_run(xml, run) }
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ module Notare
|
|||||||
justify: "both"
|
justify: "both"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(styles)
|
TABLE_ALIGNMENT_MAP = {
|
||||||
|
left: "left",
|
||||||
|
center: "center",
|
||||||
|
right: "right"
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(styles, table_styles = {})
|
||||||
@styles = styles
|
@styles = styles
|
||||||
|
@table_styles = table_styles
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_xml
|
def to_xml
|
||||||
@@ -24,6 +31,12 @@ module Notare
|
|||||||
@styles.each_value do |style|
|
@styles.each_value do |style|
|
||||||
render_style(xml, style)
|
render_style(xml, style)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
render_table_normal_style(xml) if @table_styles.any?
|
||||||
|
|
||||||
|
@table_styles.each_value do |style|
|
||||||
|
render_table_style(xml, style)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
builder.to_xml
|
builder.to_xml
|
||||||
@@ -46,8 +59,12 @@ module Notare
|
|||||||
xml["w"].pPr do
|
xml["w"].pPr do
|
||||||
xml["w"].jc("w:val" => ALIGNMENT_MAP[style.align]) if style.align
|
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"].ind("w:left" => style.indent.to_s) if style.indent
|
||||||
xml["w"].spacing("w:before" => style.spacing_before.to_s) if style.spacing_before
|
if style.spacing_before || style.spacing_after
|
||||||
xml["w"].spacing("w:after" => style.spacing_after.to_s) if 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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,6 +80,74 @@ module Notare
|
|||||||
xml["w"].highlight("w:val" => style.highlight) if style.highlight
|
xml["w"].highlight("w:val" => style.highlight) if style.highlight
|
||||||
end
|
end
|
||||||
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
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
224
test/table_style_test.rb
Normal file
224
test/table_style_test.rb
Normal file
@@ -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, '<w:tblStyle w:val="Bordered"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_table_without_style_uses_default_borders
|
||||||
|
xml = create_doc_and_read_xml do |doc|
|
||||||
|
doc.table do
|
||||||
|
doc.tr { doc.td "Cell" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes xml, "<w:tblBorders>"
|
||||||
|
refute_includes xml, "<w:tblStyle"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_borderless_table_style
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.define_table_style :no_borders, borders: :none
|
||||||
|
doc.table(style: :no_borders) do
|
||||||
|
doc.tr { doc.td "Cell" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, 'w:val="nil"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_table_style_with_shading
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.define_table_style :shaded, shading: "EEEEEE"
|
||||||
|
doc.table(style: :shaded) do
|
||||||
|
doc.tr { doc.td "Cell" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, 'w:fill="EEEEEE"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_table_style_with_cell_margins
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.define_table_style :padded, cell_margins: 100
|
||||||
|
doc.table(style: :padded) do
|
||||||
|
doc.tr { doc.td "Cell" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, "<w:tblCellMar>"
|
||||||
|
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, '<w:jc w:val="center"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_combined_table_style_properties
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.define_table_style :fancy,
|
||||||
|
borders: { style: "double", color: "0000FF", size: 8 },
|
||||||
|
shading: "F0F0F0",
|
||||||
|
cell_margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
||||||
|
align: :center
|
||||||
|
|
||||||
|
doc.table(style: :fancy) do
|
||||||
|
doc.tr { doc.td "Fancy" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, 'w:styleId="Fancy"'
|
||||||
|
assert_includes styles_xml, 'w:val="double"'
|
||||||
|
assert_includes styles_xml, 'w:fill="F0F0F0"'
|
||||||
|
assert_includes styles_xml, '<w:jc w:val="center"'
|
||||||
|
assert_includes styles_xml, "<w:tblCellMar>"
|
||||||
|
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, '<w:top w:val="double"'
|
||||||
|
assert_includes styles_xml, '<w:bottom w:val="single"'
|
||||||
|
assert_includes styles_xml, '<w:left w:val="nil"'
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Built-in styles tests ---
|
||||||
|
|
||||||
|
def test_built_in_grid_style_exists
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.table(style: :grid) do
|
||||||
|
doc.tr { doc.td "Test" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, 'w:styleId="Grid"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_built_in_borderless_style_exists
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.table(style: :borderless) do
|
||||||
|
doc.tr { doc.td "Test" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, 'w:styleId="Borderless"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cell_margins_as_hash
|
||||||
|
xml_files = create_doc_and_read_all_xml do |doc|
|
||||||
|
doc.define_table_style :asymmetric_padding,
|
||||||
|
cell_margins: { top: 100, bottom: 200, left: 150, right: 150 }
|
||||||
|
doc.table(style: :asymmetric_padding) do
|
||||||
|
doc.tr { doc.td "Cell" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
styles_xml = xml_files["word/styles.xml"]
|
||||||
|
assert_includes styles_xml, "<w:tblCellMar>"
|
||||||
|
assert_match(/<w:top[^>]*w:w="100"/, styles_xml)
|
||||||
|
assert_match(/<w:bottom[^>]*w:w="200"/, styles_xml)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -134,4 +134,170 @@ class TableTest < Minitest::Test
|
|||||||
assert_includes xml, "R0C0"
|
assert_includes xml, "R0C0"
|
||||||
assert_includes xml, "R4C4"
|
assert_includes xml, "R4C4"
|
||||||
end
|
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, '<w:tblLayout w:type="autofit"/>'
|
||||||
|
assert_includes xml, '<w:tblW w:w="0" w:type="auto"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="0" w:type="auto"/>'
|
||||||
|
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, '<w:tblLayout w:type="fixed"/>'
|
||||||
|
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, '<w:tblW w:w="7200" w:type="dxa"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="2880" w:type="dxa"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="4320" w:type="dxa"/>'
|
||||||
|
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, '<w:tblW w:w="5000" w:type="pct"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="1250" w:type="pct"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="3750" w:type="pct"/>'
|
||||||
|
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, '<w:tcW w:w="1440" w:type="dxa"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="2835" w:type="dxa"/>'
|
||||||
|
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, '<w:tcW w:w="1440" w:type="dxa"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="5760" w:type="dxa"/>'
|
||||||
|
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, '<w:tcW w:w="2880" w:type="dxa"/>'
|
||||||
|
assert_includes xml, "Bold content"
|
||||||
|
assert_includes xml, "<w:b/>"
|
||||||
|
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, '<w:tcW w:w="2880" w:type="dxa"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="0" w:type="auto"/>'
|
||||||
|
assert_includes xml, '<w:tblW w:w="0" w:type="auto"/>'
|
||||||
|
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, '<w:tcW w:w="4320" w:type="dxa"/>'
|
||||||
|
refute_includes xml, '<w:tcW w:w="1440" w:type="dxa"/>'
|
||||||
|
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, '<w:tblLayout w:type="fixed"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="2880" w:type="dxa"/>'
|
||||||
|
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, '<w:tblW w:w="5000" w:type="pct"/>'
|
||||||
|
assert_includes xml, '<w:tcW w:w="2500" w:type="pct"/>'
|
||||||
|
refute_includes xml, "<w:tblLayout" # No layout element by default
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
91
test/width_parser_test.rb
Normal file
91
test/width_parser_test.rb
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class WidthParserTest < Minitest::Test
|
||||||
|
def test_parse_auto_symbol
|
||||||
|
result = Notare::WidthParser.parse(:auto)
|
||||||
|
assert_equal 0, result.value
|
||||||
|
assert_equal "auto", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_nil
|
||||||
|
result = Notare::WidthParser.parse(nil)
|
||||||
|
assert_equal 0, result.value
|
||||||
|
assert_equal "auto", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_integer_as_twips
|
||||||
|
result = Notare::WidthParser.parse(1440)
|
||||||
|
assert_equal 1440, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_inches
|
||||||
|
result = Notare::WidthParser.parse("2in")
|
||||||
|
assert_equal 2880, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_inches_with_decimal
|
||||||
|
result = Notare::WidthParser.parse("1.5in")
|
||||||
|
assert_equal 2160, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_inches_case_insensitive
|
||||||
|
result = Notare::WidthParser.parse("2IN")
|
||||||
|
assert_equal 2880, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_centimeters
|
||||||
|
result = Notare::WidthParser.parse("5cm")
|
||||||
|
assert_equal 2835, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_centimeters_with_decimal
|
||||||
|
result = Notare::WidthParser.parse("2.54cm")
|
||||||
|
assert_equal 1440, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_percentage
|
||||||
|
result = Notare::WidthParser.parse("50%")
|
||||||
|
assert_equal 2500, result.value
|
||||||
|
assert_equal "pct", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_percentage_with_decimal
|
||||||
|
result = Notare::WidthParser.parse("33.3%")
|
||||||
|
assert_equal 1664, result.value # 33.3 * 50 = 1664.999... truncates to 1664
|
||||||
|
assert_equal "pct", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_100_percent
|
||||||
|
result = Notare::WidthParser.parse("100%")
|
||||||
|
assert_equal 5000, result.value
|
||||||
|
assert_equal "pct", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_with_spaces
|
||||||
|
result = Notare::WidthParser.parse("2 in")
|
||||||
|
assert_equal 2880, result.value
|
||||||
|
assert_equal "dxa", result.type
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_invalid_width_raises_error
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
Notare::WidthParser.parse("invalid")
|
||||||
|
end
|
||||||
|
assert_match(/Invalid width/, error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_invalid_unit_raises_error
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
Notare::WidthParser.parse("10px")
|
||||||
|
end
|
||||||
|
assert_match(/Invalid width/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user