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