notare 0.0.2 → 0.0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 913db4066f434c9f4fa4dda05b007642c08a08e44aecaaf105d12e77c7ab724e
4
- data.tar.gz: 013f0cb3ce077237d040583a8ea39ce1718b0db7ff5b9b8ff89954e4a6386c3f
3
+ metadata.gz: 1dd4e9ab394b198d4a9ddd59eb40fac35c865159e527fe14a90113bba1071fd0
4
+ data.tar.gz: e10d211085e797c80850ab3433b8802270df96bc01cd3d2ac348da6f17c675ec
5
5
  SHA512:
6
- metadata.gz: b078513721fac08090157247172d5e4ce06cbb0d10770d07169e630a147a9cef688180544c550ec0e441e6cbadbcc810dffcb51029de2c61b0a4c577af80442f
7
- data.tar.gz: 80af9a1ea5e9d585b41d516b3c49418d28f7fe8314549958d420109b24d402f0e7fdd57e9536fa350665e1d5da310a6ed8497425f0b8b291d95fd606f87abdd2
6
+ metadata.gz: ba2c27ab7d6ad5a6f5b5239f54504e8352a5fee52374685f7e09d61284781ba93e9353a0c75f07a3e047571cc55d598c3764beb3c1d08663e54d11119e515fa5
7
+ data.tar.gz: b42bc57c20a639882a8912b1da7759b2c131443ce840f3d901a1a29a70a86e4f8238ce0b9d38ed688e0332fb99b77f7c5edcebffc611302273ede404c2a72197
data/README.md CHANGED
@@ -58,6 +58,8 @@ Notare::Document.create("output.docx") do |doc|
58
58
  doc.i { doc.text "italic" }
59
59
  doc.text " and "
60
60
  doc.u { doc.text "underlined" }
61
+ doc.text " and "
62
+ doc.s { doc.text "strikethrough" }
61
63
  end
62
64
 
63
65
  # Nested formatting (bold + italic)
@@ -66,6 +68,13 @@ Notare::Document.create("output.docx") do |doc|
66
68
  doc.i { doc.text "bold and italic" }
67
69
  end
68
70
  end
71
+
72
+ # Show edits (strikethrough old, bold new)
73
+ doc.p do
74
+ doc.s { doc.text "old text" }
75
+ doc.text " "
76
+ doc.b { doc.text "new text" }
77
+ end
69
78
  end
70
79
  ```
71
80
 
@@ -96,6 +105,21 @@ Notare includes built-in styles and supports custom style definitions.
96
105
 
97
106
  #### Built-in Styles
98
107
 
108
+ | Style | Properties |
109
+ |-------|------------|
110
+ | `:title` | 26pt, bold, centered |
111
+ | `:subtitle` | 15pt, italic, gray (#666666) |
112
+ | `:quote` | italic, gray (#666666), indented |
113
+ | `:code` | Courier New, 10pt |
114
+ | `:heading1` | 24pt, bold |
115
+ | `:heading2` | 18pt, bold |
116
+ | `:heading3` | 14pt, bold |
117
+ | `:heading4` | 12pt, bold |
118
+ | `:heading5` | 11pt, bold, italic |
119
+ | `:heading6` | 10pt, bold, italic |
120
+
121
+ Note: `h1` through `h6` methods use the corresponding heading styles automatically.
122
+
99
123
  ```ruby
100
124
  Notare::Document.create("output.docx") do |doc|
101
125
  doc.p "This is a title", style: :title
@@ -146,10 +170,14 @@ end
146
170
  - `bold: true/false`
147
171
  - `italic: true/false`
148
172
  - `underline: true/false`
173
+ - `strike: true/false` - strikethrough
174
+ - `highlight: "yellow"` - text highlight (see colors below)
149
175
  - `color: "FF0000"` (hex RGB)
150
176
  - `size: 14` (points)
151
177
  - `font: "Arial"` (font family)
152
178
 
179
+ **Highlight colors:** `black`, `blue`, `cyan`, `darkBlue`, `darkCyan`, `darkGray`, `darkGreen`, `darkMagenta`, `darkRed`, `darkYellow`, `green`, `lightGray`, `magenta`, `red`, `white`, `yellow`
180
+
153
181
  **Paragraph properties:**
154
182
  - `align: :left / :center / :right / :justify`
155
183
  - `indent: 720` (twips, 1 inch = 1440 twips)
@@ -182,6 +210,40 @@ Notare::Document.create("output.docx") do |doc|
182
210
  end
183
211
  ```
184
212
 
213
+ #### Nested Lists
214
+
215
+ Lists can be nested inside list items. Mixed types (bullets inside numbered or vice versa) are supported.
216
+
217
+ ```ruby
218
+ Notare::Document.create("output.docx") do |doc|
219
+ doc.ol do
220
+ doc.li "First item"
221
+ doc.li "Second item" do
222
+ doc.ul do
223
+ doc.li "Nested bullet A"
224
+ doc.li "Nested bullet B"
225
+ end
226
+ end
227
+ doc.li "Third item"
228
+ end
229
+
230
+ # Deeply nested
231
+ doc.ul do
232
+ doc.li "Level 0"
233
+ doc.li "Has children" do
234
+ doc.ul do
235
+ doc.li "Level 1"
236
+ doc.li "Goes deeper" do
237
+ doc.ul do
238
+ doc.li "Level 2"
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ ```
246
+
185
247
  ### Tables
186
248
 
187
249
  ```ruby
@@ -199,6 +261,77 @@ Notare::Document.create("output.docx") do |doc|
199
261
  end
200
262
  ```
201
263
 
264
+ #### Table Styles
265
+
266
+ Define reusable table styles with borders, shading, cell margins, and alignment:
267
+
268
+ ```ruby
269
+ Notare::Document.create("output.docx") do |doc|
270
+ # Define a custom table style
271
+ doc.define_table_style :fancy,
272
+ borders: { style: "double", color: "0066CC", size: 6 },
273
+ shading: "E6F2FF",
274
+ cell_margins: 100,
275
+ align: :center
276
+
277
+ # Apply the style to a table
278
+ doc.table(style: :fancy) do
279
+ doc.tr do
280
+ doc.td "Product"
281
+ doc.td "Price"
282
+ end
283
+ doc.tr do
284
+ doc.td "Widget"
285
+ doc.td "$10.00"
286
+ end
287
+ end
288
+ end
289
+ ```
290
+
291
+ #### Table Style Properties
292
+
293
+ | Property | Description | Example |
294
+ |----------|-------------|---------|
295
+ | `borders` | Border configuration | `{ style: "single", color: "000000", size: 4 }` |
296
+ | `shading` | Background color (hex) | `"EEEEEE"` |
297
+ | `cell_margins` | Cell padding (twips) | `100` or `{ top: 50, bottom: 50, left: 100, right: 100 }` |
298
+ | `align` | Table alignment | `:left`, `:center`, `:right` |
299
+
300
+ **Border styles:** `single`, `double`, `dotted`, `dashed`, `triple`, `none`
301
+
302
+ **Border configuration options:**
303
+
304
+ ```ruby
305
+ # All borders the same
306
+ borders: { style: "single", color: "000000", size: 4 }
307
+
308
+ # Per-edge borders
309
+ borders: {
310
+ top: { style: "double", color: "FF0000", size: 8 },
311
+ bottom: { style: "single", color: "000000", size: 4 },
312
+ left: { style: "none" },
313
+ right: { style: "none" },
314
+ insideH: { style: "dotted", color: "CCCCCC", size: 2 },
315
+ insideV: { style: "dotted", color: "CCCCCC", size: 2 }
316
+ }
317
+
318
+ # No borders
319
+ borders: :none
320
+ ```
321
+
322
+ #### Built-in Table Styles
323
+
324
+ | Style | Description |
325
+ |-------|-------------|
326
+ | `:grid` | Standard black single-line borders |
327
+ | `:borderless` | No borders |
328
+
329
+ ```ruby
330
+ doc.table(style: :borderless) do
331
+ doc.tr { doc.td "No borders here" }
332
+ end
333
+ ```
334
+
202
335
  ### Images
203
336
 
204
337
  Images can be added to paragraphs, table cells, and list items. Supports PNG and JPEG formats.
@@ -246,6 +379,91 @@ Notare::Document.create("output.docx") do |doc|
246
379
  end
247
380
  ```
248
381
 
382
+ ### Line Breaks
383
+
384
+ Use `br` for soft line breaks within a paragraph (text continues in the same paragraph but on a new line):
385
+
386
+ ```ruby
387
+ Notare::Document.create("output.docx") do |doc|
388
+ doc.p do
389
+ doc.text "Line one"
390
+ doc.br
391
+ doc.text "Line two (same paragraph)"
392
+ doc.br
393
+ doc.text "Line three"
394
+ end
395
+
396
+ # Useful for addresses
397
+ doc.p do
398
+ doc.b { doc.text "Address:" }
399
+ doc.br
400
+ doc.text "123 Main Street"
401
+ doc.br
402
+ doc.text "Anytown, ST 12345"
403
+ end
404
+ end
405
+ ```
406
+
407
+ ### Page Breaks
408
+
409
+ Use `page_break` to force content to start on a new page:
410
+
411
+ ```ruby
412
+ Notare::Document.create("output.docx") do |doc|
413
+ doc.h1 "Chapter 1"
414
+ doc.p "Content of chapter 1..."
415
+
416
+ doc.page_break
417
+
418
+ doc.h1 "Chapter 2"
419
+ doc.p "This starts on a new page."
420
+ end
421
+ ```
422
+
423
+ ### Hyperlinks
424
+
425
+ Add clickable links with `link`:
426
+
427
+ ```ruby
428
+ Notare::Document.create("output.docx") do |doc|
429
+ # Link with custom text
430
+ doc.p do
431
+ doc.text "Visit "
432
+ doc.link "https://example.com", "our website"
433
+ doc.text " for more info."
434
+ end
435
+
436
+ # Link showing the URL as text
437
+ doc.p do
438
+ doc.text "URL: "
439
+ doc.link "https://example.com"
440
+ end
441
+
442
+ # Link with formatted content
443
+ doc.p do
444
+ doc.link "https://github.com" do
445
+ doc.b { doc.text "GitHub" }
446
+ end
447
+ end
448
+
449
+ # Links in lists
450
+ doc.ul do
451
+ doc.li do
452
+ doc.link "https://ruby-lang.org", "Ruby"
453
+ end
454
+ doc.li do
455
+ doc.link "https://rubyonrails.org", "Rails"
456
+ end
457
+ end
458
+
459
+ # Email links
460
+ doc.p do
461
+ doc.text "Contact: "
462
+ doc.link "mailto:hello@example.com", "hello@example.com"
463
+ end
464
+ end
465
+ ```
466
+
249
467
  ### Complete Example
250
468
 
251
469
  ```ruby
@@ -296,12 +514,19 @@ end
296
514
  | `b { }` | Bold formatting |
297
515
  | `i { }` | Italic formatting |
298
516
  | `u { }` | Underline formatting |
517
+ | `s { }` | Strikethrough formatting |
518
+ | `br` | Line break (soft break within paragraph) |
519
+ | `page_break` | Page break (force new page) |
520
+ | `link(url, text)` | Hyperlink with custom text |
521
+ | `link(url) { }` | Hyperlink with block content |
299
522
  | `define_style(name, **props)` | Define a custom style |
300
- | `ul { }` | Bullet list |
301
- | `ol { }` | Numbered list |
523
+ | `define_table_style(name, **props)` | Define a custom table style |
524
+ | `ul { }` | Bullet list (can be nested) |
525
+ | `ol { }` | Numbered list (can be nested) |
302
526
  | `li(text)` | List item with text |
303
527
  | `li { }` | List item with block content |
304
- | `table { }` | Table |
528
+ | `li(text) { }` | List item with text and nested content |
529
+ | `table(style:) { }` | Table with optional style |
305
530
  | `tr { }` | Table row |
306
531
  | `td(text)` | Table cell with text |
307
532
  | `td { }` | Table cell with block content |
@@ -60,6 +60,30 @@ module Notare
60
60
  with_format(:underline, &block)
61
61
  end
62
62
 
63
+ def s(&block)
64
+ with_format(:strike, &block)
65
+ end
66
+
67
+ def br
68
+ @current_target.add_run(Nodes::Break.new(type: :line))
69
+ end
70
+
71
+ def page_break
72
+ @nodes << Nodes::Break.new(type: :page)
73
+ end
74
+
75
+ def link(url, text = nil, &block)
76
+ hyperlink = register_hyperlink(url)
77
+ if block
78
+ with_target(hyperlink, &block)
79
+ elsif text
80
+ hyperlink.add_run(Nodes::Run.new(text, underline: true, color: "0000FF"))
81
+ else
82
+ hyperlink.add_run(Nodes::Run.new(url, underline: true, color: "0000FF"))
83
+ end
84
+ @current_target.add_run(hyperlink)
85
+ end
86
+
63
87
  def ul(&block)
64
88
  list(:bullet, &block)
65
89
  end
@@ -69,17 +93,15 @@ module Notare
69
93
  end
70
94
 
71
95
  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
96
+ current_type = @list_type_stack.last
97
+ item = Nodes::ListItem.new([], list_type: current_type, num_id: @current_list.num_id, level: @list_level)
98
+ item.add_run(Nodes::Run.new(text, **current_formatting)) if text
99
+ with_target(item, &block) if block
78
100
  @current_list.add_item(item)
79
101
  end
80
102
 
81
- def table(&block)
82
- tbl = Nodes::Table.new
103
+ def table(style: nil, &block)
104
+ tbl = Nodes::Table.new(style: resolve_table_style(style))
83
105
  previous_table = @current_table
84
106
  @current_table = tbl
85
107
  block.call
@@ -110,14 +132,31 @@ module Notare
110
132
 
111
133
  def list(type, &block)
112
134
  @num_id_counter ||= 0
113
- @num_id_counter += 1
135
+ @list_level ||= 0
136
+ @list_type_stack ||= []
114
137
 
115
- list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
116
138
  previous_list = @current_list
117
- @current_list = list_node
118
- block.call
119
- @current_list = previous_list
120
- @nodes << list_node
139
+ nested = !previous_list.nil?
140
+
141
+ if nested
142
+ # Nested list: reuse parent list, push new type, increment level
143
+ @list_level += 1
144
+ @list_type_stack.push(type)
145
+ block.call
146
+ @list_type_stack.pop
147
+ @list_level -= 1
148
+ else
149
+ # Top-level list: new List node
150
+ @num_id_counter += 1
151
+ mark_has_lists!
152
+ list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
153
+ @list_type_stack.push(type)
154
+ @current_list = list_node
155
+ block.call
156
+ @current_list = previous_list
157
+ @list_type_stack.pop
158
+ @nodes << list_node
159
+ end
121
160
  end
122
161
 
123
162
  def with_format(format, &block)
@@ -139,7 +178,8 @@ module Notare
139
178
  {
140
179
  bold: @format_stack.include?(:bold),
141
180
  italic: @format_stack.include?(:italic),
142
- underline: @format_stack.include?(:underline)
181
+ underline: @format_stack.include?(:underline),
182
+ strike: @format_stack.include?(:strike)
143
183
  }
144
184
  end
145
185
 
@@ -158,5 +198,12 @@ module Notare
158
198
 
159
199
  style(style_or_name) || raise(ArgumentError, "Unknown style: #{style_or_name}")
160
200
  end
201
+
202
+ def resolve_table_style(style_or_name)
203
+ return nil if style_or_name.nil?
204
+ return style_or_name if style_or_name.is_a?(TableStyle)
205
+
206
+ table_style(style_or_name) || raise(ArgumentError, "Unknown table style: #{style_or_name}")
207
+ end
161
208
  end
162
209
  end
@@ -4,7 +4,7 @@ module Notare
4
4
  class Document
5
5
  include Builder
6
6
 
7
- attr_reader :nodes, :styles
7
+ attr_reader :nodes, :styles, :table_styles, :hyperlinks
8
8
 
9
9
  def self.create(path, &block)
10
10
  doc = new
@@ -21,9 +21,13 @@ module Notare
21
21
  @current_table = nil
22
22
  @current_row = nil
23
23
  @num_id_counter = 0
24
+ @has_lists = false
24
25
  @images = {}
26
+ @hyperlinks = []
25
27
  @styles = {}
28
+ @table_styles = {}
26
29
  register_built_in_styles
30
+ register_built_in_table_styles
27
31
  end
28
32
 
29
33
  def define_style(name, **properties)
@@ -34,6 +38,14 @@ module Notare
34
38
  @styles[name]
35
39
  end
36
40
 
41
+ def define_table_style(name, **properties)
42
+ @table_styles[name] = TableStyle.new(name, **properties)
43
+ end
44
+
45
+ def table_style(name)
46
+ @table_styles[name]
47
+ end
48
+
37
49
  def save(path)
38
50
  Package.new(self).save(path)
39
51
  end
@@ -42,10 +54,25 @@ module Notare
42
54
  @nodes.select { |n| n.is_a?(Nodes::List) }
43
55
  end
44
56
 
57
+ def uses_lists?
58
+ @has_lists
59
+ end
60
+
61
+ def mark_has_lists!
62
+ @has_lists = true
63
+ end
64
+
45
65
  def images
46
66
  @images.values
47
67
  end
48
68
 
69
+ def register_hyperlink(url)
70
+ rid = next_hyperlink_rid
71
+ hyperlink = Nodes::Hyperlink.new(url: url, rid: rid)
72
+ @hyperlinks << hyperlink
73
+ hyperlink
74
+ end
75
+
49
76
  def register_image(path, width: nil, height: nil)
50
77
  return @images[path] if @images[path]
51
78
 
@@ -61,11 +88,17 @@ module Notare
61
88
  def next_image_rid
62
89
  # rId1 = styles.xml (always present)
63
90
  # rId2 = numbering.xml (if lists present)
64
- # rId3+ = images
65
- base = lists.any? ? 3 : 2
91
+ # rId3+ = images, then hyperlinks
92
+ base = @has_lists ? 3 : 2
66
93
  "rId#{base + @images.size}"
67
94
  end
68
95
 
96
+ def next_hyperlink_rid
97
+ # Hyperlinks come after images
98
+ base = @has_lists ? 3 : 2
99
+ "rId#{base + @images.size + @hyperlinks.size}"
100
+ end
101
+
69
102
  def register_built_in_styles
70
103
  # Headings (spacing_before ensures they're rendered as paragraph styles)
71
104
  define_style :heading1, size: 24, bold: true, spacing_before: 240, spacing_after: 120
@@ -81,5 +114,13 @@ module Notare
81
114
  define_style :quote, italic: true, color: "666666", indent: 720
82
115
  define_style :code, font: "Courier New", size: 10
83
116
  end
117
+
118
+ def register_built_in_table_styles
119
+ define_table_style :grid,
120
+ borders: { style: "single", color: "000000", size: 4 }
121
+
122
+ define_table_style :borderless,
123
+ borders: :none
124
+ end
84
125
  end
85
126
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Break < Base
6
+ attr_reader :type
7
+
8
+ def initialize(type: :line)
9
+ super()
10
+ @type = type
11
+ end
12
+
13
+ def page?
14
+ type == :page
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Hyperlink < Base
6
+ attr_reader :url, :rid, :runs
7
+
8
+ def initialize(url:, rid:)
9
+ super()
10
+ @url = url
11
+ @rid = rid
12
+ @runs = []
13
+ end
14
+
15
+ def add_run(run)
16
+ @runs << run
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,13 +3,14 @@
3
3
  module Notare
4
4
  module Nodes
5
5
  class ListItem < Base
6
- attr_reader :runs, :list_type, :num_id
6
+ attr_reader :runs, :list_type, :num_id, :level
7
7
 
8
- def initialize(runs = [], list_type:, num_id:)
8
+ def initialize(runs = [], list_type:, num_id:, level: 0)
9
9
  super()
10
10
  @runs = runs
11
11
  @list_type = list_type
12
12
  @num_id = num_id
13
+ @level = level
13
14
  end
14
15
 
15
16
  def add_run(run)
@@ -3,14 +3,18 @@
3
3
  module Notare
4
4
  module Nodes
5
5
  class Run < Base
6
- attr_reader :text, :bold, :italic, :underline, :style
6
+ attr_reader :text, :bold, :italic, :underline, :strike, :highlight, :color, :style
7
7
 
8
- def initialize(text, bold: false, italic: false, underline: false, style: nil)
8
+ def initialize(text, bold: false, italic: false, underline: false,
9
+ strike: false, highlight: nil, color: nil, style: nil)
9
10
  super()
10
11
  @text = text
11
12
  @bold = bold
12
13
  @italic = italic
13
14
  @underline = underline
15
+ @strike = strike
16
+ @highlight = highlight
17
+ @color = color
14
18
  @style = style
15
19
  end
16
20
  end
@@ -3,11 +3,12 @@
3
3
  module Notare
4
4
  module Nodes
5
5
  class Table < Base
6
- attr_reader :rows
6
+ attr_reader :rows, :style
7
7
 
8
- def initialize
9
- super
8
+ def initialize(style: nil)
9
+ super()
10
10
  @rows = []
11
+ @style = style
11
12
  end
12
13
 
13
14
  def add_row(row)
@@ -29,13 +29,17 @@ module Notare
29
29
  private
30
30
 
31
31
  def lists?
32
- @document.lists.any?
32
+ @document.uses_lists?
33
33
  end
34
34
 
35
35
  def images
36
36
  @document.images
37
37
  end
38
38
 
39
+ def hyperlinks
40
+ @document.hyperlinks
41
+ end
42
+
39
43
  def content_types_xml
40
44
  Xml::ContentTypes.new(has_numbering: lists?, images: images, has_styles: true).to_xml
41
45
  end
@@ -45,7 +49,9 @@ module Notare
45
49
  end
46
50
 
47
51
  def document_relationships_xml
48
- Xml::DocumentRelationships.new(has_numbering: lists?, images: images, has_styles: true).to_xml
52
+ Xml::DocumentRelationships.new(
53
+ has_numbering: lists?, images: images, hyperlinks: hyperlinks, has_styles: true
54
+ ).to_xml
49
55
  end
50
56
 
51
57
  def document_xml
@@ -53,7 +59,7 @@ module Notare
53
59
  end
54
60
 
55
61
  def styles_xml
56
- Xml::StylesXml.new(@document.styles).to_xml
62
+ Xml::StylesXml.new(@document.styles, @document.table_styles).to_xml
57
63
  end
58
64
 
59
65
  def numbering_xml
data/lib/notare/style.rb CHANGED
@@ -2,18 +2,24 @@
2
2
 
3
3
  module Notare
4
4
  class Style
5
- attr_reader :name, :bold, :italic, :underline, :color, :size, :font,
5
+ attr_reader :name, :bold, :italic, :underline, :strike, :highlight, :color, :size, :font,
6
6
  :align, :indent, :spacing_before, :spacing_after
7
7
 
8
8
  ALIGNMENTS = %i[left center right justify].freeze
9
+ HIGHLIGHT_COLORS = %w[
10
+ black blue cyan darkBlue darkCyan darkGray darkGreen darkMagenta
11
+ darkRed darkYellow green lightGray magenta red white yellow
12
+ ].freeze
9
13
 
10
- def initialize(name, bold: nil, italic: nil, underline: nil, color: nil,
11
- size: nil, font: nil, align: nil, indent: nil,
12
- spacing_before: nil, spacing_after: nil)
14
+ def initialize(name, bold: nil, italic: nil, underline: nil, strike: nil,
15
+ highlight: nil, color: nil, size: nil, font: nil, align: nil,
16
+ indent: nil, spacing_before: nil, spacing_after: nil)
13
17
  @name = name
14
18
  @bold = bold
15
19
  @italic = italic
16
20
  @underline = underline
21
+ @strike = strike
22
+ @highlight = validate_highlight(highlight)
17
23
  @color = normalize_color(color)
18
24
  @size = size
19
25
  @font = font
@@ -36,7 +42,7 @@ module Notare
36
42
  end
37
43
 
38
44
  def text_properties?
39
- !!(bold || italic || underline || color || size || font)
45
+ !!(bold || italic || underline || strike || highlight || color || size || font)
40
46
  end
41
47
 
42
48
  # Size in half-points for OOXML (14pt = 28 half-points)
@@ -61,5 +67,14 @@ module Notare
61
67
 
62
68
  raise ArgumentError, "Invalid alignment: #{align}. Use #{ALIGNMENTS.join(", ")}"
63
69
  end
70
+
71
+ def validate_highlight(highlight)
72
+ return nil if highlight.nil?
73
+
74
+ color = highlight.to_s
75
+ return color if HIGHLIGHT_COLORS.include?(color)
76
+
77
+ raise ArgumentError, "Invalid highlight color: #{highlight}. Use one of: #{HIGHLIGHT_COLORS.join(", ")}"
78
+ end
64
79
  end
65
80
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ class TableStyle
5
+ attr_reader :name, :borders, :shading, :cell_margins, :align
6
+
7
+ BORDER_STYLES = %w[single double dotted dashed triple none nil].freeze
8
+ BORDER_POSITIONS = %i[top bottom left right insideH insideV].freeze
9
+ ALIGNMENTS = %i[left center right].freeze
10
+
11
+ def initialize(name, borders: nil, shading: nil, cell_margins: nil, align: nil)
12
+ @name = name
13
+ @borders = normalize_borders(borders)
14
+ @shading = normalize_color(shading)
15
+ @cell_margins = normalize_cell_margins(cell_margins)
16
+ @align = validate_align(align)
17
+ end
18
+
19
+ def style_id
20
+ name.to_s.split("_").map(&:capitalize).join
21
+ end
22
+
23
+ def display_name
24
+ name.to_s.split("_").map(&:capitalize).join(" ")
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_borders(borders)
30
+ return nil if borders.nil?
31
+ return :none if borders == :none
32
+
33
+ # Check if it's a per-edge configuration
34
+ if borders.keys.any? { |k| BORDER_POSITIONS.include?(k) }
35
+ borders.transform_values { |v| normalize_single_border(v) }
36
+ else
37
+ # Single border config applied to all edges
38
+ normalize_single_border(borders)
39
+ end
40
+ end
41
+
42
+ def normalize_single_border(border)
43
+ return :none if border == :none || border[:style] == "none"
44
+
45
+ style = border[:style] || "single"
46
+ unless BORDER_STYLES.include?(style)
47
+ raise ArgumentError, "Invalid border style: #{style}. Use #{BORDER_STYLES.join(", ")}"
48
+ end
49
+
50
+ {
51
+ style: style,
52
+ color: normalize_color(border[:color]) || "000000",
53
+ size: border[:size] || 4
54
+ }
55
+ end
56
+
57
+ def normalize_color(color)
58
+ return nil if color.nil?
59
+
60
+ hex = color.to_s.sub(/^#/, "").upcase
61
+ return hex if hex.match?(/\A[0-9A-F]{6}\z/)
62
+
63
+ raise ArgumentError, "Invalid color: #{color}. Use 6-digit hex (e.g., 'FF0000')"
64
+ end
65
+
66
+ def normalize_cell_margins(margins)
67
+ return nil if margins.nil?
68
+
69
+ if margins.is_a?(Hash)
70
+ margins.slice(:top, :bottom, :left, :right)
71
+ else
72
+ margins.to_i
73
+ end
74
+ end
75
+
76
+ def validate_align(align)
77
+ return nil if align.nil?
78
+ return align if ALIGNMENTS.include?(align)
79
+
80
+ raise ArgumentError, "Invalid alignment: #{align}. Use #{ALIGNMENTS.join(", ")}"
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Notare
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.4"
5
5
  end
@@ -37,6 +37,16 @@ module Notare
37
37
  render_list(xml, node)
38
38
  when Nodes::Table
39
39
  render_table(xml, node)
40
+ when Nodes::Break
41
+ render_page_break(xml, node)
42
+ end
43
+ end
44
+
45
+ def render_page_break(xml, _node)
46
+ xml["w"].p do
47
+ xml["w"].r do
48
+ xml["w"].br("w:type" => "page")
49
+ end
40
50
  end
41
51
  end
42
52
 
@@ -59,7 +69,7 @@ module Notare
59
69
  xml["w"].p do
60
70
  xml["w"].pPr do
61
71
  xml["w"].numPr do
62
- xml["w"].ilvl("w:val" => "0")
72
+ xml["w"].ilvl("w:val" => item.level.to_s)
63
73
  xml["w"].numId("w:val" => item.num_id.to_s)
64
74
  end
65
75
  end
@@ -71,19 +81,42 @@ module Notare
71
81
  case run
72
82
  when Nodes::Image
73
83
  render_image(xml, run)
84
+ when Nodes::Break
85
+ render_break(xml, run)
86
+ when Nodes::Hyperlink
87
+ render_hyperlink(xml, run)
74
88
  when Nodes::Run
75
89
  render_text_run(xml, run)
76
90
  end
77
91
  end
78
92
 
93
+ def render_hyperlink(xml, hyperlink)
94
+ xml["w"].hyperlink("r:id" => hyperlink.rid) do
95
+ hyperlink.runs.each { |run| render_run(xml, run) }
96
+ end
97
+ end
98
+
99
+ def render_break(xml, break_node)
100
+ xml["w"].r do
101
+ if break_node.page?
102
+ xml["w"].br("w:type" => "page")
103
+ else
104
+ xml["w"].br
105
+ end
106
+ end
107
+ end
108
+
79
109
  def render_text_run(xml, run)
80
110
  xml["w"].r do
81
- if run.bold || run.italic || run.underline || run.style
111
+ if run.bold || run.italic || run.underline || run.strike || run.highlight || run.color || run.style
82
112
  xml["w"].rPr do
83
113
  xml["w"].rStyle("w:val" => run.style.style_id) if run.style
84
114
  xml["w"].b if run.bold
85
115
  xml["w"].i if run.italic
86
116
  xml["w"].u("w:val" => "single") if run.underline
117
+ xml["w"].strike if run.strike
118
+ xml["w"].highlight("w:val" => run.highlight) if run.highlight
119
+ xml["w"].color("w:val" => run.color) if run.color
87
120
  end
88
121
  end
89
122
  xml["w"].t(run.text, "xml:space" => "preserve")
@@ -136,9 +169,13 @@ module Notare
136
169
  xml["w"].tbl do
137
170
  xml["w"].tblPr do
138
171
  xml["w"].tblW("w:w" => "5000", "w:type" => "pct")
139
- xml["w"].tblBorders do
140
- %w[top left bottom right insideH insideV].each do |border|
141
- xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000")
172
+ if table.style
173
+ xml["w"].tblStyle("w:val" => table.style.style_id)
174
+ else
175
+ xml["w"].tblBorders do
176
+ %w[top left bottom right insideH insideV].each do |border|
177
+ xml["w"].send(border, "w:val" => "single", "w:sz" => "4", "w:space" => "0", "w:color" => "000000")
178
+ end
142
179
  end
143
180
  end
144
181
  end
@@ -4,6 +4,8 @@ module Notare
4
4
  module Xml
5
5
  class Numbering
6
6
  NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
7
+ BULLET_CHARS = ["•", "○", "■"].freeze
8
+ NUMBER_FORMATS = %w[decimal lowerLetter lowerRoman].freeze
7
9
 
8
10
  def initialize(lists)
9
11
  @lists = lists
@@ -28,13 +30,16 @@ module Notare
28
30
 
29
31
  def render_abstract_num(xml, list)
30
32
  xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do
31
- xml["w"].lvl("w:ilvl" => "0") do
32
- xml["w"].start("w:val" => "1")
33
- xml["w"].numFmt("w:val" => num_format(list.type))
34
- xml["w"].lvlText("w:val" => lvl_text(list.type))
35
- xml["w"].lvlJc("w:val" => "left")
36
- xml["w"].pPr do
37
- xml["w"].ind("w:left" => "720", "w:hanging" => "360")
33
+ 9.times do |level|
34
+ xml["w"].lvl("w:ilvl" => level.to_s) do
35
+ xml["w"].start("w:val" => "1")
36
+ xml["w"].numFmt("w:val" => num_format_for_level(list.type, level))
37
+ xml["w"].lvlText("w:val" => lvl_text_for_level(list.type, level))
38
+ xml["w"].lvlJc("w:val" => "left")
39
+ xml["w"].pPr do
40
+ left = 720 * (level + 1)
41
+ xml["w"].ind("w:left" => left.to_s, "w:hanging" => "360")
42
+ end
38
43
  end
39
44
  end
40
45
  end
@@ -46,12 +51,20 @@ module Notare
46
51
  end
47
52
  end
48
53
 
49
- def num_format(type)
50
- type == :bullet ? "bullet" : "decimal"
54
+ def num_format_for_level(type, level)
55
+ if type == :bullet
56
+ "bullet"
57
+ else
58
+ NUMBER_FORMATS[level % NUMBER_FORMATS.length]
59
+ end
51
60
  end
52
61
 
53
- def lvl_text(type)
54
- type == :bullet ? "•" : "%1."
62
+ def lvl_text_for_level(type, level)
63
+ if type == :bullet
64
+ BULLET_CHARS[level % BULLET_CHARS.length]
65
+ else
66
+ "%#{level + 1}."
67
+ end
55
68
  end
56
69
  end
57
70
  end
@@ -24,10 +24,12 @@ module Notare
24
24
  STYLES_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
25
25
  NUMBERING_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"
26
26
  IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
27
+ HYPERLINK_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
27
28
 
28
- def initialize(has_numbering: false, images: [], has_styles: false)
29
+ def initialize(has_numbering: false, images: [], hyperlinks: [], has_styles: false)
29
30
  @has_numbering = has_numbering
30
31
  @images = images
32
+ @hyperlinks = hyperlinks
31
33
  @has_styles = has_styles
32
34
  end
33
35
 
@@ -60,6 +62,16 @@ module Notare
60
62
  Target: "media/#{image.filename}"
61
63
  )
62
64
  end
65
+
66
+ # Hyperlinks come after images
67
+ @hyperlinks.each do |hyperlink|
68
+ xml.Relationship(
69
+ Id: hyperlink.rid,
70
+ Type: HYPERLINK_TYPE,
71
+ Target: hyperlink.url,
72
+ TargetMode: "External"
73
+ )
74
+ end
63
75
  end
64
76
  end
65
77
  builder.to_xml
@@ -12,8 +12,15 @@ module Notare
12
12
  justify: "both"
13
13
  }.freeze
14
14
 
15
- def initialize(styles)
15
+ TABLE_ALIGNMENT_MAP = {
16
+ left: "left",
17
+ center: "center",
18
+ right: "right"
19
+ }.freeze
20
+
21
+ def initialize(styles, table_styles = {})
16
22
  @styles = styles
23
+ @table_styles = table_styles
17
24
  end
18
25
 
19
26
  def to_xml
@@ -24,6 +31,10 @@ module Notare
24
31
  @styles.each_value do |style|
25
32
  render_style(xml, style)
26
33
  end
34
+
35
+ @table_styles.each_value do |style|
36
+ render_table_style(xml, style)
37
+ end
27
38
  end
28
39
  end
29
40
  builder.to_xml
@@ -59,6 +70,61 @@ module Notare
59
70
  xml["w"].b if style.bold
60
71
  xml["w"].i if style.italic
61
72
  xml["w"].u("w:val" => "single") if style.underline
73
+ xml["w"].strike if style.strike
74
+ xml["w"].highlight("w:val" => style.highlight) if style.highlight
75
+ end
76
+ end
77
+
78
+ def render_table_style(xml, style)
79
+ xml["w"].style("w:type" => "table", "w:styleId" => style.style_id) do
80
+ xml["w"].name("w:val" => style.display_name)
81
+
82
+ xml["w"].tblPr do
83
+ render_table_borders(xml, style.borders) if style.borders
84
+ render_table_shading(xml, style.shading) if style.shading
85
+ render_table_cell_margins(xml, style.cell_margins) if style.cell_margins
86
+ xml["w"].jc("w:val" => TABLE_ALIGNMENT_MAP[style.align]) if style.align
87
+ end
88
+ end
89
+ end
90
+
91
+ def render_table_borders(xml, borders)
92
+ xml["w"].tblBorders do
93
+ %i[top left bottom right insideH insideV].each do |pos|
94
+ border = borders == :none ? :none : (borders[pos] || borders)
95
+ render_single_border(xml, pos, border)
96
+ end
97
+ end
98
+ end
99
+
100
+ def render_single_border(xml, position, border)
101
+ if border == :none
102
+ xml["w"].send(position, "w:val" => "nil")
103
+ else
104
+ xml["w"].send(position,
105
+ "w:val" => border[:style],
106
+ "w:sz" => border[:size].to_s,
107
+ "w:space" => "0",
108
+ "w:color" => border[:color])
109
+ end
110
+ end
111
+
112
+ def render_table_shading(xml, color)
113
+ xml["w"].shd("w:val" => "clear", "w:color" => "auto", "w:fill" => color)
114
+ end
115
+
116
+ def render_table_cell_margins(xml, margins)
117
+ xml["w"].tblCellMar do
118
+ if margins.is_a?(Hash)
119
+ xml["w"].top("w:w" => margins[:top].to_s, "w:type" => "dxa") if margins[:top]
120
+ xml["w"].left("w:w" => margins[:left].to_s, "w:type" => "dxa") if margins[:left]
121
+ xml["w"].bottom("w:w" => margins[:bottom].to_s, "w:type" => "dxa") if margins[:bottom]
122
+ xml["w"].right("w:w" => margins[:right].to_s, "w:type" => "dxa") if margins[:right]
123
+ else
124
+ %i[top left bottom right].each do |side|
125
+ xml["w"].send(side, "w:w" => margins.to_s, "w:type" => "dxa")
126
+ end
127
+ end
62
128
  end
63
129
  end
64
130
  end
data/lib/notare.rb CHANGED
@@ -4,6 +4,8 @@ require "nokogiri"
4
4
 
5
5
  require_relative "notare/version"
6
6
  require_relative "notare/nodes/base"
7
+ require_relative "notare/nodes/break"
8
+ require_relative "notare/nodes/hyperlink"
7
9
  require_relative "notare/nodes/run"
8
10
  require_relative "notare/nodes/image"
9
11
  require_relative "notare/nodes/paragraph"
@@ -14,6 +16,7 @@ require_relative "notare/nodes/table_row"
14
16
  require_relative "notare/nodes/table_cell"
15
17
  require_relative "notare/image_dimensions"
16
18
  require_relative "notare/style"
19
+ require_relative "notare/table_style"
17
20
  require_relative "notare/xml/content_types"
18
21
  require_relative "notare/xml/relationships"
19
22
  require_relative "notare/xml/document_xml"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: notare
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mathias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-02 00:00:00.000000000 Z
11
+ date: 2025-12-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fastimage
@@ -121,6 +121,8 @@ files:
121
121
  - lib/notare/document.rb
122
122
  - lib/notare/image_dimensions.rb
123
123
  - lib/notare/nodes/base.rb
124
+ - lib/notare/nodes/break.rb
125
+ - lib/notare/nodes/hyperlink.rb
124
126
  - lib/notare/nodes/image.rb
125
127
  - lib/notare/nodes/list.rb
126
128
  - lib/notare/nodes/list_item.rb
@@ -131,6 +133,7 @@ files:
131
133
  - lib/notare/nodes/table_row.rb
132
134
  - lib/notare/package.rb
133
135
  - lib/notare/style.rb
136
+ - lib/notare/table_style.rb
134
137
  - lib/notare/version.rb
135
138
  - lib/notare/xml/content_types.rb
136
139
  - lib/notare/xml/document_xml.rb