7 Commits

Author SHA1 Message Date
ee07c747b9 Merge pull request 'Support configurable default font and size' (#10) from feature/default-font-and-size into main
All checks were successful
CI Pipeline / build (push) Successful in 29s
Reviewed-on: #10
Reviewed-by: mathias234 <mathias@kaukus.no>
2026-03-05 14:49:09 +00:00
e937552913 Fix RuboCop Style/SelectByKind and Style/PredicateWithKind offenses
All checks were successful
CI Pipeline / build (pull_request) Successful in 1m8s
2026-03-05 13:43:15 +01:00
26e0d59cf1 Support configurable default font and size
Some checks failed
CI Pipeline / build (pull_request) Failing after 46s
Add default_font and default_size options to Document.create, rendered
as w:docDefaults in styles.xml. Defaults to Arial 12pt. Adjust heading
sizes for better visual hierarchy.

Styling based on uutilsynet guidance for accessible documents:
https://www.uutilsynet.no/veiledning/rettleiar-universelt-utforma-word-og-pdf-dokument/1636
2026-03-05 13:09:40 +01:00
64c8679044 Sanitize invalid XML characters in text content
All checks were successful
CI Pipeline / build (push) Successful in 49s
Strip invalid XML 1.0 control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F)
from text to prevent corrupted docx files that fail to open in LibreOffice.

Fixes SAXParseException 'PCData Invalid Char value' errors.
2026-01-22 09:10:33 +01:00
8b4f538cbb Update version
All checks were successful
CI Pipeline / build (push) Successful in 12s
2025-12-03 13:53:04 +01:00
bc69880c9b Merge pull request 'Support table and table column sizing' (#9) from feature/table-column-sizing into main
All checks were successful
CI Pipeline / build (push) Successful in 12s
Reviewed-on: #9
2025-12-03 12:52:10 +00:00
843466549a Support table and table column sizing
All checks were successful
CI Pipeline / build (pull_request) Successful in 13s
2025-12-03 13:50:56 +01:00
20 changed files with 676 additions and 44 deletions

View File

@@ -2,7 +2,9 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(bundle exec rake test:*)", "Bash(bundle exec rake test:*)",
"Bash(bundle exec rake:*)" "Bash(bundle exec rake:*)",
"WebSearch",
"Bash(unzip:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -332,6 +332,75 @@ doc.table(style: :borderless) do
end 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.
@@ -526,10 +595,10 @@ end
| `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(style:) { }` | Table with optional style | | `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

View File

@@ -343,6 +343,48 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
end end
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
# ============================================================================ # ============================================================================

View File

@@ -3,6 +3,7 @@
require "nokogiri" require "nokogiri"
require_relative "notare/version" require_relative "notare/version"
require_relative "notare/xml_sanitizer"
require_relative "notare/nodes/base" require_relative "notare/nodes/base"
require_relative "notare/nodes/break" require_relative "notare/nodes/break"
require_relative "notare/nodes/hyperlink" require_relative "notare/nodes/hyperlink"
@@ -17,6 +18,7 @@ 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/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"

View File

@@ -100,8 +100,8 @@ module Notare
@current_list.add_item(item) @current_list.add_item(item)
end end
def table(style: nil, &block) def table(style: nil, layout: nil, columns: nil, &block)
tbl = Nodes::Table.new(style: resolve_table_style(style)) 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

View File

@@ -4,16 +4,21 @@ module Notare
class Document class Document
include Builder include Builder
attr_reader :nodes, :styles, :table_styles, :hyperlinks DEFAULT_FONT = "Arial"
DEFAULT_SIZE = 12
def self.create(path, &block) attr_reader :nodes, :styles, :table_styles, :hyperlinks, :default_font, :default_size
doc = new
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) block.call(doc)
doc.save(path) doc.save(path)
doc doc
end end
def initialize def initialize(default_font: DEFAULT_FONT, default_size: DEFAULT_SIZE)
@default_font = default_font
@default_size = default_size
@nodes = [] @nodes = []
@format_stack = [] @format_stack = []
@current_target = nil @current_target = nil
@@ -51,7 +56,7 @@ module Notare
end end
def lists def lists
@nodes.select { |n| n.is_a?(Nodes::List) } @nodes.grep(Nodes::List)
end end
def uses_lists? def uses_lists?
@@ -88,25 +93,25 @@ 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
def register_built_in_styles def register_built_in_styles
# Headings (spacing_before ensures they're rendered as paragraph 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 :heading1, size: 20, bold: true, spacing_before: 240, spacing_after: 120
define_style :heading2, size: 18, bold: true, spacing_before: 200, spacing_after: 100 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 :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 :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 :heading5, size: 12, 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 :heading6, size: 12, italic: true, spacing_before: 80, spacing_after: 40
# Other built-in styles # Other built-in styles
define_style :title, size: 26, bold: true, align: :center define_style :title, size: 26, bold: true, align: :center

View File

@@ -8,7 +8,7 @@ module Notare
def initialize(text, bold: false, italic: false, underline: false, def initialize(text, bold: false, italic: false, underline: false,
strike: false, highlight: nil, color: nil, style: nil) strike: false, highlight: nil, color: nil, style: nil)
super() super()
@text = text @text = XmlSanitizer.sanitize(text)
@bold = bold @bold = bold
@italic = italic @italic = italic
@underline = underline @underline = underline

View File

@@ -3,12 +3,14 @@
module Notare module Notare
module Nodes module Nodes
class Table < Base 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() super()
@rows = [] @rows = []
@style = style @style = style
@layout = layout
@columns = columns
end end
def add_row(row) def add_row(row)

View File

@@ -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)

View File

@@ -59,7 +59,12 @@ module Notare
end end
def styles_xml 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 end
def numbering_xml def numbering_xml

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Notare module Notare
VERSION = "0.0.4" VERSION = "0.0.7"
end end

View 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

View File

@@ -163,12 +163,12 @@ 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)
render_table_layout(xml, table.layout)
if table.style if table.style
xml["w"].tblStyle("w:val" => table.style.style_id) xml["w"].tblStyle("w:val" => table.style.style_id)
else else
@@ -179,25 +179,89 @@ module Notare
end end
end end
end 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
else
col_width = 5000 / cells.size
cells.map { WidthParser::ParsedWidth.new(value: col_width, type: "pct") }
end
end
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 xml["w"].tblGrid do
column_count.times do column_widths.each do |width|
xml["w"].gridCol("w:w" => col_width.to_s) 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 end
xml["w"].gridCol("w:w" => grid_width.to_s)
end end
table.rows.each { |row| render_table_row(xml, row, col_width) }
end end
end end
def render_table_row(xml, row, col_width) # 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) }

View File

@@ -18,9 +18,11 @@ module Notare
right: "right" right: "right"
}.freeze }.freeze
def initialize(styles, table_styles = {}) def initialize(styles, table_styles = {}, default_font: nil, default_size: nil)
@styles = styles @styles = styles
@table_styles = table_styles @table_styles = table_styles
@default_font = default_font
@default_size = default_size
end end
def to_xml def to_xml
@@ -28,10 +30,14 @@ module Notare
xml.styles("xmlns:w" => NAMESPACE) do xml.styles("xmlns:w" => NAMESPACE) do
xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" } 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| @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| @table_styles.each_value do |style|
render_table_style(xml, style) render_table_style(xml, style)
end end
@@ -42,6 +48,28 @@ module Notare
private 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) def render_style(xml, style)
style_type = style.paragraph_properties? ? "paragraph" : "character" style_type = style.paragraph_properties? ? "paragraph" : "character"
@@ -57,8 +85,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
@@ -75,9 +107,24 @@ module Notare
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) def render_table_style(xml, style)
xml["w"].style("w:type" => "table", "w:styleId" => style.style_id) do xml["w"].style("w:type" => "table", "w:styleId" => style.style_id) do
xml["w"].name("w:val" => style.display_name) xml["w"].name("w:val" => style.display_name)
xml["w"].basedOn("w:val" => "TableNormal")
xml["w"].tblPr do xml["w"].tblPr do
render_table_borders(xml, style.borders) if style.borders render_table_borders(xml, style.borders) if style.borders

View 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

View File

@@ -69,6 +69,6 @@ class DocumentTest < Minitest::Test
doc.table { doc.tr { doc.td "Cell" } } doc.table { doc.tr { doc.td "Cell" } }
assert_equal 2, doc.lists.count 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
end end

View File

@@ -111,4 +111,21 @@ class ParagraphTest < Minitest::Test
# Newlines should be preserved in the text # Newlines should be preserved in the text
assert_includes xml, "Line 1\nLine 2\nLine 3" assert_includes xml, "Line 1\nLine 2\nLine 3"
end 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 end

View File

@@ -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
View 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

View 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