Skip to content

Commit 7de90f9

Browse files
committed
Implement creating, removing, and editing existing styles. Utilize this system throughout the app.
1 parent 140e6f9 commit 7de90f9

17 files changed

Lines changed: 674 additions & 38 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,29 @@ p_children = p_element.xpath("//child::*") # selects all children
181181
p_child = p_element.at_xpath("//child::*") # selects first child
182182
```
183183

184+
### Writing and Manipulating Styles
185+
``` ruby
186+
require 'docx'
187+
188+
d = Docx::Document.open('example.docx')
189+
190+
# see lib/docx/elements/style.rb for more attributes you can set!
191+
new_style = d.styles_config.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20)
192+
new_style.bold = true
193+
194+
d.paragraphs.each do |p|
195+
p.style = "Red"
196+
end
197+
198+
d.styles_config.remove_style("Red")
199+
```
200+
201+
184202
## Development
185203

186204
### todo
187205

188206
* Calculate element formatting based on values present in element properties as well as properties inherited from parents
189207
* Default formatting of inserted elements to inherited values
190208
* Implement formattable elements.
191-
* Implement styles.
192209
* Easier multi-line text insertion at a single bookmark (inserting paragraph nodes after the one containing the bookmark)

lib/docx/containers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
require 'docx/containers/text_run'
33
require 'docx/containers/paragraph'
44
require 'docx/containers/table'
5+
require 'docx/containers/styles_configuration'

lib/docx/containers/paragraph.rb

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,32 +77,42 @@ def aligned_center?
7777
end
7878

7979
def font_size
80-
size_tag = @node.xpath('w:pPr//w:sz').first
81-
size_tag ? size_tag.attributes['val'].value.to_i / 2 : @font_size
80+
size_attribute = @node.at_xpath('w:pPr//w:sz//@w:val')
81+
82+
return @font_size unless size_attribute
83+
84+
size_attribute.value.to_i / 2
8285
end
8386

8487
def style
8588
return nil unless @document
8689

87-
if style_property.nil?
90+
@document.style_name_of(style_id) ||
8891
@document.default_paragraph_style
89-
else
90-
@document.style_name(style_property.attributes['val'].value)
91-
end
9292
end
9393

94+
def style_id
95+
style_property.get_attribute('w:val')
96+
end
97+
98+
def style=(identifier)
99+
id = @document.styles_configuration.style_of(identifier).id
100+
101+
style_property.set_attribute('w:val', id)
102+
end
103+
104+
alias_method :style_id=, :style=
94105
alias_method :text, :to_s
95106

96107
private
97108

98109
def style_property
99-
properties&.at_xpath('w:pStyle')
110+
properties&.at_xpath('w:pStyle') || properties&.add_child('<w:pStyle/>').first
100111
end
101112

102113
# Returns the alignment if any, or nil if left
103114
def alignment
104-
alignment_tag = @node.xpath('.//w:jc').first
105-
alignment_tag ? alignment_tag.attributes['val'].value : nil
115+
@node.at_xpath('.//w:jc/@w:val')&.value
106116
end
107117
end
108118
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
require 'docx/containers/container'
2+
require 'docx/elements/style'
3+
4+
module Docx
5+
module Elements
6+
module Containers
7+
StyleNotFound = Class.new(StandardError)
8+
9+
class StylesConfiguration
10+
def initialize(raw_styles)
11+
@raw_styles = raw_styles
12+
@styles_parent_node = raw_styles.root
13+
end
14+
15+
attr_reader :styles, :styles_parent_node
16+
17+
def styles
18+
styles_parent_node
19+
.children
20+
.filter_map do |style|
21+
next unless style.get_attribute("w:styleId")
22+
23+
Elements::Style.new(self, style)
24+
end
25+
end
26+
27+
def style_of(id_or_name)
28+
styles.find { |style| style.id == id_or_name || style.name == id_or_name } || raise(Errors::StyleNotFound, "Style name or id '#{id_or_name}' not found")
29+
end
30+
31+
def size
32+
styles.size
33+
end
34+
35+
def add_style(id, attributes = {})
36+
Elements::Style.create(self, {id: id, name: id}.merge(attributes))
37+
end
38+
39+
def remove_style(id)
40+
style = styles.find { |style| style.id == id }
41+
42+
style.node.remove
43+
styles.delete(style)
44+
end
45+
46+
def serialize(**options)
47+
@raw_styles.serialize(**options)
48+
end
49+
end
50+
end
51+
end
52+
end

lib/docx/containers/text_run.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,11 @@ def hyperlink_id
111111
end
112112

113113
def font_size
114-
size_tag = @node.xpath('w:rPr//w:sz').first
115-
size_tag ? size_tag.attributes['val'].value.to_i / 2 : @font_size
114+
size_attribute = @node.at_xpath('w:rPr//w:sz//@w:val')
115+
116+
return @font_size unless size_attribute
117+
118+
size_attribute.value.to_i / 2
116119
end
117120

118121
private

lib/docx/document.rb

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
require 'docx/containers'
22
require 'docx/elements'
3+
require 'docx/errors'
4+
require 'docx/helpers'
35
require 'nokogiri'
46
require 'zip'
57

68
module Docx
7-
# The Document class wraps around a docx file and provides methods to
9+
# The Document class wraps around a docx file and pro.es methods to
810
# interface with it.
911
#
1012
# # get a Docx::Document for a docx file in the local directory
@@ -18,6 +20,8 @@ module Docx
1820
# puts d.text
1921
# end
2022
class Document
23+
include Docx::SimpleInspect
24+
2125
attr_reader :xml, :doc, :zip, :styles
2226

2327
def initialize(path_or_io, options = {})
@@ -82,10 +86,11 @@ def tables
8286
# Some documents have this set, others don't.
8387
# Values are returned as half-points, so to get points, that's why it's divided by 2.
8488
def font_size
85-
return nil unless @styles
89+
size_value = @styles&.at_xpath('//w:docDefaults//w:rPrDefault//w:rPr//w:sz/@w:val')&.value
90+
91+
return nil unless size_value
8692

87-
size_tag = @styles.xpath('//w:docDefaults//w:rPrDefault//w:rPr//w:sz').first
88-
size_tag ? size_tag.attributes['val'].value.to_i / 2 : nil
93+
size_value.to_i / 2
8994
end
9095

9196
# Hyperlink targets are extracted from the document.xml.rels file
@@ -130,13 +135,11 @@ def save(path)
130135
next unless entry.file?
131136

132137
out.put_next_entry(entry.name)
138+
value = @replace[entry.name] || zip.read(entry.name)
133139

134-
if @replace[entry.name]
135-
out.write(@replace[entry.name])
136-
else
137-
out.write(zip.read(entry.name))
138-
end
140+
out.write(value)
139141
end
142+
140143
end
141144
zip.close
142145
end
@@ -169,15 +172,15 @@ def replace_entry(entry_path, file_contents)
169172
end
170173

171174
def default_paragraph_style
172-
s = @styles.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']")
173-
s = s.at_xpath('w:name')
174-
s.attributes['val'].value
175+
@styles.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']/w:name/@w:val").value
176+
end
177+
178+
def style_name_of(style_id)
179+
styles_configuration.style_of(style_id).name
175180
end
176181

177-
def style_name(style_id)
178-
s = @styles.at_xpath("w:styles/w:style[@w:styleId='#{style_id}']")
179-
s = s.at_xpath('w:name')
180-
s.attributes['val'].value
182+
def styles_configuration
183+
@styles_configuration ||= Elements::Containers::StylesConfiguration.new(@styles.dup)
181184
end
182185

183186
private
@@ -206,6 +209,7 @@ def load_rels
206209
#++
207210
def update
208211
replace_entry 'word/document.xml', doc.serialize(save_with: 0)
212+
replace_entry 'word/styles.xml', styles_configuration.serialize(save_with: 0)
209213
end
210214

211215
# generate Elements::Containers::Paragraph from paragraph XML node

lib/docx/elements.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
require 'docx/elements/bookmark'
22
require 'docx/elements/element'
3-
require 'docx/elements/text'
3+
require 'docx/elements/text'
4+
require 'docx/elements/style'

lib/docx/elements/style.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
require 'docx/helpers'
2+
require 'docx/elements'
3+
require 'docx/elements/style/converters'
4+
require 'docx/elements/style/validators'
5+
6+
module Docx
7+
module Elements
8+
class Style
9+
include Docx::SimpleInspect
10+
11+
def self.attributes
12+
@@attributes
13+
end
14+
15+
def self.attribute(name, *selectors, converter: Converters::DefaultValueConverter, validator: Validators::DefaultValidator)
16+
define_method(name) do
17+
selectors
18+
.lazy
19+
.filter_map { |node_xpath| node.at_xpath(node_xpath)&.value }
20+
.map { |value| converter.decode(value) }
21+
.first
22+
end
23+
24+
define_method("#{name}=") do |value|
25+
validator.validate(value) || raise(Errors::StyleInvalidPropertyValue, "Invalid value for #{name}: #{value}")
26+
27+
selectors.map do |attribute_xpath|
28+
encoded_value = converter.encode(value).to_s
29+
if (existing_attribute = node.at_xpath(attribute_xpath))
30+
existing_attribute.value = encoded_value
31+
next value
32+
end
33+
34+
node_xpath, attribute = attribute_xpath.split("/@")
35+
36+
created_node =
37+
node_xpath
38+
.split("/")
39+
.reduce(node) do |parent_node, child_xpath|
40+
# find the child node
41+
parent_node.at_xpath(child_xpath) ||
42+
# or create the child node
43+
Nokogiri::XML::Node.new(child_xpath, parent_node).tap { |created_child_node| parent_node << created_child_node }
44+
end
45+
46+
created_node.set_attribute(attribute, encoded_value)
47+
end
48+
.first
49+
end
50+
end
51+
52+
def self.create(configuration, attributes = {})
53+
node = Nokogiri::XML::Node.new("w:style", configuration.styles_parent_node)
54+
configuration.styles_parent_node.add_child(node)
55+
56+
Elements::Style.new(configuration, node, **attributes)
57+
end
58+
59+
def initialize(configuration, node, **attributes)
60+
@configuration = configuration
61+
@node = node
62+
63+
attributes.each do |name, value|
64+
self.send("#{name}=", value)
65+
end
66+
end
67+
68+
attr_accessor :node
69+
70+
attribute :id, "./@w:styleId"
71+
attribute :name, "./w:name/@w:val", "./w:next/@w:val"
72+
attribute :type, ".//@w:type"
73+
attribute :keep_next, "./w:pPr/w:keepNext/@w:val", converter: Converters::BooleanConverter
74+
attribute :keep_lines, "./w:pPr/w:keepLines/@w:val", converter: Converters::BooleanConverter
75+
attribute :page_break_before, "./w:pPr/w:pageBreakBefore/@w:val", converter: Converters::BooleanConverter
76+
attribute :widow_control, "./w:pPr/w:widowControl/@w:val", converter: Converters::BooleanConverter
77+
attribute :shading_style, "./w:pPr/w:shd/@w:val", "./w:rPr/w:shd/@w:val"
78+
attribute :shading_color, "./w:pPr/w:shd/@w:color", "./w:rPr/w:shd/@w:color", validator: Validators::ColorValidator
79+
attribute :shading_fill, "./w:pPr/w:shd/@w:fill", "./w:rPr/w:shd/@w:fill"
80+
attribute :suppress_auto_hyphens, "./w:pPr/w:suppressAutoHyphens/@w:val", converter: Converters::BooleanConverter
81+
attribute :bidirectional_text, "./w:pPr/w:bidi/@w:val", converter: Converters::BooleanConverter
82+
attribute :spacing_before, "./w:pPr/w:spacing/@w:before"
83+
attribute :spacing_after, "./w:pPr/w:spacing/@w:after"
84+
attribute :line_spacing, "./w:pPr/w:spacing/@w:line"
85+
attribute :line_rule, "./w:pPr/w:spacing/@w:lineRule"
86+
attribute :indent_left, "./w:pPr/w:ind/@w:left"
87+
attribute :indent_right, "./w:pPr/w:ind/@w:right"
88+
attribute :indent_first_line, "./w:pPr/w:ind/@w:firstLine"
89+
attribute :align, "./w:pPr/w:jc/@w:val"
90+
attribute :font, "./w:rPr/w:rFonts/@w:ascii", "./w:rPr/w:rFonts/@w:cs", "./w:rPr/w:rFonts/@w:hAnsi", "./w:rPr/w:rFonts/@w:eastAsia" # setting :font, will set all other fonts
91+
attribute :font_ascii, "./w:rPr/w:rFonts/@w:ascii"
92+
attribute :font_cs, "./w:rPr/w:rFonts/@w:cs"
93+
attribute :font_hAnsi, "./w:rPr/w:rFonts/@w:hAnsi"
94+
attribute :font_eastAsia, "./w:rPr/w:rFonts/@w:eastAsia"
95+
attribute :bold, "./w:rPr/w:b/@w:val", "./w:rPr/w:bCs/@w:val", converter: Converters::BooleanConverter
96+
attribute :italic, "./w:rPr/w:i/@w:val", "./w:rPr/w:iCs/@w:val", converter: Converters::BooleanConverter
97+
attribute :caps, "./w:rPr/w:caps/@w:val", converter: Converters::BooleanConverter
98+
attribute :small_caps, "./w:rPr/w:smallCaps/@w:val", converter: Converters::BooleanConverter
99+
attribute :strike, "./w:rPr/w:strike/@w:val", converter: Converters::BooleanConverter
100+
attribute :double_strike, "./w:rPr/w:dstrike/@w:val", converter: Converters::BooleanConverter
101+
attribute :outline, "./w:rPr/w:outline/@w:val", converter: Converters::BooleanConverter
102+
attribute :outline_level, "./w:pPr/w:outlineLvl/@w:val"
103+
attribute :font_color, "./w:rPr/w:color/@w:val", validator: Validators::ColorValidator
104+
attribute :font_size, "./w:rPr/w:sz/@w:val", "./w:rPr/w:szCs/@w:val", converter: Converters::FontSizeConverter
105+
attribute :font_size_cs, "./w:rPr/w:szCs/@w:val", converter: Converters::FontSizeConverter
106+
attribute :underline_style, "./w:rPr/w:u/@w:val"
107+
attribute :underline_color, "./w:rPr/w:u /@w:color", validator: Validators::ColorValidator
108+
attribute :spacing, "./w:rPr/w:spacing/@w:val"
109+
attribute :kerning, "./w:rPr/w:kern/@w:val"
110+
attribute :position, "./w:rPr/w:position/@w:val"
111+
attribute :text_fill_color, "./w:rPr/w14:textFill/w14:solidFill/w14:srgbClr/@w14:val", validator: Validators::ColorValidator
112+
attribute :vertical_alignment, "./w:rPr/w:vertAlign/@w:val"
113+
attribute :lang, "./w:rPr/w:lang/@w:val"
114+
115+
def to_xml
116+
node.to_xml
117+
end
118+
119+
def remove
120+
node.remove
121+
@configuration.styles.delete(self)
122+
end
123+
end
124+
end
125+
end

0 commit comments

Comments
 (0)