diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..08c540d Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 7b4d60d..30afaf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ debug/* TODO - +*.odt *.sw* diff --git a/Rakefile b/Rakefile index 649842c..a099c11 100644 --- a/Rakefile +++ b/Rakefile @@ -1,33 +1,6 @@ require 'rake' -require 'spec/rake/spectask' -require 'rake/gempackagetask' +require 'rspec/core/rake_task' -task :default => [:spec] +task default: :spec -Spec::Rake::SpecTask.new("spec") - -spec = Gem::Specification.new do |s| - s.name = %q{serenity-odt} - s.version = "0.2.2" - - s.authors = ["Tomas Kramar"] - s.description = <<-EOF - Embedded ruby for OpenOffice Text Document (.odt) files. You provide an .odt template - with ruby code in a special markup and the data, and Serenity generates the document. - Very similar to .erb files. - EOF - s.email = %q{kramar.tomas@gmail.com} - s.files = Dir.glob('lib/**/*.rb') + %w{README.md Rakefile LICENSE} - s.has_rdoc = false - s.require_paths = ["lib"] - s.rubygems_version = %q{1.3.5} - s.summary = %q{Embedded ruby for OpenOffice Text Document (.odt) files} - s.test_files = Dir.glob('spec/**/*.rb') + Dir.glob('fixtures/*.odt') - s.add_dependency('rubyzip', '>= 0.9.1') - s.add_dependency('nokogiri', '>= 1.0') - s.add_development_dependency('rspec', '>= 1.2.9') -end - -Rake::GemPackageTask.new(spec) do |p| - p.gem_spec = spec -end +RSpec::Core::RakeTask.new(:spec) diff --git a/fixtures/advanced.odt b/fixtures/advanced.odt index c0d3950..3bd6a8b 100644 Binary files a/fixtures/advanced.odt and b/fixtures/advanced.odt differ diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000..fc5607a Binary files /dev/null and b/lib/.DS_Store differ diff --git a/lib/serenity.rb b/lib/serenity.rb index fd045ea..ae4803d 100644 --- a/lib/serenity.rb +++ b/lib/serenity.rb @@ -1,4 +1,3 @@ -require 'rubygems' require 'serenity/line' require 'serenity/debug' require 'serenity/node_type' diff --git a/lib/serenity/template.rb b/lib/serenity/template.rb index 41bf178..0f908dd 100644 --- a/lib/serenity/template.rb +++ b/lib/serenity/template.rb @@ -1,4 +1,4 @@ -require 'zip/zip' +require 'zip' require 'fileutils' module Serenity @@ -12,7 +12,57 @@ def initialize(template, output) def process context tmpfiles = [] - Zip::ZipFile.open(@template) do |zipfile| + Zip::File.open(@template) do |zipfile| + # Pre-read embedded object templates for inline processing + object_entries = zipfile.entries + .map(&:name) + .select { |name| name.match?(%r{\AObject \d+/(content|styles)\.xml\z}) } + + object_templates = {} + object_entries.each { |name| object_templates[name] = zipfile.read(name) } + + objects_by_dir = object_templates.keys.group_by { |name| name.split('/').first } + + # Find highest existing object number for generating unique names + max_obj_num = zipfile.entries.map(&:name) + .grep(%r{\AObject (\d+)/}) { $1.to_i }.max || 0 + + # Pre-read all object-related files for copying to duplicates + object_all_files = {} + zipfile.entries.each do |entry| + name = entry.name + if name.match?(%r{\AObject \d+/}) || name.match?(%r{\AObjectReplacements/Object \d+\z}) + object_all_files[name] = zipfile.read(name) + end + end + + # Counters and results shared by processor lambdas + obj_counter = Hash.new(0) + obj_results = {} + + # Build a processor lambda per object directory. + # Each call evaluates the embedded template with the current binding + # (capturing loop variables like `person`), saves/restores _buf to avoid + # clobbering the outer template buffer, and stores the result keyed by + # iteration number. + processors = {} + objects_by_dir.each do |obj_dir, obj_files| + processors[obj_dir] = lambda do |ctx| + buf_save = ctx.local_variable_get(:_buf) + + obj_counter[obj_dir] += 1 + n = obj_counter[obj_dir] + + obj_files.each do |name| + file_part = name.split('/').last + obj_results["#{obj_dir}__#{n}/#{file_part}"] = + OdtEruby.new(XmlReader.new(object_templates[name])).evaluate(ctx) + end + + ctx.local_variable_set(:_buf, buf_save) + end + end + %w(content.xml styles.xml).each do |xml_file| content = zipfile.read(xml_file) @@ -22,16 +72,118 @@ def process context zipfile.replace(r.first, r.last) end + # Inject inline processing for embedded objects at their draw:object + # reference points so loop variables are in scope during evaluation + if xml_file == 'content.xml' && !processors.empty? + context.local_variable_set(:_serenity_processors, processors) + + objects_by_dir.each do |obj_dir, _| + ref_pattern = /(]*?xlink:href="\.\/#{Regexp.escape(obj_dir)}"[^>]*?\/>.*?<\/draw:frame>)/m + content = content.sub(ref_pattern, "\\1{% _serenity_processors['#{obj_dir}'].call(binding) %}") + end + end + odteruby = OdtEruby.new(XmlReader.new(content)) out = odteruby.evaluate(context) out.force_encoding Encoding.default_external + # Post-process content.xml: give each loop iteration its own object + if xml_file == 'content.xml' + objects_by_dir.each do |obj_dir, obj_files| + count = obj_counter[obj_dir] + + # Rename each draw:frame's object references to a unique object dir + iteration = 0 + out = out.gsub(/]*>.*?<\/draw:frame>/m) do |frame| + if frame.include?("./#{obj_dir}") + iteration += 1 + new_obj_dir = iteration == 1 ? obj_dir : "Object #{max_obj_num + iteration - 1}" + frame.gsub(obj_dir, new_obj_dir) + else + frame + end + end + + # Write evaluated templates and supporting files for each iteration + (1..count).each do |n| + final_obj = n == 1 ? obj_dir : "Object #{max_obj_num + n - 1}" + + # Write evaluated content.xml / styles.xml + obj_files.each do |name| + file_part = name.split('/').last + result = obj_results["#{obj_dir}__#{n}/#{file_part}"] + next unless result + + result.force_encoding Encoding.default_external + tmpfiles << (file = Tempfile.new("serenity")) + file << result + file.close + + final_name = "#{final_obj}/#{file_part}" + if zipfile.find_entry(final_name) + zipfile.replace(final_name, file.path) + else + zipfile.add(final_name, file.path) + end + end + + # Copy non-template files (meta.xml, ObjectReplacements) for new objects + next if n == 1 + object_all_files.each do |name, data| + next if object_templates.key?(name) + + new_name = nil + if name.start_with?("#{obj_dir}/") + new_name = name.sub(obj_dir, final_obj) + elsif name == "ObjectReplacements/#{obj_dir}" + new_name = "ObjectReplacements/#{final_obj}" + end + + if new_name + tmpfiles << (file = Tempfile.new("serenity")) + file.binmode + file << data + file.close + zipfile.add(new_name, file.path) + end + end + end + end + end + tmpfiles << (file = Tempfile.new("serenity")) file << out file.close - zipfile.replace(xml_file, file.path) end + + # Update manifest.xml with entries for new object directories + if obj_counter.values.any? { |c| c > 1 } + manifest = zipfile.read('META-INF/manifest.xml') + new_entries = "" + + objects_by_dir.each do |obj_dir, _| + count = obj_counter[obj_dir] + (2..count).each do |n| + new_obj = "Object #{max_obj_num + n - 1}" + new_entries << %( \n) + new_entries << %( \n) + new_entries << %( \n) + if object_all_files["#{obj_dir}/meta.xml"] + new_entries << %( \n) + end + if object_all_files["ObjectReplacements/#{obj_dir}"] + new_entries << %( \n) + end + end + end + + manifest = manifest.sub("", "#{new_entries}") + tmpfiles << (file = Tempfile.new("serenity")) + file << manifest + file.close + zipfile.replace('META-INF/manifest.xml', file.path) + end end end end diff --git a/serenity-odt.gemspec b/serenity-odt.gemspec index 762b88b..904d639 100644 --- a/serenity-odt.gemspec +++ b/serenity-odt.gemspec @@ -1,4 +1,3 @@ -# Describe your gem and declare its dependencies: Gem::Specification.new do |s| s.name = "serenity-odt" s.version = "0.2.2" @@ -8,8 +7,9 @@ Gem::Specification.new do |s| s.summary = "Parse ODT file and substitutes placeholders like ERb." s.description = "Embedded ruby for OpenOffice/LibreOffice Text Document (.odt) files. You provide an .odt template with ruby code in a special markup and the data, and Serenity generates the document. Very similar to .erb files." + s.required_ruby_version = ">= 3.0" s.files = Dir["{lib}/**/*"] + ["LICENSE", "Rakefile", "README.md"] - s.add_dependency "rubyzip", '>=0.9.1' - s.add_dependency "nokogiri", '>=1.0' - s.add_development_dependency('rspec', '>= 1.2.9') + s.add_dependency "rubyzip", ">= 2.0" + s.add_dependency "nokogiri", ">= 1.10" + s.add_development_dependency "rspec", "~> 3.0" end diff --git a/spec/escape_xml_spec.rb b/spec/escape_xml_spec.rb index 65b6c65..2beb0bc 100644 --- a/spec/escape_xml_spec.rb +++ b/spec/escape_xml_spec.rb @@ -1,19 +1,19 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper') describe String do - it 'should escape <' do - '1 < 2'.escape_xml.should == '1 < 2' + it 'escapes <' do + expect('1 < 2'.escape_xml).to eq('1 < 2') end - it 'should escape >' do - '2 > 1'.escape_xml.should == '2 > 1' + it 'escapes >' do + expect('2 > 1'.escape_xml).to eq('2 > 1') end - it 'should escape &' do - '1 & 2'.escape_xml.should == '1 & 2' + it 'escapes &' do + expect('1 & 2'.escape_xml).to eq('1 & 2') end - it 'should escape < > &' do - '1 < 2 && 2 > 1'.escape_xml.should == '1 < 2 && 2 > 1' + it 'escapes < > &' do + expect('1 < 2 && 2 > 1'.escape_xml).to eq('1 < 2 && 2 > 1') end end diff --git a/spec/generator_spec.rb b/spec/generator_spec.rb index 18348a4..34ab2f8 100644 --- a/spec/generator_spec.rb +++ b/spec/generator_spec.rb @@ -6,7 +6,7 @@ module Serenity FileUtils.rm(fixture('loop_output.odt')) end - it 'should make context from instance variables and run the provided template' do + it 'makes context from instance variables and runs the provided template' do class GeneratorClient include Serenity::Generator @@ -18,8 +18,8 @@ def generate_odt end client = GeneratorClient.new - lambda { client.generate_odt }.should_not raise_error - fixture('loop_output.odt').should be_a_document + expect { client.generate_odt }.not_to raise_error + expect(fixture('loop_output.odt')).to be_a_document end end end diff --git a/spec/odteruby_spec.rb b/spec/odteruby_spec.rb index e6eea3e..c1a0169 100644 --- a/spec/odteruby_spec.rb +++ b/spec/odteruby_spec.rb @@ -1,7 +1,6 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper') module Serenity - describe OdtEruby do before(:each) do name = 'test_name' @@ -12,24 +11,24 @@ module Serenity @context = binding end - def squeeze text + def squeeze(text) text.each_char.inject('') { |memo, line| memo += line.strip } unless text.nil? end - def run_spec template, expected, context=@context - content = OdtEruby.new(XmlReader.new template) - result = content.evaluate context + def run_spec(template, expected, context = @context) + content = OdtEruby.new(XmlReader.new(template)) + result = content.evaluate(context) - squeeze(result).should == squeeze(expected) + expect(squeeze(result)).to eq(squeeze(expected)) end - it 'should escape single quotes properly' do + it 'escapes single quotes properly' do expected = template = "It's a 'quote'" run_spec template, expected end - it 'should properly escape special XML characters ("<", ">", "&")' do + it 'properly escapes special XML characters ("<", ">", "&")' do template = "{%= description %}" description = 'This will only hold true if length < 1 && var == true or length > 1000' expected = "This will only hold true if length < 1 && var == true or length > 1000" @@ -37,7 +36,7 @@ def run_spec template, expected, context=@context run_spec template, expected, binding end - it 'should replace variables with values from context' do + it 'replaces variables with values from context' do template = <<-EOF {%= name %} {%= type %} @@ -53,14 +52,14 @@ def run_spec template, expected, context=@context run_spec template, expected end - it 'should replace multiple variables on one line' do + it 'replaces multiple variables on one line' do template = '{%= type %} and {%= name %}' expected = 'test_type and test_name' run_spec template, expected end - it 'should remove empty tags after a control structure processing' do + it 'removes empty tags after a control structure processing' do template = <<-EOF @@ -90,7 +89,7 @@ def run_spec template, expected, context=@context run_spec template, expected end - it 'should replace \n with soft newlines' do + it 'replaces \n with soft newlines' do text_with_newline = "First line\nSecond line" template = '{%= text_with_newline %}' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 19f8995..917816d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,21 +1,17 @@ $:.unshift(File.join(File.dirname(__FILE__), "..", "lib")) -require 'rubygems' require 'serenity' -require 'ruby-debug' -require 'rspec' -Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f} +Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f } Ship = Struct.new(:name, :type) -Person = Struct.new(:name, :skill) +Person = Struct.new(:name, :skill, :col1, :col2, :col3) -def fixture name +def fixture(name) File.join(File.dirname(__FILE__), '..', 'fixtures', name) end RSpec.configure do |config| - config.filter_run :focus => true + config.filter_run focus: true config.run_all_when_everything_filtered = true end - diff --git a/spec/support/matchers/be_a_document.rb b/spec/support/matchers/be_a_document.rb index e78e82a..4892e50 100644 --- a/spec/support/matchers/be_a_document.rb +++ b/spec/support/matchers/be_a_document.rb @@ -1,16 +1,15 @@ module Serenity RSpec::Matchers.define :be_a_document do match do |actual| - File.exists? actual + File.exist? actual end - failure_message_for_should do |actual| + failure_message do |actual| "expected that a file #{actual} would exist" end - failure_message_for_should_not do |actual| + failure_message_when_negated do |actual| "expected that a file #{actual} would not exist" end - end end diff --git a/spec/support/matchers/contain_in.rb b/spec/support/matchers/contain_in.rb index d7b7474..3b058f9 100644 --- a/spec/support/matchers/contain_in.rb +++ b/spec/support/matchers/contain_in.rb @@ -1,23 +1,21 @@ # encoding: utf-8 -require "zip/zip" +require "zip" module Serenity RSpec::Matchers.define :contain_in do |xml_file, expected| - match do |actual| - content = Zip::ZipFile.open(actual) { |zip_file| zip_file.read(xml_file) } + content = Zip::File.open(actual) { |zip_file| zip_file.read(xml_file) } content.force_encoding("UTF-8") content =~ Regexp.new(".*#{Regexp.escape(expected)}.*") end - failure_message_for_should do |actual| + failure_message do |actual| "expected #{actual} to contain the text #{expected}" end - failure_message_for_should_not do |actual| + failure_message_when_negated do |actual| "expected #{actual} to not contain the text #{expected}" end - end end diff --git a/spec/template_spec.rb b/spec/template_spec.rb index 7487a8b..674a70c 100644 --- a/spec/template_spec.rb +++ b/spec/template_spec.rb @@ -3,85 +3,96 @@ require 'fileutils' module Serenity - describe Template do - after(:each) do # FileUtils.rm(Dir['*.odt']) end - it "should process a document with simple variable substitution" do + it "processes a document with simple variable substitution" do @name = 'Malcolm Reynolds' @title = 'captain' template = Template.new(fixture('variables.odt'), 'output_variables.odt') template.process binding - 'output_variables.odt'.should contain_in('content.xml', 'Malcolm Reynolds') - 'output_variables.odt'.should contain_in('content.xml', 'captain') + expect('output_variables.odt').to contain_in('content.xml', 'Malcolm Reynolds') + expect('output_variables.odt').to contain_in('content.xml', 'captain') end - it "should unroll a simple for loop" do + it "unrolls a simple for loop" do @crew = %w{'River', 'Jayne', 'Wash'} template = Template.new(fixture('loop.odt'), 'output_loop.odt') template.process binding end - it "should unroll an advanced loop with tables" do + it "unrolls an advanced loop with tables" do @ships = [Ship.new('Firefly', 'transport'), Ship.new('Colonial', 'battle')] template = Template.new(fixture('loop_table.odt'), 'output_loop_table.odt') template.process binding ['Firefly', 'transport', 'Colonial', 'battle'].each do |text| - 'output_loop_table.odt'.should contain_in('content.xml', text) + expect('output_loop_table.odt').to contain_in('content.xml', text) end end - it "should process an advanced document" do - @persons = [Person.new('Malcolm', 'captain'), Person.new('River', 'psychic'), Person.new('Jay', 'gunslinger')] + it "processes an advanced document" do + @persons = [ + Person.new('Malcolm', 'captain', 10.5, 20.3, 30.1), + Person.new('River', 'psychic', 40.2, 50.7, 60.4), + Person.new('Jay', 'gunslinger', 70.8, 80.9, 90.6) + ] template = Template.new(fixture('advanced.odt'), 'output_advanced.odt') template.process binding ['Malcolm', 'captain', 'River', 'psychic', 'Jay', 'gunslinger'].each do |text| - 'output_advanced.odt'.should contain_in('content.xml', text) + expect('output_advanced.odt').to contain_in('content.xml', text) + end + + # Each person gets their own chart with their name and column values + { 'Object 1' => ['Malcolm', '10.5', '20.3', '30.1'], + 'Object 2' => ['River', '40.2', '50.7', '60.4'], + 'Object 3' => ['Jay', '70.8', '80.9', '90.6'] }.each do |obj, values| + values.each do |val| + expect('output_advanced.odt').to contain_in("#{obj}/content.xml", val) + end end end - it "should process a greek document" do + it "processes a greek document" do @h = {'ελληνικο' => 'κειμενο'} template = Template.new(fixture('greek.odt'), 'output_greek.odt') template.process binding - 'output_greek.odt'.should contain_in('content.xml', 'κειμενο') + expect('output_greek.odt').to contain_in('content.xml', 'κειμενο') end - it "should loop and generate table rows" do + it "loops and generates table rows" do @ships = [Ship.new('Firefly', 'transport'), Ship.new('Colonial', 'battle')] template = Template.new(fixture('table_rows.odt'), 'output_table_rows.odt') template.process binding ['Firefly', 'transport', 'Colonial', 'battle'].each do |text| - 'output_table_rows.odt'.should contain_in('content.xml', text) + expect('output_table_rows.odt').to contain_in('content.xml', text) end end - it "should parse the header" do + it "parses the header" do @title = 'captain' template = Template.new(fixture('header.odt'), 'output_header.odt') template.process(binding) - 'output_header.odt'.should contain_in('styles.xml', 'captain') + expect('output_header.odt').to contain_in('styles.xml', 'captain') end - it 'should parse the footer' do + it 'parses the footer' do @title = 'captain' template = Template.new(fixture('footer.odt'), 'output_footer.odt') template.process(binding) - 'output_footer.odt'.should contain_in('styles.xml', 'captain') + expect('output_footer.odt').to contain_in('styles.xml', 'captain') end end end diff --git a/spec/xml_reader_spec.rb b/spec/xml_reader_spec.rb index 82b0b69..de3c901 100644 --- a/spec/xml_reader_spec.rb +++ b/spec/xml_reader_spec.rb @@ -1,11 +1,10 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper') module Serenity - Node = Struct.new(:text, :type) describe XmlReader do - it "should stream the xml, tag by tag" do + it "streams the xml, tag by tag" do xml = <<-EOF This is a sentence{%= yeah %} @@ -31,11 +30,10 @@ module Serenity idx = 0 reader.each_node do |node, type| - expected[idx].text.should == node.strip - expected[idx].type.should == type + expect(node.strip).to eq(expected[idx].text) + expect(type).to eq(expected[idx].type) idx += 1 end end end end -