sablon 0.1.1 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +36 -5
- data/lib/sablon.rb +0 -3
- data/lib/sablon/configuration/html_tag.rb +1 -1
- data/lib/sablon/content.rb +56 -0
- data/lib/sablon/context.rb +2 -0
- data/lib/sablon/document_object_model/content_types.rb +35 -0
- data/lib/sablon/document_object_model/file_handler.rb +26 -0
- data/lib/sablon/document_object_model/model.rb +94 -0
- data/lib/sablon/document_object_model/numbering.rb +94 -0
- data/lib/sablon/document_object_model/relationships.rb +111 -0
- data/lib/sablon/environment.rb +13 -16
- data/lib/sablon/html/ast.rb +14 -13
- data/lib/sablon/html/ast_builder.rb +18 -5
- data/lib/sablon/html/node_properties.rb +3 -3
- data/lib/sablon/operations.rb +59 -0
- data/lib/sablon/processor/document.rb +48 -11
- data/lib/sablon/processor/section_properties.rb +11 -4
- data/lib/sablon/template.rb +88 -47
- data/lib/sablon/version.rb +1 -1
- data/misc/image-example.png +0 -0
- data/test/configuration_test.rb +22 -22
- data/test/content_test.rb +50 -0
- data/test/context_test.rb +37 -1
- data/test/environment_test.rb +4 -1
- data/test/executable_test.rb +0 -2
- data/test/fixtures/cv_sample.docx +0 -0
- data/test/fixtures/html_sample.docx +0 -0
- data/test/fixtures/images/c3po.jpg +0 -0
- data/test/fixtures/images/clone.jpg +0 -0
- data/test/fixtures/images/darth_vader.jpg +0 -0
- data/test/fixtures/images/r2d2.jpg +0 -0
- data/test/fixtures/images_sample.docx +0 -0
- data/test/fixtures/images_template.docx +0 -0
- data/test/fixtures/loops_sample.docx +0 -0
- data/test/fixtures/loops_template.docx +0 -0
- data/test/fixtures/recipe_sample.docx +0 -0
- data/test/fixtures/xml/image.xml +91 -0
- data/test/fixtures/xml/loop_with_unique_ids.xml +152 -0
- data/test/fixtures/xml/mock_document/word/document.xml +12 -0
- data/test/html/ast_test.rb +10 -5
- data/test/html/converter_style_test.rb +9 -9
- data/test/html/converter_test.rb +66 -81
- data/test/html/node_properties_test.rb +2 -2
- data/test/html_test.rb +2 -6
- data/test/processor/document_test.rb +80 -3
- data/test/processor/section_properties_test.rb +68 -0
- data/test/sablon_test.rb +77 -5
- data/test/test_helper.rb +109 -9
- metadata +33 -9
- data/lib/sablon/numbering.rb +0 -23
- data/lib/sablon/processor/numbering.rb +0 -47
- data/lib/sablon/relationship.rb +0 -47
- data/lib/sablon/test/assertions.rb +0 -22
- data/test/section_properties_test.rb +0 -41
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'sablon/document_object_model/file_handler'
|
3
|
+
|
4
|
+
module Sablon
|
5
|
+
module DOM
|
6
|
+
# Adds new relationships to the entry's corresponding relationships file
|
7
|
+
class Relationships < FileHandler
|
8
|
+
#
|
9
|
+
# extends the Model class so it now has an "add_relationship" method
|
10
|
+
def self.extend_model(model_klass)
|
11
|
+
super do
|
12
|
+
#
|
13
|
+
# adds a relationship to the rels file for the current entry
|
14
|
+
define_method(:add_relationship) do |rel_attr|
|
15
|
+
# detemine name of rels file to augment
|
16
|
+
rels_name = Relationships.rels_entry_name_for(@current_entry)
|
17
|
+
|
18
|
+
# create the file if needed and update DOM
|
19
|
+
create_entry_if_not_exist(rels_name, Relationships.file_template)
|
20
|
+
@dom[rels_name].add_relationship(rel_attr)
|
21
|
+
end
|
22
|
+
#
|
23
|
+
# adds file to the /word/media folder without overwriting an
|
24
|
+
# existing file
|
25
|
+
define_method(:add_media) do |name, data, rel_attr|
|
26
|
+
rel_attr[:Target] = "media/#{name}"
|
27
|
+
extension = name.match(/\.(\w+?)$/).to_a[1]
|
28
|
+
type = rel_attr[:Type].match(%r{/(\w+?)$}).to_a[1] + "/#{extension}"
|
29
|
+
#
|
30
|
+
if @zip_contents["word/#{rel_attr[:Target]}"]
|
31
|
+
names = @zip_contents.keys.map { |n| File.basename(n) }
|
32
|
+
pattern = "^(\\d+)-#{name}"
|
33
|
+
max_val = names.collect { |n| n.match(pattern).to_a[1].to_i }.max
|
34
|
+
rel_attr[:Target] = "media/#{max_val + 1}-#{name}"
|
35
|
+
end
|
36
|
+
#
|
37
|
+
# add the content to the zip and create the relationship
|
38
|
+
@zip_contents["word/#{rel_attr[:Target]}"] = data
|
39
|
+
add_content_type(extension, type)
|
40
|
+
add_relationship(rel_attr)
|
41
|
+
end
|
42
|
+
#
|
43
|
+
# locates an existing rId in the approprirate rels file
|
44
|
+
define_method(:find_relationship_by) do |attribute, value, entry = nil|
|
45
|
+
entry = @current_entry if entry.nil?
|
46
|
+
# find the rels file and search it if it exists
|
47
|
+
rels_name = Relationships.rels_entry_name_for(entry)
|
48
|
+
return unless @dom[rels_name]
|
49
|
+
#
|
50
|
+
@dom[rels_name].find_relationship_by(attribute, value)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.file_template
|
56
|
+
<<-XML.gsub(/^\s+|\n/, '')
|
57
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
58
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
59
|
+
</Relationships>
|
60
|
+
XML
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.rels_entry_name_for(entry_name)
|
64
|
+
par_dir = Pathname.new(File.dirname(entry_name))
|
65
|
+
par_dir.join('_rels', "#{File.basename(entry_name)}.rels").to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
# Sets up the class instance to handle new relationships for a document.
|
69
|
+
# I only care about tags that have an integer component
|
70
|
+
def initialize(xml_node)
|
71
|
+
super
|
72
|
+
#
|
73
|
+
@relationships = xml_node.root
|
74
|
+
@max_rid = max_attribute_value('Relationship', 'Id')
|
75
|
+
end
|
76
|
+
|
77
|
+
# Finds the maximum value of an attribute by converting it to an
|
78
|
+
# integer. Non numeric portions of values are ignored.
|
79
|
+
def max_attribute_value(selector, attr_name)
|
80
|
+
super(@relationships, selector, attr_name, query_method: :css)
|
81
|
+
end
|
82
|
+
|
83
|
+
# adds a new relationship and returns the corresponding rId for it
|
84
|
+
def add_relationship(rel_attr)
|
85
|
+
rel_attr['Id'] = "rId#{next_rid}"
|
86
|
+
@relationships << relationship_tag(rel_attr)
|
87
|
+
#
|
88
|
+
rel_attr['Id']
|
89
|
+
end
|
90
|
+
|
91
|
+
# Reurns an XML node based on the attribute value or nil if one does
|
92
|
+
# not exist
|
93
|
+
def find_relationship_by(attribute, value)
|
94
|
+
@relationships.css(%(Relationship[#{attribute}="#{value}"])).first
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# increments the max rid and returns it
|
100
|
+
def next_rid
|
101
|
+
@max_rid += 1
|
102
|
+
end
|
103
|
+
|
104
|
+
# Builds the relationship WordML tag and returns it
|
105
|
+
def relationship_tag(rel_attr)
|
106
|
+
attr_str = rel_attr.map { |k, v| %(#{k}="#{v}") }.join(' ')
|
107
|
+
"<Relationship #{attr_str}/>"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/sablon/environment.rb
CHANGED
@@ -3,31 +3,28 @@ module Sablon
|
|
3
3
|
# to manage data during template processing.
|
4
4
|
class Environment
|
5
5
|
attr_reader :template
|
6
|
-
attr_reader :numbering
|
7
6
|
attr_reader :context
|
8
|
-
attr_reader :
|
7
|
+
attr_reader :section_properties
|
9
8
|
|
10
9
|
# returns a new environment with merged contexts
|
11
10
|
def alter_context(context = {})
|
12
11
|
new_context = @context.merge(context)
|
13
|
-
Environment.new(
|
12
|
+
Environment.new(template, new_context)
|
13
|
+
end
|
14
|
+
|
15
|
+
# reader method for the DOM::Model instance stored on the template
|
16
|
+
def document
|
17
|
+
@template.document
|
18
|
+
end
|
19
|
+
|
20
|
+
def section_properties=(properties)
|
21
|
+
@section_properties = Context.transform_hash(properties)
|
14
22
|
end
|
15
23
|
|
16
24
|
private
|
17
25
|
|
18
|
-
def initialize(template, context = {}
|
19
|
-
|
20
|
-
# create new references
|
21
|
-
if parent_env
|
22
|
-
@template = parent_env.template
|
23
|
-
@numbering = parent_env.numbering
|
24
|
-
@relationship = parent_env.relationship
|
25
|
-
else
|
26
|
-
@template = template
|
27
|
-
@numbering = Numbering.new
|
28
|
-
@relationship = Relationship.new
|
29
|
-
end
|
30
|
-
#
|
26
|
+
def initialize(template, context = {})
|
27
|
+
@template = template
|
31
28
|
@context = Context.transform_hash(context)
|
32
29
|
end
|
33
30
|
end
|
data/lib/sablon/html/ast.rb
CHANGED
@@ -30,8 +30,11 @@ module Sablon
|
|
30
30
|
# process the styles as a hash and store values
|
31
31
|
style_attrs = {}
|
32
32
|
properties.each do |key, value|
|
33
|
+
key = key.strip if key.respond_to? :strip
|
34
|
+
value = value.strip if value.respond_to? :strip
|
35
|
+
#
|
33
36
|
unless key.is_a? Symbol
|
34
|
-
key, value = *convert_style_property(key
|
37
|
+
key, value = *convert_style_property(key, value)
|
35
38
|
end
|
36
39
|
style_attrs[key] = value if key
|
37
40
|
end
|
@@ -127,8 +130,11 @@ module Sablon
|
|
127
130
|
class Root < Collection
|
128
131
|
def initialize(env, node)
|
129
132
|
# strip text nodes from the root level element, these are typically
|
130
|
-
# extra whitespace from indenting the markup
|
131
|
-
|
133
|
+
# extra whitespace from indenting the markup if there are any
|
134
|
+
# block level tags at the top level
|
135
|
+
if ASTBuilder.any_block_tags?(node.children)
|
136
|
+
node.search('./text()').remove
|
137
|
+
end
|
132
138
|
|
133
139
|
# convert children from HTML to AST nodes
|
134
140
|
super(ASTBuilder.html_to_ast(env, node.children, {}))
|
@@ -204,8 +210,8 @@ module Sablon
|
|
204
210
|
#
|
205
211
|
@definition = nil
|
206
212
|
if node.ancestors(".//#{@list_tag}").length.zero?
|
207
|
-
# Only register a definition
|
208
|
-
@definition = env.
|
213
|
+
# Only register a definition upon the first list tag encountered
|
214
|
+
@definition = env.document.add_list_definition(properties['pStyle'])
|
209
215
|
end
|
210
216
|
|
211
217
|
# update attributes of all child nodes
|
@@ -214,10 +220,6 @@ module Sablon
|
|
214
220
|
# Move any list tags that are a child of a list item up one level
|
215
221
|
process_child_nodes(node)
|
216
222
|
|
217
|
-
# strip text nodes from the list level element, this is typically
|
218
|
-
# extra whitespace from indenting the markup
|
219
|
-
node.search('./text()').remove
|
220
|
-
|
221
223
|
# convert children from HTML to AST nodes
|
222
224
|
super(ASTBuilder.html_to_ast(env, node.children, properties))
|
223
225
|
end
|
@@ -532,14 +534,13 @@ module Sablon
|
|
532
534
|
@runs = Collection.new(@runs)
|
533
535
|
@target = node.attributes['href'].value
|
534
536
|
#
|
535
|
-
|
536
|
-
Id: 'rId' + SecureRandom.uuid.delete('-'),
|
537
|
+
rel_attr = {
|
537
538
|
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
|
538
539
|
Target: @target,
|
539
540
|
TargetMode: 'External'
|
540
541
|
}
|
541
|
-
env.
|
542
|
-
@attributes = { 'r:id' =>
|
542
|
+
rid = env.document.add_relationship(rel_attr)
|
543
|
+
@attributes = { 'r:id' => rid }
|
543
544
|
end
|
544
545
|
|
545
546
|
def to_docx
|
@@ -9,6 +9,23 @@ module Sablon
|
|
9
9
|
builder.nodes
|
10
10
|
end
|
11
11
|
|
12
|
+
# Checks if there are any block level tags in the current node set
|
13
|
+
# this is used at the root level to determine if top level text nodes
|
14
|
+
# should be removed
|
15
|
+
def self.any_block_tags?(nodes)
|
16
|
+
nodes.detect { |node| fetch_tag(node.name).type == :block }
|
17
|
+
end
|
18
|
+
|
19
|
+
# Retrieves a HTMLTag instance from the permitted_html_tags hash or
|
20
|
+
# raises an ArgumentError if the tag is not registered
|
21
|
+
def self.fetch_tag(tag_name)
|
22
|
+
tag_name = tag_name.to_sym
|
23
|
+
unless Sablon::Configuration.instance.permitted_html_tags[tag_name]
|
24
|
+
raise ArgumentError, "Don't know how to handle HTML tag: #{tag_name}"
|
25
|
+
end
|
26
|
+
Sablon::Configuration.instance.permitted_html_tags[tag_name]
|
27
|
+
end
|
28
|
+
|
12
29
|
private
|
13
30
|
|
14
31
|
def initialize(env, nodes, properties)
|
@@ -42,11 +59,7 @@ module Sablon
|
|
42
59
|
# retrieves a HTMLTag instance from the cpermitted_html_tags hash or
|
43
60
|
# raises an ArgumentError if the tag is not registered in the hash
|
44
61
|
def fetch_tag(tag_name)
|
45
|
-
tag_name
|
46
|
-
unless Sablon::Configuration.instance.permitted_html_tags[tag_name]
|
47
|
-
raise ArgumentError, "Don't know how to handle HTML tag: #{tag_name}"
|
48
|
-
end
|
49
|
-
Sablon::Configuration.instance.permitted_html_tags[tag_name]
|
62
|
+
self.class.fetch_tag(tag_name)
|
50
63
|
end
|
51
64
|
|
52
65
|
# Checking that the current tag is an allowed child of the parent_tag.
|
@@ -35,11 +35,11 @@ module Sablon
|
|
35
35
|
end
|
36
36
|
|
37
37
|
def [](key)
|
38
|
-
@properties[key]
|
38
|
+
@properties[key.to_sym]
|
39
39
|
end
|
40
40
|
|
41
41
|
def []=(key, value)
|
42
|
-
@properties[key] = value
|
42
|
+
@properties[key.to_sym] = value
|
43
43
|
end
|
44
44
|
|
45
45
|
def to_docx
|
@@ -57,7 +57,7 @@ module Sablon
|
|
57
57
|
#
|
58
58
|
properties.each do |key, value|
|
59
59
|
if whitelist.include? key.to_s
|
60
|
-
@properties[key] = value
|
60
|
+
@properties[key.to_sym] = value
|
61
61
|
else
|
62
62
|
@transferred_properties[key] = value
|
63
63
|
end
|
data/lib/sablon/operations.rb
CHANGED
@@ -21,8 +21,36 @@ module Sablon
|
|
21
21
|
iter_env = env.alter_context(iterator_name => item)
|
22
22
|
block.process(iter_env)
|
23
23
|
end
|
24
|
+
update_unique_ids(env, content)
|
24
25
|
block.replace(content.reverse)
|
25
26
|
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# updates all unique id's present in the xml being copied
|
31
|
+
def update_unique_ids(env, content)
|
32
|
+
doc_xml = env.document.zip_contents[env.document.current_entry]
|
33
|
+
dom_entry = env.document[env.document.current_entry]
|
34
|
+
#
|
35
|
+
# update all docPr tags created
|
36
|
+
selector = "//*[local-name() = 'docPr']"
|
37
|
+
init_id_val = dom_entry.max_attribute_value(doc_xml, selector, 'id')
|
38
|
+
update_tag_attribute(content, 'docPr', 'id', init_id_val)
|
39
|
+
#
|
40
|
+
# update all cNvPr tags created
|
41
|
+
selector = "//*[local-name() = 'cNvPr']"
|
42
|
+
init_id_val = dom_entry.max_attribute_value(doc_xml, selector, 'id')
|
43
|
+
update_tag_attribute(content, 'cNvPr', 'id', init_id_val)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Increments the attribute value of each element with the id by 1
|
47
|
+
def update_tag_attribute(content, tag_name, attr_name, init_val)
|
48
|
+
content.each do |nodeset|
|
49
|
+
nodeset.xpath(".//*[local-name() = '#{tag_name}']").each do |node|
|
50
|
+
node[attr_name] = (init_val += 1).to_s
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
26
54
|
end
|
27
55
|
|
28
56
|
class Condition < Struct.new(:conditon_expr, :block, :predicate)
|
@@ -50,6 +78,37 @@ module Sablon
|
|
50
78
|
block.replace []
|
51
79
|
end
|
52
80
|
end
|
81
|
+
|
82
|
+
class Image < Struct.new(:image_reference, :block)
|
83
|
+
def evaluate(env)
|
84
|
+
image = image_reference.evaluate(env.context)
|
85
|
+
set_local_rid(env, image) if image
|
86
|
+
block.replace(image)
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def set_local_rid(env, image)
|
92
|
+
if image.rid_by_file.keys.empty?
|
93
|
+
# Only add the image once, it is reused afterwards
|
94
|
+
rel_attr = {
|
95
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'
|
96
|
+
}
|
97
|
+
rid = env.document.add_media(image.name, image.data, rel_attr)
|
98
|
+
image.rid_by_file[env.document.current_entry] = rid
|
99
|
+
elsif image.rid_by_file[env.document.current_entry].nil?
|
100
|
+
# locate an existing relationship and duplicate it
|
101
|
+
entry = image.rid_by_file.keys.first
|
102
|
+
value = image.rid_by_file[entry]
|
103
|
+
#
|
104
|
+
rel = env.document.find_relationship_by('Id', value, entry)
|
105
|
+
rid = env.document.add_relationship(rel.attributes)
|
106
|
+
image.rid_by_file[env.document.current_entry] = rid
|
107
|
+
end
|
108
|
+
#
|
109
|
+
image.local_rid = image.rid_by_file[env.document.current_entry]
|
110
|
+
end
|
111
|
+
end
|
53
112
|
end
|
54
113
|
|
55
114
|
module Expression
|
@@ -2,11 +2,9 @@
|
|
2
2
|
module Sablon
|
3
3
|
module Processor
|
4
4
|
class Document
|
5
|
-
def self.process(xml_node, env
|
5
|
+
def self.process(xml_node, env)
|
6
6
|
processor = new(parser)
|
7
7
|
processor.manipulate xml_node, env
|
8
|
-
processor.write_properties xml_node, properties if properties.any?
|
9
|
-
xml_node
|
10
8
|
end
|
11
9
|
|
12
10
|
def self.parser
|
@@ -26,14 +24,8 @@ module Sablon
|
|
26
24
|
xml_node
|
27
25
|
end
|
28
26
|
|
29
|
-
def write_properties(xml_node, properties)
|
30
|
-
if start_page_number = properties[:start_page_number] || properties["start_page_number"]
|
31
|
-
section_properties = SectionProperties.from_document(xml_node)
|
32
|
-
section_properties.start_page_number = start_page_number
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
27
|
private
|
28
|
+
|
37
29
|
def build_operations(fields)
|
38
30
|
OperationConstruction.new(fields).operations
|
39
31
|
end
|
@@ -51,7 +43,7 @@ module Sablon
|
|
51
43
|
|
52
44
|
class Block < Struct.new(:start_field, :end_field)
|
53
45
|
def self.enclosed_by(start_field, end_field)
|
54
|
-
@blocks ||= [RowBlock, ParagraphBlock, InlineParagraphBlock]
|
46
|
+
@blocks ||= [ImageBlock, RowBlock, ParagraphBlock, InlineParagraphBlock]
|
55
47
|
block_class = @blocks.detect { |klass| klass.encloses?(start_field, end_field) }
|
56
48
|
block_class.new start_field, end_field
|
57
49
|
end
|
@@ -117,6 +109,48 @@ module Sablon
|
|
117
109
|
end
|
118
110
|
end
|
119
111
|
|
112
|
+
class ImageBlock < ParagraphBlock
|
113
|
+
def self.parent(node)
|
114
|
+
node.ancestors(".//w:p").first
|
115
|
+
end
|
116
|
+
|
117
|
+
def self.encloses?(start_field, end_field)
|
118
|
+
start_field.expression.start_with?('@')
|
119
|
+
end
|
120
|
+
|
121
|
+
def replace(image)
|
122
|
+
#
|
123
|
+
if image
|
124
|
+
nodes_between_fields.each do |node|
|
125
|
+
pic_prop = node.at_xpath('.//pic:cNvPr', pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture')
|
126
|
+
pic_prop.attributes['name'].value = image.name if pic_prop
|
127
|
+
blip = node.at_xpath('.//a:blip', a: 'http://schemas.openxmlformats.org/drawingml/2006/main')
|
128
|
+
blip.attributes['embed'].value = image.local_rid if blip
|
129
|
+
end
|
130
|
+
end
|
131
|
+
#
|
132
|
+
start_field.remove
|
133
|
+
end_field.remove
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
|
138
|
+
# Collects all nodes between the two nodes provided into an array.
|
139
|
+
# Each entry in the array should be a paragraph tag.
|
140
|
+
# https://stackoverflow.com/a/820776
|
141
|
+
def nodes_between_fields
|
142
|
+
first = self.class.parent(start_field)
|
143
|
+
last = self.class.parent(end_field)
|
144
|
+
#
|
145
|
+
result = [first]
|
146
|
+
until first == last
|
147
|
+
first = first.next
|
148
|
+
result << first
|
149
|
+
end
|
150
|
+
result
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
120
154
|
class InlineParagraphBlock < Block
|
121
155
|
def self.parent(node)
|
122
156
|
node.ancestors ".//w:p"
|
@@ -171,6 +205,9 @@ module Sablon
|
|
171
205
|
when /([^ ]+):if/
|
172
206
|
block = consume_block("#{$1}:endIf")
|
173
207
|
Statement::Condition.new(Expression.parse($1), block)
|
208
|
+
when /^@([^ ]+):start/
|
209
|
+
block = consume_block("@#{$1}:end")
|
210
|
+
Statement::Image.new(Expression.parse($1), block)
|
174
211
|
when /^comment$/
|
175
212
|
block = consume_block("endComment")
|
176
213
|
Statement::Comment.new(block)
|