Compare commits
7 Commits
00cabb6dfb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee07c747b9 | |||
| e937552913 | |||
| 26e0d59cf1 | |||
| 64c8679044 | |||
| 8b4f538cbb | |||
| bc69880c9b | |||
| 843466549a |
@@ -2,7 +2,9 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bundle exec rake test:*)",
|
||||
"Bash(bundle exec rake:*)"
|
||||
"Bash(bundle exec rake:*)",
|
||||
"WebSearch",
|
||||
"Bash(unzip:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
75
README.md
75
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
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
require "nokogiri"
|
||||
|
||||
require_relative "notare/version"
|
||||
require_relative "notare/xml_sanitizer"
|
||||
require_relative "notare/nodes/base"
|
||||
require_relative "notare/nodes/break"
|
||||
require_relative "notare/nodes/hyperlink"
|
||||
@@ -17,6 +18,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,16 +4,21 @@ module Notare
|
||||
class Document
|
||||
include Builder
|
||||
|
||||
attr_reader :nodes, :styles, :table_styles, :hyperlinks
|
||||
DEFAULT_FONT = "Arial"
|
||||
DEFAULT_SIZE = 12
|
||||
|
||||
def self.create(path, &block)
|
||||
doc = new
|
||||
attr_reader :nodes, :styles, :table_styles, :hyperlinks, :default_font, :default_size
|
||||
|
||||
def self.create(path, default_font: DEFAULT_FONT, default_size: DEFAULT_SIZE, &block)
|
||||
doc = new(default_font: default_font, default_size: default_size)
|
||||
block.call(doc)
|
||||
doc.save(path)
|
||||
doc
|
||||
end
|
||||
|
||||
def initialize
|
||||
def initialize(default_font: DEFAULT_FONT, default_size: DEFAULT_SIZE)
|
||||
@default_font = default_font
|
||||
@default_size = default_size
|
||||
@nodes = []
|
||||
@format_stack = []
|
||||
@current_target = nil
|
||||
@@ -51,7 +56,7 @@ module Notare
|
||||
end
|
||||
|
||||
def lists
|
||||
@nodes.select { |n| n.is_a?(Nodes::List) }
|
||||
@nodes.grep(Nodes::List)
|
||||
end
|
||||
|
||||
def uses_lists?
|
||||
@@ -88,25 +93,25 @@ 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
|
||||
|
||||
def register_built_in_styles
|
||||
# Headings (spacing_before ensures they're rendered as paragraph styles)
|
||||
define_style :heading1, size: 24, bold: true, spacing_before: 240, spacing_after: 120
|
||||
define_style :heading2, size: 18, bold: true, spacing_before: 200, spacing_after: 100
|
||||
define_style :heading1, size: 20, bold: true, spacing_before: 240, spacing_after: 120
|
||||
define_style :heading2, size: 16, bold: true, spacing_before: 200, spacing_after: 100
|
||||
define_style :heading3, size: 14, bold: true, spacing_before: 160, spacing_after: 80
|
||||
define_style :heading4, size: 12, bold: true, spacing_before: 120, spacing_after: 60
|
||||
define_style :heading5, size: 11, bold: true, italic: true, spacing_before: 100, spacing_after: 40
|
||||
define_style :heading6, size: 10, bold: true, italic: true, spacing_before: 80, spacing_after: 40
|
||||
define_style :heading5, size: 12, bold: true, italic: true, spacing_before: 100, spacing_after: 40
|
||||
define_style :heading6, size: 12, italic: true, spacing_before: 80, spacing_after: 40
|
||||
|
||||
# Other built-in styles
|
||||
define_style :title, size: 26, bold: true, align: :center
|
||||
|
||||
@@ -8,7 +8,7 @@ module Notare
|
||||
def initialize(text, bold: false, italic: false, underline: false,
|
||||
strike: false, highlight: nil, color: nil, style: nil)
|
||||
super()
|
||||
@text = text
|
||||
@text = XmlSanitizer.sanitize(text)
|
||||
@bold = bold
|
||||
@italic = italic
|
||||
@underline = underline
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,7 +59,12 @@ module Notare
|
||||
end
|
||||
|
||||
def styles_xml
|
||||
Xml::StylesXml.new(@document.styles, @document.table_styles).to_xml
|
||||
Xml::StylesXml.new(
|
||||
@document.styles,
|
||||
@document.table_styles,
|
||||
default_font: @document.default_font,
|
||||
default_size: @document.default_size
|
||||
).to_xml
|
||||
end
|
||||
|
||||
def numbering_xml
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Notare
|
||||
VERSION = "0.0.4"
|
||||
VERSION = "0.0.7"
|
||||
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,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) }
|
||||
|
||||
@@ -18,9 +18,11 @@ module Notare
|
||||
right: "right"
|
||||
}.freeze
|
||||
|
||||
def initialize(styles, table_styles = {})
|
||||
def initialize(styles, table_styles = {}, default_font: nil, default_size: nil)
|
||||
@styles = styles
|
||||
@table_styles = table_styles
|
||||
@default_font = default_font
|
||||
@default_size = default_size
|
||||
end
|
||||
|
||||
def to_xml
|
||||
@@ -28,10 +30,14 @@ module Notare
|
||||
xml.styles("xmlns:w" => NAMESPACE) do
|
||||
xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" }
|
||||
|
||||
render_doc_defaults(xml) if @default_font || @default_size
|
||||
|
||||
@styles.each_value do |style|
|
||||
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
|
||||
@@ -42,6 +48,28 @@ module Notare
|
||||
|
||||
private
|
||||
|
||||
def render_doc_defaults(xml)
|
||||
xml["w"].docDefaults do
|
||||
xml["w"].rPrDefault do
|
||||
xml["w"].rPr do
|
||||
if @default_font
|
||||
xml["w"].rFonts(
|
||||
"w:ascii" => @default_font,
|
||||
"w:hAnsi" => @default_font,
|
||||
"w:eastAsia" => @default_font,
|
||||
"w:cs" => @default_font
|
||||
)
|
||||
end
|
||||
if @default_size
|
||||
half_points = (@default_size * 2).to_i
|
||||
xml["w"].sz("w:val" => half_points.to_s)
|
||||
xml["w"].szCs("w:val" => half_points.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def render_style(xml, style)
|
||||
style_type = style.paragraph_properties? ? "paragraph" : "character"
|
||||
|
||||
@@ -57,8 +85,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 +107,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
|
||||
|
||||
15
lib/notare/xml_sanitizer.rb
Normal file
15
lib/notare/xml_sanitizer.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Notare
|
||||
module XmlSanitizer
|
||||
# Invalid XML 1.0 characters: 0x00, 0x01-0x08, 0x0B-0x0C, 0x0E-0x1F
|
||||
# Valid whitespace preserved: 0x09 (tab), 0x0A (LF), 0x0D (CR)
|
||||
INVALID_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/
|
||||
|
||||
def self.sanitize(text)
|
||||
return text unless text.is_a?(String)
|
||||
|
||||
text.gsub(INVALID_XML_CHARS, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,6 +69,6 @@ class DocumentTest < Minitest::Test
|
||||
doc.table { doc.tr { doc.td "Cell" } }
|
||||
|
||||
assert_equal 2, doc.lists.count
|
||||
assert(doc.lists.all? { |l| l.is_a?(Notare::Nodes::List) })
|
||||
assert(doc.lists.all?(Notare::Nodes::List))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,4 +111,21 @@ class ParagraphTest < Minitest::Test
|
||||
# Newlines should be preserved in the text
|
||||
assert_includes xml, "Line 1\nLine 2\nLine 3"
|
||||
end
|
||||
|
||||
def test_invalid_xml_characters_are_stripped
|
||||
xml = create_doc_and_read_xml do |doc|
|
||||
doc.p "infrastruktur\x02bidrag"
|
||||
doc.p "hello\x00world"
|
||||
doc.p "test\x01\x03\x04value"
|
||||
end
|
||||
|
||||
# Invalid characters should be stripped
|
||||
assert_includes xml, "infrastrukturbidrag"
|
||||
assert_includes xml, "helloworld"
|
||||
assert_includes xml, "testvalue"
|
||||
|
||||
# Verify the XML is valid by parsing it (will raise if invalid)
|
||||
doc = Nokogiri::XML(xml, &:strict)
|
||||
assert doc.errors.empty?, "XML should be valid: #{doc.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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, '<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
|
||||
|
||||
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
|
||||
73
test/xml_sanitizer_test.rb
Normal file
73
test/xml_sanitizer_test.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class XmlSanitizerTest < Minitest::Test
|
||||
def test_removes_null_character
|
||||
assert_equal "hello", Notare::XmlSanitizer.sanitize("hel\x00lo")
|
||||
end
|
||||
|
||||
def test_removes_control_characters_0x01_to_0x08
|
||||
input = "a\x01b\x02c\x03d\x04e\x05f\x06g\x07h\x08i"
|
||||
assert_equal "abcdefghi", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_removes_control_characters_0x0b_and_0x0c
|
||||
input = "hello\x0Bworld\x0Ctest"
|
||||
assert_equal "helloworldtest", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_removes_control_characters_0x0e_to_0x1f
|
||||
input = "a\x0Eb\x0Fc\x10d\x11e\x1Ff"
|
||||
assert_equal "abcdef", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_preserves_tab_character
|
||||
input = "hello\tworld"
|
||||
assert_equal "hello\tworld", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_preserves_newline_character
|
||||
input = "hello\nworld"
|
||||
assert_equal "hello\nworld", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_preserves_carriage_return_character
|
||||
input = "hello\rworld"
|
||||
assert_equal "hello\rworld", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_preserves_crlf
|
||||
input = "hello\r\nworld"
|
||||
assert_equal "hello\r\nworld", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_returns_nil_unchanged
|
||||
assert_nil Notare::XmlSanitizer.sanitize(nil)
|
||||
end
|
||||
|
||||
def test_returns_non_string_unchanged
|
||||
assert_equal 123, Notare::XmlSanitizer.sanitize(123)
|
||||
assert_equal :symbol, Notare::XmlSanitizer.sanitize(:symbol)
|
||||
end
|
||||
|
||||
def test_preserves_unicode_characters
|
||||
input = "café naïve 日本語 🎉"
|
||||
assert_equal "café naïve 日本語 🎉", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_preserves_regular_text
|
||||
input = "Hello, World! This is normal text."
|
||||
assert_equal input, Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
|
||||
def test_handles_empty_string
|
||||
assert_equal "", Notare::XmlSanitizer.sanitize("")
|
||||
end
|
||||
|
||||
def test_real_world_case_stx_character
|
||||
# The actual case from the failed.docx: 0x02 (STX) character
|
||||
input = "infrastruktur\x02bidrag"
|
||||
assert_equal "infrastrukturbidrag", Notare::XmlSanitizer.sanitize(input)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user