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.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class List < Base
6
+ attr_reader :items, :type, :num_id
7
+
8
+ def initialize(type:, num_id:)
9
+ super()
10
+ @type = type
11
+ @num_id = num_id
12
+ @items = []
13
+ end
14
+
15
+ def add_item(item)
16
+ @items << item
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class ListItem < Base
6
+ attr_reader :runs, :list_type, :num_id
7
+
8
+ def initialize(runs = [], list_type:, num_id:)
9
+ super()
10
+ @runs = runs
11
+ @list_type = list_type
12
+ @num_id = num_id
13
+ end
14
+
15
+ def add_run(run)
16
+ @runs << run
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Paragraph < Base
6
+ attr_reader :runs, :style
7
+
8
+ def initialize(runs = [], style: nil)
9
+ super()
10
+ @runs = runs
11
+ @style = style
12
+ end
13
+
14
+ def add_run(run)
15
+ @runs << run
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Run < Base
6
+ attr_reader :text, :bold, :italic, :underline, :style
7
+
8
+ def initialize(text, bold: false, italic: false, underline: false, style: nil)
9
+ super()
10
+ @text = text
11
+ @bold = bold
12
+ @italic = italic
13
+ @underline = underline
14
+ @style = style
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class Table < Base
6
+ attr_reader :rows
7
+
8
+ def initialize
9
+ super
10
+ @rows = []
11
+ end
12
+
13
+ def add_row(row)
14
+ @rows << row
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class TableCell < Base
6
+ attr_reader :runs
7
+
8
+ def initialize
9
+ super
10
+ @runs = []
11
+ end
12
+
13
+ def add_run(run)
14
+ @runs << run
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Nodes
5
+ class TableRow < Base
6
+ attr_reader :cells
7
+
8
+ def initialize
9
+ super
10
+ @cells = []
11
+ end
12
+
13
+ def add_cell(cell)
14
+ @cells << cell
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ module Notare
6
+ class Package
7
+ def initialize(document)
8
+ @document = document
9
+ end
10
+
11
+ def save(path)
12
+ Zip::File.open(path, Zip::File::CREATE) do |zipfile|
13
+ zipfile.get_output_stream("[Content_Types].xml") { |f| f.write(content_types_xml) }
14
+ zipfile.get_output_stream("_rels/.rels") { |f| f.write(relationships_xml) }
15
+ zipfile.get_output_stream("word/_rels/document.xml.rels") { |f| f.write(document_relationships_xml) }
16
+ zipfile.get_output_stream("word/document.xml") { |f| f.write(document_xml) }
17
+ zipfile.get_output_stream("word/styles.xml") { |f| f.write(styles_xml) }
18
+
19
+ zipfile.get_output_stream("word/numbering.xml") { |f| f.write(numbering_xml) } if lists?
20
+
21
+ images.each do |image|
22
+ zipfile.get_output_stream("word/media/#{image.filename}") do |f|
23
+ f.write(File.binread(image.path))
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def lists?
32
+ @document.lists.any?
33
+ end
34
+
35
+ def images
36
+ @document.images
37
+ end
38
+
39
+ def content_types_xml
40
+ Xml::ContentTypes.new(has_numbering: lists?, images: images, has_styles: true).to_xml
41
+ end
42
+
43
+ def relationships_xml
44
+ Xml::Relationships.new.to_xml
45
+ end
46
+
47
+ def document_relationships_xml
48
+ Xml::DocumentRelationships.new(has_numbering: lists?, images: images, has_styles: true).to_xml
49
+ end
50
+
51
+ def document_xml
52
+ Xml::DocumentXml.new(@document.nodes).to_xml
53
+ end
54
+
55
+ def styles_xml
56
+ Xml::StylesXml.new(@document.styles).to_xml
57
+ end
58
+
59
+ def numbering_xml
60
+ Xml::Numbering.new(@document.lists).to_xml
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ class Style
5
+ attr_reader :name, :bold, :italic, :underline, :color, :size, :font,
6
+ :align, :indent, :spacing_before, :spacing_after
7
+
8
+ ALIGNMENTS = %i[left center right justify].freeze
9
+
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)
13
+ @name = name
14
+ @bold = bold
15
+ @italic = italic
16
+ @underline = underline
17
+ @color = normalize_color(color)
18
+ @size = size
19
+ @font = font
20
+ @align = validate_align(align)
21
+ @indent = indent
22
+ @spacing_before = spacing_before
23
+ @spacing_after = spacing_after
24
+ end
25
+
26
+ def style_id
27
+ name.to_s.split("_").map(&:capitalize).join
28
+ end
29
+
30
+ def display_name
31
+ name.to_s.split("_").map(&:capitalize).join(" ")
32
+ end
33
+
34
+ def paragraph_properties?
35
+ !!(align || indent || spacing_before || spacing_after)
36
+ end
37
+
38
+ def text_properties?
39
+ !!(bold || italic || underline || color || size || font)
40
+ end
41
+
42
+ # Size in half-points for OOXML (14pt = 28 half-points)
43
+ def size_half_points
44
+ size ? (size * 2).to_i : nil
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_color(color)
50
+ return nil if color.nil?
51
+
52
+ hex = color.to_s.sub(/^#/, "").upcase
53
+ return hex if hex.match?(/\A[0-9A-F]{6}\z/)
54
+
55
+ raise ArgumentError, "Invalid color: #{color}. Use 6-digit hex (e.g., 'FF0000')"
56
+ end
57
+
58
+ def validate_align(align)
59
+ return nil if align.nil?
60
+ return align if ALIGNMENTS.include?(align)
61
+
62
+ raise ArgumentError, "Invalid alignment: #{align}. Use #{ALIGNMENTS.join(", ")}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ VERSION = "0.0.2"
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Xml
5
+ class ContentTypes
6
+ NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
7
+
8
+ def initialize(has_numbering: false, images: [], has_styles: false)
9
+ @has_numbering = has_numbering
10
+ @images = images
11
+ @has_styles = has_styles
12
+ end
13
+
14
+ def to_xml
15
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
16
+ xml.Types(xmlns: NAMESPACE) do
17
+ xml.Default(Extension: "rels", ContentType: "application/vnd.openxmlformats-package.relationships+xml")
18
+ xml.Default(Extension: "xml", ContentType: "application/xml")
19
+
20
+ image_extensions.each do |ext, content_type|
21
+ xml.Default(Extension: ext, ContentType: content_type)
22
+ end
23
+
24
+ xml.Override(
25
+ PartName: "/word/document.xml",
26
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
27
+ )
28
+ if @has_styles
29
+ xml.Override(
30
+ PartName: "/word/styles.xml",
31
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"
32
+ )
33
+ end
34
+ if @has_numbering
35
+ xml.Override(
36
+ PartName: "/word/numbering.xml",
37
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"
38
+ )
39
+ end
40
+ end
41
+ end
42
+ builder.to_xml
43
+ end
44
+
45
+ private
46
+
47
+ def image_extensions
48
+ extensions = {}
49
+ @images.each do |image|
50
+ extensions[image.extension] ||= image.content_type
51
+ end
52
+ extensions
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Xml
5
+ class DocumentXml
6
+ NAMESPACES = {
7
+ "xmlns:w" => "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
8
+ "xmlns:r" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
9
+ "xmlns:wp" => "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
10
+ "xmlns:a" => "http://schemas.openxmlformats.org/drawingml/2006/main",
11
+ "xmlns:pic" => "http://schemas.openxmlformats.org/drawingml/2006/picture"
12
+ }.freeze
13
+
14
+ def initialize(nodes)
15
+ @nodes = nodes
16
+ end
17
+
18
+ def to_xml
19
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
20
+ xml.document(NAMESPACES) do
21
+ xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" }
22
+ xml["w"].body do
23
+ @nodes.each { |node| render_node(xml, node) }
24
+ end
25
+ end
26
+ end
27
+ builder.to_xml
28
+ end
29
+
30
+ private
31
+
32
+ def render_node(xml, node)
33
+ case node
34
+ when Nodes::Paragraph
35
+ render_paragraph(xml, node)
36
+ when Nodes::List
37
+ render_list(xml, node)
38
+ when Nodes::Table
39
+ render_table(xml, node)
40
+ end
41
+ end
42
+
43
+ def render_paragraph(xml, para)
44
+ xml["w"].p do
45
+ if para.style
46
+ xml["w"].pPr do
47
+ xml["w"].pStyle("w:val" => para.style.style_id)
48
+ end
49
+ end
50
+ para.runs.each { |run| render_run(xml, run) }
51
+ end
52
+ end
53
+
54
+ def render_list(xml, list)
55
+ list.items.each { |item| render_list_item(xml, item) }
56
+ end
57
+
58
+ def render_list_item(xml, item)
59
+ xml["w"].p do
60
+ xml["w"].pPr do
61
+ xml["w"].numPr do
62
+ xml["w"].ilvl("w:val" => "0")
63
+ xml["w"].numId("w:val" => item.num_id.to_s)
64
+ end
65
+ end
66
+ item.runs.each { |run| render_run(xml, run) }
67
+ end
68
+ end
69
+
70
+ def render_run(xml, run)
71
+ case run
72
+ when Nodes::Image
73
+ render_image(xml, run)
74
+ when Nodes::Run
75
+ render_text_run(xml, run)
76
+ end
77
+ end
78
+
79
+ def render_text_run(xml, run)
80
+ xml["w"].r do
81
+ if run.bold || run.italic || run.underline || run.style
82
+ xml["w"].rPr do
83
+ xml["w"].rStyle("w:val" => run.style.style_id) if run.style
84
+ xml["w"].b if run.bold
85
+ xml["w"].i if run.italic
86
+ xml["w"].u("w:val" => "single") if run.underline
87
+ end
88
+ end
89
+ xml["w"].t(run.text, "xml:space" => "preserve")
90
+ end
91
+ end
92
+
93
+ def render_image(xml, image)
94
+ xml["w"].r do
95
+ xml["w"].drawing do
96
+ xml["wp"].inline(distT: "0", distB: "0", distL: "0", distR: "0") do
97
+ xml["wp"].extent(cx: image.width_emu.to_s, cy: image.height_emu.to_s)
98
+ xml["wp"].docPr(id: image.doc_pr_id.to_s, name: image.filename)
99
+ xml["wp"].cNvGraphicFramePr do
100
+ xml["a"].graphicFrameLocks(noChangeAspect: "1")
101
+ end
102
+ xml["a"].graphic do
103
+ xml["a"].graphicData(uri: "http://schemas.openxmlformats.org/drawingml/2006/picture") do
104
+ xml["pic"].pic do
105
+ xml["pic"].nvPicPr do
106
+ xml["pic"].cNvPr(id: "0", name: image.filename)
107
+ xml["pic"].cNvPicPr
108
+ end
109
+ xml["pic"].blipFill do
110
+ xml["a"].blip("r:embed" => image.rid)
111
+ xml["a"].stretch do
112
+ xml["a"].fillRect
113
+ end
114
+ end
115
+ xml["pic"].spPr do
116
+ xml["a"].xfrm do
117
+ xml["a"].off(x: "0", y: "0")
118
+ xml["a"].ext(cx: image.width_emu.to_s, cy: image.height_emu.to_s)
119
+ end
120
+ xml["a"].prstGeom(prst: "rect") do
121
+ xml["a"].avLst
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def render_table(xml, table)
133
+ column_count = table.rows.first&.cells&.size || 1
134
+ col_width = 5000 / column_count
135
+
136
+ xml["w"].tbl do
137
+ xml["w"].tblPr do
138
+ 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")
142
+ end
143
+ end
144
+ end
145
+ xml["w"].tblGrid do
146
+ column_count.times do
147
+ xml["w"].gridCol("w:w" => col_width.to_s)
148
+ end
149
+ end
150
+ table.rows.each { |row| render_table_row(xml, row, col_width) }
151
+ end
152
+ end
153
+
154
+ def render_table_row(xml, row, col_width)
155
+ xml["w"].tr do
156
+ row.cells.each { |cell| render_table_cell(xml, cell, col_width) }
157
+ end
158
+ end
159
+
160
+ def render_table_cell(xml, cell, col_width)
161
+ xml["w"].tc do
162
+ xml["w"].tcPr do
163
+ xml["w"].tcW("w:w" => col_width.to_s, "w:type" => "pct")
164
+ end
165
+ xml["w"].p do
166
+ cell.runs.each { |run| render_run(xml, run) }
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Xml
5
+ class Numbering
6
+ NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
7
+
8
+ def initialize(lists)
9
+ @lists = lists
10
+ end
11
+
12
+ def to_xml
13
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
14
+ xml.numbering("xmlns:w" => NAMESPACE) do
15
+ xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "w" }
16
+
17
+ @lists.each do |list|
18
+ render_abstract_num(xml, list)
19
+
20
+ render_num(xml, list)
21
+ end
22
+ end
23
+ end
24
+ builder.to_xml
25
+ end
26
+
27
+ private
28
+
29
+ def render_abstract_num(xml, list)
30
+ 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")
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def render_num(xml, list)
44
+ xml["w"].num("w:numId" => list.num_id.to_s) do
45
+ xml["w"].abstractNumId("w:val" => list.num_id.to_s)
46
+ end
47
+ end
48
+
49
+ def num_format(type)
50
+ type == :bullet ? "bullet" : "decimal"
51
+ end
52
+
53
+ def lvl_text(type)
54
+ type == :bullet ? "•" : "%1."
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notare
4
+ module Xml
5
+ class Relationships
6
+ NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
7
+
8
+ def to_xml
9
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
10
+ xml.Relationships(xmlns: NAMESPACE) do
11
+ xml.Relationship(
12
+ Id: "rId1",
13
+ Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
14
+ Target: "word/document.xml"
15
+ )
16
+ end
17
+ end
18
+ builder.to_xml
19
+ end
20
+ end
21
+
22
+ class DocumentRelationships
23
+ NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
24
+ STYLES_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
25
+ NUMBERING_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"
26
+ IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
27
+
28
+ def initialize(has_numbering: false, images: [], has_styles: false)
29
+ @has_numbering = has_numbering
30
+ @images = images
31
+ @has_styles = has_styles
32
+ end
33
+
34
+ def to_xml
35
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
36
+ xml.Relationships(xmlns: NAMESPACE) do
37
+ # rId1 = styles.xml (always first when present)
38
+ if @has_styles
39
+ xml.Relationship(
40
+ Id: "rId1",
41
+ Type: STYLES_TYPE,
42
+ Target: "styles.xml"
43
+ )
44
+ end
45
+
46
+ # rId2 = numbering.xml (if lists present)
47
+ if @has_numbering
48
+ xml.Relationship(
49
+ Id: "rId2",
50
+ Type: NUMBERING_TYPE,
51
+ Target: "numbering.xml"
52
+ )
53
+ end
54
+
55
+ # Images start at rId2 or rId3 depending on numbering
56
+ @images.each do |image|
57
+ xml.Relationship(
58
+ Id: image.rid,
59
+ Type: IMAGE_TYPE,
60
+ Target: "media/#{image.filename}"
61
+ )
62
+ end
63
+ end
64
+ end
65
+ builder.to_xml
66
+ end
67
+ end
68
+ end
69
+ end