From 7de90f9979a96ac016d0d62b009f8ab2696c8ff0 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Fri, 1 Dec 2023 12:15:31 -0500 Subject: [PATCH 1/8] Implement creating, removing, and editing existing styles. Utilize this system throughout the app. --- README.md | 19 ++- lib/docx/containers.rb | 1 + lib/docx/containers/paragraph.rb | 28 ++-- lib/docx/containers/styles_configuration.rb | 52 +++++++ lib/docx/containers/text_run.rb | 7 +- lib/docx/document.rb | 36 +++-- lib/docx/elements.rb | 3 +- lib/docx/elements/style.rb | 125 +++++++++++++++ lib/docx/elements/style/converters.rb | 37 +++++ lib/docx/elements/style/validators.rb | 21 +++ lib/docx/errors.rb | 6 + lib/docx/helpers.rb | 22 +++ spec/docx/document_spec.rb | 163 ++++++++++++++++++-- spec/docx/elements/style_spec.rb | 130 ++++++++++++++++ spec/fixtures/partial_styles/basic.xml | 15 ++ spec/fixtures/partial_styles/full.xml | 47 ++++++ spec/fixtures/styles.docx | Bin 0 -> 10214 bytes 17 files changed, 674 insertions(+), 38 deletions(-) create mode 100644 lib/docx/containers/styles_configuration.rb create mode 100644 lib/docx/elements/style.rb create mode 100644 lib/docx/elements/style/converters.rb create mode 100644 lib/docx/elements/style/validators.rb create mode 100644 lib/docx/errors.rb create mode 100644 lib/docx/helpers.rb create mode 100644 spec/docx/elements/style_spec.rb create mode 100644 spec/fixtures/partial_styles/basic.xml create mode 100644 spec/fixtures/partial_styles/full.xml create mode 100644 spec/fixtures/styles.docx diff --git a/README.md b/README.md index ac9054e..a3231d8 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,24 @@ 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') + +# see lib/docx/elements/style.rb for more attributes you can set! +new_style = d.styles_config.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.styles_config.remove_style("Red") +``` + + ## Development ### todo @@ -188,5 +206,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 34004de..44ec6c0 100755 --- a/lib/docx/containers/paragraph.rb +++ b/lib/docx/containers/paragraph.rb @@ -77,32 +77,42 @@ 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 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 1bc717f..b376021 100755 --- a/lib/docx/containers/text_run.rb +++ b/lib/docx/containers/text_run.rb @@ -111,8 +111,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..a4dd3dd 100755 --- a/lib/docx/document.rb +++ b/lib/docx/document.rb @@ -1,10 +1,12 @@ require 'docx/containers' require 'docx/elements' +require 'docx/errors' +require 'docx/helpers' require 'nokogiri' require 'zip' module Docx - # The Document class wraps around a docx file and provides methods to + # The Document class wraps around a docx file and pro.es methods to # interface with it. # # # get a Docx::Document for a docx file in the local directory @@ -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..614d329 --- /dev/null +++ b/lib/docx/elements/style.rb @@ -0,0 +1,125 @@ +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.attribute(name, *selectors, converter: Converters::DefaultValueConverter, validator: Validators::DefaultValidator) + 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| + validator.validate(value) || raise(Errors::StyleInvalidPropertyValue, "Invalid value for #{name}: #{value}") + + selectors.map do |attribute_xpath| + encoded_value = converter.encode(value).to_s + if (existing_attribute = node.at_xpath(attribute_xpath)) + existing_attribute.value = encoded_value + next value + end + + 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" + attribute :name, "./w:name/@w:val", "./w:next/@w:val" + attribute :type, ".//@w:type" + 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 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..64a4559 --- /dev/null +++ b/lib/docx/elements/style/validators.rb @@ -0,0 +1,21 @@ +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 + end + end + end +end diff --git a/lib/docx/errors.rb b/lib/docx/errors.rb new file mode 100644 index 0000000..85526aa --- /dev/null +++ b/lib/docx/errors.rb @@ -0,0 +1,6 @@ +module Docx + module Errors + StyleNotFound = Class.new(StandardError) + StyleInvalidPropertyValue = 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 980327a..e4d3648 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 @@ -505,23 +520,153 @@ 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 'reads document styles' do + styles_config = @doc.styles_configuration + + expect(styles_config.size).to eq 37 + + expect(styles_config.style_of('Normal')).to be_a(Docx::Elements::Style) + end + + it 'manipulates 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 before do @doc = Docx::Document.open(@fixtures_path + '/internal-links.docx') diff --git a/spec/docx/elements/style_spec.rb b/spec/docx/elements/style_spec.rb new file mode 100644 index 0000000..17c9cb2 --- /dev/null +++ b/spec/docx/elements/style_spec.rb @@ -0,0 +1,130 @@ +# 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(:node) do + Nokogiri::XML(File.open(fixture_path)).root.children[1] + end + 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 + + 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" + + 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('') + 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 0000000000000000000000000000000000000000..3176887ac950aaf1a1daba243cdd21caa689f055 GIT binary patch literal 10214 zcmZ`Nu_l<#X9E;ZRSY#x zu8AZ$x$!$QIaO^sLk{BitTL%3PTXpkh`MB!(s(Zh-LKH>sq3;>9?+yYy6DOc;*Qa2 z(9KZv!iI!6B|Gz$zK++lX8}LZXg6JQr}#e4li);DZ|lztvF_y1F}W{I9gBb7q;t1; zcv~C~yAMYRshqr^=8blc4MR8>ITUr$CoqpDJkMuP{-KFa@{0}lJKDr@MyReBVb?t z&9mfYl?Ud-c`QJ%l(F4j#G!1R>!^uQKSZd~?NOov(A47JkQ2!AB%U{?b_b$FkTkI* z5Z=FyvXPr_qjT}l$kBEWrT9mTio}E=&R$|O3kCuL|MIJ653q7zWccg5Ja)taoEbsv z?jA1JOFXWfnli&mn50#C8kwnKPv|Rj!*qd<-aCxtc*hj6#Kg)dB;l*Rb>JNBE=zBg z!TY%^>cxYgNpsirvVyxRGpegM%Xo>Bhrz1x*8w@%rt-)keYZPu#fCW~0@kDJRlE+t zLyOomsJe4EgnO9?W>HBNr))d*slLl!-f#yKt~giKML6%%T!f-_pb4`#lh0pZ)r#Dy zRY1VQDIBkUP!7J8?fR5jK=+gk{dPk>=ZbpQk1Ag&|M)85=Q_W;;a3FwI_8;XEjvDt;Al36lEI(rkEy;-Xl(fvjKn);WJ?~6GX!KY36Kn{KN#U z{pW%8B@#34`TJeFcZ~41!7y>}PZR77mCAP$wyLX6xRY`h&b=UkDn+^+JlC9VR}3+GX8@_Uif&9~O$@Y~;lE{K~xz)BJa7k<^j;-1v&XsT~DhnZncYE>m;M8GlZR zt;Lh=_tV|)ypx#EP`|rXaJF%#5;f;$R$1mE5PFM^Q>_U$#Pe;bj#lA3zqy3`Ga@Q+ z9&e~$BBJsV5!8Q2#0Y2)_$ws&u{xIiOc+7uceE6Dc=e8iT?G-5y4FldMn*2AT-G9E z&{EthJ{@MI>65K%Favj7(fdiNrMVWd#J0M4ZK7g{xe#m|!bhiQUbDy7pydZTee_%X zU8$^CA7VD+77O{doI{;SHT1Sjn=!-;zt?6571ycB9EQtSJtDG(&IjMkgb}#a)q9hG!R-9=!`)yt4suqI)@z#VC zg5E`ciCxnReuuOdSAzmnbA(1*kEYKV%(O`oR|S`v6MY6DH^(4}{=;Ae5>tthW=6aY zSM12s=Q!fZwBKcMet9z29m%w=Dix*|>ELv}UM2mbS zg82TzGa*te3Zt%feswK*j2N9}v;Ro@aSQ~jfHXU;%ZPt?^W(RYJ2Jfo1R#x66FFF{ zRdVHO%$4Jz8!i&8*b=3T6j=o%0`>Mb2D|Qfr-H9REI>HAf4yoW(;Y)BbcNRVfQE$* zhD_E*8ax38nHH5(9YQjq31p(1dhG2xswPs+L}hHY?oJl03;5B48bunPl909P5W>h( z=ld)lzXn+?Oka7@E70AEJdVfRI+dCtKVd`fhy++&Ix`dlFs5xzTZH)7PL^9d;HgFW=Rpi~hx`l6Y z@3U2Lzo_%|tUWo8(c_lom3es9?~;PUBf*m%|Fl7A{3JkQw6^jtH;3F?zmmS|IKqGV z=mg`l^1RSGI>o?Umr}H4KM{@JfJfBF@WDa7W)aj_Ms(4@sVbCaMOAq*T0%~L(&<`MC;^4 zY1m{#_!Oo8897$MT+ z6BIig&IXS=g>1SlKpjPe!Vs`vKU2?U!j$!`a3-A1zH2CiB(_rxJ;Ajm`5DF=s6gY( zDNRJUh|TADA>pMimyyB!E}8z&Q!n&wv(?bvC3K^*609TCImmXj>}99|yAiKJ8V=Wk zl8$m!$IRQ!)V8aU;CW#oHo2+>kT*-kj6}}1uR2-R{h+22vEz%W9V0{^ zgQAZdE~9RnH`A)Xm=XKV6XFTr-p5m`ZL&jC&6?19YfcrLgm9|3gQkk7)yxK6LpTJb5TIKWAgZ5Pl>L@V#Ktq?NJ!v@iNaTmw zIsTugfXAvNcj`-(Xom#>L3=sQew_j)FSJwDz|iVfnK(_*QTV4!NXmV2zzB-lTHKa! zJ`5ap)zQF}Gm}|nS?##%OhRf1VZI#x#6|<$$jIR3Doh{Wa5J$knwAYi;S!0Udu!Ni zXh@i|u)94hakZqc1r>@RuDyxHtw<^zIRw5kZzs()C6cTHY8k4UhVxy`Qkx1k{4h5H zUOuo|Ct{!>2bbA4R$jA03BCM>RGo5(T7|J}ebB6HL-+tJx*2=Zm!ws88Z7_`Od+ZJ z(l<$yF+-!l(3lkN&7~MRQ5?ff%hiV%Kx0EMjZ#WVCB2S_Fy~0aqmweulGDy;@8NnC zY1k*~T%9-*%PI`+ZredqhWc#+8k=%X=;5N%&|^+-ZSy{J`OIY=t3q4`+`?t|octR) zE>tfj9o~L&kfSbJgT0$B-kc{FCJ8Zu3Ls|2Qv=x}CMgB9C4mcLujxV7ELi`a*prZ}Y5#aEvFvX47 zbTgrd-Fb@b@Nw2DtBFxqDK4mG6YD2SRbzORUB=Y;=fte%A0;&%=@Gty)SXJLlu4k54S!mVFPu z1aDlKAfQ=cg78_h;3}ML>-nJ9jd$z&LdMR*kGVY8(iG({2oH#!H8_j@F(p!o`A_u% zgyZUCKt=Gr4Gc8WI&;2L4n%z$Ru-Bkn_;MGGCTC zM0X?>j4%&~AZMjI`&yZAXMn6NEKkPz9L$=*u)*}cmeTn^+isB~r)FJ$|3@yS-ktUn z4a5E#4UIp@yhMe4XRUNOjv<1yG`m0Km#hru=6Z4^0+_z2R5rdQC)gf#Bmgs+v;(AMMeOZw4wLs zBm+9H!^yI$Oc`3(Hj|nt#|P84u4F`E`Myx^0T~1T%e&*s%ABH|d+0ws!92Wv5cra< z)-eCdRvRa4Lx8=xjp<+6+Lt)?a@9o`IQM{ye@wa1)JnL0Up&KhI>`qTSSKb8CJlDF z?v3Q{d`gX9))zLt z6T;gt5H{8fPId>1O0JyAAW7CYG`&->{B|N!_TeY`8zv<)Ac*24Mp83+Qqm&6Ac2Af zNHKm=a~mi{mjFJ9cTz8S*n-}e#w{%AM309H{-lW+vQ!{!zmGS;yuhD9gDJ7tZg@%E zd#HbLMhAeS<4ef?PG2<{l@%tG_A6RmevU=h7-wDx6F508!I9a?R=P@bx$BEEIRUiLG=4Mh>cH%B00 zQT^075(K45*eo;jPCcdMQY6IZPU<$QB@&get&gsmP9Ct)vOO&$w)A=66kcORiJVar z66OXGGyPrU4V~~I2xNMJwQiN(;RGciKas?iS3OzJU~xD2lJNR#?5Q;<*U(3lO{Fsz z2Kw?SxoUzep2EmFSgE~Lw3B3I`eCnwYIj$HGC#^+_zVWpQ?5i;10|lZwfdeW%hid9 zf5Jd-u#{xDZxq^#WD1kF)f`VmDp5{f%h^QF&59I3vPJu~!Wm>8gbtRU-?=JEwJP;s z5=EU-Lmpk^;}@`C(77I9G2%Jmp7FzPh zbGF3Elw~?`xWhtOa2neKPCL@`0g!tCHs0Fs%9)Tb-9D)GEXEp(ukoO%&-WN9qOlUB z_>q{r;bKwLWv{vxmFI5pK19d$#IQG!NV{RtSpgZPjg-fI>c<2YV?=y`?+=?DJ)t;0 z5Qc;8nYQ76O@2Z*nlhQ}yeStGS0{c)Po9RKnTvBn#D6AvAc5+5+RGU|@bX~%hZ4B} z3|0P>=Q>e5NH3LC^f9g5UcrhQCF~oj+yPl$C#Y-*`uug`M%d z7w!{Xko1`YJ!Al=1b%_^I`H(2B9)sK{Ls)}0-1)HwCjDA--`4&4Sk?8+vq0ZZj+$? zL+^pyHqM)R1A@;X;3(B6>D*>ss_Tm+$Fd>kO?F6)mM*AA>XC~EM`LiNM@tTqTF}J0 zB^^9BuLvG*kj9&wm+kt68w3RTKdW(YbhG-K5}aylJ1lczc%N5Zd~e&$h>#(5PJyvyw%NeX zrUNZbbgNW{(}X;8`Plw=Khqk0Y&on8CP~OZG-b(R`#GX-Ark5)kqP1&Jj$Tg%|TYU z(>0C`&y`<-=u6-F+W=I{y4107C|^*0PwEy?W~Z6;c{O)!qwN6*j~L|&Z5)cHKoGIB zLYJtXch}V}@ca6hOmC@U#m}UqWh8N#Eh$f#qi~$&7e2bf$Th>+ZU#|z?~E|z4DJ%Z zP~$5@NGHo&=Ei?jdQY-JlBt3}ym&3Za0}t*yK-i8B|bZfC|Ebx#N__WM02D`GWNX) z96RPN&LBHHI?|7LI%u1QU--k`7YzrZNZ;T*D*bU1XsGCLcKHtk5~r$)bxl$;I1-~2 zLIl_!uPPTk0)7VA^>e%9@9?0}6r;Cgk(soiKpkR8$6T{j$9ylmVRM&HzFfK1aAWsEJJ4tf@J{z z(!U`}j2QVHQ_NGyLW6Kt&35DLZTQy1L*Zxhy-P|Yw6A3(VbZ%aa6jtusJniU=UW44 zc1PqA(TwS3V1rA`KOKP~Lnj%FJGHbK_)v2v=tMz8zadt@Ss5ORqrAAc<=}HllVhL* zv7|+}$Rc0KDh+8KvgjYJ8fiA!u_nEe^L>;3p63FN*<8zTnl4m|uXNqEFEJ^8fO@x0 z4x)tfIwt=j@!W2BEFo>9TSNPcX*_Di5FIWlx?PkEq9#YjGN52|nQmO~%6W)QU?uUs z_-Yoj%@GplSyNFx0KG`GG9&e!p3)|LuT<=;O414m&J2r$?vOF4M_6fF{~4c~CQ>L{ ztY)6H!OK^0RikeBRJvKd^u1!aqVvOOUd6guTyYn4C5}L6SEYhbfrgr7#xO%I&sn35 zO=PQVPezUPQb*l!3V>#Q*_oojhZ0SF_-24{B?@mk88oMyRkV5HAyIU)5;upCN0i4j z!~(@SleA<_#A$Flq&afX~h-x*0-6zE#)ErwHA-f{Ts*RX$MJP~9V}FqY6;34{UarYII9Cz(PT z#4tdx6F#lC0T3SQp_8PTOqGzmI*d%^1wo7$_h~%XyZm6K=FRFL`*aY7#$3t` zS$JbIkIp!T>Z!iTu59cjya_*)3DFa|RAV~`D~H4IFD6WUS-@Q%mceE0Z%2-gH5SnVPH%F48m%)8}`raT7DRi>SBBkl`6 z*MQGveJQt4sm2=bC_K83nn*a%>1j6UK&-DoX=lI!&nV;ebcRVVoIowq!-=mAU)EFd zAgH8Y*!!(?K9^y5CcAXIT*zHXsmlIYGl&+^&wB(3RJ6Gb6-nW*Z)1izl@BHXb~DkI zvJ}v#X18<`Ntk^W^tsd;O+9gH>8_F~_`Zu}!u_jnLY(4>Xj^@q=M=Nm;S*%x-z!ML znl^SwQ=BAKwDBG@K)|=sD;ol}k4heO%R)%q3cozO@h>;#sYZQZ@3SX0Eeu-$dl#s; zG@^;1Twr|fM)o-1o2#HbmN;F$6_t>sY+JJYje~-{$6azThtE3 z%o~9|7U4}l_>pQ!ixC(fXb-T|77FpFq*$7&31jd{_Rc)%5pFPWlE6VdR}0G>mqT;C@bq?z;FYv+-e^Y-X8*-k4R z-8q-pa*_S|3c_i>N5OTPIuCWtuLIJu3{{4%3w#S|edR;=>hV@;s8rWufP{Xf<4{KT zuce8oz-0m9ejb#h=N;3}&#x{}7b82-G%wc%bx05pg8!7Wzk4wM)-HebW9nx|lwXvK zk6T`uWN~c}CG$}#2jc06-I3eidA3cmLx5P7d>{}*x@aaal`c7xfS<*8tO1+w9>PeQ zQYA%{nUVxvt)WK#P~xY3ZNZ9FwVQubZfhTVc|&jcC-!{A9B<=MEJPR^@_eBdWg2qM z78w=Rv2gH8U;3}EeWRXGV+MY%Xz;8 zDQ?3qVJ983*QRWX96Z$W(RuG+#J>p*GW?8Q+J40tGET`38ed#X|KeuCKV57B{Fj>W z%g2$?V{k7-DBzr5Y_U~N4dqioThXLHb}=$atTld$KrHpm>6n<(#im_yI?$xVggs@I zS#+1qUrh;2h&D~tS>N_leuR+iCZ{E9kEWwn>0YvCKpe_Q+FQXKgB*_tet*g&-=*s! zeu7DHqOTV^@3>}l`eNwgL%38+@PJQ9#d$)fKRSm$`xD3vbDZCMXRv8$sO*GP-jc5d zo!9c6$@rDkDC1x1YU7%_?BsFi76i?7rYe_i(Ent7dr48NeFA z`0JbbmmIDs2V7ytX+Kv-X=$vnqIL4)7CTuyoi9K{YZQsD6P2-|Rw6m1Xh%G?>-=c2 zR@DP{($G^?E(m`z{gh%w*no7Pha)zG22hENYgh?;2Q878vH!FuGu`gF%SpD0!W?D3 zTAvn<2%oi>%OB-m;pD;gWwN@$%G(y#CIm$cM$&PVLw^A-P?Fs$)x#d|TR59gwI@e5 zou4q0rv4)WkrWMTb!udP3ZW2zHc1fbkyaxW`UxqMw3t?^!xlK1fMM4RY;={Eg83{i z+%Yldp#c9^ukKD#3LTlF%fl%cLU?$T!9dTcdc30vu-iRAt3Ild$b5*%4ab{3gy*c{ z5IP)eS<^+`jyfF+GZVZ-U;nNw0bA)%I8Z(X_hUNWm7b^>+B}P*6FW`!jxF|OcnCQW z%h0OzJ_`K%BNXCtBXooVb*i4s6pS3C~o3}X!=o2#x?O%xQh3xa>1D{wfu1p^g;d-qf$f6PFf(R8U3^M z_qbf!_u+eh_;J6pf{ol!MrxaWu@as6^I-)0on$t7rq9jyyTa&9pQk0_4FR^TMjV3U z@2F8I&>T~!i@8ciX+n^AwXDkGq`lk#S|rX018J5nXtJ_S;5dmdl|Hf>xLfd0zrLt$ z`g$o&8*xo>C)LgkQwfO+H6`EhXq1lnQJseD*DP^?)T2&5805&KNBCkQS#-^nMd~`@ zoUr0nQ4?Y)YH_f<^8-*}s&$rqTDojh^BBMWJ3piaaX$0GE2@_*c4pr_~J@Y zvwfrkB}3J!NTgE5W?2$uII%pmu~OT&`o;}-;-bOpAIaG8S_F)ADK9<@=0S%jGF_nV zi%-+DmnM1Y+{UNCEt1w2!2!UCak4o`^0^&TiLOO(C{0@J2#z+9?|sWOV4RDYR)k6| zsR%~`I-B^fT$s>IKU_B2p`?UUda(ko+I0^nQKR(cD#(}0x3tX`YIyjc_ojH}iyDpT zA}o!n`4+&iQ|&WZh&%B4-0cr1KJtqEWr|bX@Rch?ga%|V!Ek9&y!A4jpETYQ} zwkM%F`j=LW<*)rzlrQlm!+i5V5g+Z~g{j|}`E8^F&pX35W8-iWSp7vcORTZ1Ff z$eNLn0ZmJqP0E8ko=y1# zCME1wL;P<{xy9;z*l+C)g=ee_x7Q<(3vO$22+O0cDwhoyO>pF>2GQTK?lSpE6oOSh zO$vY+u>d?2wbsW=Mh8BeL5$sh9AW|PLu%;s-?M1`Sf8W zTQXh1@sZcl0#0JzJ)g*EwSRM5{x*jwZF7#`S0~?!1t80-A*Lj~4U#2-iDxm#iLn%^ zr;GJ|PQwdFs>e!%9m3N^3F9LG2?8vjlZu$B?_^|{xYF|*mh)y*iG|A2Z+Nf27hn&hSscS$gaml= z&@CR#o5l*Vt>Kc6I8SkefBf|Q9eHCI^xhz@rQ7acd2F?Vs60U?S~IVHLovcswSJYr zvzyF&2L>s%i@gm+8PFF=?EjzQc_}FW`ura=J+BSC9x?fi&U-1z{~9=X4SYR>@f+Cs z@`nHIRR1%Q@f!VlVBj}e?xmyfU-W;*2VTQpH^zU%^Io{xZ}@-Po?p1?*Hm*y~e+8pZv!2Bm5WtFZ%Nu|61Drjo(K62mf!O|26!z9{wAC zg7gpk4|V)C{ Date: Mon, 4 Dec 2023 11:58:39 -0500 Subject: [PATCH 2/8] Adding more documentation --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3231d8..6766619 100644 --- a/README.md +++ b/README.md @@ -186,8 +186,10 @@ p_child = p_element.at_xpath("//child::*") # selects first child require 'docx' d = Docx::Document.open('example.docx') +existing_style = d.styles_config.style_of("Heading 1") +existing_style.font_color = "000000" -# see lib/docx/elements/style.rb for more attributes you can set! +# see attributes below new_style = d.styles_config.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20) new_style.bold = true @@ -195,9 +197,61 @@ d.paragraphs.each do |p| p.style = "Red" end +d.paragraphs.each do |p| + p.style = "Heading 1" +end + d.styles_config.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 From 6522c8107ced981fdfdbe92a1e33e70743f47b95 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Tue, 5 Dec 2023 10:25:43 -0500 Subject: [PATCH 3/8] Add validator for type and whether a field is required --- lib/docx/elements/style.rb | 48 +++++++++++++++++++----- lib/docx/elements/style/validators.rb | 10 +++++ lib/docx/errors.rb | 1 + spec/docx/elements/style_spec.rb | 54 +++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/lib/docx/elements/style.rb b/lib/docx/elements/style.rb index 614d329..d58c3d3 100644 --- a/lib/docx/elements/style.rb +++ b/lib/docx/elements/style.rb @@ -12,7 +12,14 @@ def self.attributes @@attributes end - def self.attribute(name, *selectors, converter: Converters::DefaultValueConverter, validator: Validators::DefaultValidator) + 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 @@ -22,15 +29,27 @@ def self.attribute(name, *selectors, converter: Converters::DefaultValueConverte end define_method("#{name}=") do |value| - validator.validate(value) || raise(Errors::StyleInvalidPropertyValue, "Invalid value for #{name}: #{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| - encoded_value = converter.encode(value).to_s if (existing_attribute = node.at_xpath(attribute_xpath)) - existing_attribute.value = encoded_value - next value + if encoded_value.nil? + existing_attribute.remove + else + existing_attribute.value = encoded_value + end + + next encoded_value end + next encoded_value if encoded_value.nil? + node_xpath, attribute = attribute_xpath.split("/@") created_node = @@ -40,7 +59,8 @@ def self.attribute(name, *selectors, converter: Converters::DefaultValueConverte # 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 } + 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) @@ -67,9 +87,9 @@ def initialize(configuration, node, **attributes) attr_accessor :node - attribute :id, "./@w:styleId" - attribute :name, "./w:name/@w:val", "./w:next/@w:val" - attribute :type, ".//@w:type" + 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 @@ -112,6 +132,16 @@ def initialize(configuration, node, **attributes) 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 diff --git a/lib/docx/elements/style/validators.rb b/lib/docx/elements/style/validators.rb index 64a4559..6224f6c 100644 --- a/lib/docx/elements/style/validators.rb +++ b/lib/docx/elements/style/validators.rb @@ -15,6 +15,16 @@ 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 diff --git a/lib/docx/errors.rb b/lib/docx/errors.rb index 85526aa..a8d4b3c 100644 --- a/lib/docx/errors.rb +++ b/lib/docx/errors.rb @@ -2,5 +2,6 @@ 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/spec/docx/elements/style_spec.rb b/spec/docx/elements/style_spec.rb index 17c9cb2..8600a3b 100644 --- a/spec/docx/elements/style_spec.rb +++ b/spec/docx/elements/style_spec.rb @@ -5,10 +5,8 @@ describe Docx::Elements::Style do let(:fixture_path) { Dir.pwd + "/spec/fixtures/partial_styles/full.xml" } - - let(:node) do - Nokogiri::XML(File.open(fixture_path)).root.children[1] - end + 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 @@ -83,6 +81,14 @@ 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) @@ -99,6 +105,46 @@ 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" } From fe2f425c6ff357e0be3971a376035746ef32be44 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Wed, 6 Dec 2023 10:14:24 -0500 Subject: [PATCH 4/8] Correct documentation --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6766619..31b4b39 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,11 @@ p_child = p_element.at_xpath("//child::*") # selects first child require 'docx' d = Docx::Document.open('example.docx') -existing_style = d.styles_config.style_of("Heading 1") +existing_style = d.styles_configuration.style_of("Heading 1") existing_style.font_color = "000000" # see attributes below -new_style = d.styles_config.add_style("Red", name: "Red", font_color: "FF0000", font_size: 20) +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| @@ -201,7 +201,7 @@ d.paragraphs.each do |p| p.style = "Heading 1" end -d.styles_config.remove_style("Red") +d.styles_configuration.remove_style("Red") ``` #### Style Attributes From 3bcace90ac44a8844d805dfc1237178e145c88b3 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Wed, 6 Dec 2023 10:14:36 -0500 Subject: [PATCH 5/8] Fix setting fonts --- lib/docx/elements/style.rb | 2 +- spec/docx/elements/style_spec.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/docx/elements/style.rb b/lib/docx/elements/style.rb index d58c3d3..1748fa4 100644 --- a/lib/docx/elements/style.rb +++ b/lib/docx/elements/style.rb @@ -42,7 +42,7 @@ def self.attribute(name, *selectors, required: false, converter: Converters::Def if encoded_value.nil? existing_attribute.remove else - existing_attribute.value = encoded_value + existing_attribute.value = encoded_value.to_s end next encoded_value diff --git a/spec/docx/elements/style_spec.rb b/spec/docx/elements/style_spec.rb index 8600a3b..d6145ae 100644 --- a/spec/docx/elements/style_spec.rb +++ b/spec/docx/elements/style_spec.rb @@ -97,11 +97,14 @@ it "should change underlying XML when attributes are changed" do style.id = "blue" style.name = "Blue" + style.font_size = 20 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('') end end From e08a1ef011d3d2ea11f879f7c55a5f540a2f438a Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Wed, 6 Dec 2023 17:19:29 -0500 Subject: [PATCH 6/8] More comprehensive speces --- spec/docx/document_spec.rb | 15 ++++++++++++--- spec/docx/elements/style_spec.rb | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/spec/docx/document_spec.rb b/spec/docx/document_spec.rb index e4d3648..1cf5d40 100755 --- a/spec/docx/document_spec.rb +++ b/spec/docx/document_spec.rb @@ -610,15 +610,24 @@ expect(@doc.default_paragraph_style).to eq 'Normal' end - it 'reads document styles' do + it 'manipulates existing document styles' do styles_config = @doc.styles_configuration expect(styles_config.size).to eq 37 - expect(styles_config.style_of('Normal')).to be_a(Docx::Elements::Style) + 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 'manipulates document styles' do + it 'creates document styles' do styles_config = @doc.styles_configuration expect(styles_config.size).to eq 37 diff --git a/spec/docx/elements/style_spec.rb b/spec/docx/elements/style_spec.rb index d6145ae..109ae54 100644 --- a/spec/docx/elements/style_spec.rb +++ b/spec/docx/elements/style_spec.rb @@ -98,6 +98,7 @@ 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('') @@ -105,6 +106,7 @@ 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 From 631c451805c57e2ae2118aa8720e08899c976a76 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Wed, 6 Dec 2023 17:19:35 -0500 Subject: [PATCH 7/8] Fix typo --- lib/docx/elements/style.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/docx/elements/style.rb b/lib/docx/elements/style.rb index 1748fa4..5f615ba 100644 --- a/lib/docx/elements/style.rb +++ b/lib/docx/elements/style.rb @@ -124,7 +124,7 @@ def initialize(configuration, node, **attributes) 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 :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" From 22094cab31d4ba8e362e34d51ba4e29ca7d5a043 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Mon, 11 Dec 2023 11:06:35 -0500 Subject: [PATCH 8/8] Fix typo --- lib/docx/document.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/docx/document.rb b/lib/docx/document.rb index a4dd3dd..4fe0ee1 100755 --- a/lib/docx/document.rb +++ b/lib/docx/document.rb @@ -6,7 +6,7 @@ require 'zip' module Docx - # The Document class wraps around a docx file and pro.es methods to + # The Document class wraps around a docx file and provides methods to # interface with it. # # # get a Docx::Document for a docx file in the local directory