Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
debug/*
TODO

*.odt
*.sw*
33 changes: 3 additions & 30 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -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)
Binary file modified fixtures/advanced.odt
Binary file not shown.
Binary file added lib/.DS_Store
Binary file not shown.
1 change: 0 additions & 1 deletion lib/serenity.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
require 'rubygems'
require 'serenity/line'
require 'serenity/debug'
require 'serenity/node_type'
Expand Down
158 changes: 155 additions & 3 deletions lib/serenity/template.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require 'zip/zip'
require 'zip'
require 'fileutils'

module Serenity
Expand All @@ -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)

Expand All @@ -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 = /(<draw:object[^>]*?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[^>]*>.*?<\/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 << %( <manifest:file-entry manifest:media-type="application/vnd.oasis.opendocument.chart" manifest:full-path="#{new_obj}/"/>\n)
new_entries << %( <manifest:file-entry manifest:media-type="text/xml" manifest:full-path="#{new_obj}/content.xml"/>\n)
new_entries << %( <manifest:file-entry manifest:media-type="text/xml" manifest:full-path="#{new_obj}/styles.xml"/>\n)
if object_all_files["#{obj_dir}/meta.xml"]
new_entries << %( <manifest:file-entry manifest:media-type="text/xml" manifest:full-path="#{new_obj}/meta.xml"/>\n)
end
if object_all_files["ObjectReplacements/#{obj_dir}"]
new_entries << %( <manifest:file-entry manifest:media-type="application/x-openoffice-gdimetafile;windows_formatname=&quot;GDIMetaFile&quot;" manifest:full-path="ObjectReplacements/#{new_obj}"/>\n)
end
end
end

manifest = manifest.sub("</manifest:manifest>", "#{new_entries}</manifest:manifest>")
tmpfiles << (file = Tempfile.new("serenity"))
file << manifest
file.close
zipfile.replace('META-INF/manifest.xml', file.path)
end
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions serenity-odt.gemspec
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
16 changes: 8 additions & 8 deletions spec/escape_xml_spec.rb
Original file line number Diff line number Diff line change
@@ -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 &lt; 2'
it 'escapes <' do
expect('1 < 2'.escape_xml).to eq('1 &lt; 2')
end

it 'should escape >' do
'2 > 1'.escape_xml.should == '2 &gt; 1'
it 'escapes >' do
expect('2 > 1'.escape_xml).to eq('2 &gt; 1')
end

it 'should escape &' do
'1 & 2'.escape_xml.should == '1 &amp; 2'
it 'escapes &' do
expect('1 & 2'.escape_xml).to eq('1 &amp; 2')
end

it 'should escape < > &' do
'1 < 2 && 2 > 1'.escape_xml.should == '1 &lt; 2 &amp;&amp; 2 &gt; 1'
it 'escapes < > &' do
expect('1 < 2 && 2 > 1'.escape_xml).to eq('1 &lt; 2 &amp;&amp; 2 &gt; 1')
end
end
6 changes: 3 additions & 3 deletions spec/generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
23 changes: 11 additions & 12 deletions spec/odteruby_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')

module Serenity

describe OdtEruby do
before(:each) do
name = 'test_name'
Expand All @@ -12,32 +11,32 @@ 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 = "<text:p>It's a 'quote'</text:p>"

run_spec template, expected
end

it 'should properly escape special XML characters ("<", ">", "&")' do
it 'properly escapes special XML characters ("<", ">", "&")' do
template = "<text:p>{%= description %}</text:p>"
description = 'This will only hold true if length < 1 && var == true or length > 1000'
expected = "<text:p>This will only hold true if length &lt; 1 &amp;&amp; var == true or length &gt; 1000</text:p>"

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
<text:p text:style-name="Text_1_body">{%= name %}</text:p>
<text:p text:style-name="Text_1_body">{%= type %}</text:p>
Expand All @@ -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 = '<text:p text:style-name="Text_1_body">{%= type %} and {%= name %}</text:p>'
expected = '<text:p text:style-name="Text_1_body">test_type and test_name</text:p>'

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
<table:table style="Table_1">
<table:row style="Table_1_A1">
Expand Down Expand Up @@ -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:p text:style-name="P2">{%= text_with_newline %}</text:p>'
Expand Down
12 changes: 4 additions & 8 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -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

Loading