├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── openxml-package.rb ├── openxml-package │ └── version.rb └── openxml │ ├── builder.rb │ ├── builder │ └── element.rb │ ├── contains_properties.rb │ ├── content_types_presets.rb │ ├── element.rb │ ├── errors.rb │ ├── has_attributes.rb │ ├── has_children.rb │ ├── has_properties.rb │ ├── package.rb │ ├── part.rb │ ├── parts.rb │ ├── parts │ ├── content_types.rb │ ├── rels.rb │ └── unparsed_part.rb │ ├── properties.rb │ ├── properties │ ├── base_property.rb │ ├── boolean_property.rb │ ├── complex_property.rb │ ├── container_property.rb │ ├── integer_property.rb │ ├── on_off_property.rb │ ├── positive_integer_property.rb │ ├── string_property.rb │ ├── toggle_property.rb │ ├── transparent_container_property.rb │ └── value_property.rb │ ├── relationship.rb │ ├── render_when_empty.rb │ ├── types.rb │ └── unmet_requirement.rb ├── openxml-package.gemspec ├── test ├── contains_properties_test.rb ├── content_types_test.rb ├── element_test.rb ├── has_attributes_test.rb ├── has_children_test.rb ├── has_properties_test.rb ├── package_test.rb ├── part_test.rb ├── rels_test.rb ├── support │ ├── external_hyperlink.docx │ └── sample.docx └── test_helper.rb └── tmp └── .keep /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push] 3 | 4 | jobs: 5 | ruby: 6 | name: Ruby Tests 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby-version: [ "3.0", "3.1", "3.2", "3.3" ] 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Setup Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | ruby-version: ${{ matrix.ruby-version }} 20 | - name: Run Tests 21 | run: bundle exec rake test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-version 2 | *.gem 3 | *.rbc 4 | .bundle 5 | .config 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | /tmp/* 19 | !/tmp/.keep 20 | *.bundle 21 | *.so 22 | *.o 23 | *.a 24 | mkmf.log 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.5 - Oct 7, 2024 2 | 3 | - Ruby 3.2 support 4 | 5 | ## 0.3.4 - Oct 16, 2019 6 | 7 | - Removed rubyzip lock 8 | 9 | 10 | ## 0.3.3 - Oct 2, 2018 11 | 12 | - Optimized setting attributes 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in openxml-package.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Bob Lail 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenXml::Package 2 | 3 | [![Gem Version](https://badge.fury.io/rb/openxml-package.svg)](https://rubygems.org/gems/openxml-package) 4 | [![Code Climate](https://codeclimate.com/github/openxml/openxml-package.svg)](https://codeclimate.com/github/openxml/openxml-package) 5 | [![Build Status](https://travis-ci.org/openxml/openxml-package.svg)](https://travis-ci.org/openxml/openxml-package) 6 | 7 | A Ruby implementation of [DocumentFormat.OpenXml.Packaging.OpenXmlPackage](https://msdn.microsoft.com/en-us/library/documentformat.openxml.packaging.openxmlpackage_members.aspx) from Microsoft's Open XML SDK. 8 | 9 | The base class for [OpenXml::Docx::Package](https://github.com/openxml/openxml-docx/blob/master/lib/openxml/docx/package.rb), [OpenXml::Xlsx::Package](https://github.com/openxml/openxml-xlsx/blob/master/lib/openxml/xlsx/package.rb), and [OpenXml::Pptx::Package](https://github.com/openxml/openxml-pptx/blob/master/lib/openxml/xlsx/package.rb). 10 | 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | gem 'openxml-package' 17 | 18 | And then execute: 19 | 20 | $ bundle 21 | 22 | Or install it yourself as: 23 | 24 | $ gem install openxml-package 25 | 26 | 27 | 28 | ## Usage 29 | 30 | #### Writing 31 | 32 | You can assemble an Open XML Package in-memory and then write it to disk: 33 | 34 | ```ruby 35 | package = OpenXml::Package.new 36 | package.add_part "content/document.xml", OpenXml::Parts::UnparsedPart.new("") 37 | package.add_part "media/image.png", OpenXml::Parts::UnparsedPart.new(File.open(image_path, "rb", &:read)) 38 | package.write_to "~/Desktop/output.zip" 39 | ``` 40 | 41 | 42 | #### Reading 43 | 44 | You can read the contents of an Open XML Package: 45 | 46 | ```ruby 47 | OpenXml::Package.open("~/Desktop/output.zip") do |package| 48 | package.parts.keys # => ["content/document.xml", "media/image.png"] 49 | end 50 | ``` 51 | 52 | 53 | #### Subclassing 54 | 55 | `OpenXml::Package` is intended to be the base class for libraries that implement Open XML formats for Microsoft Office products. 56 | 57 | For example, a very simple Microsoft Word document can be defined as follows: 58 | 59 | ```ruby 60 | require "openxml/package" 61 | 62 | module Rocx 63 | class Package < OpenXml::Package 64 | attr_reader :document, 65 | :doc_rels, 66 | :settings, 67 | :styles 68 | 69 | content_types do 70 | default "png", TYPE_PNG 71 | override "/word/styles.xml", TYPE_STYLES 72 | override "/word/settings.xml", TYPE_SETTINGS 73 | end 74 | 75 | def initialize 76 | super 77 | 78 | rels.add_relationship REL_DOCUMENT, "/word/document.xml" 79 | @doc_rels = OpenXml::Parts::Rels.new([ 80 | { type: REL_STYLES, target: "/word/styles.xml"}, 81 | { type: REL_SETTINGS, target: "/word/settings.xml"} 82 | ]) 83 | @settings = Rocx::Parts::Settings.new 84 | @styles = Rocx::Parts::Styles.new 85 | @document = Rocx::Parts::Document.new 86 | 87 | add_part "word/_rels/document.xml.rels", doc_rels 88 | add_part "word/document.xml", document 89 | add_part "word/settings.xml", settings 90 | add_part "word/styles.xml", styles 91 | end 92 | 93 | end 94 | end 95 | ``` 96 | 97 | This gem also defines two "Parts" that are commonly used in Open XML packages. 98 | 99 | ##### OpenXml::Parts::ContentTypes 100 | 101 | Is used to identify the ContentType of all of the files in the package. There are two ways of identifying content types: 102 | 103 | 1. **Default**: declares the default content type for a file with a given extension 104 | 2. **Override**: declares the content type for a specific file with the given path inside the package 105 | 106 | ##### OpenXml::Parts::Rels 107 | 108 | Is used to identify links within the package 109 | 110 | 111 | 112 | ## Contributing 113 | 114 | 1. Fork it ( https://github.com/openxml/openxml-package/fork ) 115 | 2. Create your feature branch (`git checkout -b my-new-feature`) 116 | 3. Commit your changes (`git commit -am 'Add some feature'`) 117 | 4. Push to the branch (`git push origin my-new-feature`) 118 | 5. Create a new Pull Request 119 | 120 | #### Reference 121 | 122 | - [DocumentFormat.OpenXml.Packaging.OpenXmlPackage](http://msdn.microsoft.com/en-us/library/documentformat.openxml.packaging.openxmlpackage_members(v=office.14).aspx) 123 | - [DocumentFormat.OpenXml.Packaging.OpenXmlPartContainer](http://msdn.microsoft.com/en-us/library/documentformat.openxml.packaging.openxmlpartcontainer_members(v=office.14).aspx) 124 | - [DocumentFormat.OpenXml.Packaging.OpenXmlPart](http://msdn.microsoft.com/en-us/library/documentformat.openxml.packaging.openxmlpart_members(v=office.14).aspx) 125 | - [System.IO.Packaging.Package](http://msdn.microsoft.com/en-us/library/system.io.packaging.package.aspx) 126 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "lib" 6 | t.libs << "test" 7 | t.pattern = "test/**/*_test.rb" 8 | t.verbose = false 9 | end 10 | -------------------------------------------------------------------------------- /lib/openxml-package.rb: -------------------------------------------------------------------------------- 1 | require "openxml/package" 2 | -------------------------------------------------------------------------------- /lib/openxml-package/version.rb: -------------------------------------------------------------------------------- 1 | module OpenXmlPackage 2 | VERSION = "0.3.5" 3 | end 4 | -------------------------------------------------------------------------------- /lib/openxml/builder.rb: -------------------------------------------------------------------------------- 1 | # Constructing a large XML document (5MB) with the Ox 2 | # gem is about 4x faster than with Nokogiri and about 3 | # 5x fater than with Builder. 4 | # 5 | # This class mimics the XML Builder DSL. 6 | require "ox" 7 | require "openxml/builder/element" 8 | 9 | module OpenXml 10 | class Builder 11 | attr_reader :parent 12 | 13 | def initialize(options={}) 14 | @to_s_options = { with_xml: true } 15 | 16 | @ns = nil 17 | @document = Ox::Document.new( 18 | encoding: "UTF-8", 19 | version: "1.0", 20 | standalone: options[:standalone]) 21 | @parent = @document 22 | yield self if block_given? 23 | end 24 | 25 | def to_s(args={}) 26 | options = @to_s_options 27 | 28 | # Unless we would like to debug the files, 29 | # don't add whitespace during generation. 30 | options = options.merge(indent: -1) unless args[:debug] 31 | 32 | Ox.dump(@document, options).strip 33 | end 34 | alias :to_xml :to_s 35 | 36 | def [](ns) 37 | @ns = ns.to_sym if ns 38 | return self 39 | end 40 | 41 | def method_missing(tag_name, *args) 42 | new_element = OpenXml::Builder::Element.new(tag_name) 43 | attributes = extract_options!(args) 44 | attributes.each do |key, value| 45 | new_element[key] = value 46 | end 47 | 48 | new_element.namespace = @ns 49 | @ns = nil 50 | 51 | if block_given? 52 | begin 53 | was_current = @parent 54 | @parent = new_element 55 | yield self 56 | ensure 57 | @parent = was_current 58 | end 59 | elsif value = args.first 60 | new_element << value.to_s 61 | end 62 | 63 | @parent << new_element.__getobj__ 64 | end 65 | 66 | private 67 | 68 | def extract_options!(args) 69 | if args.last.is_a?(Hash) && args.last.instance_of?(Hash) 70 | args.pop 71 | else 72 | {} 73 | end 74 | end 75 | 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/openxml/builder/element.rb: -------------------------------------------------------------------------------- 1 | require "delegate" 2 | 3 | module OpenXml 4 | class Builder 5 | class Element < SimpleDelegator 6 | attr_reader :namespace 7 | 8 | def initialize(*args) 9 | super Ox::Element.new(*args) 10 | end 11 | 12 | def []=(attribute, value) 13 | namespace_def = attribute.downcase.to_s.match(/^xmlns(?:\:(?.*))?$/) 14 | namespaces << namespace_def[:prefix].to_sym if namespace_def && namespace_def[:prefix] 15 | super 16 | end 17 | 18 | def namespace=(ns) 19 | @namespace = ns.to_sym if ns 20 | tag = name.match(/^(?:\w*?\:)?(?\w*)$/i)[:tag] 21 | self.value = "#{namespace}:#{tag}" if namespace 22 | end 23 | 24 | def namespaces=(ns) 25 | @namespaces = Array(ns) 26 | end 27 | 28 | def namespaces 29 | @namespaces ||= [] 30 | end 31 | alias :namespace_definitions :namespaces 32 | 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/openxml/contains_properties.rb: -------------------------------------------------------------------------------- 1 | require "openxml/has_properties" 2 | 3 | module OpenXml 4 | module ContainsProperties 5 | 6 | def self.included(base) 7 | base.class_eval do 8 | include HasProperties 9 | include InstanceMethods 10 | end 11 | end 12 | 13 | module InstanceMethods 14 | 15 | def property_xml(xml) 16 | ensure_required_choices 17 | props = active_properties 18 | return unless render_properties? props 19 | props.each { |prop| prop.to_xml(xml) } 20 | end 21 | 22 | def properties_attributes 23 | {} 24 | end 25 | 26 | end 27 | 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/openxml/content_types_presets.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | class ContentTypesPresets 3 | attr_reader :defaults, :overrides 4 | 5 | def initialize 6 | @defaults, @overrides = {}, {} 7 | end 8 | 9 | def default(extension, content_type) 10 | defaults[extension] = content_type 11 | end 12 | 13 | def override(part_name, content_type) 14 | overrides[part_name] = content_type 15 | end 16 | 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/openxml/element.rb: -------------------------------------------------------------------------------- 1 | require "openxml/has_attributes" 2 | 3 | module OpenXml 4 | class Element 5 | include HasAttributes 6 | 7 | class << self 8 | attr_reader :property_name 9 | 10 | def tag(*args) 11 | @tag = args.first if args.any? 12 | @tag ||= nil 13 | end 14 | 15 | def name(*args) 16 | @property_name = args.first if args.any? 17 | @property_name ||= nil 18 | end 19 | 20 | def namespace(*args) 21 | @namespace = args.first if args.any? 22 | @namespace ||= nil 23 | end 24 | 25 | end 26 | 27 | def tag 28 | self.class.tag || default_tag 29 | end 30 | 31 | def name 32 | self.class.property_name || default_name 33 | end 34 | 35 | def namespace 36 | ([self.class] + self.class.ancestors).select { |klass| klass.respond_to?(:namespace) }.map(&:namespace).find(&:itself) 37 | end 38 | 39 | def to_xml(xml) 40 | raise UndefinedNamespaceError, self.class unless namespace 41 | 42 | xml[namespace].public_send(tag, xml_attributes) do 43 | yield xml if block_given? 44 | end 45 | end 46 | 47 | private 48 | 49 | def default_tag 50 | (class_name[0, 1].downcase + class_name[1..-1]).to_sym 51 | end 52 | 53 | def default_name 54 | class_name.gsub(/(.)([A-Z])/, '\1_\2').downcase 55 | end 56 | 57 | def class_name 58 | self.class.to_s.split(/::/).last 59 | end 60 | 61 | end 62 | 63 | class UndefinedNamespaceError < RuntimeError 64 | def initialize(klass) 65 | super "#{klass} does not define its namespace" 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/openxml/errors.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Errors 3 | 4 | class MissingContentTypesPart < StandardError 5 | end 6 | 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/openxml/has_attributes.rb: -------------------------------------------------------------------------------- 1 | require "openxml/unmet_requirement" 2 | 3 | module OpenXml 4 | module HasAttributes 5 | 6 | def self.included(base) 7 | base.extend(ClassMethods) 8 | end 9 | 10 | module ClassMethods 11 | 12 | RESERVED_NAMES = %w{ tag name namespace properties_tag }.freeze 13 | 14 | def attribute(name, expects: nil, one_of: nil, in_range: nil, displays_as: nil, namespace: nil, matches: nil, validation: nil, required: false, deprecated: false) 15 | raise ArgumentError if RESERVED_NAMES.member? name.to_s 16 | 17 | required_attributes.push(name) if required 18 | 19 | attr_reader name 20 | 21 | define_method "#{name}=" do |value| 22 | valid_in?(value, one_of) unless one_of.nil? 23 | send(expects, value) unless expects.nil? 24 | matches?(value, matches) unless matches.nil? 25 | in_range?(value, in_range) unless in_range.nil? 26 | validation.call(value) if validation.respond_to? :call 27 | instance_variable_set "@#{name}", value 28 | end 29 | 30 | camelized_name = name.to_s.gsub(/_([a-z])/i) { $1.upcase }.to_sym 31 | attributes[name] = [displays_as || camelized_name, namespace || attribute_namespace] 32 | end 33 | 34 | def attributes 35 | @attributes ||= {}.tap do |attrs| 36 | if superclass.respond_to?(:attributes) 37 | superclass.attributes.each do |key, value| 38 | attrs[key] = value.dup 39 | end 40 | end 41 | end 42 | end 43 | 44 | def required_attributes 45 | @required_attributes ||= [].tap do |attrs| 46 | attrs.push(*superclass.required_attributes) if superclass.respond_to?(:required_attributes) 47 | end 48 | end 49 | 50 | def with_namespace(namespace, &block) 51 | @attribute_namespace = namespace 52 | instance_eval(&block) 53 | end 54 | 55 | def attribute_namespace 56 | @attribute_namespace ||= nil 57 | end 58 | 59 | end 60 | 61 | def render? 62 | attributes.keys.map(&method(:send)).any? 63 | end 64 | 65 | def attributes 66 | self.class.attributes 67 | end 68 | 69 | def required_attributes 70 | self.class.required_attributes 71 | end 72 | 73 | private 74 | 75 | def xml_attributes 76 | ensure_required_attributes_set 77 | attributes.each_with_object({}) do |(name, options), attrs| 78 | display, namespace = options 79 | value = send(name) 80 | attr_name = "#{namespace}:#{display}" 81 | attr_name = display.to_s if namespace.nil? 82 | attrs[attr_name] = value unless value.nil? 83 | end 84 | end 85 | 86 | def ensure_required_attributes_set 87 | unset_attributes = required_attributes.reject do |attr| 88 | instance_variable_defined?("@#{attr}") 89 | end 90 | return if unset_attributes.empty? 91 | 92 | raise OpenXml::UnmetRequirementError, "Required attribute(s) #{unset_attributes.join(", ")} have not been set" 93 | end 94 | 95 | def boolean(value) 96 | return if [true, false].member? value 97 | raise ArgumentError, "Invalid #{name}: frame must be true or false" 98 | end 99 | 100 | def hex_color(value) 101 | return if value == :auto || value =~ /^[0-9A-F]{6}$/ 102 | raise ArgumentError, "Invalid #{name}: must be :auto or a hex color, e.g. 4F1B8C" 103 | end 104 | 105 | def hex_digit(value) 106 | return if value =~ /^[0-9A-F]{2}$/ 107 | raise ArgumentError, "Invalid #{name}: must be a two-digit hex number, e.g. BF" 108 | end 109 | 110 | def hex_digit_4(value) 111 | return if value =~ /^[0-9A-F]{4}$/ 112 | raise ArgumentError, "Invalid #{name}: must be a four-digit hex number, e.g. BF12" 113 | end 114 | 115 | def long_hex_number(value) 116 | return if value =~ /^[0-9A-F]{8}$/ 117 | raise ArgumentError, "Invalid #{name}: must be an eight-digit hex number, e.g., FFAC0013" 118 | end 119 | 120 | def hex_string(value) 121 | return if value =~ /^[0-9A-F]+$/ 122 | raise ArgumentError, "Invalid #{name}: must be a string of hexadecimal numbers, e.g. FFA23C6E" 123 | end 124 | 125 | def integer(value) 126 | return if value.is_a?(Integer) 127 | raise ArgumentError, "Invalid #{name}: must be an integer" 128 | end 129 | 130 | def positive_integer(value) 131 | return if value.is_a?(Integer) && value >= 0 132 | raise ArgumentError, "Invalid #{name}: must be a positive integer" 133 | end 134 | 135 | def string(value) 136 | return if value.is_a?(String) && value.length > 0 137 | raise ArgumentError, "Invalid #{name}: must be a string" 138 | end 139 | 140 | def string_or_blank(value) 141 | return if value.is_a?(String) 142 | raise ArgumentError, "Invalid #{name}: must be a string, even if the string is empty" 143 | end 144 | 145 | def in_range?(value, range) 146 | return if range.include?(value.to_i) 147 | raise ArgumentError, "Invalid #{name}: must be a number between #{range.begin} and #{range.end}" 148 | end 149 | 150 | def percentage(value) 151 | return if value.is_a?(String) && value =~ /-?[0-9]+(\.[0-9]+)?%/ # Regex supplied in sec. 22.9.2.9 of Office Open XML docs 152 | raise ArgumentError, "Invalid #{name}: must be a percentage" 153 | end 154 | 155 | def on_or_off(value) 156 | valid_in? value, %i{ on off } 157 | end 158 | 159 | def valid_in?(value, list) 160 | return if list.member?(value) 161 | raise ArgumentError, "Invalid #{name}: must be one of #{list} (was #{value.inspect})" 162 | end 163 | 164 | def matches?(value, regexp) 165 | return if value.is_a?(String) && value =~ regexp 166 | raise ArgumentError, "Value does not match #{regexp}" 167 | end 168 | 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/openxml/has_children.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module HasChildren 3 | attr_reader :children 4 | 5 | def initialize(*) 6 | super 7 | @children = [] 8 | end 9 | 10 | def <<(child) 11 | children << child 12 | end 13 | 14 | def push(child) 15 | children.push(child) 16 | end 17 | 18 | def to_xml(xml) 19 | super(xml) do 20 | yield xml if block_given? 21 | children.each do |child| 22 | child.to_xml(xml) 23 | end 24 | end 25 | end 26 | 27 | def render? 28 | super || children.any? 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/openxml/has_properties.rb: -------------------------------------------------------------------------------- 1 | require "openxml/unmet_requirement" 2 | 3 | module OpenXml 4 | module HasProperties 5 | 6 | class ChoiceGroupUniqueError < RuntimeError; end 7 | 8 | def self.included(base) 9 | base.extend ClassMethods 10 | end 11 | 12 | module ClassMethods 13 | 14 | def properties_tag(*args) 15 | @properties_tag = args.first if args.any? 16 | @properties_tag ||= nil 17 | end 18 | 19 | def value_property(name, as: nil, klass: nil, required: false, default_value: nil) 20 | attr_reader name 21 | 22 | properties[name] = (as || name).to_s 23 | required_properties[name] = default_value if required 24 | classified_name = properties[name].split("_").map(&:capitalize).join 25 | class_name = klass.to_s unless klass.nil? 26 | class_name ||= (to_s.split("::")[0...-2] + ["Properties", classified_name]).join("::") 27 | 28 | (choice_groups[current_group] ||= []).push(name) unless current_group.nil? 29 | 30 | class_eval <<-CODE, __FILE__, __LINE__ + 1 31 | def #{name}=(value) 32 | group_index = #{@current_group.inspect} 33 | ensure_unique_in_group(:#{name}, group_index) unless group_index.nil? 34 | instance_variable_set "@#{name}", #{class_name}.new(value) 35 | end 36 | CODE 37 | end 38 | 39 | def property(name, as: nil, klass: nil, required: false) 40 | properties[name] = (as || name).to_s 41 | required_properties[name] = true if required 42 | classified_name = properties[name].split("_").map(&:capitalize).join 43 | class_name = klass.to_s unless klass.nil? 44 | class_name ||= (to_s.split("::")[0...-2] + ["Properties", classified_name]).join("::") 45 | 46 | (choice_groups[current_group] ||= []).push(name) unless current_group.nil? 47 | 48 | class_eval <<-CODE, __FILE__, __LINE__ + 1 49 | def #{name}(*args) 50 | unless instance_variable_defined?("@#{name}") 51 | group_index = #{@current_group.inspect} 52 | ensure_unique_in_group(:#{name}, group_index) unless group_index.nil? 53 | instance_variable_set "@#{name}", #{class_name}.new(*args) 54 | end 55 | 56 | instance_variable_get "@#{name}" 57 | end 58 | CODE 59 | end 60 | 61 | def property_choice(required: false) 62 | @current_group = choice_groups.length 63 | required_choices << @current_group if required 64 | yield 65 | @current_group = nil 66 | end 67 | 68 | def current_group 69 | @current_group ||= nil 70 | end 71 | 72 | def properties 73 | @properties ||= {}.tap do |props| 74 | props.merge!(superclass.properties) if superclass.respond_to?(:properties) 75 | end 76 | end 77 | 78 | def choice_groups 79 | @choice_groups ||= [].tap do |choices| 80 | choices.push(*superclass.choice_groups.map(&:dup)) if superclass.respond_to?(:choice_groups) 81 | end 82 | end 83 | 84 | def required_properties 85 | @required_properties ||= {}.tap do |props| 86 | props.merge!(superclass.required_properties) if superclass.respond_to?(:required_properties) 87 | end 88 | end 89 | 90 | def required_choices 91 | @required_choices ||= [].tap do |choices| 92 | choices.push(*superclass.required_choices) if superclass.respond_to?(:required_choices) 93 | end 94 | end 95 | 96 | def properties_attribute(name, **args) 97 | properties_element.attribute name, **args 98 | class_eval <<~RUBY, __FILE__, __LINE__ + 1 99 | def #{name}=(value) 100 | properties_element.#{name} = value 101 | end 102 | 103 | def #{name} 104 | properties_element.#{name} 105 | end 106 | RUBY 107 | end 108 | 109 | def properties_element 110 | this = self 111 | parent_klass = superclass.respond_to?(:properties_element) ? superclass.properties_element : OpenXml::Element 112 | @properties_element ||= Class.new(parent_klass) do 113 | tag :"#{this.properties_tag || this.default_properties_tag}" 114 | namespace :"#{this.namespace}" 115 | end 116 | end 117 | 118 | def default_properties_tag 119 | :"#{tag}Pr" 120 | end 121 | 122 | end 123 | 124 | def initialize(*_args) 125 | super 126 | build_required_properties 127 | end 128 | 129 | def properties_element 130 | @properties_element ||= self.class.properties_element.new 131 | end 132 | 133 | def properties_attributes 134 | properties_element.attributes 135 | end 136 | 137 | def render? 138 | return true unless defined?(super) 139 | render_properties? || super 140 | end 141 | 142 | def to_xml(xml) 143 | super(xml) do 144 | property_xml(xml) 145 | yield xml if block_given? 146 | end 147 | end 148 | 149 | def property_xml(xml) 150 | ensure_required_choices 151 | props = active_properties 152 | return unless render_properties? props 153 | 154 | properties_element.to_xml(xml) do 155 | props.each { |prop| prop.to_xml(xml) } 156 | end 157 | end 158 | 159 | def build_required_properties 160 | required_properties.each do |prop, default_value| 161 | public_send(:"#{prop}=", default_value) if respond_to? :"#{prop}=" 162 | public_send(:"#{prop}") 163 | end 164 | end 165 | 166 | private 167 | 168 | def properties 169 | self.class.properties 170 | end 171 | 172 | def active_properties 173 | properties.keys.map { |property| instance_variable_get("@#{property}") }.compact 174 | end 175 | 176 | def render_properties?(properties=active_properties) 177 | properties.any?(&:render?) || properties_attributes.keys.any? do |key| 178 | properties_element.instance_variable_defined?("@#{key}") 179 | end 180 | end 181 | 182 | def properties_tag 183 | self.class.properties_tag || default_properties_tag 184 | end 185 | 186 | def default_properties_tag 187 | :"#{tag}Pr" 188 | end 189 | 190 | def choice_groups 191 | self.class.choice_groups 192 | end 193 | 194 | def required_properties 195 | self.class.required_properties 196 | end 197 | 198 | def required_choices 199 | self.class.required_choices 200 | end 201 | 202 | def ensure_unique_in_group(name, group_index) 203 | other_names = (choice_groups[group_index] - [name]) 204 | return if other_names.none? { |other_name| instance_variable_defined?("@#{other_name}") } 205 | raise ChoiceGroupUniqueError, "Property #{name} cannot also be set with #{other_names.join(", ")}." 206 | end 207 | 208 | def unmet_choices 209 | required_choices.reject do |choice_index| 210 | choice_groups[choice_index].one? do |prop_name| 211 | instance_variable_defined?("@#{prop_name}") 212 | end 213 | end 214 | end 215 | 216 | def ensure_required_choices 217 | unmet_choice_groups = unmet_choices.map { |index| choice_groups[index].join(", ") } 218 | return if unmet_choice_groups.empty? 219 | raise OpenXml::UnmetRequirementError, "Required choice from among group(s) (#{unmet_choice_groups.join("), (")}) not made" 220 | end 221 | 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/openxml/package.rb: -------------------------------------------------------------------------------- 1 | require "openxml-package/version" 2 | require "openxml/content_types_presets" 3 | require "openxml/errors" 4 | require "openxml/types" 5 | require "openxml/parts" 6 | require "zip" 7 | 8 | module OpenXml 9 | class Package 10 | attr_reader :parts, :content_types, :rels 11 | 12 | 13 | 14 | class << self 15 | def content_types_presets 16 | @content_types_presets ||= OpenXml::ContentTypesPresets.new 17 | end 18 | 19 | def content_types(&block) 20 | content_types_presets.instance_eval(&block) 21 | end 22 | 23 | def open(path) 24 | if block_given? 25 | Zip::File.open(path) do |zipfile| 26 | yield new(zipfile) 27 | end 28 | else 29 | new Zip::File.open(path) 30 | end 31 | end 32 | 33 | def from_stream(stream) 34 | stream = StringIO.new(stream) if stream.is_a?(String) 35 | 36 | # Hack: Zip::Entry.read_c_dir_entry initializes 37 | # a new Zip::Entry by calling `io.path`. Zip::Entry 38 | # uses this to open the original zipfile; but in 39 | # this case, the StringIO _is_ the original. 40 | def stream.path 41 | self 42 | end 43 | 44 | zipfile = ::Zip::File.new("", true, true) 45 | zipfile.read_from_stream(stream) 46 | new(zipfile) 47 | end 48 | end 49 | 50 | 51 | 52 | def initialize(zipfile=nil) 53 | @zipfile = zipfile 54 | @parts = {} 55 | 56 | set_defaults 57 | read_zipfile! if zipfile 58 | end 59 | 60 | 61 | 62 | def add_part(path, part) 63 | @parts[path] = part 64 | end 65 | 66 | def get_part(path) 67 | @parts.fetch(path) 68 | end 69 | 70 | def type_of(path) 71 | raise Errors::MissingContentTypesPart, "We haven't yet read [ContentTypes].xml; but are reading #{path.inspect}" unless content_types 72 | content_types.of(path) 73 | end 74 | 75 | 76 | 77 | def close 78 | zipfile.close if zipfile 79 | end 80 | 81 | def write_to(path) 82 | File.open(path, "wb") do |file| 83 | file.write to_stream.string 84 | end 85 | end 86 | alias :save :write_to 87 | 88 | def to_stream 89 | Zip::OutputStream.write_buffer do |io| 90 | parts.each do |path, part| 91 | io.put_next_entry path 92 | io.write part.content 93 | end 94 | end 95 | end 96 | 97 | private 98 | 99 | attr_reader :zipfile 100 | 101 | def read_zipfile! 102 | zipfile.entries.each do |entry| 103 | next if entry.directory? 104 | path, part = entry.name, Parts::UnparsedPart.new(entry) 105 | add_part path, case path 106 | when "[Content_Types].xml" then @content_types = Parts::ContentTypes.parse(part.content) 107 | when "_rels/.rels" then @rels = Parts::Rels.parse(part.content) 108 | else part_for(path, type_of(path), part) 109 | end 110 | end 111 | end 112 | 113 | protected 114 | 115 | def set_defaults 116 | presets = self.class.content_types_presets 117 | @content_types = Parts::ContentTypes.new(presets.defaults.dup, presets.overrides.dup) 118 | add_part "[Content_Types].xml", content_types 119 | 120 | @rels = Parts::Rels.new 121 | add_part "_rels/.rels", rels 122 | end 123 | 124 | def part_for(path, content_type, part) 125 | case content_type 126 | when Types::RELATIONSHIPS then Parts::Rels.parse(part.content) 127 | else part 128 | end 129 | end 130 | 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/openxml/part.rb: -------------------------------------------------------------------------------- 1 | require "openxml/builder" 2 | 3 | module OpenXml 4 | class Part 5 | 6 | def build_xml(options={}) 7 | OpenXml::Builder.new(options) { |xml| yield xml } 8 | end 9 | 10 | def build_standalone_xml(&block) 11 | build_xml(standalone: :yes, &block) 12 | end 13 | 14 | def read 15 | to_xml.to_s 16 | end 17 | alias :content :read 18 | 19 | def to_xml 20 | raise NotImplementedError, "#{self.class} needs to implements to_xml" 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/openxml/parts.rb: -------------------------------------------------------------------------------- 1 | require "openxml/part" 2 | 3 | module OpenXml 4 | module Parts 5 | end 6 | end 7 | 8 | Dir.glob("#{File.join(File.dirname(__FILE__), "parts", "*.rb")}").each do |file| 9 | require file 10 | end 11 | -------------------------------------------------------------------------------- /lib/openxml/parts/content_types.rb: -------------------------------------------------------------------------------- 1 | require "nokogiri" 2 | 3 | module OpenXml 4 | module Parts 5 | class ContentTypes < OpenXml::Part 6 | attr_reader :defaults, :overrides 7 | 8 | REQUIRED_DEFAULTS = { 9 | "xml" => Types::XML, 10 | "rels" => Types::RELATIONSHIPS 11 | }.freeze 12 | 13 | def self.parse(xml) 14 | document = Nokogiri::XML(xml) 15 | self.new.tap do |part| 16 | document.css("Default").each do |default| 17 | part.add_default default["Extension"], default["ContentType"] 18 | end 19 | document.css("Override").each do |default| 20 | part.add_override default["PartName"], default["ContentType"] 21 | end 22 | end 23 | end 24 | 25 | def initialize(defaults={}, overrides={}) 26 | @defaults = REQUIRED_DEFAULTS.merge(defaults) 27 | @overrides = overrides 28 | end 29 | 30 | def add_default(extension, content_type) 31 | defaults[extension] = content_type 32 | end 33 | 34 | def add_override(part_name, content_type) 35 | overrides[part_name] = content_type 36 | end 37 | 38 | def of(path) 39 | overrides.fetch(path, defaults[File.extname(path)[1..-1]]) 40 | end 41 | 42 | def to_xml 43 | build_standalone_xml do |xml| 44 | xml.Types(xmlns: "http://schemas.openxmlformats.org/package/2006/content-types") { 45 | defaults.each { |extension, content_type| xml.Default("Extension" => extension, "ContentType" => content_type) } 46 | overrides.each { |part_name, content_type| xml.Override("PartName" => part_name, "ContentType" => content_type) } 47 | } 48 | end 49 | end 50 | 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/openxml/parts/rels.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | require "openxml/relationship" 3 | require "nokogiri" 4 | 5 | module OpenXml 6 | module Parts 7 | class Rels < OpenXml::Part 8 | include Enumerable 9 | 10 | def self.parse(xml) 11 | document = Nokogiri::XML(xml) 12 | self.new.tap do |part| 13 | document.css("Relationship").each do |rel| 14 | part.add_relationship rel["Type"], rel["Target"], rel["Id"], rel["TargetMode"] 15 | end 16 | end 17 | end 18 | 19 | def initialize(defaults=[]) 20 | @relationships = [] 21 | Array(defaults).each do |default| 22 | add_relationship(*default.values_at("Type", "Target", "Id", "TargetMode")) 23 | end 24 | end 25 | 26 | def add_relationship(type, target, id=next_id, target_mode=nil) 27 | OpenXml::Elements::Relationship.new(type, target, id, target_mode).tap do |relationship| 28 | relationships.push relationship 29 | end 30 | end 31 | 32 | def push(relationship) 33 | relationships.push relationship 34 | end 35 | 36 | def each(&block) 37 | relationships.each(&block) 38 | end 39 | 40 | def empty? 41 | relationships.empty? 42 | end 43 | 44 | def to_xml 45 | build_standalone_xml do |xml| 46 | xml.Relationships(xmlns: "http://schemas.openxmlformats.org/package/2006/relationships") do 47 | relationships.each do |rel| 48 | attributes = { "Id" => rel.id, "Type" => rel.type, "Target" => rel.target } 49 | attributes["TargetMode"] = rel.target_mode if rel.target_mode 50 | xml.Relationship(attributes) 51 | end 52 | end 53 | end 54 | end 55 | 56 | private 57 | attr_reader :relationships 58 | 59 | def next_id 60 | @current_id = (@current_id || 0) + 1 61 | "rId#{@current_id}" 62 | end 63 | 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/openxml/parts/unparsed_part.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Parts 3 | class UnparsedPart 4 | 5 | def initialize(content) 6 | @content = content 7 | end 8 | 9 | def content 10 | @content = @content.get_input_stream.read if promise? 11 | @content 12 | end 13 | 14 | def promise? 15 | @content.respond_to? :get_input_stream 16 | end 17 | 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/openxml/properties.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | end 4 | end 5 | 6 | require "openxml/properties/base_property" 7 | require "openxml/properties/complex_property" 8 | require "openxml/properties/value_property" 9 | 10 | require "openxml/properties/boolean_property" 11 | require "openxml/properties/integer_property" 12 | require "openxml/properties/positive_integer_property" 13 | require "openxml/properties/string_property" 14 | require "openxml/properties/on_off_property" 15 | require "openxml/properties/toggle_property" 16 | 17 | require "openxml/properties/container_property" 18 | require "openxml/properties/transparent_container_property" 19 | 20 | Dir.glob(File.join(File.dirname(__FILE__), "properties", "*.rb").to_s).each do |file| 21 | require file 22 | end 23 | -------------------------------------------------------------------------------- /lib/openxml/properties/base_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class BaseProperty 4 | attr_reader :value 5 | 6 | class << self 7 | attr_reader :property_name 8 | attr_reader :allowed_tags 9 | 10 | def tag_is_one_of(tags) 11 | attr_accessor :tag 12 | @allowed_tags = tags 13 | end 14 | 15 | def tag(*args) 16 | @tag = args.first if args.any? 17 | @tag ||= nil 18 | end 19 | 20 | def name(*args) 21 | @property_name = args.first if args.any? 22 | @property_name ||= nil 23 | end 24 | 25 | def namespace(*args) 26 | @namespace = args.first if args.any? 27 | @namespace ||= nil 28 | end 29 | 30 | end 31 | 32 | def initialize(tag=nil, *_args) 33 | return unless self.class.allowed_tags 34 | validate_tag tag 35 | @tag = tag 36 | end 37 | 38 | def validate_tag(tag) 39 | return if self.class.allowed_tags.include?(tag) 40 | allowed = self.class.allowed_tags.join(", ") 41 | raise ArgumentError, "Invalid tag name for #{name}: #{tag.inspect}. It should be one of #{allowed}." 42 | end 43 | 44 | def render? 45 | !value.nil? 46 | end 47 | 48 | def name 49 | self.class.property_name || default_name 50 | end 51 | 52 | def default_name 53 | class_name.gsub(/(.)([A-Z])/, '\1_\2').downcase 54 | end 55 | 56 | def tag 57 | self.class.tag || default_tag 58 | end 59 | 60 | def default_tag 61 | (class_name[0, 1].downcase + class_name[1..-1]).to_sym 62 | end 63 | 64 | def namespace 65 | self.class.namespace 66 | end 67 | 68 | private 69 | 70 | def apply_namespace(xml) 71 | namespace.nil? ? xml : xml[namespace] 72 | end 73 | 74 | def class_name 75 | self.class.to_s.split(/::/).last 76 | end 77 | 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/openxml/properties/boolean_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class BooleanProperty < ValueProperty 4 | 5 | def ok_values 6 | [nil, true, false] 7 | end 8 | 9 | def to_xml(xml) 10 | super if value 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/openxml/properties/complex_property.rb: -------------------------------------------------------------------------------- 1 | require "openxml/has_attributes" 2 | 3 | module OpenXml 4 | module Properties 5 | class ComplexProperty < BaseProperty 6 | include HasAttributes 7 | 8 | def to_xml(xml) 9 | return unless render? 10 | apply_namespace(xml).public_send(tag, xml_attributes) do 11 | yield xml if block_given? 12 | end 13 | end 14 | 15 | def render? 16 | !xml_attributes.empty? 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/openxml/properties/container_property.rb: -------------------------------------------------------------------------------- 1 | require "openxml/has_attributes" 2 | 3 | module OpenXml 4 | module Properties 5 | class ContainerProperty < BaseProperty 6 | include Enumerable 7 | include HasAttributes 8 | 9 | class << self 10 | 11 | def child_class(*args) 12 | unless args.empty? 13 | @child_classes = args.map { |arg| 14 | prop_name = arg.to_s.split(/_/).map(&:capitalize).join # LazyCamelCase 15 | const_name = (self.to_s.split(/::/)[0...-1] + [prop_name]).join("::") 16 | Object.const_get const_name 17 | } 18 | end 19 | 20 | @child_classes 21 | end 22 | alias child_classes child_class 23 | 24 | end 25 | 26 | def initialize 27 | @children = [] 28 | end 29 | 30 | def <<(child) 31 | raise ArgumentError, invalid_child_message unless valid_child?(child) 32 | children << child 33 | end 34 | 35 | def each(*args, &block) 36 | children.each(*args, &block) 37 | end 38 | 39 | def render? 40 | !children.length.zero? 41 | end 42 | 43 | def to_xml(xml) 44 | return unless render? 45 | 46 | apply_namespace(xml).public_send(tag, xml_attributes) { 47 | each { |child| child.to_xml(xml) } 48 | } 49 | end 50 | 51 | private 52 | 53 | attr_reader :children 54 | 55 | def invalid_child_message 56 | class_name = self.class.to_s.split(/::/).last 57 | "#{class_name} must be instances of one of the following: #{child_classes}" 58 | end 59 | 60 | def valid_child?(child) 61 | child_classes.any? { |child_class| child.is_a?(child_class) } 62 | end 63 | 64 | def child_classes 65 | self.class.child_classes 66 | end 67 | 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/openxml/properties/integer_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class IntegerProperty < ValueProperty 4 | 5 | def valid? 6 | value.is_a? Integer 7 | end 8 | 9 | def invalid_message 10 | "Invalid #{name}: must be an integer" 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/openxml/properties/on_off_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class OnOffProperty < ValueProperty 4 | 5 | def ok_values 6 | [true, false, :on, :off] # :on and :off are from the Transitional Spec 7 | end 8 | 9 | def to_xml(xml) 10 | if value == true 11 | apply_namespace(xml).public_send(tag) do 12 | yield xml if block_given? 13 | end 14 | else 15 | super 16 | end 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/openxml/properties/positive_integer_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class PositiveIntegerProperty < IntegerProperty 4 | 5 | def valid? 6 | super && value >= 0 7 | end 8 | 9 | def invalid_message 10 | "Invalid #{name}: must be a positive integer" 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/openxml/properties/string_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class StringProperty < ValueProperty 4 | 5 | def valid? 6 | value.is_a?(String) && !value.length.zero? 7 | end 8 | 9 | def invalid_message 10 | "Invalid value for #{name}; string expected (provided: #{value.inspect})" 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/openxml/properties/toggle_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class ToggleProperty < OnOffProperty 4 | # Toggle properties are no different in representation than on/off properties; 5 | # rather, the difference is in how they compose with one another (cf. 6 | # Section 17.7.3). It's helpful, then, to retain the concept, but entirely 7 | # unnecessary to duplicate implementation. 8 | # cf. Section A.6.9 of the spec, and Section A.7.9 of the transitional spec. 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/openxml/properties/transparent_container_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class TransparentContainerProperty < ContainerProperty 4 | 5 | def to_xml(xml) 6 | return unless render? 7 | each { |child| child.to_xml(xml) } 8 | end 9 | 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/openxml/properties/value_property.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Properties 3 | class ValueProperty < BaseProperty 4 | attr_reader :value 5 | 6 | def initialize(value) 7 | @value = value 8 | raise ArgumentError, invalid_message unless valid? 9 | end 10 | 11 | def valid? 12 | ok_values.member? value 13 | end 14 | 15 | def invalid_message 16 | "#{value.inspect} is an invalid value for #{name}; acceptable: #{ok_values.join(", ")}" 17 | end 18 | 19 | def render? 20 | !value.nil? 21 | end 22 | 23 | def to_xml(xml) 24 | apply_namespace(xml).public_send(tag, :"#{value_attribute}" => value) do 25 | yield xml if block_given? 26 | end 27 | end 28 | 29 | private 30 | 31 | def value_attribute 32 | namespace.nil? ? "val" : "#{namespace}:val" 33 | end 34 | 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/openxml/relationship.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Elements 3 | class Relationship < Struct.new(:type, :target, :id, :target_mode) 4 | def initialize(type, target, id=nil, target_mode=nil) 5 | super type, target, id || "R#{SecureRandom.hex}", target_mode 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/openxml/render_when_empty.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module RenderWhenEmpty 3 | 4 | def render? 5 | true 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/openxml/types.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | module Types 3 | XML = "application/xml".freeze 4 | RELATIONSHIPS = "application/vnd.openxmlformats-package.relationships+xml".freeze 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/openxml/unmet_requirement.rb: -------------------------------------------------------------------------------- 1 | module OpenXml 2 | class UnmetRequirementError < RuntimeError; end 3 | end 4 | -------------------------------------------------------------------------------- /openxml-package.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "openxml-package/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "openxml-package" 8 | spec.version = OpenXmlPackage::VERSION 9 | spec.authors = ["Bob Lail"] 10 | spec.email = ["bob.lailfamily@gmail.com"] 11 | 12 | spec.summary = %q{A Ruby implementation of OpenXmlPackage from Microsoft's Open XML SDK} 13 | spec.description = %q{A Ruby implementation of OpenXmlPackage from Microsoft's Open XML SDK} 14 | spec.homepage = "https://github.com/openxml/openxml-package" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "rubyzip", "~> 2.3" 23 | spec.add_dependency "nokogiri" 24 | spec.add_dependency "ox" 25 | 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "minitest" 29 | spec.add_development_dependency "minitest-reporters" 30 | spec.add_development_dependency "minitest-reporters-turn_reporter" 31 | spec.add_development_dependency "pry" 32 | spec.add_development_dependency "rr" 33 | spec.add_development_dependency "simplecov" 34 | spec.add_development_dependency "shoulda-context" 35 | 36 | end 37 | -------------------------------------------------------------------------------- /test/contains_properties_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "openxml/element" 3 | require "openxml/properties" 4 | require "openxml/contains_properties" 5 | 6 | class ContainsPropertiesTest < Minitest::Test 7 | should "allow properties to be rendered as direct children" do 8 | element = Class.new(OpenXml::Element) do 9 | include OpenXml::ContainsProperties 10 | tag :bodyPr 11 | namespace :a 12 | 13 | value_property :string_property 14 | end.new 15 | element.string_property = "A Value" 16 | 17 | rendered_xml = build(element) 18 | refute_match(/a:bodyPrPr/, rendered_xml) 19 | assert_match(/a:stringProperty val="A Value"/, rendered_xml) 20 | end 21 | 22 | private 23 | 24 | def build(element) 25 | builder = Nokogiri::XML::Builder.new 26 | builder.document("xmlns:a" => "http://microsoft.com") do |xml| 27 | element.to_xml(xml) 28 | end 29 | builder.to_xml 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /test/content_types_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ContentTypesTest < Minitest::Test 4 | attr_reader :content_types 5 | 6 | WORDPROCESSING_DOCUMENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" 7 | 8 | def setup 9 | @content_types = OpenXml::Parts::ContentTypes.new( 10 | {"xml" => OpenXml::Types::XML, "rels" => OpenXml::Types::RELATIONSHIPS}, 11 | {"word/document.xml" => WORDPROCESSING_DOCUMENT_TYPE}) 12 | end 13 | 14 | 15 | context "Given a path without an override" do 16 | should "identify the content type from its extension" do 17 | assert_equal OpenXml::Types::XML, content_types.of("content/some.xml") 18 | end 19 | end 20 | 21 | context "Given a path with an override" do 22 | should "identify the content type from its path" do 23 | assert_equal WORDPROCESSING_DOCUMENT_TYPE, content_types.of("word/document.xml") 24 | end 25 | end 26 | 27 | context "Given a path with an unrecognized extension" do 28 | should "be nil" do 29 | assert_nil content_types.of("img/screenshot.jpg") 30 | end 31 | end 32 | 33 | 34 | end 35 | -------------------------------------------------------------------------------- /test/element_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ElementTest < Minitest::Test 4 | attr_reader :element 5 | 6 | def setup 7 | @element = OpenXml::Builder::Element.new("tag") 8 | end 9 | 10 | context "The element" do 11 | should "correctly assign attributes" do 12 | element[:attribute] = "value" 13 | element["namespaced:attribute"] = "value" 14 | dump = Ox.dump(element.__getobj__) 15 | assert_equal "\n\n", dump 16 | end 17 | 18 | should "parse out available namespace prefixes from namespace definition attributes" do 19 | nsdef_prefix = "xmlns:namespace" 20 | nsdef_uri = "http://schema.somenamespace.org/" 21 | element[nsdef_prefix] = nsdef_uri 22 | assert_equal [:namespace], element.namespaces 23 | end 24 | 25 | should "only parse namespace definition attributes with a prefix" do 26 | element["xmlns"] = "http://schema.somenamespace.org/" 27 | assert element.namespaces.empty? 28 | end 29 | 30 | should "correctly use a namespace prefix" do 31 | element.namespace = "namespace" 32 | dump = Ox.dump(element.__getobj__) 33 | assert_equal "\n\n", dump 34 | end 35 | end 36 | 37 | 38 | end 39 | -------------------------------------------------------------------------------- /test/has_attributes_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "openxml/has_attributes" 3 | 4 | class HasAttributesTest < Minitest::Test 5 | context "ClassMethod.attribute" do 6 | should "define a reader method for the attribute" do 7 | element = class_with_attribute :an_attribute 8 | assert element.new.respond_to? :an_attribute 9 | end 10 | 11 | should "define a writer method for the attribute" do 12 | element = class_with_attribute :an_attribute 13 | assert element.new.respond_to? :an_attribute= 14 | end 15 | 16 | should "be named by its camelCased version if no displays_as given" do 17 | element = class_with_attribute :an_attribute 18 | assert_equal :anAttribute, element.attributes[:an_attribute][0] 19 | end 20 | 21 | should "be named by its displays_as property if given" do 22 | element = class_with_attribute :an_attribute, displays_as: :theAttr 23 | assert_equal :theAttr, element.attributes[:an_attribute][0] 24 | end 25 | 26 | context "in a with_namespace block" do 27 | should "use the provided namespace" do 28 | element = Class.new do 29 | include OpenXml::HasAttributes 30 | with_namespace :a_namespace do 31 | attribute :an_attribute 32 | end 33 | end 34 | assert_equal :a_namespace, element.attributes[:an_attribute][1] 35 | end 36 | end 37 | end 38 | 39 | context "validations" do 40 | should "raise an ArgumentError if the name is disallowed" do 41 | assert_raises ArgumentError do 42 | class_with_attribute :name 43 | end 44 | end 45 | 46 | should "raise an ArgumentError if the value does not match the expects parameter" do 47 | assert_raises ArgumentError do 48 | element = class_with_attribute :an_attribute, expects: :integer 49 | element.new.an_attribute = "A String" 50 | end 51 | end 52 | 53 | should "raise an ArgumentError if the value is not one of the enumerated values" do 54 | assert_raises ArgumentError do 55 | element = class_with_attribute :an_attribute, one_of: %i{ left right } 56 | element.new.an_attribute = :start 57 | end 58 | end 59 | 60 | should "raise an ArgumentError if the value is not in the given range" do 61 | assert_raises ArgumentError do 62 | element = class_with_attribute :an_attribute, in_range: 1...9000 63 | element.new.an_attribute = 9001 64 | end 65 | end 66 | 67 | should "raise an ArgumentError if the value fails the regex match" do 68 | assert_raises ArgumentError do 69 | element = class_with_attribute :an_attribute, matches: /^[a-f0-9]+$/i 70 | element.new.an_attribute = "AGGPPTAAGGTT" 71 | end 72 | end 73 | end 74 | 75 | context "a subclass of a class with attributes" do 76 | should "inherit its superclass' attributes" do 77 | element = Class.new(class_with_attribute(:an_attribute)).new 78 | assert_equal %i{ an_attribute }, element.attributes.keys 79 | end 80 | 81 | should "not modify the attributes of its superclass" do 82 | parent = class_with_attribute(:an_attribute) 83 | element = Class.new(parent) do 84 | attribute :another_attribute 85 | end 86 | 87 | assert_equal %i{ an_attribute another_attribute }, element.new.attributes.keys 88 | assert_equal %i{ an_attribute }, parent.new.attributes.keys 89 | end 90 | 91 | should "inherit the accessors of its superclass" do 92 | element = Class.new(class_with_attribute(:an_attribute)).new 93 | 94 | assert element.respond_to? :an_attribute, "Should respond to read accessor" 95 | assert element.respond_to? :an_attribute=, "Should respond to write accessor" 96 | end 97 | 98 | should "inherit the required attributes of its superclass" do 99 | element = Class.new(class_with_attribute(:an_attribute, required: true)).new 100 | 101 | assert_equal %i{ an_attribute }, element.required_attributes 102 | end 103 | 104 | should "not modify the required attributes of its superclass" do 105 | parent = class_with_attribute(:an_attribute, required: true) 106 | element = Class.new(parent) do 107 | attribute :another_attribute, required: true 108 | end 109 | 110 | assert_equal %i{ an_attribute another_attribute }, element.new.required_attributes 111 | assert_equal %i{ an_attribute }, parent.new.required_attributes 112 | end 113 | end 114 | 115 | context "a class with a required attribute" do 116 | should "raise an exception if the attribute is not set when generating xml" do 117 | element = class_with_attribute(:an_attribute, required: true).new 118 | assert_raises OpenXml::UnmetRequirementError do 119 | element.send(:xml_attributes) 120 | end 121 | end 122 | end 123 | 124 | private 125 | 126 | def class_with_attribute(attr_name, **args) 127 | Class.new do 128 | include OpenXml::HasAttributes 129 | attribute attr_name, **args 130 | 131 | def name 132 | "element" 133 | end 134 | end 135 | end 136 | 137 | end 138 | -------------------------------------------------------------------------------- /test/has_children_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "openxml/has_children" 3 | 4 | class HasChildrenTest < Minitest::Test 5 | attr_reader :element 6 | 7 | context "with HasChildren included" do 8 | setup do 9 | base_class = Class.new do 10 | def to_xml(xml) 11 | yield xml if block_given? 12 | xml 13 | end 14 | 15 | def render? 16 | false 17 | end 18 | end 19 | 20 | @element = Class.new(base_class) do 21 | include OpenXml::HasChildren 22 | end.new 23 | end 24 | 25 | should "append children using the shovel operator" do 26 | assert_equal 0, element.children.count 27 | element << :child_placeholder 28 | assert_equal 1, element.children.count 29 | end 30 | 31 | should "enable rendering if there are any children" do 32 | refute element.render? 33 | element.push :child_placeholder 34 | assert element.render? 35 | end 36 | 37 | should "call to_xml on all of its children" do 38 | child = Minitest::Mock.new 39 | child.expect :to_xml, "xml", %w{ xml } 40 | element << child 41 | element.to_xml "xml" 42 | child.verify 43 | end 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /test/has_properties_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "openxml/element" 3 | require "openxml/properties" 4 | require "openxml/has_properties" 5 | 6 | class HasPropertiesTest < Minitest::Test 7 | attr_reader :element 8 | 9 | context "When HasProperties is included," do 10 | context ".value_property" do 11 | setup do 12 | @element = Class.new do 13 | include OpenXml::HasProperties 14 | 15 | value_property :string_property 16 | end 17 | end 18 | 19 | should "generate accessor methods for the property" do 20 | an_element = element.new 21 | assert an_element.respond_to? :string_property 22 | assert an_element.respond_to? :string_property= 23 | end 24 | 25 | should "instantiate the property on assignment of a value" do 26 | an_element = element.new 27 | an_element.string_property = "A Value" 28 | an_element.string_property.is_a?(OpenXml::Properties::StringProperty) 29 | end 30 | end 31 | 32 | context ".property" do 33 | setup do 34 | @element = Class.new do 35 | include OpenXml::HasProperties 36 | 37 | property :complex_property 38 | property :polymorphic_property 39 | end 40 | end 41 | 42 | should "generate a reader method only for the property" do 43 | an_element = element.new 44 | assert an_element.respond_to? :complex_property 45 | end 46 | 47 | should "instantiate the property on first access" do 48 | an_element = element.new 49 | refute an_element.instance_variable_defined?("@complex_property") 50 | assert an_element.complex_property.is_a?(OpenXml::Properties::ComplexProperty) 51 | end 52 | 53 | should "allow a parameter to be passed in to initialize on access" do 54 | an_element = element.new 55 | an_element.polymorphic_property(:tagTwo) 56 | assert_equal an_element.polymorphic_property.tag, :tagTwo 57 | end 58 | end 59 | 60 | context ".property_choice" do 61 | setup do 62 | @element = Class.new do 63 | include OpenXml::HasProperties 64 | 65 | property_choice required: true do 66 | value_property :property_one, as: :boolean_property 67 | property :property_two, as: :complex_property 68 | end 69 | end 70 | end 71 | 72 | should "raise an exception when attempting to use more than one property in the group" do 73 | an_element = element.new 74 | assert_raises OpenXml::HasProperties::ChoiceGroupUniqueError do 75 | an_element.property_one = true 76 | an_element.property_two 77 | end 78 | 79 | another_element = element.new 80 | assert_raises OpenXml::HasProperties::ChoiceGroupUniqueError do 81 | another_element.property_two 82 | another_element.property_one = true 83 | end 84 | end 85 | 86 | should "raise an exception when a required choice has not been made" do 87 | assert_raises OpenXml::UnmetRequirementError do 88 | element.new.property_xml(Nokogiri::XML::Builder.new) 89 | end 90 | end 91 | end 92 | 93 | context "#to_xml" do 94 | setup do 95 | base_class = Class.new do 96 | def self.namespace 97 | :w 98 | end 99 | 100 | def namespace 101 | self.class.namespace 102 | end 103 | 104 | def to_xml(xml) 105 | xml.public_send(tag, "xmlns:w" => "http://microsoft.com") do 106 | yield xml if block_given? 107 | end 108 | end 109 | end 110 | 111 | @element = Class.new(base_class) do 112 | include OpenXml::HasProperties 113 | value_property :boolean_property 114 | 115 | def self.tag 116 | "p" 117 | end 118 | 119 | def tag 120 | self.class.tag 121 | end 122 | end 123 | end 124 | 125 | should "generate the property tag as part of to_xml" do 126 | an_element = element.new 127 | an_element.boolean_property = true 128 | 129 | assert_match(//, xml(an_element)) 130 | end 131 | 132 | should "call to_xml on each property" do 133 | builder = OpenXml::Builder.new 134 | mock = Minitest::Mock.new 135 | mock.expect(:render?, true) 136 | mock.expect(:to_xml, nil, [ builder ]) 137 | 138 | OpenXml::Properties::BooleanProperty.stub :new, mock do 139 | an_element = element.new 140 | an_element.boolean_property = true 141 | 142 | an_element.to_xml(builder) 143 | mock.verify 144 | end 145 | end 146 | end 147 | 148 | should "allow attributes to be set on the properties tag" do 149 | element = Class.new(OpenXml::Element) do 150 | include OpenXml::HasProperties 151 | tag :p 152 | namespace :w 153 | 154 | properties_attribute :bold, displays_as: :b, expects: :boolean 155 | end.new 156 | element.bold = true 157 | 158 | assert_match(/w:pPr b="true"/, xml(element)) 159 | end 160 | end 161 | 162 | context "a subclass of a class that has included HasProperties" do 163 | should "inherit the properties of its superclass" do 164 | parent = Class.new do 165 | include OpenXml::HasProperties 166 | value_property :boolean_property 167 | end 168 | child = Class.new(parent) 169 | 170 | assert_equal %i{ boolean_property }, child.new.send(:properties).keys 171 | end 172 | 173 | should "not modify the properties of its superclass" do 174 | parent = Class.new do 175 | include OpenXml::HasProperties 176 | value_property :boolean_property 177 | end 178 | child = Class.new(parent) do 179 | value_property :another_bool, as: :boolean_property 180 | end 181 | 182 | assert_equal %i{ boolean_property }, parent.properties.keys 183 | assert_equal %i{ another_bool boolean_property }, child.new.send(:properties).keys.sort 184 | end 185 | 186 | should "inherit the required properties of its superclass" do 187 | parent = Class.new do 188 | include OpenXml::HasProperties 189 | property :complex_property, required: true 190 | end 191 | child = Class.new(parent) 192 | 193 | assert_equal %i{ complex_property }, child.new.send(:required_properties).keys 194 | end 195 | 196 | should "not modify the required properties of its superclass" do 197 | parent = Class.new do 198 | include OpenXml::HasProperties 199 | property :complex_property, required: true 200 | end 201 | child = Class.new(parent) do 202 | property :another_one, as: :complex_property, required: true 203 | end 204 | 205 | assert_equal %i{ complex_property }, parent.required_properties.keys 206 | assert_equal %i{ another_one complex_property }, child.new.send(:required_properties).keys.sort 207 | end 208 | 209 | should "inherit the required choice groups of its superclass" do 210 | parent = Class.new do 211 | include OpenXml::HasProperties 212 | property_choice required: true do 213 | property :boolean_property 214 | property :complex_property 215 | end 216 | end 217 | child = Class.new(parent) 218 | 219 | assert_equal [0], child.required_choices 220 | end 221 | 222 | should "not modify the required choice groups of its superclass" do 223 | parent = Class.new do 224 | include OpenXml::HasProperties 225 | property_choice required: true do 226 | property :boolean_property 227 | property :complex_property 228 | end 229 | end 230 | child = Class.new(parent) do 231 | property_choice required: true do 232 | property :another_boolean, as: :boolean_property 233 | property :another_complex, as: :complex_property 234 | end 235 | end 236 | 237 | assert_equal [0], parent.required_choices 238 | assert_equal [0, 1], child.required_choices 239 | end 240 | 241 | should "inherit the accessors of its superclass" do 242 | parent = Class.new do 243 | include OpenXml::HasProperties 244 | value_property :boolean_property 245 | end 246 | child = Class.new(parent).new 247 | 248 | assert child.respond_to?(:boolean_property=), "Should respond to property assignment" 249 | assert child.respond_to?(:boolean_property), "Should respond to property accessor" 250 | end 251 | 252 | should "inherit the choice groups of its superclass" do 253 | parent = Class.new do 254 | include OpenXml::HasProperties 255 | property_choice do 256 | value_property :boolean_property 257 | end 258 | end 259 | child = Class.new(parent).new 260 | 261 | assert_equal 1, child.send(:choice_groups).count 262 | assert_equal %i{ boolean_property }, child.send(:choice_groups).first 263 | end 264 | 265 | should "not modify the choice groups of its superclass" do 266 | parent = Class.new do 267 | include OpenXml::HasProperties 268 | property_choice do 269 | value_property :boolean_property 270 | end 271 | end 272 | child = Class.new(parent) do 273 | property_choice do 274 | value_property :another_boolean, as: :boolean_property 275 | end 276 | end 277 | 278 | assert_equal 1, parent.choice_groups.count 279 | assert_equal %i{ boolean_property }, parent.choice_groups.first 280 | assert_equal 2, child.choice_groups.count 281 | assert_equal %i{ boolean_property }, child.choice_groups.first 282 | assert_equal %i{ another_boolean }, child.choice_groups.last 283 | end 284 | 285 | should "inherit the attributes of the properties tag of its superclass" do 286 | parent = Class.new(OpenXml::Element) do 287 | include OpenXml::HasProperties 288 | properties_attribute :an_attribute 289 | end 290 | child = Class.new(parent) do 291 | tag :p 292 | end 293 | 294 | assert_equal %i{ an_attribute }, child.new.properties_attributes.keys 295 | end 296 | 297 | should "not modify the attributes of the properties tag of its superclass" do 298 | parent = Class.new(OpenXml::Element) do 299 | include OpenXml::HasProperties 300 | tag :q 301 | properties_attribute :an_attribute 302 | end 303 | child = Class.new(parent) do 304 | tag :p 305 | properties_attribute :another_attribute 306 | end 307 | 308 | assert_equal %i{ an_attribute }, parent.new.properties_attributes.keys 309 | assert_equal %i{ an_attribute another_attribute }, child.new.properties_attributes.keys.sort 310 | end 311 | end 312 | 313 | context "#build_required_properties" do 314 | setup do 315 | @element = Class.new do 316 | include OpenXml::HasProperties 317 | property :property_haver_property, required: true 318 | value_property :boolean_property, required: true, default_value: false 319 | 320 | end 321 | end 322 | 323 | should "instantiate each required property" do 324 | an_element = element.new 325 | assert an_element.instance_variable_defined?(:"@property_haver_property") 326 | assert an_element.instance_variable_defined?(:"@boolean_property") 327 | end 328 | 329 | should "set the specified default for value properties" do 330 | an_element = element.new 331 | assert_equal false, an_element.boolean_property.value 332 | end 333 | end 334 | 335 | private 336 | 337 | def xml(element) 338 | builder = Nokogiri::XML::Builder.new 339 | builder.document("xmlns:w" => "http://microsoft.com") do |xml| 340 | element.to_xml(xml) 341 | end 342 | builder.to_xml 343 | end 344 | 345 | end 346 | 347 | module OpenXml 348 | module Properties 349 | 350 | class PolymorphicProperty < BaseProperty 351 | tag_is_one_of %i{ tagOne tagTwo } 352 | 353 | end 354 | 355 | class PropertyHaverProperty < ComplexProperty 356 | include OpenXml::HasProperties 357 | 358 | end 359 | 360 | end 361 | end 362 | -------------------------------------------------------------------------------- /test/package_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "fileutils" 3 | require "set" 4 | 5 | class OpenXmlPackageTest < Minitest::Test 6 | attr_reader :package, :temp_file 7 | 8 | 9 | 10 | context "#add_part" do 11 | should "accept a path and a part" do 12 | package = OpenXml::Package.new 13 | parts = package.parts.count 14 | package.add_part "PATH", OpenXml::Part.new 15 | assert_equal parts + 1, package.parts.count 16 | end 17 | end 18 | 19 | 20 | 21 | context "Writing" do 22 | setup do 23 | @temp_file = expand_path "../tmp/test.zip" 24 | FileUtils.rm temp_file, force: true 25 | end 26 | 27 | context "Given a simple part" do 28 | setup do 29 | @package = OpenXml::Package.new 30 | package.add_part "content/document.xml", OpenXml::Parts::UnparsedPart.new(document_content) 31 | end 32 | 33 | should "write a valid zip file with the expected parts" do 34 | package.write_to temp_file 35 | assert File.exist?(temp_file), "Expected the file #{temp_file.inspect} to have been created" 36 | assert_equal %w{[Content_Types].xml _rels/.rels content/document.xml}, 37 | Zip::File.open(temp_file).entries.map(&:name) 38 | end 39 | end 40 | end 41 | 42 | 43 | 44 | context "Reading" do 45 | context "Given a sample Word document" do 46 | setup do 47 | @temp_file = expand_path "./support/sample.docx" 48 | @expected_contents = Set[ 49 | "[Content_Types].xml", 50 | "_rels/.rels", 51 | "docProps/app.xml", 52 | "docProps/core.xml", 53 | "docProps/thumbnail.jpeg", 54 | "word/_rels/document.xml.rels", 55 | "word/document.xml", 56 | "word/fontTable.xml", 57 | "word/media/image1.png", 58 | "word/settings.xml", 59 | "word/styles.xml", 60 | "word/stylesWithEffects.xml", 61 | "word/theme/theme1.xml", 62 | "word/webSettings.xml" ] 63 | end 64 | 65 | context ".open" do 66 | setup do 67 | @package = OpenXml::Package.open(temp_file) 68 | end 69 | 70 | teardown do 71 | package.close 72 | end 73 | 74 | should "discover the expected parts" do 75 | assert_equal @expected_contents, package.parts.keys.to_set 76 | end 77 | 78 | should "read their content on-demand" do 79 | assert_equal web_settings_content, package.get_part("word/webSettings.xml").content 80 | end 81 | end 82 | 83 | context ".from_stream" do 84 | setup do 85 | @package = OpenXml::Package.from_stream(File.open(temp_file, "rb", &:read)) 86 | end 87 | 88 | should "also discover the expected parts" do 89 | assert_equal @expected_contents, package.parts.keys.to_set 90 | end 91 | 92 | should "read their content" do 93 | assert_equal web_settings_content, package.get_part("word/webSettings.xml").content 94 | end 95 | end 96 | 97 | context "ContentTypes" do 98 | setup do 99 | @package = OpenXml::Package.open(temp_file) 100 | end 101 | 102 | teardown do 103 | package.close 104 | end 105 | 106 | should "be parsed" do 107 | assert_equal %w{jpeg png rels xml}, package.content_types.defaults.keys.sort 108 | assert_equal "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", 109 | package.content_types.overrides["/word/document.xml"] 110 | end 111 | end 112 | 113 | context "Rels" do 114 | setup do 115 | @package = OpenXml::Package.open(temp_file) 116 | end 117 | 118 | teardown do 119 | package.close 120 | end 121 | 122 | should "be parsed" do 123 | assert_equal %w{docProps/core.xml docProps/app.xml word/document.xml docProps/thumbnail.jpeg}, 124 | package.rels.map(&:target) 125 | end 126 | end 127 | end 128 | 129 | context "Given a document with an external hyperlink" do 130 | context ".open" do 131 | should "parse the TargetMode attribute of the Relationship element" do 132 | path = expand_path "support/external_hyperlink.docx" 133 | OpenXml::Package.open(path) do |package| 134 | part = package.parts["word/_rels/document.xml.rels"] 135 | relationship = part.find { |r| r.target == "http://example.com" } 136 | assert_equal relationship.target_mode, 'External' 137 | end 138 | end 139 | end 140 | end 141 | end 142 | 143 | 144 | 145 | private 146 | 147 | def document_content 148 | <<-STR 149 | 150 | Works! 151 | 152 | STR 153 | end 154 | 155 | def web_settings_content 156 | "\r\n" 157 | end 158 | 159 | def expand_path(path) 160 | File.expand_path(File.join(File.dirname(__FILE__), path)) 161 | end 162 | 163 | end 164 | -------------------------------------------------------------------------------- /test/part_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "fileutils" 3 | require "set" 4 | 5 | class PartTest < Minitest::Test 6 | attr_reader :part, :builder 7 | 8 | 9 | 10 | context "Building" do 11 | setup do 12 | @part = OpenXml::Part.new 13 | end 14 | 15 | context "Given simple xml for one part" do 16 | setup do 17 | @builder = @part.build_xml { |xml| 18 | xml.document do |inner_xml| 19 | 2.times { inner_xml.child(attribute: "value", other_attr: "other value") } 20 | end 21 | } 22 | end 23 | 24 | should "build the expected xml" do 25 | assert_equal basic_xml, builder.to_s(debug: true) 26 | end 27 | end 28 | 29 | context "Given namespaced xml for one part" do 30 | setup do 31 | @builder = @part.build_xml do |xml| 32 | xml.document("xmlns:ns" => "some:namespace:uri") do 33 | 2.times { xml["ns"].child(attribute: "value", other_attr: "other value") } 34 | end 35 | end 36 | end 37 | 38 | should "build the expected xml" do 39 | assert_equal namespaced_xml, builder.to_s(debug: true) 40 | end 41 | end 42 | 43 | context "For a standalone XML element" do 44 | setup do 45 | @builder = @part.build_standalone_xml do |xml| 46 | xml.document do 47 | xml.child(attribute: "value", other_attr: "other value") 48 | end 49 | end 50 | end 51 | 52 | should "build the expected xml" do 53 | assert_equal standalone_xml, builder.to_s(debug: true) 54 | end 55 | end 56 | end 57 | 58 | 59 | 60 | private 61 | 62 | def basic_xml 63 | <<-STR.strip 64 | 65 | 66 | 67 | 68 | 69 | STR 70 | end 71 | 72 | def namespaced_xml 73 | <<-STR.strip 74 | 75 | 76 | 77 | 78 | 79 | STR 80 | end 81 | 82 | def standalone_xml 83 | <<-STR.strip 84 | 85 | 86 | 87 | 88 | STR 89 | end 90 | 91 | end 92 | -------------------------------------------------------------------------------- /test/rels_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class RelsTest < Minitest::Test 4 | context "with no defaults" do 5 | should "be empty" do 6 | assert OpenXml::Parts::Rels.new.empty? 7 | end 8 | end 9 | 10 | context "#to_xml" do 11 | context "given a document with an external hyperlink" do 12 | should "write the TargetMode attribute of the Relationship element" do 13 | path = File.expand_path "../support/external_hyperlink.docx", __FILE__ 14 | OpenXml::Package.open(path) do |package| 15 | part = package.parts["word/_rels/document.xml.rels"] 16 | assert_match(/