diff --git a/README.md b/README.md
index ac9054e..31b4b39 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,78 @@ p_children = p_element.xpath("//child::*") # selects all children
p_child = p_element.at_xpath("//child::*") # selects first child
```
+### Writing and Manipulating Styles
+``` ruby
+require 'docx'
+
+d = Docx::Document.open('example.docx')
+existing_style = d.styles_configuration.style_of("Heading 1")
+existing_style.font_color = "000000"
+
+# see attributes below
+new_style = d.styles_configuration.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20)
+new_style.bold = true
+
+d.paragraphs.each do |p|
+ p.style = "Red"
+end
+
+d.paragraphs.each do |p|
+ p.style = "Heading 1"
+end
+
+d.styles_configuration.remove_style("Red")
+```
+
+#### Style Attributes
+
+The following is a list of attributes and what they control within the style.
+
+- **id**: The unique identifier of the style. (required)
+- **name**: The human-readable name of the style. (required)
+- **type**: Indicates the type of the style (e.g., paragraph, character).
+- **keep_next**: Boolean value controlling whether to keep a paragraph and the next one on the same page. Valid values: `true`/`false`.
+- **keep_lines**: Boolean value specifying whether to keep all lines of a paragraph together on one page. Valid values: `true`/`false`.
+- **page_break_before**: Boolean value indicating whether to insert a page break before the paragraph. Valid values: `true`/`false`.
+- **widow_control**: Boolean value controlling widow and orphan lines in a paragraph. Valid values: `true`/`false`.
+- **shading_style**: Defines the shading pattern style.
+- **shading_color**: Specifies the color of the shading pattern. Valid values: Hex color codes.
+- **shading_fill**: Indicates the background fill color of shading.
+- **suppress_auto_hyphens**: Boolean value controlling automatic hyphenation. Valid values: `true`/`false`.
+- **bidirectional_text**: Boolean value indicating if the paragraph contains bidirectional text. Valid values: `true`/`false`.
+- **spacing_before**: Defines the spacing before a paragraph.
+- **spacing_after**: Specifies the spacing after a paragraph.
+- **line_spacing**: Indicates the line spacing of a paragraph.
+- **line_rule**: Defines how line spacing is calculated.
+- **indent_left**: Sets the left indentation of a paragraph.
+- **indent_right**: Specifies the right indentation of a paragraph.
+- **indent_first_line**: Indicates the first line indentation of a paragraph.
+- **align**: Controls the text alignment within a paragraph.
+- **font**: Sets the font for different scripts (ASCII, complex script, East Asian, etc.).
+- **font_ascii**: Specifies the font for ASCII characters.
+- **font_cs**: Indicates the font for complex script characters.
+- **font_hAnsi**: Sets the font for high ANSI characters.
+- **font_eastAsia**: Specifies the font for East Asian characters.
+- **bold**: Boolean value controlling bold formatting. Valid values: `true`/`false`.
+- **italic**: Boolean value indicating italic formatting. Valid values: `true`/`false`.
+- **caps**: Boolean value controlling capitalization. Valid values: `true`/`false`.
+- **small_caps**: Boolean value specifying small capital letters. Valid values: `true`/`false`.
+- **strike**: Boolean value indicating strikethrough formatting. Valid values: `true`/`false`.
+- **double_strike**: Boolean value defining double strikethrough formatting. Valid values: `true`/`false`.
+- **outline**: Boolean value specifying outline effects. Valid values: `true`/`false`.
+- **outline_level**: Indicates the outline level in a document's hierarchy.
+- **font_color**: Sets the text color. Valid values: Hex color codes.
+- **font_size**: Controls the font size.
+- **font_size_cs**: Specifies the font size for complex script characters.
+- **underline_style**: Indicates the style of underlining.
+- **underline_color**: Specifies the color of the underline. Valid values: Hex color codes.
+- **spacing**: Controls character spacing.
+- **kerning**: Sets the space between characters.
+- **position**: Controls the position of characters (superscript/subscript).
+- **text_fill_color**: Sets the fill color of text. Valid values: Hex color codes.
+- **vertical_alignment**: Controls the vertical alignment of text within a line.
+- **lang**: Specifies the language tag for the text.
+
## Development
### todo
@@ -188,5 +260,4 @@ p_child = p_element.at_xpath("//child::*") # selects first child
* Calculate element formatting based on values present in element properties as well as properties inherited from parents
* Default formatting of inserted elements to inherited values
* Implement formattable elements.
-* Implement styles.
* Easier multi-line text insertion at a single bookmark (inserting paragraph nodes after the one containing the bookmark)
diff --git a/lib/docx/containers.rb b/lib/docx/containers.rb
index d1433ce..e149d64 100644
--- a/lib/docx/containers.rb
+++ b/lib/docx/containers.rb
@@ -2,3 +2,4 @@
require 'docx/containers/text_run'
require 'docx/containers/paragraph'
require 'docx/containers/table'
+require 'docx/containers/styles_configuration'
diff --git a/lib/docx/containers/paragraph.rb b/lib/docx/containers/paragraph.rb
index 3af2c1e..f51496e 100755
--- a/lib/docx/containers/paragraph.rb
+++ b/lib/docx/containers/paragraph.rb
@@ -78,8 +78,11 @@ def aligned_center?
end
def font_size
- size_tag = @node.xpath('w:pPr//w:sz').first
- size_tag ? size_tag.attributes['val'].value.to_i / 2 : @font_size
+ size_attribute = @node.at_xpath('w:pPr//w:sz//@w:val')
+
+ return @font_size unless size_attribute
+
+ size_attribute.value.to_i / 2
end
def font_color
@@ -90,25 +93,32 @@ def font_color
def style
return nil unless @document
- if style_property.nil?
+ @document.style_name_of(style_id) ||
@document.default_paragraph_style
- else
- @document.style_name(style_property.attributes['val'].value)
- end
end
+ def style_id
+ style_property.get_attribute('w:val')
+ end
+
+ def style=(identifier)
+ id = @document.styles_configuration.style_of(identifier).id
+
+ style_property.set_attribute('w:val', id)
+ end
+
+ alias_method :style_id=, :style=
alias_method :text, :to_s
private
def style_property
- properties&.at_xpath('w:pStyle')
+ properties&.at_xpath('w:pStyle') || properties&.add_child('').first
end
# Returns the alignment if any, or nil if left
def alignment
- alignment_tag = @node.xpath('.//w:jc').first
- alignment_tag ? alignment_tag.attributes['val'].value : nil
+ @node.at_xpath('.//w:jc/@w:val')&.value
end
end
end
diff --git a/lib/docx/containers/styles_configuration.rb b/lib/docx/containers/styles_configuration.rb
new file mode 100644
index 0000000..6209f51
--- /dev/null
+++ b/lib/docx/containers/styles_configuration.rb
@@ -0,0 +1,52 @@
+require 'docx/containers/container'
+require 'docx/elements/style'
+
+module Docx
+ module Elements
+ module Containers
+ StyleNotFound = Class.new(StandardError)
+
+ class StylesConfiguration
+ def initialize(raw_styles)
+ @raw_styles = raw_styles
+ @styles_parent_node = raw_styles.root
+ end
+
+ attr_reader :styles, :styles_parent_node
+
+ def styles
+ styles_parent_node
+ .children
+ .filter_map do |style|
+ next unless style.get_attribute("w:styleId")
+
+ Elements::Style.new(self, style)
+ end
+ end
+
+ def style_of(id_or_name)
+ 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")
+ end
+
+ def size
+ styles.size
+ end
+
+ def add_style(id, attributes = {})
+ Elements::Style.create(self, {id: id, name: id}.merge(attributes))
+ end
+
+ def remove_style(id)
+ style = styles.find { |style| style.id == id }
+
+ style.node.remove
+ styles.delete(style)
+ end
+
+ def serialize(**options)
+ @raw_styles.serialize(**options)
+ end
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/docx/containers/text_run.rb b/lib/docx/containers/text_run.rb
index 722bea9..55ed62c 100755
--- a/lib/docx/containers/text_run.rb
+++ b/lib/docx/containers/text_run.rb
@@ -118,8 +118,11 @@ def hyperlink_id
end
def font_size
- size_tag = @node.xpath('w:rPr//w:sz').first
- size_tag ? size_tag.attributes['val'].value.to_i / 2 : @font_size
+ size_attribute = @node.at_xpath('w:rPr//w:sz//@w:val')
+
+ return @font_size unless size_attribute
+
+ size_attribute.value.to_i / 2
end
private
diff --git a/lib/docx/document.rb b/lib/docx/document.rb
index a3e10de..4fe0ee1 100755
--- a/lib/docx/document.rb
+++ b/lib/docx/document.rb
@@ -1,5 +1,7 @@
require 'docx/containers'
require 'docx/elements'
+require 'docx/errors'
+require 'docx/helpers'
require 'nokogiri'
require 'zip'
@@ -18,6 +20,8 @@ module Docx
# puts d.text
# end
class Document
+ include Docx::SimpleInspect
+
attr_reader :xml, :doc, :zip, :styles
def initialize(path_or_io, options = {})
@@ -82,10 +86,11 @@ def tables
# Some documents have this set, others don't.
# Values are returned as half-points, so to get points, that's why it's divided by 2.
def font_size
- return nil unless @styles
+ size_value = @styles&.at_xpath('//w:docDefaults//w:rPrDefault//w:rPr//w:sz/@w:val')&.value
+
+ return nil unless size_value
- size_tag = @styles.xpath('//w:docDefaults//w:rPrDefault//w:rPr//w:sz').first
- size_tag ? size_tag.attributes['val'].value.to_i / 2 : nil
+ size_value.to_i / 2
end
# Hyperlink targets are extracted from the document.xml.rels file
@@ -130,13 +135,11 @@ def save(path)
next unless entry.file?
out.put_next_entry(entry.name)
+ value = @replace[entry.name] || zip.read(entry.name)
- if @replace[entry.name]
- out.write(@replace[entry.name])
- else
- out.write(zip.read(entry.name))
- end
+ out.write(value)
end
+
end
zip.close
end
@@ -169,15 +172,15 @@ def replace_entry(entry_path, file_contents)
end
def default_paragraph_style
- s = @styles.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']")
- s = s.at_xpath('w:name')
- s.attributes['val'].value
+ @styles.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']/w:name/@w:val").value
+ end
+
+ def style_name_of(style_id)
+ styles_configuration.style_of(style_id).name
end
- def style_name(style_id)
- s = @styles.at_xpath("w:styles/w:style[@w:styleId='#{style_id}']")
- s = s.at_xpath('w:name')
- s.attributes['val'].value
+ def styles_configuration
+ @styles_configuration ||= Elements::Containers::StylesConfiguration.new(@styles.dup)
end
private
@@ -206,6 +209,7 @@ def load_rels
#++
def update
replace_entry 'word/document.xml', doc.serialize(save_with: 0)
+ replace_entry 'word/styles.xml', styles_configuration.serialize(save_with: 0)
end
# generate Elements::Containers::Paragraph from paragraph XML node
diff --git a/lib/docx/elements.rb b/lib/docx/elements.rb
index 0855314..70e1e6e 100644
--- a/lib/docx/elements.rb
+++ b/lib/docx/elements.rb
@@ -1,3 +1,4 @@
require 'docx/elements/bookmark'
require 'docx/elements/element'
-require 'docx/elements/text'
\ No newline at end of file
+require 'docx/elements/text'
+require 'docx/elements/style'
\ No newline at end of file
diff --git a/lib/docx/elements/style.rb b/lib/docx/elements/style.rb
new file mode 100644
index 0000000..5f615ba
--- /dev/null
+++ b/lib/docx/elements/style.rb
@@ -0,0 +1,155 @@
+require 'docx/helpers'
+require 'docx/elements'
+require 'docx/elements/style/converters'
+require 'docx/elements/style/validators'
+
+module Docx
+ module Elements
+ class Style
+ include Docx::SimpleInspect
+
+ def self.attributes
+ @@attributes
+ end
+
+ def self.required_attributes
+ @@attributes.select { |a| a[:required] }
+ end
+
+ def self.attribute(name, *selectors, required: false, converter: Converters::DefaultValueConverter, validator: Validators::DefaultValidator)
+ @@attributes ||= []
+ @@attributes << {name: name, selectors: selectors, required: required, converter: converter, validator: validator}
+
+ define_method(name) do
+ selectors
+ .lazy
+ .filter_map { |node_xpath| node.at_xpath(node_xpath)&.value }
+ .map { |value| converter.decode(value) }
+ .first
+ end
+
+ define_method("#{name}=") do |value|
+ (required && value.nil?) &&
+ raise(Errors::StyleRequiredPropertyValue, "Required value #{name}")
+
+ validator.validate(value) ||
+ raise(Errors::StyleInvalidPropertyValue, "Invalid value for #{name}: '#{value.nil? ? "nil" : value}'")
+
+ encoded_value = converter.encode(value)
+
+ selectors.map do |attribute_xpath|
+ if (existing_attribute = node.at_xpath(attribute_xpath))
+ if encoded_value.nil?
+ existing_attribute.remove
+ else
+ existing_attribute.value = encoded_value.to_s
+ end
+
+ next encoded_value
+ end
+
+ next encoded_value if encoded_value.nil?
+
+ node_xpath, attribute = attribute_xpath.split("/@")
+
+ created_node =
+ node_xpath
+ .split("/")
+ .reduce(node) do |parent_node, child_xpath|
+ # find the child node
+ parent_node.at_xpath(child_xpath) ||
+ # or create the child node
+ Nokogiri::XML::Node.new(child_xpath, parent_node)
+ .tap { |created_child_node| parent_node << created_child_node }
+ end
+
+ created_node.set_attribute(attribute, encoded_value)
+ end
+ .first
+ end
+ end
+
+ def self.create(configuration, attributes = {})
+ node = Nokogiri::XML::Node.new("w:style", configuration.styles_parent_node)
+ configuration.styles_parent_node.add_child(node)
+
+ Elements::Style.new(configuration, node, **attributes)
+ end
+
+ def initialize(configuration, node, **attributes)
+ @configuration = configuration
+ @node = node
+
+ attributes.each do |name, value|
+ self.send("#{name}=", value)
+ end
+ end
+
+ attr_accessor :node
+
+ attribute :id, "./@w:styleId", required: true
+ attribute :name, "./w:name/@w:val", "./w:next/@w:val", required: true
+ attribute :type, ".//@w:type", required: true, validator: Validators::ValueValidator.new("paragraph", "character", "table", "numbering")
+ attribute :keep_next, "./w:pPr/w:keepNext/@w:val", converter: Converters::BooleanConverter
+ attribute :keep_lines, "./w:pPr/w:keepLines/@w:val", converter: Converters::BooleanConverter
+ attribute :page_break_before, "./w:pPr/w:pageBreakBefore/@w:val", converter: Converters::BooleanConverter
+ attribute :widow_control, "./w:pPr/w:widowControl/@w:val", converter: Converters::BooleanConverter
+ attribute :shading_style, "./w:pPr/w:shd/@w:val", "./w:rPr/w:shd/@w:val"
+ attribute :shading_color, "./w:pPr/w:shd/@w:color", "./w:rPr/w:shd/@w:color", validator: Validators::ColorValidator
+ attribute :shading_fill, "./w:pPr/w:shd/@w:fill", "./w:rPr/w:shd/@w:fill"
+ attribute :suppress_auto_hyphens, "./w:pPr/w:suppressAutoHyphens/@w:val", converter: Converters::BooleanConverter
+ attribute :bidirectional_text, "./w:pPr/w:bidi/@w:val", converter: Converters::BooleanConverter
+ attribute :spacing_before, "./w:pPr/w:spacing/@w:before"
+ attribute :spacing_after, "./w:pPr/w:spacing/@w:after"
+ attribute :line_spacing, "./w:pPr/w:spacing/@w:line"
+ attribute :line_rule, "./w:pPr/w:spacing/@w:lineRule"
+ attribute :indent_left, "./w:pPr/w:ind/@w:left"
+ attribute :indent_right, "./w:pPr/w:ind/@w:right"
+ attribute :indent_first_line, "./w:pPr/w:ind/@w:firstLine"
+ attribute :align, "./w:pPr/w:jc/@w:val"
+ 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
+ attribute :font_ascii, "./w:rPr/w:rFonts/@w:ascii"
+ attribute :font_cs, "./w:rPr/w:rFonts/@w:cs"
+ attribute :font_hAnsi, "./w:rPr/w:rFonts/@w:hAnsi"
+ attribute :font_eastAsia, "./w:rPr/w:rFonts/@w:eastAsia"
+ attribute :bold, "./w:rPr/w:b/@w:val", "./w:rPr/w:bCs/@w:val", converter: Converters::BooleanConverter
+ attribute :italic, "./w:rPr/w:i/@w:val", "./w:rPr/w:iCs/@w:val", converter: Converters::BooleanConverter
+ attribute :caps, "./w:rPr/w:caps/@w:val", converter: Converters::BooleanConverter
+ attribute :small_caps, "./w:rPr/w:smallCaps/@w:val", converter: Converters::BooleanConverter
+ attribute :strike, "./w:rPr/w:strike/@w:val", converter: Converters::BooleanConverter
+ attribute :double_strike, "./w:rPr/w:dstrike/@w:val", converter: Converters::BooleanConverter
+ attribute :outline, "./w:rPr/w:outline/@w:val", converter: Converters::BooleanConverter
+ attribute :outline_level, "./w:pPr/w:outlineLvl/@w:val"
+ attribute :font_color, "./w:rPr/w:color/@w:val", validator: Validators::ColorValidator
+ attribute :font_size, "./w:rPr/w:sz/@w:val", "./w:rPr/w:szCs/@w:val", converter: Converters::FontSizeConverter
+ attribute :font_size_cs, "./w:rPr/w:szCs/@w:val", converter: Converters::FontSizeConverter
+ attribute :underline_style, "./w:rPr/w:u/@w:val"
+ attribute :underline_color, "./w:rPr/w:u/@w:color", validator: Validators::ColorValidator
+ attribute :spacing, "./w:rPr/w:spacing/@w:val"
+ attribute :kerning, "./w:rPr/w:kern/@w:val"
+ attribute :position, "./w:rPr/w:position/@w:val"
+ attribute :text_fill_color, "./w:rPr/w14:textFill/w14:solidFill/w14:srgbClr/@w14:val", validator: Validators::ColorValidator
+ attribute :vertical_alignment, "./w:rPr/w:vertAlign/@w:val"
+ attribute :lang, "./w:rPr/w:lang/@w:val"
+
+ def valid?
+ self.class.required_attributes.all? do |a|
+ validator = a[:validator]
+ attribute_name = a[:name]
+ attribute_value = self.send(attribute_name)
+
+ validator&.validate(attribute_value)
+ end
+ end
+
+ def to_xml
+ node.to_xml
+ end
+
+ def remove
+ node.remove
+ @configuration.styles.delete(self)
+ end
+ end
+ end
+end
diff --git a/lib/docx/elements/style/converters.rb b/lib/docx/elements/style/converters.rb
new file mode 100644
index 0000000..711fe3d
--- /dev/null
+++ b/lib/docx/elements/style/converters.rb
@@ -0,0 +1,37 @@
+module Docx
+ module Elements
+ class Style
+ module Converters
+ class DefaultValueConverter
+ def self.encode(value)
+ value
+ end
+
+ def self.decode(value)
+ value
+ end
+ end
+
+ class FontSizeConverter
+ def self.encode(value)
+ value.to_i * 2
+ end
+
+ def self.decode(value)
+ value.to_i / 2
+ end
+ end
+
+ class BooleanConverter
+ def self.encode(value)
+ value ? "1" : "0"
+ end
+
+ def self.decode(value)
+ value == "1"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/docx/elements/style/validators.rb b/lib/docx/elements/style/validators.rb
new file mode 100644
index 0000000..6224f6c
--- /dev/null
+++ b/lib/docx/elements/style/validators.rb
@@ -0,0 +1,31 @@
+module Docx
+ module Elements
+ class Style
+ module Validators
+ class DefaultValidator
+ def self.validate(value)
+ true
+ end
+ end
+
+ class ColorValidator
+ COLOR_REGEX = /^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
+
+ def self.validate(value)
+ value =~ COLOR_REGEX
+ end
+ end
+
+ class ValueValidator
+ def initialize(*values)
+ @values = values
+ end
+
+ def validate(value)
+ @values.include?(value)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/docx/errors.rb b/lib/docx/errors.rb
new file mode 100644
index 0000000..a8d4b3c
--- /dev/null
+++ b/lib/docx/errors.rb
@@ -0,0 +1,7 @@
+module Docx
+ module Errors
+ StyleNotFound = Class.new(StandardError)
+ StyleInvalidPropertyValue = Class.new(StandardError)
+ StyleRequiredPropertyValue = Class.new(StandardError)
+ end
+end
\ No newline at end of file
diff --git a/lib/docx/helpers.rb b/lib/docx/helpers.rb
new file mode 100644
index 0000000..257845d
--- /dev/null
+++ b/lib/docx/helpers.rb
@@ -0,0 +1,22 @@
+module Docx
+ module SimpleInspect
+ # Returns a string representation of the document that is far more readable and understandable
+ # than the default inspect method. But you can still get the default inspect method by passing
+ # true as the first argument.
+ def inspect(full = false)
+ return(super) if full
+
+ variable_values =
+ instance_variables.map do |var|
+ value = v = instance_variable_get(var).inspect
+
+ [
+ var,
+ value.length > 100 ? "#{value[0..100]}..." : value
+ ].join('=')
+ end
+
+ "#<#{self.class}:0x#{(object_id << 1).to_s(16)} #{variable_values.join(' ')}>"
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/docx/document_spec.rb b/spec/docx/document_spec.rb
index 42587dc..81d57b8 100755
--- a/spec/docx/document_spec.rb
+++ b/spec/docx/document_spec.rb
@@ -35,6 +35,18 @@
end
end
+ describe "#inspect" do
+ it "isn't too long" do
+ doc = Docx::Document.open(@fixtures_path + '/office365.docx')
+
+ expect(doc.inspect.length).to be < 1000
+
+ doc.instance_variables.each do |var|
+ expect(doc.inspect).to match(/#{var}/)
+ end
+ end
+ end
+
describe 'reading' do
context 'using normal file' do
before do
@@ -347,12 +359,15 @@
context 'wps modified docx file' do
before { @doc = Docx::Document.open(@fixtures_path + '/saving_wps.docx') }
+
it 'should save to a normal file path' do
@new_doc_path = @fixtures_path + '/new_save.docx'
@doc.save(@new_doc_path)
@new_doc = Docx::Document.open(@new_doc_path)
expect(@new_doc.paragraphs.size).to eq(@doc.paragraphs.size)
end
+
+ after { File.delete(@new_doc_path) if File.exist?(@new_doc_path) }
end
end
@@ -518,21 +533,160 @@
end
end
- describe 'reading style' do
+ describe 'reading and manipulating paragraph style' do
before do
- @doc = Docx::Document.open(@fixtures_path + '/test_with_style.docx')
+ @doc = Docx::Document.open(@fixtures_path + '/styles.docx')
end
it 'read default style when not' do
nb = @doc.paragraphs.size
- expect(nb).to eq 6
- expect(@doc.paragraphs[0].style).to eq 'Normal'
- expect(@doc.paragraphs[1].style).to eq 'STYLE1'
- expect(@doc.paragraphs[2].style).to eq 'heading 1'
- expect(@doc.paragraphs[3].style).to eq 'Normal'
- expect(@doc.paragraphs[4].style).to eq 'Normal'
- expect(@doc.paragraphs[5].style).to eq 'STYLE1'
+
+ expect(@doc.paragraphs.map(&:style)).to eq([
+ "Title",
+ "Subtitle",
+ "Author",
+ "Date",
+ "Compact",
+ "Heading 1",
+ "Heading 2",
+ "Heading 3",
+ "Heading 4",
+ "Heading 5",
+ "Heading 6",
+ "Heading 7",
+ "Heading 8",
+ "Heading 9",
+ "First Paragraph",
+ "Body Text",
+ "Block Text",
+ "Table Caption",
+ "Image Caption",
+ "Definition Term",
+ "Definition",
+ "Definition Term",
+ "Definition",
+ ])
+
+ expect(@doc.paragraphs.map(&:style_id)).to eq([
+ "Title",
+ "Subtitle",
+ "Author",
+ "Date",
+ "Compact",
+ "Heading1",
+ "Heading2",
+ "Heading3",
+ "Heading4",
+ "Heading5",
+ "Heading6",
+ "Heading7",
+ "Heading8",
+ "Heading9",
+ "FirstParagraph",
+ "BodyText",
+ "BlockText",
+ "TableCaption",
+ "ImageCaption",
+ "DefinitionTerm",
+ "Definition",
+ "DefinitionTerm",
+ "Definition",
+ ])
+ end
+
+ it 'set paragraph style' do
+ nb = @doc.paragraphs.size
+ expect(nb).to eq 23
+
+ @doc.paragraphs.each do |p|
+ p.style = 'Heading 1'
+ expect(p.style).to eq 'Heading 1'
+ end
+
+ @doc.paragraphs.each do |p|
+ p.style_id = 'Heading2'
+ expect(p.style).to eq 'Heading 2'
+ end
+ end
+
+ it 'raises if invalid paragraph style' do
+ expect { @doc.paragraphs.first.style = 'invalid' }.to raise_error(Docx::Errors::StyleNotFound)
+ end
+ end
+
+ describe 'reading and manipulating document styles' do
+ before do
+ @doc = Docx::Document.open(@fixtures_path + '/styles.docx')
+ end
+
+ it '#default_paragraphy_style' do
+ expect(@doc.default_paragraph_style).to eq 'Normal'
end
+
+ it 'manipulates existing document styles' do
+ styles_config = @doc.styles_configuration
+
+ expect(styles_config.size).to eq 37
+
+ heading_style = styles_config.style_of('Normal')
+ expect(heading_style).to be_a(Docx::Elements::Style)
+
+ expect(heading_style.id).to eq "Normal"
+ expect(heading_style.font_color).to eq(nil)
+
+ heading_style.font_color = "000000"
+ expect(heading_style.font_color).to eq("000000")
+
+ expect(heading_style.node.at_xpath("w:rPr/w:color/@w:val").value).to eq("000000")
+ end
+
+ it 'creates document styles' do
+ styles_config = @doc.styles_configuration
+
+ expect(styles_config.size).to eq 37
+ expect { styles_config.style_of('Red') } .to raise_error(Docx::Errors::StyleNotFound)
+
+ red_style = styles_config.add_style("Red")
+ expect(styles_config.size).to eq 38
+
+ expect(red_style).to be_a(Docx::Elements::Style)
+ expect(red_style.id).to eq "Red"
+ expect(red_style.name).to eq "Red"
+
+ expect { red_style.font_color = "#FFFFFF" }.to raise_error(Docx::Errors::StyleInvalidPropertyValue)
+ expect { red_style.font_color = "blue" }.to raise_error(Docx::Errors::StyleInvalidPropertyValue)
+ expect { red_style.font_color = "FF0000" }.not_to raise_error
+
+ styles_config.remove_style("Red")
+ expect(styles_config.size).to eq 37
+ expect { styles_config.style_of('Red') }.to raise_error(Docx::Errors::StyleNotFound)
+ end
+
+ it 'persists document styles' do
+ styles_config = @doc.styles_configuration
+ styles_config.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20)
+ @doc.paragraphs[5].style = "Red"
+
+ first_modified_styles_path = @fixtures_path + '/styles_modified.docx'
+ second_modified_styles_path = @fixtures_path + '/styles_modified2.docx'
+ @doc.save(first_modified_styles_path)
+
+ modified_styles_doc = Docx::Document.open(first_modified_styles_path)
+ modified_styles_config = modified_styles_doc.styles_configuration
+
+ expect(modified_styles_config.style_of('Red')).to be_a(Docx::Elements::Style)
+ modified_styles_config.remove_style("Red")
+ modified_styles_doc.save(second_modified_styles_path)
+
+ modified_styles_doc = Docx::Document.open(second_modified_styles_path)
+ modified_styles_config = modified_styles_doc.styles_configuration
+ expect { modified_styles_config.style_of('Red') }.to raise_error(Docx::Errors::StyleNotFound)
+
+ File.delete(first_modified_styles_path)
+ File.delete(second_modified_styles_path)
+ end
+
+ after { File.delete(@new_doc_path) if @new_doc_path && File.exist?(@new_doc_path) }
end
describe '#to_html' do
diff --git a/spec/docx/elements/style_spec.rb b/spec/docx/elements/style_spec.rb
new file mode 100644
index 0000000..109ae54
--- /dev/null
+++ b/spec/docx/elements/style_spec.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'docx'
+
+describe Docx::Elements::Style do
+ let(:fixture_path) { Dir.pwd + "/spec/fixtures/partial_styles/full.xml" }
+ let(:fixture_xml) { File.read(fixture_path) }
+ let(:node) { Nokogiri::XML(fixture_xml).root.children[1] }
+ let(:style) { described_class.new(double(:configuration), node) }
+
+ it "should extract attributes" do
+ expect(style.id).to eq("Red")
+ end
+
+ describe "attribute getters" do
+ it { expect(style.id).to eq("Red") }
+ it { expect(style.name).to eq("Red") }
+ it { expect(style.type).to eq("paragraph") }
+ it { expect(style.keep_next).to eq(false) }
+ it { expect(style.keep_lines).to eq(false) }
+ it { expect(style.page_break_before).to eq(false) }
+ it { expect(style.widow_control).to eq(true) }
+ it { expect(style.suppress_auto_hyphens).to eq(false) }
+ it { expect(style.bidirectional_text).to eq(false) }
+ it { expect(style.spacing_before).to eq("0") }
+ it { expect(style.spacing_after).to eq("200") }
+ it { expect(style.line_spacing).to eq("240") }
+ it { expect(style.line_rule).to eq("auto") }
+ it { expect(style.indent_left).to eq("0") }
+ it { expect(style.indent_right).to eq("0") }
+ it { expect(style.indent_first_line).to eq("0") }
+ it { expect(style.align).to eq("left") }
+ it { expect(style.outline_level).to eq("9") }
+ it { expect(style.font).to eq("Cambria") }
+ it { expect(style.font_ascii).to eq("Cambria") }
+ it { expect(style.font_cs).to eq("Arial Unicode MS") }
+ it { expect(style.font_hAnsi).to eq("Cambria") }
+ it { expect(style.font_eastAsia).to eq("Arial Unicode MS") }
+ it { expect(style.bold).to eq(true) }
+ it { expect(style.italic).to eq(false) }
+ it { expect(style.caps).to eq(false) }
+ it { expect(style.small_caps).to eq(false) }
+ it { expect(style.strike).to eq(false) }
+ it { expect(style.double_strike).to eq(false) }
+ it { expect(style.outline).to eq(false) }
+ it { expect(style.shading_style).to eq("clear") }
+ it { expect(style.shading_color).to eq("auto") }
+ it { expect(style.shading_fill).to eq("auto") } # TODO
+ it { expect(style.font_color).to eq("99403d") }
+ it { expect(style.font_size).to eq(12) }
+ it { expect(style.font_size_cs).to eq(12) }
+ it { expect(style.underline_style).to eq("none") }
+ it { expect(style.underline_color).to eq("000000") }
+ it { expect(style.spacing).to eq("0") }
+ it { expect(style.kerning).to eq("0") }
+ it { expect(style.position).to eq("0") }
+ it { expect(style.text_fill_color).to eq("9A403E") }
+ it { expect(style.vertical_alignment).to eq("baseline") }
+ it { expect(style.lang).to eq("en-US") }
+ end
+
+ it "should allow setting simple attributes" do
+ style.id = "Blue"
+
+ # Get persisted to the style method
+ expect(style.id).to eq("Blue")
+
+ # Gets persisted to the ./node
+ expect(node.at_xpath("./@w:styleId").value).to eq("Blue")
+ end
+
+ it "should allow setting complex attributes" do
+ style.shading_style = "complex"
+
+ # Get persisted to the style method
+ expect(style.shading_style).to eq("complex")
+
+ # Gets persisted to the node
+ expect(node.at_xpath("./w:pPr/w:shd/@w:val").value).to eq("complex")
+ expect(node.at_xpath("./w:rPr/w:shd/@w:val").value).to eq("complex")
+ end
+
+ it "should allow setting attributes to nil" do
+ style.shading_style = nil
+
+ expect(style.shading_style).to eq(nil)
+ expect(node.at_xpath("./w:pPr/w:shd/@w:val")).to eq(nil)
+ expect { node.at_xpath("./w:pPr/w:shd/@w:val").value }.to raise_error(NoMethodError) # i.e. it's gone!
+ end
+
+ describe "#to_xml" do
+ it "should return the node as XML" do
+ expect(style.to_xml).to eq(node.to_xml)
+ end
+
+ it "should change underlying XML when attributes are changed" do
+ style.id = "blue"
+ style.name = "Blue"
+ style.font_size = 20
+ style.font_color = "0000FF"
+
+ expect(style.to_xml).to eq(node.to_xml)
+ expect(style.to_xml).to include('')
+ expect(style.to_xml).to include('')
+ expect(style.to_xml).to include('')
+ expect(style.to_xml).to include('')
+ expect(style.to_xml).to include('')
+ expect(style.to_xml).to include('')
+ end
+ end
+
+ describe "validation" do
+ let(:fixture_path) { Dir.pwd + "/spec/fixtures/partial_styles/basic.xml" }
+
+ it "validation: id" do
+ expect { style.id = nil }.to raise_error(Docx::Errors::StyleRequiredPropertyValue)
+ end
+
+ it "validation: name" do
+ expect { style.name = nil }.to raise_error(Docx::Errors::StyleRequiredPropertyValue)
+ end
+
+ it "validation: type" do
+ expect { style.type = nil }.to raise_error(Docx::Errors::StyleRequiredPropertyValue)
+
+ expect { style.type = "invalid" }.to raise_error(Docx::Errors::StyleInvalidPropertyValue)
+ end
+
+ it "true" do
+ expect(style).to be_valid
+ end
+
+ describe "unhappy" do
+ let(:fixture_xml) do
+ <<~XML
+
+
+
+
+
+
+ XML
+ end
+
+ it "false" do
+ expect(style).to_not be_valid
+ end
+ end
+
+ end
+
+ describe "basic" do
+ let(:fixture_path) { Dir.pwd + "/spec/fixtures/partial_styles/basic.xml" }
+
+ it "should allow setting simple attributes" do
+ expect(style.id).to eq("MyCustomStyle")
+ style.id = "Blue"
+
+ # Get persisted to the style method
+ expect(style.id).to eq("Blue")
+
+ # Gets persisted to the node
+ expect(node.at_xpath("./@w:styleId").value).to eq("Blue")
+ end
+
+ it "should allow setting complex attributes" do
+ expect(style.shading_style).to eq(nil)
+ expect(style.to_xml).to_not include('')
+ style.shading_style = "complex"
+
+ # Get persisted to the style method
+ expect(style.shading_style).to eq("complex")
+
+ # Gets persisted to the node
+ expect(node.at_xpath("./w:pPr/w:shd/@w:val").value).to eq("complex")
+ expect(node.at_xpath("./w:rPr/w:shd/@w:val").value).to eq("complex")
+ expect(style.to_xml).to include('')
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/fixtures/partial_styles/basic.xml b/spec/fixtures/partial_styles/basic.xml
new file mode 100644
index 0000000..ed125d5
--- /dev/null
+++ b/spec/fixtures/partial_styles/basic.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spec/fixtures/partial_styles/full.xml b/spec/fixtures/partial_styles/full.xml
new file mode 100644
index 0000000..3e1d8fb
--- /dev/null
+++ b/spec/fixtures/partial_styles/full.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spec/fixtures/styles.docx b/spec/fixtures/styles.docx
new file mode 100644
index 0000000..3176887
Binary files /dev/null and b/spec/fixtures/styles.docx differ