notare 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 913db4066f434c9f4fa4dda05b007642c08a08e44aecaaf105d12e77c7ab724e
4
+ data.tar.gz: 013f0cb3ce077237d040583a8ea39ce1718b0db7ff5b9b8ff89954e4a6386c3f
5
+ SHA512:
6
+ metadata.gz: b078513721fac08090157247172d5e4ce06cbb0d10770d07169e630a147a9cef688180544c550ec0e441e6cbadbcc810dffcb51029de2c61b0a4c577af80442f
7
+ data.tar.gz: 80af9a1ea5e9d585b41d516b3c49418d28f7fe8314549958d420109b24d402f0e7fdd57e9536fa350665e1d5da310a6ed8497425f0b8b291d95fd606f87abdd2
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mathias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # Notare
2
+
3
+ A Ruby gem for creating docx files with a simple DSL
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'notare'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install notare
20
+
21
+ ## Usage
22
+
23
+ ### Basic Example
24
+
25
+ ```ruby
26
+ require 'notare'
27
+
28
+ Notare::Document.create("output.docx") do |doc|
29
+ doc.p "Hello World"
30
+ end
31
+ ```
32
+
33
+ ### Paragraphs
34
+
35
+ ```ruby
36
+ Notare::Document.create("output.docx") do |doc|
37
+ # Simple paragraph
38
+ doc.p "This is a paragraph."
39
+
40
+ # Paragraph with multiple text runs
41
+ doc.p do
42
+ doc.text "First part. "
43
+ doc.text "Second part."
44
+ end
45
+ end
46
+ ```
47
+
48
+ ### Text Formatting
49
+
50
+ Formatting uses nested blocks. Nesting combines formatting styles.
51
+
52
+ ```ruby
53
+ Notare::Document.create("output.docx") do |doc|
54
+ doc.p do
55
+ doc.text "Normal text "
56
+ doc.b { doc.text "bold" }
57
+ doc.text " and "
58
+ doc.i { doc.text "italic" }
59
+ doc.text " and "
60
+ doc.u { doc.text "underlined" }
61
+ end
62
+
63
+ # Nested formatting (bold + italic)
64
+ doc.p do
65
+ doc.b do
66
+ doc.i { doc.text "bold and italic" }
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ ### Headings
73
+
74
+ Use `h1` through `h6` for document headings:
75
+
76
+ ```ruby
77
+ Notare::Document.create("output.docx") do |doc|
78
+ doc.h1 "Document Title"
79
+ doc.h2 "Chapter 1"
80
+ doc.h3 "Section 1.1"
81
+ doc.h4 "Subsection"
82
+ doc.h5 "Minor heading"
83
+ doc.h6 "Smallest heading"
84
+
85
+ # Headings with formatted content
86
+ doc.h2 do
87
+ doc.text "Chapter with "
88
+ doc.i { doc.text "emphasis" }
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Styles
94
+
95
+ Notare includes built-in styles and supports custom style definitions.
96
+
97
+ #### Built-in Styles
98
+
99
+ ```ruby
100
+ Notare::Document.create("output.docx") do |doc|
101
+ doc.p "This is a title", style: :title
102
+ doc.p "A subtitle", style: :subtitle
103
+ doc.p "A quotation", style: :quote
104
+ doc.p "puts 'code'", style: :code
105
+ end
106
+ ```
107
+
108
+ #### Custom Styles
109
+
110
+ Define your own styles with text and paragraph properties:
111
+
112
+ ```ruby
113
+ Notare::Document.create("output.docx") do |doc|
114
+ # Define custom styles
115
+ doc.define_style :warning,
116
+ bold: true,
117
+ color: "FF0000",
118
+ size: 14
119
+
120
+ doc.define_style :note,
121
+ italic: true,
122
+ color: "0066CC",
123
+ font: "Georgia"
124
+
125
+ doc.define_style :centered,
126
+ align: :center,
127
+ size: 12
128
+
129
+ # Apply to paragraphs
130
+ doc.p "Warning message!", style: :warning
131
+ doc.p "Centered text", style: :centered
132
+
133
+ # Apply to text runs
134
+ doc.p do
135
+ doc.text "Normal text, "
136
+ doc.text "important!", style: :warning
137
+ doc.text ", and "
138
+ doc.text "a note", style: :note
139
+ end
140
+ end
141
+ ```
142
+
143
+ #### Style Properties
144
+
145
+ **Text properties:**
146
+ - `bold: true/false`
147
+ - `italic: true/false`
148
+ - `underline: true/false`
149
+ - `color: "FF0000"` (hex RGB)
150
+ - `size: 14` (points)
151
+ - `font: "Arial"` (font family)
152
+
153
+ **Paragraph properties:**
154
+ - `align: :left / :center / :right / :justify`
155
+ - `indent: 720` (twips, 1 inch = 1440 twips)
156
+ - `spacing_before: 240` (twips)
157
+ - `spacing_after: 240` (twips)
158
+
159
+ ### Lists
160
+
161
+ #### Bullet Lists
162
+
163
+ ```ruby
164
+ Notare::Document.create("output.docx") do |doc|
165
+ doc.ul do
166
+ doc.li "First item"
167
+ doc.li "Second item"
168
+ doc.li { doc.b { doc.text "Bold item" } }
169
+ end
170
+ end
171
+ ```
172
+
173
+ #### Numbered Lists
174
+
175
+ ```ruby
176
+ Notare::Document.create("output.docx") do |doc|
177
+ doc.ol do
178
+ doc.li "First"
179
+ doc.li "Second"
180
+ doc.li "Third"
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### Tables
186
+
187
+ ```ruby
188
+ Notare::Document.create("output.docx") do |doc|
189
+ doc.table do
190
+ doc.tr do
191
+ doc.td "Header 1"
192
+ doc.td "Header 2"
193
+ end
194
+ doc.tr do
195
+ doc.td "Cell 1"
196
+ doc.td { doc.b { doc.text "Bold cell" } }
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+ ### Images
203
+
204
+ Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
205
+
206
+ ```ruby
207
+ Notare::Document.create("output.docx") do |doc|
208
+ # Simple image (uses native dimensions)
209
+ doc.p do
210
+ doc.image "photo.png"
211
+ end
212
+
213
+ # Image with explicit dimensions (inches, cm, or pixels)
214
+ doc.p do
215
+ doc.image "logo.png", width: "2in", height: "1in"
216
+ end
217
+
218
+ # Specify only width (height auto-calculated to maintain aspect ratio)
219
+ doc.p do
220
+ doc.image "banner.jpg", width: "5cm"
221
+ end
222
+
223
+ # Image with text in the same paragraph
224
+ doc.p do
225
+ doc.text "Company Logo: "
226
+ doc.image "logo.png", width: "0.5in", height: "0.5in"
227
+ end
228
+
229
+ # Image in a table cell
230
+ doc.table do
231
+ doc.tr do
232
+ doc.td "Product"
233
+ doc.td do
234
+ doc.image "product.jpg", width: "1in", height: "1in"
235
+ end
236
+ end
237
+ end
238
+
239
+ # Image in a list item
240
+ doc.ul do
241
+ doc.li do
242
+ doc.image "icon.png", width: "16px", height: "16px"
243
+ doc.text " List item with icon"
244
+ end
245
+ end
246
+ end
247
+ ```
248
+
249
+ ### Complete Example
250
+
251
+ ```ruby
252
+ Notare::Document.create("report.docx") do |doc|
253
+ doc.p "Monthly Report"
254
+
255
+ doc.p do
256
+ doc.text "This report contains "
257
+ doc.b { doc.text "important" }
258
+ doc.text " information."
259
+ end
260
+
261
+ doc.p "Key Points:"
262
+
263
+ doc.ul do
264
+ doc.li "Revenue increased by 15%"
265
+ doc.li "Customer satisfaction improved"
266
+ doc.li { doc.i { doc.text "See appendix for details" } }
267
+ end
268
+
269
+ doc.p "Summary Table:"
270
+
271
+ doc.table do
272
+ doc.tr do
273
+ doc.td "Metric"
274
+ doc.td "Value"
275
+ end
276
+ doc.tr do
277
+ doc.td "Revenue"
278
+ doc.td "$1.2M"
279
+ end
280
+ doc.tr do
281
+ doc.td "Growth"
282
+ doc.td { doc.b { doc.text "15%" } }
283
+ end
284
+ end
285
+ end
286
+ ```
287
+
288
+ ## API Reference
289
+
290
+ | Method | Description |
291
+ |--------|-------------|
292
+ | `p(text, style:)` | Create a paragraph with text and optional style |
293
+ | `p(style:) { }` | Create a paragraph with block content and optional style |
294
+ | `text(value, style:)` | Add text with optional style to the current context |
295
+ | `h1(text)` - `h6(text)` | Create headings (level 1-6) |
296
+ | `b { }` | Bold formatting |
297
+ | `i { }` | Italic formatting |
298
+ | `u { }` | Underline formatting |
299
+ | `define_style(name, **props)` | Define a custom style |
300
+ | `ul { }` | Bullet list |
301
+ | `ol { }` | Numbered list |
302
+ | `li(text)` | List item with text |
303
+ | `li { }` | List item with block content |
304
+ | `table { }` | Table |
305
+ | `tr { }` | Table row |
306
+ | `td(text)` | Table cell with text |
307
+ | `td { }` | Table cell with block content |
308
+ | `image(path, width:, height:)` | Insert image (PNG/JPEG). Dimensions: `"2in"`, `"5cm"`, `"100px"`, or integer pixels |
309
+
310
+ ## Development
311
+
312
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.
313
+
314
+ ```bash
315
+ bundle install
316
+ bundle exec rake test # Run tests
317
+ bundle exec rake rubocop # Run linter
318
+ bundle exec rake # Run both
319
+ ```
320
+
321
+ ## License
322
+
323
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Builder
5
+ def p(text = nil, style: nil, &block)
6
+ para = Nodes::Paragraph.new(style: resolve_style(style))
7
+ if block
8
+ with_target(para, &block)
9
+ elsif text
10
+ para.add_run(Nodes::Run.new(text, **current_formatting))
11
+ end
12
+ @nodes << para
13
+ end
14
+
15
+ def text(value, style: nil)
16
+ formatting = current_formatting.merge(style: resolve_style(style))
17
+ @current_target.add_run(Nodes::Run.new(value, **formatting))
18
+ end
19
+
20
+ # Heading shortcuts
21
+ def h1(text = nil, &block)
22
+ p(text, style: :heading1, &block)
23
+ end
24
+
25
+ def h2(text = nil, &block)
26
+ p(text, style: :heading2, &block)
27
+ end
28
+
29
+ def h3(text = nil, &block)
30
+ p(text, style: :heading3, &block)
31
+ end
32
+
33
+ def h4(text = nil, &block)
34
+ p(text, style: :heading4, &block)
35
+ end
36
+
37
+ def h5(text = nil, &block)
38
+ p(text, style: :heading5, &block)
39
+ end
40
+
41
+ def h6(text = nil, &block)
42
+ p(text, style: :heading6, &block)
43
+ end
44
+
45
+ def image(path, width: nil, height: nil)
46
+ validate_image_path!(path)
47
+ img = register_image(path, width: width, height: height)
48
+ @current_target.add_run(img)
49
+ end
50
+
51
+ def b(&block)
52
+ with_format(:bold, &block)
53
+ end
54
+
55
+ def i(&block)
56
+ with_format(:italic, &block)
57
+ end
58
+
59
+ def u(&block)
60
+ with_format(:underline, &block)
61
+ end
62
+
63
+ def ul(&block)
64
+ list(:bullet, &block)
65
+ end
66
+
67
+ def ol(&block)
68
+ list(:number, &block)
69
+ end
70
+
71
+ def li(text = nil, &block)
72
+ item = Nodes::ListItem.new([], list_type: @current_list.type, num_id: @current_list.num_id)
73
+ if block
74
+ with_target(item, &block)
75
+ elsif text
76
+ item.add_run(Nodes::Run.new(text, **current_formatting))
77
+ end
78
+ @current_list.add_item(item)
79
+ end
80
+
81
+ def table(&block)
82
+ tbl = Nodes::Table.new
83
+ previous_table = @current_table
84
+ @current_table = tbl
85
+ block.call
86
+ @current_table = previous_table
87
+ @nodes << tbl
88
+ end
89
+
90
+ def tr(&block)
91
+ row = Nodes::TableRow.new
92
+ previous_row = @current_row
93
+ @current_row = row
94
+ block.call
95
+ @current_row = previous_row
96
+ @current_table.add_row(row)
97
+ end
98
+
99
+ def td(text = nil, &block)
100
+ cell = Nodes::TableCell.new
101
+ if block
102
+ with_target(cell, &block)
103
+ elsif text
104
+ cell.add_run(Nodes::Run.new(text, **current_formatting))
105
+ end
106
+ @current_row.add_cell(cell)
107
+ end
108
+
109
+ private
110
+
111
+ def list(type, &block)
112
+ @num_id_counter ||= 0
113
+ @num_id_counter += 1
114
+
115
+ list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
116
+ previous_list = @current_list
117
+ @current_list = list_node
118
+ block.call
119
+ @current_list = previous_list
120
+ @nodes << list_node
121
+ end
122
+
123
+ def with_format(format, &block)
124
+ @format_stack ||= []
125
+ @format_stack.push(format)
126
+ block.call
127
+ @format_stack.pop
128
+ end
129
+
130
+ def with_target(target, &block)
131
+ previous_target = @current_target
132
+ @current_target = target
133
+ block.call
134
+ @current_target = previous_target
135
+ end
136
+
137
+ def current_formatting
138
+ @format_stack ||= []
139
+ {
140
+ bold: @format_stack.include?(:bold),
141
+ italic: @format_stack.include?(:italic),
142
+ underline: @format_stack.include?(:underline)
143
+ }
144
+ end
145
+
146
+ def validate_image_path!(path)
147
+ raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
148
+
149
+ ext = File.extname(path).downcase
150
+ return if %w[.png .jpg .jpeg].include?(ext)
151
+
152
+ raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG."
153
+ end
154
+
155
+ def resolve_style(style_or_name)
156
+ return nil if style_or_name.nil?
157
+ return style_or_name if style_or_name.is_a?(Style)
158
+
159
+ style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}")
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ class Document
5
+ include Builder
6
+
7
+ attr_reader :nodes, :styles
8
+
9
+ def self.create(path, &block)
10
+ doc = new
11
+ block.call(doc)
12
+ doc.save(path)
13
+ doc
14
+ end
15
+
16
+ def initialize
17
+ @nodes = []
18
+ @format_stack = []
19
+ @current_target = nil
20
+ @current_list = nil
21
+ @current_table = nil
22
+ @current_row = nil
23
+ @num_id_counter = 0
24
+ @images = {}
25
+ @styles = {}
26
+ register_built_in_styles
27
+ end
28
+
29
+ def define_style(name, **properties)
30
+ @styles[name] = Style.new(name, **properties)
31
+ end
32
+
33
+ def style(name)
34
+ @styles[name]
35
+ end
36
+
37
+ def save(path)
38
+ Package.new(self).save(path)
39
+ end
40
+
41
+ def lists
42
+ @nodes.select { |n| n.is_a?(Nodes::List) }
43
+ end
44
+
45
+ def images
46
+ @images.values
47
+ end
48
+
49
+ def register_image(path, width: nil, height: nil)
50
+ return @images[path] if @images[path]
51
+
52
+ rid = next_image_rid
53
+ width_emu, height_emu = ImageDimensions.calculate_emus(path, width: width, height: height)
54
+ image = Nodes::Image.new(path, rid: rid, width_emu: width_emu, height_emu: height_emu)
55
+ @images[path] = image
56
+ image
57
+ end
58
+
59
+ private
60
+
61
+ def next_image_rid
62
+ # rId1 = styles.xml (always present)
63
+ # rId2 = numbering.xml (if lists present)
64
+ # rId3+ = images
65
+ base = lists.any? ? 3 : 2
66
+ "rId#{base + @images.size}"
67
+ end
68
+
69
+ def register_built_in_styles
70
+ # Headings (spacing_before ensures they're rendered as paragraph styles)
71
+ define_style :heading1, size: 24, bold: true, spacing_before: 240, spacing_after: 120
72
+ define_style :heading2, size: 18, bold: true, spacing_before: 200, spacing_after: 100
73
+ define_style :heading3, size: 14, bold: true, spacing_before: 160, spacing_after: 80
74
+ define_style :heading4, size: 12, bold: true, spacing_before: 120, spacing_after: 60
75
+ define_style :heading5, size: 11, bold: true, italic: true, spacing_before: 100, spacing_after: 40
76
+ define_style :heading6, size: 10, bold: true, italic: true, spacing_before: 80, spacing_after: 40
77
+
78
+ # Other built-in styles
79
+ define_style :title, size: 26, bold: true, align: :center
80
+ define_style :subtitle, size: 15, italic: true, color: "666666"
81
+ define_style :quote, italic: true, color: "666666", indent: 720
82
+ define_style :code, font: "Courier New", size: 10
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastimage"
4
+
5
+ module Notare
6
+ class ImageDimensions
7
+ EMUS_PER_INCH = 914_400
8
+ DEFAULT_DPI = 96
9
+
10
+ def self.read(path)
11
+ FastImage.size(path) || raise(ArgumentError, "Could not read image dimensions: #{path}")
12
+ end
13
+
14
+ def self.calculate_emus(path, width: nil, height: nil)
15
+ native_width, native_height = read(path)
16
+ calculate_dimensions_emu(native_width, native_height, width, height)
17
+ end
18
+
19
+ def self.calculate_dimensions_emu(native_width, native_height, width, height)
20
+ if width && height
21
+ [parse_dimension(width), parse_dimension(height)]
22
+ elsif width
23
+ w = parse_dimension(width)
24
+ ratio = native_height.to_f / native_width
25
+ [w, (w * ratio).to_i]
26
+ elsif height
27
+ h = parse_dimension(height)
28
+ ratio = native_width.to_f / native_height
29
+ [(h * ratio).to_i, h]
30
+ else
31
+ pixels_to_emus(native_width, native_height)
32
+ end
33
+ end
34
+
35
+ def self.parse_dimension(value)
36
+ case value
37
+ when Integer
38
+ pixels_to_emus(value, 0).first
39
+ when /\A(\d+(?:\.\d+)?)\s*in\z/i
40
+ (::Regexp.last_match(1).to_f * EMUS_PER_INCH).to_i
41
+ when /\A(\d+(?:\.\d+)?)\s*cm\z/i
42
+ (::Regexp.last_match(1).to_f * 360_000).to_i
43
+ when /\A(\d+(?:\.\d+)?)\s*px\z/i, /\A(\d+)\z/
44
+ pixels_to_emus(::Regexp.last_match(1).to_i, 0).first
45
+ else
46
+ raise ArgumentError, "Invalid dimension format: #{value}. Use '2in', '5cm', '100px', or integer pixels."
47
+ end
48
+ end
49
+
50
+ def self.pixels_to_emus(width_px, height_px)
51
+ emu_per_pixel = EMUS_PER_INCH / DEFAULT_DPI
52
+ [(width_px * emu_per_pixel).to_i, (height_px * emu_per_pixel).to_i]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Base
6
+ # Base class for all document nodes
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Image < Base
6
+ attr_reader :path, :width_emu, :height_emu, :rid, :filename
7
+
8
+ EMUS_PER_INCH = 914_400
9
+ EMUS_PER_CM = 360_000
10
+ DEFAULT_DPI = 96
11
+
12
+ def initialize(path, rid:, width_emu:, height_emu:)
13
+ super()
14
+ @path = path
15
+ @rid = rid
16
+ @width_emu = width_emu
17
+ @height_emu = height_emu
18
+ @filename = "image#{rid.delete_prefix("rId")}.#{extension}"
19
+ end
20
+
21
+ def extension
22
+ case File.extname(@path).downcase
23
+ when ".png" then "png"
24
+ when ".jpg", ".jpeg" then "jpeg"
25
+ else raise ArgumentError, "Unsupported image format: #{File.extname(@path)}"
26
+ end
27
+ end
28
+
29
+ def content_type
30
+ extension == "png" ? "image/png" : "image/jpeg"
31
+ end
32
+
33
+ def doc_pr_id
34
+ rid.delete_prefix("rId").to_i
35
+ end
36
+ end
37
+ end
38
+ end