10 Commits

Author SHA1 Message Date
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
37 changed files with 948 additions and 174 deletions

View File

@@ -19,24 +19,29 @@ bundle exec ruby -Ilib:test test/paragraph_test.rb -n test_paragraph_with_text
## Architecture ## 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 ### 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: - **Package** (`lib/notare/package.rb`): Assembles the docx ZIP structure using rubyzip. Coordinates XML generation.
- `DocumentXml`: Main content with paragraphs, lists, tables
- **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 - `ContentTypes`: [Content_Types].xml
- `Relationships`: .rels files - `Relationships`: .rels files
- `Numbering`: numbering.xml for lists - `Numbering`: numbering.xml for lists
- **ImageDimensions** (`lib/notare/image_dimensions.rb`): Uses fastimage gem to read image dimensions for EMU calculations.
### Data Flow ### Data Flow
1. User calls DSL methods on Document 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 ### 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.

119
README.md
View File

@@ -1,4 +1,4 @@
# Ezdoc # Notare
A Ruby gem for creating docx files with a simple DSL 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: Add this line to your application's Gemfile:
```ruby ```ruby
gem 'ezdoc' gem 'notare'
``` ```
And then execute: And then execute:
@@ -16,16 +16,16 @@ And then execute:
Or install it yourself as: Or install it yourself as:
$ gem install ezdoc $ gem install notare
## Usage ## Usage
### Basic Example ### Basic Example
```ruby ```ruby
require 'ezdoc' require 'notare'
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
doc.p "Hello World" doc.p "Hello World"
end end
``` ```
@@ -33,7 +33,7 @@ end
### Paragraphs ### Paragraphs
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
# Simple paragraph # Simple paragraph
doc.p "This is a paragraph." doc.p "This is a paragraph."
@@ -50,7 +50,7 @@ end
Formatting uses nested blocks. Nesting combines formatting styles. Formatting uses nested blocks. Nesting combines formatting styles.
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
doc.p do doc.p do
doc.text "Normal text " doc.text "Normal text "
doc.b { doc.text "bold" } doc.b { doc.text "bold" }
@@ -69,12 +69,99 @@ Ezdoc::Document.create("output.docx") do |doc|
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
```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`
- `color: "FF0000"` (hex RGB)
- `size: 14` (points)
- `font: "Arial"` (font family)
**Paragraph properties:**
- `align: :left / :center / :right / :justify`
- `indent: 720` (twips, 1 inch = 1440 twips)
- `spacing_before: 240` (twips)
- `spacing_after: 240` (twips)
### Lists ### Lists
#### Bullet Lists #### Bullet Lists
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
doc.ul do doc.ul do
doc.li "First item" doc.li "First item"
doc.li "Second item" doc.li "Second item"
@@ -86,7 +173,7 @@ end
#### Numbered Lists #### Numbered Lists
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
doc.ol do doc.ol do
doc.li "First" doc.li "First"
doc.li "Second" doc.li "Second"
@@ -98,7 +185,7 @@ end
### Tables ### Tables
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
doc.table do doc.table do
doc.tr do doc.tr do
doc.td "Header 1" doc.td "Header 1"
@@ -117,7 +204,7 @@ end
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.
```ruby ```ruby
Ezdoc::Document.create("output.docx") do |doc| Notare::Document.create("output.docx") do |doc|
# Simple image (uses native dimensions) # Simple image (uses native dimensions)
doc.p do doc.p do
doc.image "photo.png" doc.image "photo.png"
@@ -162,7 +249,7 @@ end
### Complete Example ### Complete Example
```ruby ```ruby
Ezdoc::Document.create("report.docx") do |doc| Notare::Document.create("report.docx") do |doc|
doc.p "Monthly Report" doc.p "Monthly Report"
doc.p do doc.p do
@@ -202,12 +289,14 @@ end
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| `p(text)` | Create a paragraph with text | | `p(text, style:)` | Create a paragraph with text and optional style |
| `p { }` | Create a paragraph with block content | | `p(style:) { }` | Create a paragraph with block content and optional style |
| `text(value)` | Add text to the current context | | `text(value, style:)` | Add text with optional style to the current context |
| `h1(text)` - `h6(text)` | Create headings (level 1-6) |
| `b { }` | Bold formatting | | `b { }` | Bold formatting |
| `i { }` | Italic formatting | | `i { }` | Italic formatting |
| `u { }` | Underline formatting | | `u { }` | Underline formatting |
| `define_style(name, **props)` | Define a custom style |
| `ul { }` | Bullet list | | `ul { }` | Bullet list |
| `ol { }` | Numbered list | | `ol { }` | Numbered list |
| `li(text)` | List item with text | | `li(text)` | List item with text |

182
examples/full_demo.rb Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env ruby
# 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
# ============================================================================
# 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 ", and "
doc.b do
doc.i do
doc.u { doc.text "combined" }
end
end
doc.text " formatting."
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
# ============================================================================
# 5. Lists
# ============================================================================
doc.h2 "5. 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
# ============================================================================
# 6. Tables
# ============================================================================
doc.h2 "6. Tables"
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"
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
end
# ============================================================================
# 7. Images
# ============================================================================
doc.h2 "7. 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
# ============================================================================
# 8. Combined Features
# ============================================================================
doc.h2 "8. Combined Features"
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 " to demonstrate the full power of Notare."
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,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

28
lib/notare.rb Normal file
View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
require "nokogiri"
require_relative "notare/version"
require_relative "notare/nodes/base"
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/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

View File

@@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
module Ezdoc module Notare
module Builder module Builder
def p(text = nil, &block) def p(text = nil, style: nil, &block)
para = Nodes::Paragraph.new para = Nodes::Paragraph.new(style: resolve_style(style))
if block if block
with_target(para, &block) with_target(para, &block)
elsif text elsif text
@@ -12,8 +12,34 @@ module Ezdoc
@nodes << para @nodes << para
end end
def text(value) def text(value, style: nil)
@current_target.add_run(Nodes::Run.new(value, **current_formatting)) 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 end
def image(path, width: nil, height: nil) def image(path, width: nil, height: nil)
@@ -125,5 +151,12 @@ module Ezdoc
raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG." raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG."
end 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
end end
end end

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

@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Notare
class Document
include Builder
attr_reader :nodes, :styles
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 = {}
@styles = {}
register_built_in_styles
end
def define_style(name, **properties)
@styles[name] = Style.new(name, **properties)
end
def style(name)
@styles[name]
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
# rId1 = styles.xml (always present)
# rId2 = numbering.xml (if lists present)
# rId3+ = images
base = lists.any? ? 3 : 2
"rId#{base + @images.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
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
require "zip" require "zip"
module Ezdoc module Notare
class Package class Package
def initialize(document) def initialize(document)
@document = document @document = document
@@ -14,6 +14,7 @@ module Ezdoc
zipfile.get_output_stream("_rels/.rels") { |f| f.write(relationships_xml) } 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/_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/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? zipfile.get_output_stream("word/numbering.xml") { |f| f.write(numbering_xml) } if lists?
@@ -36,7 +37,7 @@ module Ezdoc
end end
def content_types_xml 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 end
def relationships_xml def relationships_xml
@@ -44,13 +45,17 @@ module Ezdoc
end end
def document_relationships_xml def document_relationships_xml
Xml::DocumentRelationships.new(has_numbering: lists?, images: images).to_xml Xml::DocumentRelationships.new(has_numbering: lists?, images: images, has_styles: true).to_xml
end end
def document_xml def document_xml
Xml::DocumentXml.new(@document.nodes).to_xml Xml::DocumentXml.new(@document.nodes).to_xml
end end
def styles_xml
Xml::StylesXml.new(@document.styles).to_xml
end
def numbering_xml def numbering_xml
Xml::Numbering.new(@document.lists).to_xml Xml::Numbering.new(@document.lists).to_xml
end end

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

@@ -0,0 +1,65 @@
# frozen_string_literal: true
module Notare
class Style
attr_reader :name, :bold, :italic, :underline, :color, :size, :font,
:align, :indent, :spacing_before, :spacing_after
ALIGNMENTS = %i[left center right justify].freeze
def initialize(name, bold: nil, italic: nil, underline: 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
@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 || 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
end
end

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Ezdoc module Notare
module Xml module Xml
class DocumentXml class DocumentXml
NAMESPACES = { NAMESPACES = {
@@ -42,6 +42,11 @@ module Ezdoc
def render_paragraph(xml, para) def render_paragraph(xml, para)
xml["w"].p do 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) } para.runs.each { |run| render_run(xml, run) }
end end
end end
@@ -73,8 +78,9 @@ module Ezdoc
def render_text_run(xml, run) def render_text_run(xml, run)
xml["w"].r do xml["w"].r do
if run.bold || run.italic || run.underline if run.bold || run.italic || run.underline || run.style
xml["w"].rPr do xml["w"].rPr do
xml["w"].rStyle("w:val" => run.style.style_id) if run.style
xml["w"].b if run.bold xml["w"].b if run.bold
xml["w"].i if run.italic xml["w"].i if run.italic
xml["w"].u("w:val" => "single") if run.underline xml["w"].u("w:val" => "single") if run.underline
@@ -124,27 +130,38 @@ module Ezdoc
end end
def render_table(xml, table) def render_table(xml, table)
column_count = table.rows.first&.cells&.size || 1
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" => "0", "w:type" => "auto") xml["w"].tblW("w:w" => "5000", "w:type" => "pct")
xml["w"].tblBorders do xml["w"].tblBorders do
%w[top left bottom right insideH insideV].each do |border| %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"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000")
end end
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
end end
def render_table_row(xml, row) def render_table_row(xml, row, col_width)
xml["w"].tr do 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
end end
def render_table_cell(xml, cell) def render_table_cell(xml, cell, col_width)
xml["w"].tc do 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 xml["w"].p do
cell.runs.each { |run| render_run(xml, run) } cell.runs.each { |run| render_run(xml, run) }
end end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Ezdoc module Notare
module Xml module Xml
class Numbering class Numbering
NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Ezdoc module Notare
module Xml module Xml
class Relationships class Relationships
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships" NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
@@ -21,24 +21,38 @@ module Ezdoc
class DocumentRelationships class DocumentRelationships
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships" 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" IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
def initialize(has_numbering: false, images: []) def initialize(has_numbering: false, images: [], has_styles: false)
@has_numbering = has_numbering @has_numbering = has_numbering
@images = images @images = images
@has_styles = has_styles
end end
def to_xml def to_xml
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
xml.Relationships(xmlns: NAMESPACE) do xml.Relationships(xmlns: NAMESPACE) do
if @has_numbering # rId1 = styles.xml (always first when present)
if @has_styles
xml.Relationship( xml.Relationship(
Id: "rId1", 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" Target: "numbering.xml"
) )
end end
# Images start at rId2 or rId3 depending on numbering
@images.each do |image| @images.each do |image|
xml.Relationship( xml.Relationship(
Id: image.rid, Id: image.rid,

View File

@@ -0,0 +1,66 @@
# 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
def initialize(styles)
@styles = 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
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
end
end
end
end
end

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
require "test_helper" require "test_helper"
class ListTest < Minitest::Test class ListTest < Minitest::Test
include EzdocTestHelpers include NotareTestHelpers
# #
# Bullet List Tests # Bullet List Tests

View File

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

171
test/style_test.rb Normal file
View File

@@ -0,0 +1,171 @@
# 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
end

View File

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

View File

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