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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +323 -0
- data/lib/notare/builder.rb +162 -0
- data/lib/notare/document.rb +85 -0
- data/lib/notare/image_dimensions.rb +55 -0
- data/lib/notare/nodes/base.rb +9 -0
- data/lib/notare/nodes/image.rb +38 -0
- data/lib/notare/nodes/list.rb +20 -0
- data/lib/notare/nodes/list_item.rb +20 -0
- data/lib/notare/nodes/paragraph.rb +19 -0
- data/lib/notare/nodes/run.rb +18 -0
- data/lib/notare/nodes/table.rb +18 -0
- data/lib/notare/nodes/table_cell.rb +18 -0
- data/lib/notare/nodes/table_row.rb +18 -0
- data/lib/notare/package.rb +63 -0
- data/lib/notare/style.rb +65 -0
- data/lib/notare/version.rb +5 -0
- data/lib/notare/xml/content_types.rb +56 -0
- data/lib/notare/xml/document_xml.rb +172 -0
- data/lib/notare/xml/numbering.rb +58 -0
- data/lib/notare/xml/relationships.rb +69 -0
- data/lib/notare/xml/styles_xml.rb +66 -0
- data/lib/notare.rb +28 -0
- metadata +164 -0
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,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
|