18 Commits

Author SHA1 Message Date
ac916d980b Merge pull request 'Implement table styles' (#8) from feature/table-styles into main
Some checks failed
CI Pipeline / build (push) Has been cancelled
Reviewed-on: #8
2025-12-03 11:14:48 +00:00
9a70d91fd5 Implement table styles
All checks were successful
CI Pipeline / build (pull_request) Successful in 14s
2025-12-03 12:14:31 +01:00
67a60c8c6e Update readme
All checks were successful
CI Pipeline / build (push) Successful in 12s
2025-12-03 11:57:02 +01:00
52d715a6de Merge pull request 'Implement nested lists' (#7) from feature/nested-lists into main
All checks were successful
CI Pipeline / build (push) Successful in 12s
Reviewed-on: #7
2025-12-02 14:00:57 +00:00
75b3a163c7 Implement nested lists
All checks were successful
CI Pipeline / build (pull_request) Successful in 13s
2025-12-02 15:00:39 +01:00
6a54f9f8da Merge pull request 'Implement many more nodes' (#6) from feature/add-more-elements into main
All checks were successful
CI Pipeline / build (push) Successful in 14s
Reviewed-on: #6
2025-12-02 13:44:53 +00:00
597bc91c40 Implement many more nodes
All checks were successful
CI Pipeline / build (pull_request) Successful in 12s
Adds these new styling and formatting nodes
* strike
* highlight
* linebreaks
* pagebreaks
* Hyperlinks
2025-12-02 14:43:53 +01:00
243b06d8f8 Not a script
All checks were successful
CI Pipeline / build (push) Successful in 11s
2025-12-02 13:46:05 +01:00
d1819f2b64 Update version
Some checks failed
CI Pipeline / build (push) Failing after 11s
2025-12-02 13:44:59 +01:00
df8fb2fb8c Update git path 2025-12-02 13:43:02 +01:00
dec346254c Project rename
All checks were successful
CI Pipeline / build (push) Successful in 12s
2025-12-02 13:21:13 +01:00
29ebb9a8d1 Merge pull request 'Fix issue where Word Online wasn't properly able to render our tables' (#5) from bug/tables-word-online into main
All checks were successful
CI Pipeline / build (push) Successful in 12s
Reviewed-on: Kaukus/ezdoc#5
2025-12-02 12:12:39 +00:00
6cbc9e4d98 Fix issue where Word Online wasn't properly able to render our tables
All checks were successful
CI Pipeline / build (pull_request) Successful in 12s
2025-12-02 12:26:40 +01:00
c7020140f4 Merge pull request 'Fix bug with heading styles' (#4) from bug/heading-styles into main
All checks were successful
CI Pipeline / build (push) Successful in 11s
Reviewed-on: Kaukus/ezdoc#4
2025-12-02 11:14:04 +00:00
f551a22819 Fix bug with heading styles
All checks were successful
CI Pipeline / build (pull_request) Successful in 12s
2025-12-02 12:11:30 +01:00
e9a3908ea6 Merge pull request 'Implement styles' (#3) from feature/styles into main
All checks were successful
CI Pipeline / build (push) Successful in 11s
Reviewed-on: Kaukus/ezdoc#3
2025-12-02 11:03:36 +00:00
58492e9ef6 Implement styles
All checks were successful
CI Pipeline / build (pull_request) Successful in 12s
2025-12-02 12:02:51 +01:00
1fffecf0eb Merge pull request 'Add support for images' (#2) from feature/images into main
All checks were successful
CI Pipeline / build (push) Successful in 11s
Reviewed-on: Kaukus/ezdoc#2
2025-12-02 10:45:35 +00:00
47 changed files with 2622 additions and 335 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(bundle exec rake test:*)",
"Bash(bundle exec rake:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -19,24 +19,29 @@ bundle exec ruby -Ilib:test test/paragraph_test.rb -n test_paragraph_with_text
## Architecture
Ezdoc is a Ruby gem for creating .docx files using a DSL. The gem generates valid Office Open XML (OOXML) documents.
Notare is a Ruby gem for creating .docx files using a DSL. The gem generates valid Office Open XML (OOXML) documents.
### Core Components
- **Document** (`lib/ezdoc/document.rb`): Entry point via `Document.create`. Includes the Builder module and maintains a collection of nodes.
- **Document** (`lib/notare/document.rb`): Entry point via `Document.create`. Includes the Builder module and maintains a collection of nodes.
- **Builder** (`lib/ezdoc/builder.rb`): DSL methods (`p`, `text`, `b`, `i`, `u`, `ul`, `ol`, `li`, `table`, `tr`, `td`). Uses a format stack for nested formatting and target tracking for content placement.
- **Builder** (`lib/notare/builder.rb`): DSL methods (`p`, `text`, `h1`-`h6`, `b`, `i`, `u`, `ul`, `ol`, `li`, `table`, `tr`, `td`, `image`). Uses a format stack for nested formatting and target tracking for content placement.
- **Nodes** (`lib/ezdoc/nodes/`): Document element representations (Paragraph, Run, List, ListItem, Table, TableRow, TableCell). All inherit from `Nodes::Base`.
- **Nodes** (`lib/notare/nodes/`): Document element representations (Paragraph, Run, Image, List, ListItem, Table, TableRow, TableCell). All inherit from `Nodes::Base`.
- **Package** (`lib/ezdoc/package.rb`): Assembles the docx ZIP structure using rubyzip. Coordinates XML generation.
- **Style** (`lib/notare/style.rb`): Style definitions with text properties (bold, italic, color, size, font) and paragraph properties (align, indent, spacing).
- **XML generators** (`lib/ezdoc/xml/`): Generate OOXML-compliant XML:
- `DocumentXml`: Main content with paragraphs, lists, tables
- **Package** (`lib/notare/package.rb`): Assembles the docx ZIP structure using rubyzip. Coordinates XML generation.
- **XML generators** (`lib/notare/xml/`): Generate OOXML-compliant XML:
- `DocumentXml`: Main content with paragraphs, lists, tables, images
- `StylesXml`: styles.xml with built-in and custom styles
- `ContentTypes`: [Content_Types].xml
- `Relationships`: .rels files
- `Numbering`: numbering.xml for lists
- **ImageDimensions** (`lib/notare/image_dimensions.rb`): Uses fastimage gem to read image dimensions for EMU calculations.
### Data Flow
1. User calls DSL methods on Document
@@ -46,4 +51,4 @@ Ezdoc is a Ruby gem for creating .docx files using a DSL. The gem generates vali
### Testing
Tests use Minitest. `EzdocTestHelpers` module provides helpers that create temp documents and extract XML for assertions.
Tests use Minitest. `NotareTestHelpers` module provides helpers that create temp documents and extract XML for assertions.

350
README.md
View File

@@ -1,4 +1,4 @@
# Ezdoc
# Notare
A Ruby gem for creating docx files with a simple DSL
@@ -7,7 +7,7 @@ A Ruby gem for creating docx files with a simple DSL
Add this line to your application's Gemfile:
```ruby
gem 'ezdoc'
gem 'notare'
```
And then execute:
@@ -16,16 +16,16 @@ And then execute:
Or install it yourself as:
$ gem install ezdoc
$ gem install notare
## Usage
### Basic Example
```ruby
require 'ezdoc'
require 'notare'
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
doc.p "Hello World"
end
```
@@ -33,7 +33,7 @@ end
### Paragraphs
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
# Simple paragraph
doc.p "This is a paragraph."
@@ -50,7 +50,7 @@ end
Formatting uses nested blocks. Nesting combines formatting styles.
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
doc.p do
doc.text "Normal text "
doc.b { doc.text "bold" }
@@ -58,6 +58,8 @@ Ezdoc::Document.create("output.docx") do |doc|
doc.i { doc.text "italic" }
doc.text " and "
doc.u { doc.text "underlined" }
doc.text " and "
doc.s { doc.text "strikethrough" }
end
# Nested formatting (bold + italic)
@@ -66,15 +68,128 @@ Ezdoc::Document.create("output.docx") do |doc|
doc.i { doc.text "bold and italic" }
end
end
# Show edits (strikethrough old, bold new)
doc.p do
doc.s { doc.text "old text" }
doc.text " "
doc.b { doc.text "new text" }
end
end
```
### Headings
Use `h1` through `h6` for document headings:
```ruby
Notare::Document.create("output.docx") do |doc|
doc.h1 "Document Title"
doc.h2 "Chapter 1"
doc.h3 "Section 1.1"
doc.h4 "Subsection"
doc.h5 "Minor heading"
doc.h6 "Smallest heading"
# Headings with formatted content
doc.h2 do
doc.text "Chapter with "
doc.i { doc.text "emphasis" }
end
end
```
### Styles
Notare includes built-in styles and supports custom style definitions.
#### 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
Notare::Document.create("output.docx") do |doc|
doc.p "This is a title", style: :title
doc.p "A subtitle", style: :subtitle
doc.p "A quotation", style: :quote
doc.p "puts 'code'", style: :code
end
```
#### Custom Styles
Define your own styles with text and paragraph properties:
```ruby
Notare::Document.create("output.docx") do |doc|
# Define custom styles
doc.define_style :warning,
bold: true,
color: "FF0000",
size: 14
doc.define_style :note,
italic: true,
color: "0066CC",
font: "Georgia"
doc.define_style :centered,
align: :center,
size: 12
# Apply to paragraphs
doc.p "Warning message!", style: :warning
doc.p "Centered text", style: :centered
# Apply to text runs
doc.p do
doc.text "Normal text, "
doc.text "important!", style: :warning
doc.text ", and "
doc.text "a note", style: :note
end
end
```
#### Style Properties
**Text properties:**
- `bold: true/false`
- `italic: true/false`
- `underline: true/false`
- `strike: true/false` - strikethrough
- `highlight: "yellow"` - text highlight (see colors below)
- `color: "FF0000"` (hex RGB)
- `size: 14` (points)
- `font: "Arial"` (font family)
**Highlight colors:** `black`, `blue`, `cyan`, `darkBlue`, `darkCyan`, `darkGray`, `darkGreen`, `darkMagenta`, `darkRed`, `darkYellow`, `green`, `lightGray`, `magenta`, `red`, `white`, `yellow`
**Paragraph properties:**
- `align: :left / :center / :right / :justify`
- `indent: 720` (twips, 1 inch = 1440 twips)
- `spacing_before: 240` (twips)
- `spacing_after: 240` (twips)
### Lists
#### Bullet Lists
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
doc.ul do
doc.li "First item"
doc.li "Second item"
@@ -86,7 +201,7 @@ end
#### Numbered Lists
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
doc.ol do
doc.li "First"
doc.li "Second"
@@ -95,10 +210,44 @@ Ezdoc::Document.create("output.docx") do |doc|
end
```
#### Nested Lists
Lists can be nested inside list items. Mixed types (bullets inside numbered or vice versa) are supported.
```ruby
Notare::Document.create("output.docx") do |doc|
doc.ol do
doc.li "First item"
doc.li "Second item" do
doc.ul do
doc.li "Nested bullet A"
doc.li "Nested bullet B"
end
end
doc.li "Third item"
end
# Deeply nested
doc.ul do
doc.li "Level 0"
doc.li "Has children" do
doc.ul do
doc.li "Level 1"
doc.li "Goes deeper" do
doc.ul do
doc.li "Level 2"
end
end
end
end
end
end
```
### Tables
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
doc.table do
doc.tr do
doc.td "Header 1"
@@ -112,12 +261,83 @@ Ezdoc::Document.create("output.docx") do |doc|
end
```
#### Table Styles
Define reusable table styles with borders, shading, cell margins, and alignment:
```ruby
Notare::Document.create("output.docx") do |doc|
# Define a custom table style
doc.define_table_style :fancy,
borders: { style: "double", color: "0066CC", size: 6 },
shading: "E6F2FF",
cell_margins: 100,
align: :center
# Apply the style to a table
doc.table(style: :fancy) do
doc.tr do
doc.td "Product"
doc.td "Price"
end
doc.tr do
doc.td "Widget"
doc.td "$10.00"
end
end
end
```
#### Table Style Properties
| Property | Description | Example |
|----------|-------------|---------|
| `borders` | Border configuration | `{ style: "single", color: "000000", size: 4 }` |
| `shading` | Background color (hex) | `"EEEEEE"` |
| `cell_margins` | Cell padding (twips) | `100` or `{ top: 50, bottom: 50, left: 100, right: 100 }` |
| `align` | Table alignment | `:left`, `:center`, `:right` |
**Border styles:** `single`, `double`, `dotted`, `dashed`, `triple`, `none`
**Border configuration options:**
```ruby
# All borders the same
borders: { style: "single", color: "000000", size: 4 }
# Per-edge borders
borders: {
top: { style: "double", color: "FF0000", size: 8 },
bottom: { style: "single", color: "000000", size: 4 },
left: { style: "none" },
right: { style: "none" },
insideH: { style: "dotted", color: "CCCCCC", size: 2 },
insideV: { style: "dotted", color: "CCCCCC", size: 2 }
}
# No borders
borders: :none
```
#### Built-in Table Styles
| Style | Description |
|-------|-------------|
| `:grid` | Standard black single-line borders |
| `:borderless` | No borders |
```ruby
doc.table(style: :borderless) do
doc.tr { doc.td "No borders here" }
end
```
### Images
Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
```ruby
Ezdoc::Document.create("output.docx") do |doc|
Notare::Document.create("output.docx") do |doc|
# Simple image (uses native dimensions)
doc.p do
doc.image "photo.png"
@@ -159,10 +379,95 @@ Ezdoc::Document.create("output.docx") do |doc|
end
```
### Line Breaks
Use `br` for soft line breaks within a paragraph (text continues in the same paragraph but on a new line):
```ruby
Notare::Document.create("output.docx") do |doc|
doc.p do
doc.text "Line one"
doc.br
doc.text "Line two (same paragraph)"
doc.br
doc.text "Line three"
end
# Useful for addresses
doc.p do
doc.b { doc.text "Address:" }
doc.br
doc.text "123 Main Street"
doc.br
doc.text "Anytown, ST 12345"
end
end
```
### Page Breaks
Use `page_break` to force content to start on a new page:
```ruby
Notare::Document.create("output.docx") do |doc|
doc.h1 "Chapter 1"
doc.p "Content of chapter 1..."
doc.page_break
doc.h1 "Chapter 2"
doc.p "This starts on a new page."
end
```
### Hyperlinks
Add clickable links with `link`:
```ruby
Notare::Document.create("output.docx") do |doc|
# Link with custom text
doc.p do
doc.text "Visit "
doc.link "https://example.com", "our website"
doc.text " for more info."
end
# Link showing the URL as text
doc.p do
doc.text "URL: "
doc.link "https://example.com"
end
# Link with formatted content
doc.p do
doc.link "https://github.com" do
doc.b { doc.text "GitHub" }
end
end
# Links in lists
doc.ul do
doc.li do
doc.link "https://ruby-lang.org", "Ruby"
end
doc.li do
doc.link "https://rubyonrails.org", "Rails"
end
end
# Email links
doc.p do
doc.text "Contact: "
doc.link "mailto:hello@example.com", "hello@example.com"
end
end
```
### Complete Example
```ruby
Ezdoc::Document.create("report.docx") do |doc|
Notare::Document.create("report.docx") do |doc|
doc.p "Monthly Report"
doc.p do
@@ -202,17 +507,26 @@ end
| Method | Description |
|--------|-------------|
| `p(text)` | Create a paragraph with text |
| `p { }` | Create a paragraph with block content |
| `text(value)` | Add text to the current context |
| `p(text, style:)` | Create a paragraph with text and optional style |
| `p(style:) { }` | Create a paragraph with block content and optional style |
| `text(value, style:)` | Add text with optional style to the current context |
| `h1(text)` - `h6(text)` | Create headings (level 1-6) |
| `b { }` | Bold formatting |
| `i { }` | Italic formatting |
| `u { }` | Underline formatting |
| `ul { }` | Bullet list |
| `ol { }` | Numbered list |
| `s { }` | Strikethrough formatting |
| `br` | Line break (soft break within paragraph) |
| `page_break` | Page break (force new page) |
| `link(url, text)` | Hyperlink with custom text |
| `link(url) { }` | Hyperlink with block content |
| `define_style(name, **props)` | Define a custom style |
| `define_table_style(name, **props)` | Define a custom table style |
| `ul { }` | Bullet list (can be nested) |
| `ol { }` | Numbered list (can be nested) |
| `li(text)` | List item with text |
| `li { }` | List item with block content |
| `table { }` | Table |
| `li(text) { }` | List item with text and nested content |
| `table(style:) { }` | Table with optional style |
| `tr { }` | Table row |
| `td(text)` | Table cell with text |
| `td { }` | Table cell with block content |

412
examples/full_demo.rb Normal file
View File

@@ -0,0 +1,412 @@
# frozen_string_literal: true
# Full demo of all Notare features
# Run with: bundle exec ruby examples/full_demo.rb
require_relative "../lib/notare"
OUTPUT_FILE = File.expand_path("../example.docx", __dir__)
FIXTURES_DIR = File.expand_path("../test/fixtures", __dir__)
Notare::Document.create(OUTPUT_FILE) do |doc|
# ============================================================================
# Custom Styles
# ============================================================================
doc.define_style :highlight, bold: true, color: "FF6600"
doc.define_style :success, color: "228B22", italic: true
doc.define_style :centered_large, align: :center, size: 16, bold: true
doc.define_style :deleted_text, strike: true, color: "999999"
doc.define_style :important, highlight: "yellow", bold: true
# ============================================================================
# Custom Table Styles
# ============================================================================
doc.define_table_style :fancy_table,
borders: { style: "double", color: "0066CC", size: 6 },
shading: "E6F2FF",
cell_margins: 80,
align: :center
doc.define_table_style :minimal_table,
borders: {
top: { style: "single", color: "CCCCCC", size: 4 },
bottom: { style: "single", color: "CCCCCC", size: 4 },
left: { style: "none" },
right: { style: "none" },
insideH: { style: "dotted", color: "DDDDDD", size: 2 },
insideV: { style: "none" }
}
# ============================================================================
# Title and Introduction
# ============================================================================
doc.h1 "Notare Feature Demo"
doc.p "A comprehensive example of all supported features", style: :subtitle
# ============================================================================
# 1. Text Formatting
# ============================================================================
doc.h2 "1. Text Formatting"
doc.p do
doc.text "This paragraph demonstrates "
doc.b { doc.text "bold" }
doc.text ", "
doc.i { doc.text "italic" }
doc.text ", "
doc.u { doc.text "underlined" }
doc.text ", "
doc.s { doc.text "strikethrough" }
doc.text ", and "
doc.b do
doc.i do
doc.u { doc.text "combined" }
end
end
doc.text " formatting."
end
doc.p do
doc.text "Showing edits: "
doc.s { doc.text "old text" }
doc.text " "
doc.b { doc.text "new text" }
end
# ============================================================================
# 2. Headings
# ============================================================================
doc.h2 "2. Headings"
doc.h3 "This is Heading 3"
doc.h4 "This is Heading 4"
doc.h5 "This is Heading 5"
doc.h6 "This is Heading 6"
# ============================================================================
# 3. Built-in Styles
# ============================================================================
doc.h2 "3. Built-in Styles"
doc.p "This is styled as a title", style: :title
doc.p "This is styled as a subtitle", style: :subtitle
doc.p "This is styled as a quote - perfect for citations and quotations.", style: :quote
doc.p "def hello; puts \"world\"; end", style: :code
# ============================================================================
# 4. Custom Styles
# ============================================================================
doc.h2 "4. Custom Styles"
doc.p "This text uses our custom highlight style!", style: :highlight
doc.p do
doc.text "Mixed styles: "
doc.text "success message", style: :success
doc.text " and "
doc.text "highlighted text", style: :highlight
doc.text " in one paragraph."
end
doc.p "Centered and large text", style: :centered_large
doc.p "This was removed from the document", style: :deleted_text
doc.p "This is critically important!", style: :important
# ============================================================================
# 5. Text Highlighting
# ============================================================================
doc.h2 "5. Text Highlighting"
doc.p do
doc.text "You can highlight text in "
doc.text "yellow", style: :important
doc.text " or use styles with various highlight colors."
end
doc.define_style :highlight_cyan, highlight: "cyan"
doc.define_style :highlight_green, highlight: "green"
doc.define_style :highlight_magenta, highlight: "magenta"
doc.p do
doc.text "Multiple colors: "
doc.text "cyan", style: :highlight_cyan
doc.text " "
doc.text "green", style: :highlight_green
doc.text " "
doc.text "magenta", style: :highlight_magenta
end
# ============================================================================
# 6. Line Breaks
# ============================================================================
doc.h2 "6. Line Breaks"
doc.p do
doc.text "This is the first line."
doc.br
doc.text "This is the second line (soft break)."
doc.br
doc.text "This is the third line."
end
doc.p do
doc.b { doc.text "Address:" }
doc.br
doc.text "123 Main Street"
doc.br
doc.text "Anytown, ST 12345"
end
# ============================================================================
# 7. Lists
# ============================================================================
doc.h2 "7. Lists"
doc.h3 "Bullet List"
doc.ul do
doc.li "First item"
doc.li "Second item"
doc.li do
doc.text "Item with "
doc.b { doc.text "bold" }
doc.text " text"
end
end
doc.h3 "Numbered List"
doc.ol do
doc.li "Step one"
doc.li "Step two"
doc.li "Step three"
end
doc.h3 "Nested Lists"
doc.ol do
doc.li "Main topic one"
doc.li "Main topic two" do
doc.ul do
doc.li "Supporting point A"
doc.li "Supporting point B" do
doc.ul do
doc.li "Detail 1"
doc.li "Detail 2"
end
end
doc.li "Supporting point C"
end
end
doc.li "Main topic three"
end
doc.p "Mixed nested lists with formatting:"
doc.ul do
doc.li do
doc.b { doc.text "Bold parent item" }
end
doc.li "Item with nested numbered list" do
doc.ol do
doc.li "First sub-step"
doc.li "Second sub-step"
end
end
end
# ============================================================================
# 8. Hyperlinks
# ============================================================================
doc.h2 "8. Hyperlinks"
doc.p do
doc.text "Visit "
doc.link "https://www.example.com", "Example.com"
doc.text " for more information."
end
doc.p do
doc.text "Check out "
doc.link "https://github.com" do
doc.b { doc.text "GitHub" }
end
doc.text " for code hosting."
end
doc.p do
doc.text "Or just paste the URL: "
doc.link "https://www.ruby-lang.org"
end
doc.ul do
doc.li do
doc.link "https://rubyonrails.org", "Ruby on Rails"
end
doc.li do
doc.link "https://rubygems.org", "RubyGems"
end
end
# ============================================================================
# 9. Tables
# ============================================================================
doc.h2 "9. Tables"
doc.h3 "Default Table"
doc.table do
doc.tr do
doc.td { doc.b { doc.text "Feature" } }
doc.td { doc.b { doc.text "Status" } }
doc.td { doc.b { doc.text "Notes" } }
end
doc.tr do
doc.td "Paragraphs"
doc.td { doc.text "Complete", style: :success }
doc.td "Basic text support"
end
doc.tr do
doc.td "Formatting"
doc.td { doc.text "Complete", style: :success }
doc.td "Bold, italic, underline, strikethrough"
end
doc.tr do
doc.td "Highlighting"
doc.td { doc.text "Complete", style: :success }
doc.td "16 highlight colors"
end
doc.tr do
doc.td "Line Breaks"
doc.td { doc.text "Complete", style: :success }
doc.td "Soft breaks within paragraphs"
end
doc.tr do
doc.td "Page Breaks"
doc.td { doc.text "Complete", style: :success }
doc.td "Force new pages"
end
doc.tr do
doc.td "Hyperlinks"
doc.td { doc.text "Complete", style: :success }
doc.td "Clickable links"
end
doc.tr do
doc.td "Headings"
doc.td { doc.text "Complete", style: :success }
doc.td "h1 through h6"
end
doc.tr do
doc.td "Styles"
doc.td { doc.text "Complete", style: :success }
doc.td "Built-in and custom"
end
doc.tr do
doc.td "Images"
doc.td { doc.text "Complete", style: :success }
doc.td "PNG and JPEG"
end
doc.tr do
doc.td "Nested Lists"
doc.td { doc.text "Complete", style: :success }
doc.td "Multi-level with mixed types"
end
end
doc.h3 "Styled Tables"
doc.p "Fancy table with double borders and shading:"
doc.table(style: :fancy_table) do
doc.tr do
doc.td { doc.b { doc.text "Product" } }
doc.td { doc.b { doc.text "Price" } }
doc.td { doc.b { doc.text "Quantity" } }
end
doc.tr do
doc.td "Widget A"
doc.td "$10.00"
doc.td "100"
end
doc.tr do
doc.td "Widget B"
doc.td "$15.00"
doc.td "50"
end
end
doc.p "Minimal table with horizontal lines only:"
doc.table(style: :minimal_table) do
doc.tr do
doc.td { doc.b { doc.text "Name" } }
doc.td { doc.b { doc.text "Role" } }
end
doc.tr do
doc.td "Alice"
doc.td "Developer"
end
doc.tr do
doc.td "Bob"
doc.td "Designer"
end
end
doc.p "Borderless table (built-in style):"
doc.table(style: :borderless) do
doc.tr do
doc.td "No"
doc.td "borders"
doc.td "here"
end
end
# ============================================================================
# 10. Images
# ============================================================================
doc.h2 "10. Images"
doc.p "Image with explicit dimensions:"
doc.p do
doc.image File.join(FIXTURES_DIR, "test.png"), width: "2in", height: "2in"
end
doc.p "Inline image with text:"
doc.p do
doc.text "Before "
doc.image File.join(FIXTURES_DIR, "test.jpg"), width: "0.75in", height: "0.75in"
doc.text " After"
end
doc.p "Image in a table:"
doc.table do
doc.tr do
doc.td "Description"
doc.td do
doc.image File.join(FIXTURES_DIR, "test.png"), width: "1in", height: "1in"
end
end
end
# ============================================================================
# 11. Page Breaks
# ============================================================================
doc.h2 "11. Page Breaks"
doc.p "The next element will force a new page."
doc.page_break
# ============================================================================
# 12. Combined Features (on new page)
# ============================================================================
doc.h2 "12. Combined Features"
doc.p "This section starts on a new page thanks to the page break above."
doc.p do
doc.text "This final paragraph combines "
doc.b { doc.text "multiple" }
doc.text " "
doc.i { doc.text "formatting" }
doc.text " options with "
doc.text "custom styles", style: :highlight
doc.text ", "
doc.s { doc.text "strikethrough" }
doc.text ", "
doc.text "highlighting", style: :important
doc.text ", and "
doc.link "https://example.com", "hyperlinks"
doc.text " to demonstrate the full power of Notare."
end
doc.p do
doc.text "Contact us:"
doc.br
doc.link "mailto:hello@example.com", "hello@example.com"
end
doc.p "End of demo document.", style: :centered_large
end
puts "Created #{OUTPUT_FILE}"

View File

@@ -1,26 +0,0 @@
# frozen_string_literal: true
require "nokogiri"
require_relative "ezdoc/version"
require_relative "ezdoc/nodes/base"
require_relative "ezdoc/nodes/run"
require_relative "ezdoc/nodes/image"
require_relative "ezdoc/nodes/paragraph"
require_relative "ezdoc/nodes/list"
require_relative "ezdoc/nodes/list_item"
require_relative "ezdoc/nodes/table"
require_relative "ezdoc/nodes/table_row"
require_relative "ezdoc/nodes/table_cell"
require_relative "ezdoc/image_dimensions"
require_relative "ezdoc/xml/content_types"
require_relative "ezdoc/xml/relationships"
require_relative "ezdoc/xml/document_xml"
require_relative "ezdoc/xml/numbering"
require_relative "ezdoc/builder"
require_relative "ezdoc/package"
require_relative "ezdoc/document"
module Ezdoc
class Error < StandardError; end
end

View File

@@ -1,129 +0,0 @@
# frozen_string_literal: true
module Ezdoc
module Builder
def p(text = nil, &block)
para = Nodes::Paragraph.new
if block
with_target(para, &block)
elsif text
para.add_run(Nodes::Run.new(text, **current_formatting))
end
@nodes << para
end
def text(value)
@current_target.add_run(Nodes::Run.new(value, **current_formatting))
end
def image(path, width: nil, height: nil)
validate_image_path!(path)
img = register_image(path, width: width, height: height)
@current_target.add_run(img)
end
def b(&block)
with_format(:bold, &block)
end
def i(&block)
with_format(:italic, &block)
end
def u(&block)
with_format(:underline, &block)
end
def ul(&block)
list(:bullet, &block)
end
def ol(&block)
list(:number, &block)
end
def li(text = nil, &block)
item = Nodes::ListItem.new([], list_type: @current_list.type, num_id: @current_list.num_id)
if block
with_target(item, &block)
elsif text
item.add_run(Nodes::Run.new(text, **current_formatting))
end
@current_list.add_item(item)
end
def table(&block)
tbl = Nodes::Table.new
previous_table = @current_table
@current_table = tbl
block.call
@current_table = previous_table
@nodes << tbl
end
def tr(&block)
row = Nodes::TableRow.new
previous_row = @current_row
@current_row = row
block.call
@current_row = previous_row
@current_table.add_row(row)
end
def td(text = nil, &block)
cell = Nodes::TableCell.new
if block
with_target(cell, &block)
elsif text
cell.add_run(Nodes::Run.new(text, **current_formatting))
end
@current_row.add_cell(cell)
end
private
def list(type, &block)
@num_id_counter ||= 0
@num_id_counter += 1
list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
previous_list = @current_list
@current_list = list_node
block.call
@current_list = previous_list
@nodes << list_node
end
def with_format(format, &block)
@format_stack ||= []
@format_stack.push(format)
block.call
@format_stack.pop
end
def with_target(target, &block)
previous_target = @current_target
@current_target = target
block.call
@current_target = previous_target
end
def current_formatting
@format_stack ||= []
{
bold: @format_stack.include?(:bold),
italic: @format_stack.include?(:italic),
underline: @format_stack.include?(:underline)
}
end
def validate_image_path!(path)
raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
ext = File.extname(path).downcase
return if %w[.png .jpg .jpeg].include?(ext)
raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG."
end
end
end

View File

@@ -1,56 +0,0 @@
# frozen_string_literal: true
module Ezdoc
class Document
include Builder
attr_reader :nodes
def self.create(path, &block)
doc = new
block.call(doc)
doc.save(path)
doc
end
def initialize
@nodes = []
@format_stack = []
@current_target = nil
@current_list = nil
@current_table = nil
@current_row = nil
@num_id_counter = 0
@images = {}
end
def save(path)
Package.new(self).save(path)
end
def lists
@nodes.select { |n| n.is_a?(Nodes::List) }
end
def images
@images.values
end
def register_image(path, width: nil, height: nil)
return @images[path] if @images[path]
rid = next_image_rid
width_emu, height_emu = ImageDimensions.calculate_emus(path, width: width, height: height)
image = Nodes::Image.new(path, rid: rid, width_emu: width_emu, height_emu: height_emu)
@images[path] = image
image
end
private
def next_image_rid
base = lists.any? ? 2 : 1
"rId#{base + @images.size}"
end
end
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
module Ezdoc
module Nodes
class Run < Base
attr_reader :text, :bold, :italic, :underline
def initialize(text, bold: false, italic: false, underline: false)
super()
@text = text
@bold = bold
@italic = italic
@underline = underline
end
end
end
end

31
lib/notare.rb Normal file
View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
require "nokogiri"
require_relative "notare/version"
require_relative "notare/nodes/base"
require_relative "notare/nodes/break"
require_relative "notare/nodes/hyperlink"
require_relative "notare/nodes/run"
require_relative "notare/nodes/image"
require_relative "notare/nodes/paragraph"
require_relative "notare/nodes/list"
require_relative "notare/nodes/list_item"
require_relative "notare/nodes/table"
require_relative "notare/nodes/table_row"
require_relative "notare/nodes/table_cell"
require_relative "notare/image_dimensions"
require_relative "notare/style"
require_relative "notare/table_style"
require_relative "notare/xml/content_types"
require_relative "notare/xml/relationships"
require_relative "notare/xml/document_xml"
require_relative "notare/xml/numbering"
require_relative "notare/xml/styles_xml"
require_relative "notare/builder"
require_relative "notare/package"
require_relative "notare/document"
module Notare
class Error < StandardError; end
end

209
lib/notare/builder.rb Normal file
View File

@@ -0,0 +1,209 @@
# frozen_string_literal: true
module Notare
module Builder
def p(text = nil, style: nil, &block)
para = Nodes::Paragraph.new(style: resolve_style(style))
if block
with_target(para, &block)
elsif text
para.add_run(Nodes::Run.new(text, **current_formatting))
end
@nodes << para
end
def text(value, style: nil)
formatting = current_formatting.merge(style: resolve_style(style))
@current_target.add_run(Nodes::Run.new(value, **formatting))
end
# Heading shortcuts
def h1(text = nil, &block)
p(text, style: :heading1, &block)
end
def h2(text = nil, &block)
p(text, style: :heading2, &block)
end
def h3(text = nil, &block)
p(text, style: :heading3, &block)
end
def h4(text = nil, &block)
p(text, style: :heading4, &block)
end
def h5(text = nil, &block)
p(text, style: :heading5, &block)
end
def h6(text = nil, &block)
p(text, style: :heading6, &block)
end
def image(path, width: nil, height: nil)
validate_image_path!(path)
img = register_image(path, width: width, height: height)
@current_target.add_run(img)
end
def b(&block)
with_format(:bold, &block)
end
def i(&block)
with_format(:italic, &block)
end
def u(&block)
with_format(:underline, &block)
end
def s(&block)
with_format(:strike, &block)
end
def br
@current_target.add_run(Nodes::Break.new(type: :line))
end
def page_break
@nodes << Nodes::Break.new(type: :page)
end
def link(url, text = nil, &block)
hyperlink = register_hyperlink(url)
if block
with_target(hyperlink, &block)
elsif text
hyperlink.add_run(Nodes::Run.new(text, underline: true, color: "0000FF"))
else
hyperlink.add_run(Nodes::Run.new(url, underline: true, color: "0000FF"))
end
@current_target.add_run(hyperlink)
end
def ul(&block)
list(:bullet, &block)
end
def ol(&block)
list(:number, &block)
end
def li(text = nil, &block)
current_type = @list_type_stack.last
item = Nodes::ListItem.new([], list_type: current_type, num_id: @current_list.num_id, level: @list_level)
item.add_run(Nodes::Run.new(text, **current_formatting)) if text
with_target(item, &block) if block
@current_list.add_item(item)
end
def table(style: nil, &block)
tbl = Nodes::Table.new(style: resolve_table_style(style))
previous_table = @current_table
@current_table = tbl
block.call
@current_table = previous_table
@nodes << tbl
end
def tr(&block)
row = Nodes::TableRow.new
previous_row = @current_row
@current_row = row
block.call
@current_row = previous_row
@current_table.add_row(row)
end
def td(text = nil, &block)
cell = Nodes::TableCell.new
if block
with_target(cell, &block)
elsif text
cell.add_run(Nodes::Run.new(text, **current_formatting))
end
@current_row.add_cell(cell)
end
private
def list(type, &block)
@num_id_counter ||= 0
@list_level ||= 0
@list_type_stack ||= []
previous_list = @current_list
nested = !previous_list.nil?
if nested
# Nested list: reuse parent list, push new type, increment level
@list_level += 1
@list_type_stack.push(type)
block.call
@list_type_stack.pop
@list_level -= 1
else
# Top-level list: new List node
@num_id_counter += 1
mark_has_lists!
list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
@list_type_stack.push(type)
@current_list = list_node
block.call
@current_list = previous_list
@list_type_stack.pop
@nodes << list_node
end
end
def with_format(format, &block)
@format_stack ||= []
@format_stack.push(format)
block.call
@format_stack.pop
end
def with_target(target, &block)
previous_target = @current_target
@current_target = target
block.call
@current_target = previous_target
end
def current_formatting
@format_stack ||= []
{
bold: @format_stack.include?(:bold),
italic: @format_stack.include?(:italic),
underline: @format_stack.include?(:underline),
strike: @format_stack.include?(:strike)
}
end
def validate_image_path!(path)
raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
ext = File.extname(path).downcase
return if %w[.png .jpg .jpeg].include?(ext)
raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG."
end
def resolve_style(style_or_name)
return nil if style_or_name.nil?
return style_or_name if style_or_name.is_a?(Style)
style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}")
end
def resolve_table_style(style_or_name)
return nil if style_or_name.nil?
return style_or_name if style_or_name.is_a?(TableStyle)
table_style(style_or_name) || raise(ArgumentError, "Unknown table style: #{style_or_name}")
end
end
end

126
lib/notare/document.rb Normal file
View File

@@ -0,0 +1,126 @@
# frozen_string_literal: true
module Notare
class Document
include Builder
attr_reader :nodes, :styles, :table_styles, :hyperlinks
def self.create(path, &block)
doc = new
block.call(doc)
doc.save(path)
doc
end
def initialize
@nodes = []
@format_stack = []
@current_target = nil
@current_list = nil
@current_table = nil
@current_row = nil
@num_id_counter = 0
@has_lists = false
@images = {}
@hyperlinks = []
@styles = {}
@table_styles = {}
register_built_in_styles
register_built_in_table_styles
end
def define_style(name, **properties)
@styles[name] = Style.new(name, **properties)
end
def style(name)
@styles[name]
end
def define_table_style(name, **properties)
@table_styles[name] = TableStyle.new(name, **properties)
end
def table_style(name)
@table_styles[name]
end
def save(path)
Package.new(self).save(path)
end
def lists
@nodes.select { |n| n.is_a?(Nodes::List) }
end
def uses_lists?
@has_lists
end
def mark_has_lists!
@has_lists = true
end
def images
@images.values
end
def register_hyperlink(url)
rid = next_hyperlink_rid
hyperlink = Nodes::Hyperlink.new(url: url, rid: rid)
@hyperlinks << hyperlink
hyperlink
end
def register_image(path, width: nil, height: nil)
return @images[path] if @images[path]
rid = next_image_rid
width_emu, height_emu = ImageDimensions.calculate_emus(path, width: width, height: height)
image = Nodes::Image.new(path, rid: rid, width_emu: width_emu, height_emu: height_emu)
@images[path] = image
image
end
private
def next_image_rid
# rId1 = styles.xml (always present)
# rId2 = numbering.xml (if lists present)
# rId3+ = images, then hyperlinks
base = @has_lists ? 3 : 2
"rId#{base + @images.size}"
end
def next_hyperlink_rid
# Hyperlinks come after images
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 :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
# Other built-in styles
define_style :title, size: 26, bold: true, align: :center
define_style :subtitle, size: 15, italic: true, color: "666666"
define_style :quote, italic: true, color: "666666", indent: 720
define_style :code, font: "Courier New", size: 10
end
def register_built_in_table_styles
define_table_style :grid,
borders: { style: "single", color: "000000", size: 4 }
define_table_style :borderless,
borders: :none
end
end
end

View File

@@ -2,7 +2,7 @@
require "fastimage"
module Ezdoc
module Notare
class ImageDimensions
EMUS_PER_INCH = 914_400
DEFAULT_DPI = 96

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class Base
# Base class for all document nodes

18
lib/notare/nodes/break.rb Normal file
View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Notare
module Nodes
class Break < Base
attr_reader :type
def initialize(type: :line)
super()
@type = type
end
def page?
type == :page
end
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Notare
module Nodes
class Hyperlink < Base
attr_reader :url, :rid, :runs
def initialize(url:, rid:)
super()
@url = url
@rid = rid
@runs = []
end
def add_run(run)
@runs << run
end
end
end
end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class Image < Base
attr_reader :path, :width_emu, :height_emu, :rid, :filename

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class List < Base
attr_reader :items, :type, :num_id

View File

@@ -1,15 +1,16 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class ListItem < Base
attr_reader :runs, :list_type, :num_id
attr_reader :runs, :list_type, :num_id, :level
def initialize(runs = [], list_type:, num_id:)
def initialize(runs = [], list_type:, num_id:, level: 0)
super()
@runs = runs
@list_type = list_type
@num_id = num_id
@level = level
end
def add_run(run)

View File

@@ -1,13 +1,14 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class Paragraph < Base
attr_reader :runs
attr_reader :runs, :style
def initialize(runs = [])
def initialize(runs = [], style: nil)
super()
@runs = runs
@style = style
end
def add_run(run)

22
lib/notare/nodes/run.rb Normal file
View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
module Notare
module Nodes
class Run < Base
attr_reader :text, :bold, :italic, :underline, :strike, :highlight, :color, :style
def initialize(text, bold: false, italic: false, underline: false,
strike: false, highlight: nil, color: nil, style: nil)
super()
@text = text
@bold = bold
@italic = italic
@underline = underline
@strike = strike
@highlight = highlight
@color = color
@style = style
end
end
end
end

View File

@@ -1,13 +1,14 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class Table < Base
attr_reader :rows
attr_reader :rows, :style
def initialize
super
def initialize(style: nil)
super()
@rows = []
@style = style
end
def add_row(row)

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class TableCell < Base
attr_reader :runs

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Nodes
class TableRow < Base
attr_reader :cells

View File

@@ -2,7 +2,7 @@
require "zip"
module Ezdoc
module Notare
class Package
def initialize(document)
@document = document
@@ -14,6 +14,7 @@ module Ezdoc
zipfile.get_output_stream("_rels/.rels") { |f| f.write(relationships_xml) }
zipfile.get_output_stream("word/_rels/document.xml.rels") { |f| f.write(document_relationships_xml) }
zipfile.get_output_stream("word/document.xml") { |f| f.write(document_xml) }
zipfile.get_output_stream("word/styles.xml") { |f| f.write(styles_xml) }
zipfile.get_output_stream("word/numbering.xml") { |f| f.write(numbering_xml) } if lists?
@@ -28,15 +29,19 @@ module Ezdoc
private
def lists?
@document.lists.any?
@document.uses_lists?
end
def images
@document.images
end
def hyperlinks
@document.hyperlinks
end
def content_types_xml
Xml::ContentTypes.new(has_numbering: lists?, images: images).to_xml
Xml::ContentTypes.new(has_numbering: lists?, images: images, has_styles: true).to_xml
end
def relationships_xml
@@ -44,13 +49,19 @@ module Ezdoc
end
def document_relationships_xml
Xml::DocumentRelationships.new(has_numbering: lists?, images: images).to_xml
Xml::DocumentRelationships.new(
has_numbering: lists?, images: images, hyperlinks: hyperlinks, has_styles: true
).to_xml
end
def document_xml
Xml::DocumentXml.new(@document.nodes).to_xml
end
def styles_xml
Xml::StylesXml.new(@document.styles, @document.table_styles).to_xml
end
def numbering_xml
Xml::Numbering.new(@document.lists).to_xml
end

80
lib/notare/style.rb Normal file
View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
module Notare
class Style
attr_reader :name, :bold, :italic, :underline, :strike, :highlight, :color, :size, :font,
:align, :indent, :spacing_before, :spacing_after
ALIGNMENTS = %i[left center right justify].freeze
HIGHLIGHT_COLORS = %w[
black blue cyan darkBlue darkCyan darkGray darkGreen darkMagenta
darkRed darkYellow green lightGray magenta red white yellow
].freeze
def initialize(name, bold: nil, italic: nil, underline: nil, strike: nil,
highlight: nil, color: nil, size: nil, font: nil, align: nil,
indent: nil, spacing_before: nil, spacing_after: nil)
@name = name
@bold = bold
@italic = italic
@underline = underline
@strike = strike
@highlight = validate_highlight(highlight)
@color = normalize_color(color)
@size = size
@font = font
@align = validate_align(align)
@indent = indent
@spacing_before = spacing_before
@spacing_after = spacing_after
end
def style_id
name.to_s.split("_").map(&:capitalize).join
end
def display_name
name.to_s.split("_").map(&:capitalize).join(" ")
end
def paragraph_properties?
!!(align || indent || spacing_before || spacing_after)
end
def text_properties?
!!(bold || italic || underline || strike || highlight || color || size || font)
end
# Size in half-points for OOXML (14pt = 28 half-points)
def size_half_points
size ? (size * 2).to_i : nil
end
private
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 validate_align(align)
return nil if align.nil?
return align if ALIGNMENTS.include?(align)
raise ArgumentError, "Invalid alignment: #{align}. Use #{ALIGNMENTS.join(", ")}"
end
def validate_highlight(highlight)
return nil if highlight.nil?
color = highlight.to_s
return color if HIGHLIGHT_COLORS.include?(color)
raise ArgumentError, "Invalid highlight color: #{highlight}. Use one of: #{HIGHLIGHT_COLORS.join(", ")}"
end
end
end

83
lib/notare/table_style.rb Normal file
View 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

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Ezdoc
VERSION = "0.0.1"
module Notare
VERSION = "0.0.3"
end

View File

@@ -1,13 +1,14 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Xml
class ContentTypes
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
def initialize(has_numbering: false, images: [])
def initialize(has_numbering: false, images: [], has_styles: false)
@has_numbering = has_numbering
@images = images
@has_styles = has_styles
end
def to_xml
@@ -24,6 +25,12 @@ module Ezdoc
PartName: "/word/document.xml",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
)
if @has_styles
xml.Override(
PartName: "/word/styles.xml",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"
)
end
if @has_numbering
xml.Override(
PartName: "/word/numbering.xml",

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Xml
class DocumentXml
NAMESPACES = {
@@ -37,11 +37,26 @@ module Ezdoc
render_list(xml, node)
when Nodes::Table
render_table(xml, node)
when Nodes::Break
render_page_break(xml, node)
end
end
def render_page_break(xml, _node)
xml["w"].p do
xml["w"].r do
xml["w"].br("w:type" => "page")
end
end
end
def render_paragraph(xml, para)
xml["w"].p do
if para.style
xml["w"].pPr do
xml["w"].pStyle("w:val" => para.style.style_id)
end
end
para.runs.each { |run| render_run(xml, run) }
end
end
@@ -54,7 +69,7 @@ module Ezdoc
xml["w"].p do
xml["w"].pPr do
xml["w"].numPr do
xml["w"].ilvl("w:val" => "0")
xml["w"].ilvl("w:val" => item.level.to_s)
xml["w"].numId("w:val" => item.num_id.to_s)
end
end
@@ -66,18 +81,42 @@ module Ezdoc
case run
when Nodes::Image
render_image(xml, run)
when Nodes::Break
render_break(xml, run)
when Nodes::Hyperlink
render_hyperlink(xml, run)
when Nodes::Run
render_text_run(xml, run)
end
end
def render_hyperlink(xml, hyperlink)
xml["w"].hyperlink("r:id" => hyperlink.rid) do
hyperlink.runs.each { |run| render_run(xml, run) }
end
end
def render_break(xml, break_node)
xml["w"].r do
if break_node.page?
xml["w"].br("w:type" => "page")
else
xml["w"].br
end
end
end
def render_text_run(xml, run)
xml["w"].r do
if run.bold || run.italic || run.underline
if run.bold || run.italic || run.underline || run.strike || run.highlight || run.color || run.style
xml["w"].rPr do
xml["w"].rStyle("w:val" => run.style.style_id) if run.style
xml["w"].b if run.bold
xml["w"].i if run.italic
xml["w"].u("w:val" => "single") if run.underline
xml["w"].strike if run.strike
xml["w"].highlight("w:val" => run.highlight) if run.highlight
xml["w"].color("w:val" => run.color) if run.color
end
end
xml["w"].t(run.text, "xml:space" => "preserve")
@@ -124,27 +163,42 @@ module Ezdoc
end
def render_table(xml, table)
column_count = table.rows.first&.cells&.size || 1
col_width = 5000 / column_count
xml["w"].tbl do
xml["w"].tblPr do
xml["w"].tblW("w:w" => "0", "w:type" => "auto")
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:color" => "000000")
xml["w"].tblW("w:w" => "5000", "w:type" => "pct")
if table.style
xml["w"].tblStyle("w:val" => table.style.style_id)
else
xml["w"].tblBorders do
%w[top left bottom right insideH insideV].each do |border|
xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000")
end
end
end
end
table.rows.each { |row| render_table_row(xml, row) }
xml["w"].tblGrid do
column_count.times do
xml["w"].gridCol("w:w" => col_width.to_s)
end
end
table.rows.each { |row| render_table_row(xml, row, col_width) }
end
end
def render_table_row(xml, row)
def render_table_row(xml, row, col_width)
xml["w"].tr do
row.cells.each { |cell| render_table_cell(xml, cell) }
row.cells.each { |cell| render_table_cell(xml, cell, col_width) }
end
end
def render_table_cell(xml, cell)
def render_table_cell(xml, cell, col_width)
xml["w"].tc do
xml["w"].tcPr do
xml["w"].tcW("w:w" => col_width.to_s, "w:type" => "pct")
end
xml["w"].p do
cell.runs.each { |run| render_run(xml, run) }
end

View File

@@ -1,9 +1,11 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Xml
class Numbering
NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
BULLET_CHARS = ["", "", ""].freeze
NUMBER_FORMATS = %w[decimal lowerLetter lowerRoman].freeze
def initialize(lists)
@lists = lists
@@ -28,13 +30,16 @@ module Ezdoc
def render_abstract_num(xml, list)
xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do
xml["w"].lvl("w:ilvl" => "0") do
xml["w"].start("w:val" => "1")
xml["w"].numFmt("w:val" => num_format(list.type))
xml["w"].lvlText("w:val" => lvl_text(list.type))
xml["w"].lvlJc("w:val" => "left")
xml["w"].pPr do
xml["w"].ind("w:left" => "720", "w:hanging" => "360")
9.times do |level|
xml["w"].lvl("w:ilvl" => level.to_s) do
xml["w"].start("w:val" => "1")
xml["w"].numFmt("w:val" => num_format_for_level(list.type, level))
xml["w"].lvlText("w:val" => lvl_text_for_level(list.type, level))
xml["w"].lvlJc("w:val" => "left")
xml["w"].pPr do
left = 720 * (level + 1)
xml["w"].ind("w:left" => left.to_s, "w:hanging" => "360")
end
end
end
end
@@ -46,12 +51,20 @@ module Ezdoc
end
end
def num_format(type)
type == :bullet ? "bullet" : "decimal"
def num_format_for_level(type, level)
if type == :bullet
"bullet"
else
NUMBER_FORMATS[level % NUMBER_FORMATS.length]
end
end
def lvl_text(type)
type == :bullet ? "" : "%1."
def lvl_text_for_level(type, level)
if type == :bullet
BULLET_CHARS[level % BULLET_CHARS.length]
else
"%#{level + 1}."
end
end
end
end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
module Ezdoc
module Notare
module Xml
class Relationships
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
@@ -21,24 +21,40 @@ module Ezdoc
class DocumentRelationships
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
STYLES_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
NUMBERING_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"
IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
HYPERLINK_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
def initialize(has_numbering: false, images: [])
def initialize(has_numbering: false, images: [], hyperlinks: [], has_styles: false)
@has_numbering = has_numbering
@images = images
@hyperlinks = hyperlinks
@has_styles = has_styles
end
def to_xml
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
xml.Relationships(xmlns: NAMESPACE) do
if @has_numbering
# rId1 = styles.xml (always first when present)
if @has_styles
xml.Relationship(
Id: "rId1",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
Type: STYLES_TYPE,
Target: "styles.xml"
)
end
# rId2 = numbering.xml (if lists present)
if @has_numbering
xml.Relationship(
Id: "rId2",
Type: NUMBERING_TYPE,
Target: "numbering.xml"
)
end
# Images start at rId2 or rId3 depending on numbering
@images.each do |image|
xml.Relationship(
Id: image.rid,
@@ -46,6 +62,16 @@ module Ezdoc
Target: "media/#{image.filename}"
)
end
# Hyperlinks come after images
@hyperlinks.each do |hyperlink|
xml.Relationship(
Id: hyperlink.rid,
Type: HYPERLINK_TYPE,
Target: hyperlink.url,
TargetMode: "External"
)
end
end
end
builder.to_xml

View File

@@ -0,0 +1,132 @@
# frozen_string_literal: true
module Notare
module Xml
class StylesXml
NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
ALIGNMENT_MAP = {
left: "left",
center: "center",
right: "right",
justify: "both"
}.freeze
TABLE_ALIGNMENT_MAP = {
left: "left",
center: "center",
right: "right"
}.freeze
def initialize(styles, table_styles = {})
@styles = styles
@table_styles = table_styles
end
def to_xml
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
xml.styles("xmlns:w" => NAMESPACE) do
xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" }
@styles.each_value do |style|
render_style(xml, style)
end
@table_styles.each_value do |style|
render_table_style(xml, style)
end
end
end
builder.to_xml
end
private
def render_style(xml, style)
style_type = style.paragraph_properties? ? "paragraph" : "character"
xml["w"].style("w:type" => style_type, "w:styleId" => style.style_id) do
xml["w"].name("w:val" => style.display_name)
render_paragraph_properties(xml, style) if style.paragraph_properties?
render_run_properties(xml, style) if style.text_properties?
end
end
def render_paragraph_properties(xml, style)
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
end
end
def render_run_properties(xml, style)
xml["w"].rPr do
xml["w"].rFonts("w:ascii" => style.font, "w:hAnsi" => style.font) if style.font
xml["w"].sz("w:val" => style.size_half_points.to_s) if style.size
xml["w"].color("w:val" => style.color) if style.color
xml["w"].b if style.bold
xml["w"].i if style.italic
xml["w"].u("w:val" => "single") if style.underline
xml["w"].strike if style.strike
xml["w"].highlight("w:val" => style.highlight) if style.highlight
end
end
def render_table_style(xml, style)
xml["w"].style("w:type" => "table", "w:styleId" => style.style_id) do
xml["w"].name("w:val" => style.display_name)
xml["w"].tblPr do
render_table_borders(xml, style.borders) if style.borders
render_table_shading(xml, style.shading) if style.shading
render_table_cell_margins(xml, style.cell_margins) if style.cell_margins
xml["w"].jc("w:val" => TABLE_ALIGNMENT_MAP[style.align]) if style.align
end
end
end
def render_table_borders(xml, borders)
xml["w"].tblBorders do
%i[top left bottom right insideH insideV].each do |pos|
border = borders == :none ? :none : (borders[pos] || borders)
render_single_border(xml, pos, border)
end
end
end
def render_single_border(xml, position, border)
if border == :none
xml["w"].send(position, "w:val" => "nil")
else
xml["w"].send(position,
"w:val" => border[:style],
"w:sz" => border[:size].to_s,
"w:space" => "0",
"w:color" => border[:color])
end
end
def render_table_shading(xml, color)
xml["w"].shd("w:val" => "clear", "w:color" => "auto", "w:fill" => color)
end
def render_table_cell_margins(xml, margins)
xml["w"].tblCellMar do
if margins.is_a?(Hash)
xml["w"].top("w:w" => margins[:top].to_s, "w:type" => "dxa") if margins[:top]
xml["w"].left("w:w" => margins[:left].to_s, "w:type" => "dxa") if margins[:left]
xml["w"].bottom("w:w" => margins[:bottom].to_s, "w:type" => "dxa") if margins[:bottom]
xml["w"].right("w:w" => margins[:right].to_s, "w:type" => "dxa") if margins[:right]
else
%i[top left bottom right].each do |side|
xml["w"].send(side, "w:w" => margins.to_s, "w:type" => "dxa")
end
end
end
end
end
end
end

View File

@@ -1,14 +1,14 @@
# frozen_string_literal: true
require_relative "lib/ezdoc/version"
require_relative "lib/notare/version"
Gem::Specification.new do |spec|
spec.name = "ezdoc"
spec.version = Ezdoc::VERSION
spec.name = "notare"
spec.version = Notare::VERSION
spec.authors = ["Mathias"]
spec.summary = "A Ruby gem for working with docx files"
spec.description = "Easy document manipulation for docx files in Ruby"
spec.homepage = "https://github.com/mathias/ezdoc"
spec.summary = "A Ruby gem for creating docx files with a simple DSL"
spec.description = "Notare provides a clean DSL for creating Word documents in Ruby"
spec.homepage = "https://git.kaukus.no/Kaukus/Notare"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.0.0"

View File

@@ -3,11 +3,11 @@
require "test_helper"
class DocumentTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def test_creates_valid_docx_structure
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) do |doc|
Notare::Document.create(file.path) do |doc|
doc.p "Test"
end
@@ -33,7 +33,7 @@ class DocumentTest < Minitest::Test
def test_empty_document
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) { |_doc| } # rubocop:disable Lint/EmptyBlock
Notare::Document.create(file.path) { |_doc| } # rubocop:disable Lint/EmptyBlock
assert File.exist?(file.path)
Zip::File.open(file.path) do |zip|
@@ -45,30 +45,30 @@ class DocumentTest < Minitest::Test
def test_document_create_returns_document
result = nil
Tempfile.create(["test", ".docx"]) do |file|
result = Ezdoc::Document.create(file.path) do |doc|
result = Notare::Document.create(file.path) do |doc|
doc.p "Test"
end
end
assert_instance_of Ezdoc::Document, result
assert_instance_of Notare::Document, result
end
def test_document_has_nodes
doc = Ezdoc::Document.new
doc = Notare::Document.new
doc.p "Test"
assert_equal 1, doc.nodes.count
assert_instance_of Ezdoc::Nodes::Paragraph, doc.nodes.first
assert_instance_of Notare::Nodes::Paragraph, doc.nodes.first
end
def test_document_lists_helper
doc = Ezdoc::Document.new
doc = Notare::Document.new
doc.p "Paragraph"
doc.ul { doc.li "Bullet" }
doc.ol { doc.li "Number" }
doc.table { doc.tr { doc.td "Cell" } }
assert_equal 2, doc.lists.count
assert(doc.lists.all? { |l| l.is_a?(Ezdoc::Nodes::List) })
assert(doc.lists.all? { |l| l.is_a?(Notare::Nodes::List) })
end
end

View File

@@ -3,7 +3,7 @@
require "test_helper"
class FormattingTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def test_bold_text
xml = create_doc_and_read_xml do |doc|
@@ -133,4 +133,49 @@ class FormattingTest < Minitest::Test
assert_includes xml, "bold+italic "
assert_includes xml, "all three"
end
def test_strikethrough_text
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.s { doc.text "strikethrough text" }
end
end
assert_includes xml, "<w:strike/>"
assert_includes xml, "strikethrough text"
end
def test_strikethrough_with_other_formatting
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.b do
doc.s { doc.text "bold and strikethrough" }
end
end
end
assert_includes xml, "<w:b/>"
assert_includes xml, "<w:strike/>"
assert_includes xml, "bold and strikethrough"
end
def test_all_four_formatting_options
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.b do
doc.i do
doc.u do
doc.s { doc.text "all four" }
end
end
end
end
end
assert_includes xml, "<w:b/>"
assert_includes xml, "<w:i/>"
assert_includes xml, '<w:u w:val="single"/>'
assert_includes xml, "<w:strike/>"
assert_includes xml, "all four"
end
end

87
test/heading_test.rb Normal file
View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "test_helper"
class HeadingTest < Minitest::Test
include NotareTestHelpers
def test_h1
xml = create_doc_and_read_xml { |doc| doc.h1 "Title" }
assert_includes xml, "<w:pStyle"
assert_includes xml, 'w:val="Heading1"'
assert_includes xml, "Title"
end
def test_h2
xml = create_doc_and_read_xml { |doc| doc.h2 "Chapter" }
assert_includes xml, 'w:val="Heading2"'
assert_includes xml, "Chapter"
end
def test_h3
xml = create_doc_and_read_xml { |doc| doc.h3 "Section" }
assert_includes xml, 'w:val="Heading3"'
end
def test_h4
xml = create_doc_and_read_xml { |doc| doc.h4 "Subsection" }
assert_includes xml, 'w:val="Heading4"'
end
def test_h5
xml = create_doc_and_read_xml { |doc| doc.h5 "Minor" }
assert_includes xml, 'w:val="Heading5"'
end
def test_h6
xml = create_doc_and_read_xml { |doc| doc.h6 "Smallest" }
assert_includes xml, 'w:val="Heading6"'
end
def test_heading_with_block
xml = create_doc_and_read_xml do |doc|
doc.h1 do
doc.text "Part 1 "
doc.b { doc.text "Bold" }
end
end
assert_includes xml, 'w:val="Heading1"'
assert_includes xml, "Part 1 "
assert_includes xml, "<w:b/>"
assert_includes xml, "Bold"
end
def test_multiple_headings
xml = create_doc_and_read_xml do |doc|
doc.h1 "Title"
doc.h2 "Chapter 1"
doc.h3 "Section 1.1"
doc.p "Normal paragraph"
end
assert_includes xml, 'w:val="Heading1"'
assert_includes xml, 'w:val="Heading2"'
assert_includes xml, 'w:val="Heading3"'
# Regular paragraph should not have pStyle
assert_equal 3, xml.scan("<w:pStyle").count
end
def test_heading_styles_in_styles_xml
xml_files = create_doc_and_read_all_xml do |doc|
doc.h1 "Test"
end
styles_xml = xml_files["word/styles.xml"]
assert styles_xml, "styles.xml should exist"
assert_includes styles_xml, 'w:styleId="Heading1"'
assert_includes styles_xml, "<w:b/>"
assert_includes styles_xml, "<w:sz"
end
end

127
test/hyperlink_test.rb Normal file
View File

@@ -0,0 +1,127 @@
# frozen_string_literal: true
require "test_helper"
class HyperlinkTest < Minitest::Test
include NotareTestHelpers
def test_simple_hyperlink
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.link "https://example.com", "Example"
end
end
assert_includes xml, '<w:hyperlink r:id="rId2"'
assert_includes xml, "Example"
assert_includes xml, '<w:u w:val="single"/>'
assert_includes xml, '<w:color w:val="0000FF"/>'
end
def test_hyperlink_url_as_text
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.link "https://example.com"
end
end
assert_includes xml, "https://example.com"
end
def test_hyperlink_with_block
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.link "https://example.com" do
doc.b { doc.text "Bold Link" }
end
end
end
assert_includes xml, '<w:hyperlink r:id="rId2"'
assert_includes xml, "<w:b/>"
assert_includes xml, "Bold Link"
end
def test_hyperlink_relationship
xml_files = create_doc_and_read_all_xml do |doc|
doc.p do
doc.link "https://example.com", "Example"
end
end
rels = xml_files["word/_rels/document.xml.rels"]
assert_includes rels, "https://example.com"
assert_includes rels, 'TargetMode="External"'
assert_includes rels, "relationships/hyperlink"
end
def test_multiple_hyperlinks
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.link "https://one.com", "One"
doc.text " and "
doc.link "https://two.com", "Two"
end
end
assert_includes xml, '<w:hyperlink r:id="rId2"'
assert_includes xml, '<w:hyperlink r:id="rId3"'
assert_includes xml, "One"
assert_includes xml, "Two"
end
def test_hyperlink_in_list_item
xml = create_doc_and_read_xml do |doc|
doc.ul do
doc.li do
doc.link "https://example.com", "Link in list"
end
end
end
assert_includes xml, '<w:hyperlink r:id="rId3"'
assert_includes xml, "Link in list"
end
def test_hyperlink_in_table_cell
xml = create_doc_and_read_xml do |doc|
doc.table do
doc.tr do
doc.td do
doc.link "https://example.com", "Link in table"
end
end
end
end
assert_includes xml, '<w:hyperlink r:id="rId2"'
assert_includes xml, "Link in table"
end
def test_hyperlink_with_surrounding_text
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.text "Visit "
doc.link "https://example.com", "our site"
doc.text " for more info."
end
end
assert_includes xml, "Visit "
assert_includes xml, "our site"
assert_includes xml, " for more info."
end
def test_multiple_hyperlinks_relationships
xml_files = create_doc_and_read_all_xml do |doc|
doc.p do
doc.link "https://one.com", "One"
doc.link "https://two.com", "Two"
end
end
rels = xml_files["word/_rels/document.xml.rels"]
assert_includes rels, "https://one.com"
assert_includes rels, "https://two.com"
end
end

View File

@@ -3,7 +3,7 @@
require "test_helper"
class ImageTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def setup
@png_path = File.expand_path("fixtures/test.png", __dir__)
@@ -80,7 +80,7 @@ class ImageTest < Minitest::Test
def test_image_file_embedded_in_docx
files = nil
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) do |doc|
Notare::Document.create(file.path) do |doc|
doc.p { doc.image @png_path }
end
Zip::File.open(file.path) do |zip|
@@ -124,7 +124,7 @@ class ImageTest < Minitest::Test
def test_invalid_image_path_raises_error
assert_raises(ArgumentError) do
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) do |doc|
Notare::Document.create(file.path) do |doc|
doc.p { doc.image "/nonexistent/image.png" }
end
end
@@ -138,7 +138,7 @@ class ImageTest < Minitest::Test
assert_raises(ArgumentError) do
Tempfile.create(["test", ".docx"]) do |docx_file|
Ezdoc::Document.create(docx_file.path) do |doc|
Notare::Document.create(docx_file.path) do |doc|
doc.p { doc.image gif_file.path }
end
end
@@ -149,7 +149,7 @@ class ImageTest < Minitest::Test
def test_same_image_used_multiple_times_deduplication
files = nil
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) do |doc|
Notare::Document.create(file.path) do |doc|
doc.p { doc.image @png_path }
doc.p { doc.image @png_path }
doc.p { doc.image @png_path }
@@ -166,7 +166,7 @@ class ImageTest < Minitest::Test
def test_multiple_different_images
files = nil
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path) do |doc|
Notare::Document.create(file.path) do |doc|
doc.p { doc.image @png_path }
doc.p { doc.image @jpeg_path }
end

View File

@@ -3,7 +3,7 @@
require "test_helper"
class IntegrationTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def test_complex_document_with_all_features
xml_files = create_doc_and_read_all_xml do |doc|

85
test/line_break_test.rb Normal file
View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
require "test_helper"
class LineBreakTest < Minitest::Test
include NotareTestHelpers
def test_line_break_in_paragraph
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.text "First line"
doc.br
doc.text "Second line"
end
end
assert_includes xml, "First line"
assert_includes xml, "Second line"
assert_includes xml, "<w:br/>"
end
def test_multiple_line_breaks
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.text "Line 1"
doc.br
doc.text "Line 2"
doc.br
doc.text "Line 3"
end
end
assert_equal 2, xml.scan("<w:br/>").count
end
def test_line_break_with_formatting
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.b { doc.text "Bold first line" }
doc.br
doc.i { doc.text "Italic second line" }
end
end
assert_includes xml, "<w:b/>"
assert_includes xml, "<w:i/>"
assert_includes xml, "<w:br/>"
assert_includes xml, "Bold first line"
assert_includes xml, "Italic second line"
end
def test_line_break_in_list_item
xml = create_doc_and_read_xml do |doc|
doc.ul do
doc.li do
doc.text "First line"
doc.br
doc.text "Second line"
end
end
end
assert_includes xml, "<w:br/>"
assert_includes xml, "First line"
assert_includes xml, "Second line"
end
def test_line_break_in_table_cell
xml = create_doc_and_read_xml do |doc|
doc.table do
doc.tr do
doc.td do
doc.text "Cell line 1"
doc.br
doc.text "Cell line 2"
end
end
end
end
assert_includes xml, "<w:br/>"
assert_includes xml, "Cell line 1"
assert_includes xml, "Cell line 2"
end
end

View File

@@ -3,7 +3,7 @@
require "test_helper"
class ListTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
#
# Bullet List Tests
@@ -144,4 +144,98 @@ class ListTest < Minitest::Test
refute xml_files.key?("word/numbering.xml"), "numbering.xml should not exist without lists"
end
#
# Nested List Tests
#
def test_nested_bullet_list
xml = create_doc_and_read_xml do |doc|
doc.ul do
doc.li "Parent item"
doc.li "Item with nested list" do
doc.ul do
doc.li "Nested item 1"
doc.li "Nested item 2"
end
end
doc.li "Another parent item"
end
end
assert_includes xml, "Parent item"
assert_includes xml, "Nested item 1"
assert_includes xml, "Nested item 2"
assert_includes xml, "Another parent item"
# Check for level 0 and level 1
assert_includes xml, 'w:ilvl w:val="0"'
assert_includes xml, 'w:ilvl w:val="1"'
end
def test_nested_numbered_list
xml = create_doc_and_read_xml do |doc|
doc.ol do
doc.li "First"
doc.li "Second with nested" do
doc.ol do
doc.li "Nested 1"
doc.li "Nested 2"
end
end
doc.li "Third"
end
end
assert_includes xml, "First"
assert_includes xml, "Nested 1"
assert_includes xml, "Third"
assert_includes xml, 'w:ilvl w:val="0"'
assert_includes xml, 'w:ilvl w:val="1"'
end
def test_mixed_nested_list
xml_files = create_doc_and_read_all_xml do |doc|
doc.ol do
doc.li "Numbered item 1"
doc.li "Numbered item 2" do
doc.ul do
doc.li "Bullet inside numbered"
end
end
doc.li "Numbered item 3"
end
end
doc_xml = xml_files["word/document.xml"]
assert_includes doc_xml, "Numbered item 1"
assert_includes doc_xml, "Bullet inside numbered"
assert_includes doc_xml, "Numbered item 3"
assert_includes doc_xml, 'w:ilvl w:val="0"'
assert_includes doc_xml, 'w:ilvl w:val="1"'
end
def test_deeply_nested_list
xml = create_doc_and_read_xml do |doc|
doc.ul do
doc.li "Level 0"
doc.li "Has nested" do
doc.ul do
doc.li "Level 1"
doc.li "Has deeper nested" do
doc.ul do
doc.li "Level 2"
end
end
end
end
end
end
assert_includes xml, "Level 0"
assert_includes xml, "Level 1"
assert_includes xml, "Level 2"
assert_includes xml, 'w:ilvl w:val="0"'
assert_includes xml, 'w:ilvl w:val="1"'
assert_includes xml, 'w:ilvl w:val="2"'
end
end

54
test/page_break_test.rb Normal file
View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
require "test_helper"
class PageBreakTest < Minitest::Test
include NotareTestHelpers
def test_page_break
xml = create_doc_and_read_xml do |doc|
doc.p "First page content"
doc.page_break
doc.p "Second page content"
end
assert_includes xml, "First page content"
assert_includes xml, "Second page content"
assert_includes xml, '<w:br w:type="page"/>'
end
def test_multiple_page_breaks
xml = create_doc_and_read_xml do |doc|
doc.p "Page 1"
doc.page_break
doc.p "Page 2"
doc.page_break
doc.p "Page 3"
end
assert_equal 2, xml.scan('<w:br w:type="page"/>').count
end
def test_page_break_between_different_elements
xml = create_doc_and_read_xml do |doc|
doc.h1 "Chapter 1"
doc.p "Some content"
doc.page_break
doc.h1 "Chapter 2"
doc.ul do
doc.li "Item 1"
end
end
assert_includes xml, "Chapter 1"
assert_includes xml, "Chapter 2"
assert_includes xml, '<w:br w:type="page"/>'
end
def test_page_break_renders_in_own_paragraph
xml = create_doc_and_read_xml(&:page_break)
# Page break should be wrapped in its own paragraph
assert_match(%r{<w:p>\s*<w:r>\s*<w:br w:type="page"/>\s*</w:r>\s*</w:p>}m, xml)
end
end

View File

@@ -3,7 +3,7 @@
require "test_helper"
class ParagraphTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def test_simple_paragraph
xml = create_doc_and_read_xml { |doc| doc.p "Hello World" }

223
test/style_test.rb Normal file
View File

@@ -0,0 +1,223 @@
# frozen_string_literal: true
require "test_helper"
class StyleTest < Minitest::Test
include NotareTestHelpers
def test_define_custom_style
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :warning, bold: true, color: "FF0000"
doc.p "Test", style: :warning
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, 'w:styleId="Warning"'
assert_includes styles_xml, "<w:b/>"
assert_includes styles_xml, 'w:val="FF0000"'
end
def test_apply_style_to_paragraph
xml = create_doc_and_read_xml do |doc|
doc.p "Quote text", style: :quote
end
assert_includes xml, '<w:pStyle w:val="Quote"'
assert_includes xml, "Quote text"
end
def test_apply_style_to_text_run
xml = create_doc_and_read_xml do |doc|
doc.p do
doc.text "Normal "
doc.text "code", style: :code
end
end
assert_includes xml, '<w:rStyle w:val="Code"'
assert_includes xml, "code"
end
def test_built_in_styles_exist
xml_files = create_doc_and_read_all_xml do |doc|
doc.p "Test"
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, 'w:styleId="Heading1"'
assert_includes styles_xml, 'w:styleId="Heading2"'
assert_includes styles_xml, 'w:styleId="Title"'
assert_includes styles_xml, 'w:styleId="Subtitle"'
assert_includes styles_xml, 'w:styleId="Quote"'
assert_includes styles_xml, 'w:styleId="Code"'
end
def test_style_with_color
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :red_text, color: "FF0000"
doc.p "Red", style: :red_text
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, 'w:val="FF0000"'
end
def test_style_with_font
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :mono, font: "Courier New"
doc.p "Mono", style: :mono
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, 'w:ascii="Courier New"'
end
def test_style_with_size
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :big, size: 24
doc.p "Big", style: :big
end
styles_xml = xml_files["word/styles.xml"]
# 24pt = 48 half-points
assert_includes styles_xml, 'w:val="48"'
end
def test_style_with_alignment
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :centered, align: :center
doc.p "Centered", style: :centered
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, '<w:jc w:val="center"'
end
def test_styles_xml_generated
xml_files = create_doc_and_read_all_xml do |doc|
doc.p "Test"
end
assert xml_files["word/styles.xml"], "styles.xml should be generated"
end
def test_styles_xml_in_content_types
xml_files = create_doc_and_read_all_xml do |doc|
doc.p "Test"
end
content_types = xml_files["[Content_Types].xml"]
assert_includes content_types, "/word/styles.xml"
assert_includes content_types, "wordprocessingml.styles+xml"
end
def test_styles_xml_in_relationships
xml_files = create_doc_and_read_all_xml do |doc|
doc.p "Test"
end
rels = xml_files["word/_rels/document.xml.rels"]
assert_includes rels, "styles.xml"
assert_includes rels, "relationships/styles"
end
def test_unknown_style_raises_error
assert_raises(ArgumentError) do
Tempfile.create(["test", ".docx"]) do |file|
Notare::Document.create(file.path) do |doc|
doc.p "Test", style: :nonexistent
end
end
end
end
def test_invalid_color_raises_error
assert_raises(ArgumentError) do
Notare::Style.new(:bad, color: "invalid")
end
end
def test_invalid_alignment_raises_error
assert_raises(ArgumentError) do
Notare::Style.new(:bad, align: :invalid)
end
end
def test_color_normalizes_hash
style = Notare::Style.new(:test, color: "#ff0000")
assert_equal "FF0000", style.color
end
def test_combined_style_properties
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :fancy,
bold: true,
italic: true,
color: "0000FF",
size: 16,
font: "Arial",
align: :center
doc.p "Fancy", style: :fancy
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, "<w:b/>"
assert_includes styles_xml, "<w:i/>"
assert_includes styles_xml, 'w:val="0000FF"'
assert_includes styles_xml, 'w:val="32"' # 16pt = 32 half-points
assert_includes styles_xml, 'w:ascii="Arial"'
assert_includes styles_xml, '<w:jc w:val="center"'
end
def test_style_with_highlight
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :highlighted, highlight: "yellow"
doc.p "Highlighted", style: :highlighted
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, '<w:highlight w:val="yellow"'
end
def test_style_with_strikethrough
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :deleted, strike: true
doc.p "Deleted", style: :deleted
end
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, "<w:strike/>"
end
def test_invalid_highlight_raises_error
assert_raises(ArgumentError) do
Notare::Style.new(:bad, highlight: "invalid_color")
end
end
def test_valid_highlight_colors
# Test a few valid highlight colors
%w[yellow red blue green cyan magenta].each do |color|
style = Notare::Style.new(:test, highlight: color)
assert_equal color, style.highlight
end
end
def test_highlight_in_text_run
xml_files = create_doc_and_read_all_xml do |doc|
doc.define_style :yellow_highlight, highlight: "yellow"
doc.p do
doc.text "Normal "
doc.text "highlighted", style: :yellow_highlight
end
end
# Highlight is in the style definition, not inline when using style reference
styles_xml = xml_files["word/styles.xml"]
assert_includes styles_xml, '<w:highlight w:val="yellow"'
document_xml = xml_files["word/document.xml"]
assert_includes document_xml, '<w:rStyle w:val="YellowHighlight"'
assert_includes document_xml, "highlighted"
end
end

224
test/table_style_test.rb Normal file
View 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

View File

@@ -3,7 +3,7 @@
require "test_helper"
class TableTest < Minitest::Test
include EzdocTestHelpers
include NotareTestHelpers
def test_simple_table
xml = create_doc_and_read_xml do |doc|

View File

@@ -1,17 +1,17 @@
# frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "ezdoc"
require "notare"
require "minitest/autorun"
require "tempfile"
require "zip"
module EzdocTestHelpers
module NotareTestHelpers
# Helper to create a document and return the document.xml content
def create_doc_and_read_xml(&block)
content = nil
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path, &block)
Notare::Document.create(file.path, &block)
Zip::File.open(file.path) do |zip|
content = zip.read("word/document.xml").force_encoding("UTF-8")
end
@@ -23,7 +23,7 @@ module EzdocTestHelpers
def create_doc_and_read_all_xml(&block)
result = {}
Tempfile.create(["test", ".docx"]) do |file|
Ezdoc::Document.create(file.path, &block)
Notare::Document.create(file.path, &block)
Zip::File.open(file.path) do |zip|
zip.each do |entry|
if entry.name.end_with?(".xml") || entry.name.end_with?(".rels")