ruby3mf 0.2.5 → 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,73 +1,77 @@
1
- class ContentTypes
2
-
3
- def initialize(found={}, over={})
4
- @found_types=found
5
- @found_overrides=over
6
- end
7
-
8
- def size
9
- @found_types.size + @found_overrides.size
10
- end
11
-
12
- def empty?
13
- size == 0
14
- end
15
-
16
- def get_type(target)
17
- target = (target.start_with?('/') ? target : '/' + target).downcase
18
- if @found_overrides[target]
19
- content_type = @found_overrides[target]
20
- else
21
- extension = File.extname(target).strip.downcase[1..-1]
22
- content_type = @found_types[extension]
23
- end
24
- content_type
25
- end
26
-
27
- def get_types()
28
- return @found_types.values + @found_overrides.values
29
- end
30
-
31
- private
32
-
33
- def self.parse(zip_entry)
34
- found_types = {}
35
- found_overrides = {}
36
- Log3mf.context "parse" do |l|
37
- begin
38
- doc = XmlVal.validate_parse(zip_entry)
39
-
40
- l.warning '[Content_Types].xml must contain exactly one root node' unless doc.children.size == 1
41
- l.warning '[Content_Types].xml must contain root name Types' unless doc.children.first.name == "Types"
42
-
43
- required_content_types = ['application/vnd.openxmlformats-package.relationships+xml']
44
-
45
- types_node = doc.children.first
46
- types_node.children.each do |node|
47
- l.context node.name do |l|
48
- if node.name == 'Default'
49
- extension = node['Extension'].downcase
50
- l.info "Setting type hash #{extension}=#{node['ContentType']}"
51
- l.error :duplicate_content_extension_types if !found_types[extension].nil?
52
- found_types[extension] = node['ContentType']
53
- elsif node.name == 'Override'
54
- part_name = node['PartName'].downcase
55
- l.error :empty_override_part_name if part_name.empty?
56
-
57
- l.error :duplicate_content_override_types if !found_overrides[part_name].nil?
58
- found_overrides[part_name] = node['ContentType']
59
- else
60
- l.warning "[Content_Types].xml:#{node.line} contains unexpected element #{node.name}", page: 10
61
- end
62
- end
63
- end
64
- required_content_types.each do |req_type|
65
- l.error :invalid_content_type, mt: req_type unless found_types.values.include? req_type
66
- end
67
- rescue Nokogiri::XML::SyntaxError => e
68
- l.error :content_types_invalid_xml, e: "#{e}"
69
- end
70
- end
71
- return new(found_types, found_overrides)
72
- end
73
- end
1
+ class ContentTypes
2
+
3
+ def initialize(found={}, over={})
4
+ @found_types=found
5
+ @found_overrides=over
6
+ end
7
+
8
+ def size
9
+ @found_types.size + @found_overrides.size
10
+ end
11
+
12
+ def empty?
13
+ size == 0
14
+ end
15
+
16
+ def get_type(target)
17
+ target = (target.start_with?('/') ? target : '/' + target).downcase
18
+ if @found_overrides[target]
19
+ content_type = @found_overrides[target]
20
+ else
21
+ extension = File.extname(target).strip.downcase[1..-1]
22
+ content_type = @found_types[extension]
23
+ end
24
+ content_type
25
+ end
26
+
27
+ def get_types()
28
+ return @found_types.values + @found_overrides.values
29
+ end
30
+
31
+ private
32
+
33
+ def self.parse(zip_entry)
34
+ found_types = {}
35
+ found_overrides = {}
36
+ Log3mf.context "parse" do |l|
37
+ begin
38
+ doc = XmlVal.validate_parse(zip_entry)
39
+
40
+ l.warning '[Content_Types].xml must contain exactly one root node' unless doc.children.size == 1
41
+ l.warning '[Content_Types].xml must contain root name Types' unless doc.children.first.name == "Types"
42
+
43
+ required_content_types = ['application/vnd.openxmlformats-package.relationships+xml']
44
+
45
+ extensions = doc.css(*['Default']).map{|node| node.attributes['Extension']&.value}.flatten
46
+ l.error :duplicate_content_extension_types unless extensions.uniq.length == extensions.length
47
+
48
+ override_extensions = doc.css(*['Override']).map{|node| node.attributes['PartName']&.value}.flatten
49
+ l.error :duplicate_content_override_types unless override_extensions.uniq.length == override_extensions.length
50
+
51
+ found_types = Hash[*doc.css(*['Default']).map{|node| [node.attributes['Extension']&.value&.downcase,node.attributes['ContentType']&.value]}.flatten]
52
+ found_overrides = Hash[*doc.css(*['Override']).map{|node| [node.attributes['PartName']&.value&.downcase,node.attributes['ContentType']&.value]}.flatten]
53
+
54
+ required_content_types.each do |req_type|
55
+ l.error :invalid_content_type, mt: req_type unless found_types.values.include?(req_type)
56
+ end
57
+
58
+ doc.css(*['Default']).each do |node|
59
+ extension = node['Extension']&.downcase
60
+ l.info "Setting type hash #{extension}=#{node['ContentType']}"
61
+ end
62
+
63
+ doc.css(*['Override']).each do |node|
64
+ l.error :empty_override_part_name if node['PartName']&.downcase&.empty?
65
+ end
66
+
67
+ doc.css('Types').xpath('.//*').select do |node|
68
+ l.warning "[Content_Types].xml:#{node.line} contains unexpected element #{node.name}", page: 10 unless ['Default', 'Override'].include?(node.name)
69
+ end
70
+
71
+ rescue Nokogiri::XML::SyntaxError => e
72
+ l.error :content_types_invalid_xml, e: "#{e}"
73
+ end
74
+ end
75
+ return new(found_types, found_overrides)
76
+ end
77
+ end
@@ -1,242 +1,242 @@
1
- class Document
2
-
3
- attr_accessor :types
4
- attr_accessor :relationships
5
- attr_accessor :models
6
- attr_accessor :thumbnails
7
- attr_accessor :textures
8
- attr_accessor :objects
9
- attr_accessor :parts
10
- attr_accessor :zip_filename
11
-
12
- # Relationship schemas
13
- MODEL_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel'
14
- THUMBNAIL_TYPE = 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail'
15
- TEXTURE_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dtexture'
16
- PRINT_TICKET_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/printticket'
17
-
18
- # Image Content Types
19
- THUMBNAIL_TYPES = %w[image/jpeg image/png].freeze
20
- TEXTURE_TYPES = %w[image/jpeg image/png application/vnd.ms-package.3dmanufacturing-3dmodeltexture].freeze
21
-
22
- # Relationship Type => Class validating relationship type
23
- RELATIONSHIP_TYPES = {
24
- MODEL_TYPE => {klass: 'Model3mf', collection: :models, valid_types: ['application/vnd.ms-package.3dmanufacturing-3dmodel+xml']},
25
- THUMBNAIL_TYPE => {klass: 'Thumbnail3mf', collection: :thumbnails, valid_types: THUMBNAIL_TYPES},
26
- TEXTURE_TYPE => {klass: 'Texture3mf', collection: :textures, valid_types: TEXTURE_TYPES},
27
- PRINT_TICKET_TYPE => {valid_types: ['application/vnd.ms-printing.printticket+xml']}
28
- }
29
-
30
- def initialize(zip_filename)
31
- self.models=[]
32
- self.thumbnails=[]
33
- self.textures=[]
34
- self.objects={}
35
- self.relationships={}
36
- self.types=nil
37
- self.parts=[]
38
- @zip_filename = zip_filename
39
- end
40
-
41
- #verify that each texture part in the 3MF is related to the model through a texture relationship in a rels file
42
- def self.validate_texture_parts(document, log)
43
- unless document.types.empty?
44
- document.parts.select { |part| TEXTURE_TYPES.include?(document.types.get_type(part)) }.each do |tfile|
45
- if document.textures.select { |f| f[:target] == tfile }.size == 0
46
- if document.thumbnails.select { |t| t[:target] == tfile }.size == 0
47
- log.context "part names" do |l|
48
- l.warning "#{tfile} appears to be a texture file but no rels file declares any relationship to the model", page: 13
49
- end
50
- end
51
- end
52
- end
53
- end
54
- end
55
-
56
- def self.read(input_file)
57
-
58
- m = new(input_file)
59
- begin
60
- Log3mf.context 'zip' do |l|
61
- begin
62
- Zip.warn_invalid_date = false
63
- Zip.unicode_names = true
64
-
65
- # check for the general purpose flag set - if so, warn that 3mf may not work on some systems
66
- File.open(input_file, "r") do |file|
67
- if file.read[6] == "\b"
68
- l.warning 'File format: this file may not open on all systems'
69
- end
70
- end
71
-
72
- Zip::File.open(input_file) do |zip_file|
73
-
74
- l.info 'Zip file is valid'
75
-
76
- # check for valid, absolute URI's for each path name
77
-
78
- zip_file.each do |part|
79
- l.context "part names /#{part.name}" do |l|
80
- unless part.name.end_with? '[Content_Types].xml'
81
- next unless u = parse_uri(l, part.name)
82
-
83
- u.path.split('/').each do |segment|
84
- l.error :err_uri_hidden_file if (segment.start_with? '.') && !(segment.end_with? '.rels')
85
- end
86
- m.parts << '/' + part.name unless part.directory?
87
- end
88
- end
89
- end
90
-
91
- l.context 'content types' do |l|
92
- content_type_match = zip_file.glob('\[Content_Types\].xml').first
93
- if content_type_match
94
- m.types = ContentTypes.parse(content_type_match)
95
- else
96
- l.fatal_error :missing_content_types
97
- end
98
- end
99
-
100
- l.context 'relationships' do |l|
101
- rel_file = zip_file.glob('_rels/.rels').first
102
- l.fatal_error :missing_dot_rels_file unless rel_file
103
-
104
- zip_file.glob('**/*.rels').each do |rel|
105
- m.relationships[rel.name] = Relationships.parse(rel)
106
- end
107
-
108
- root_rels = m.relationships['_rels/.rels']
109
- unless root_rels.nil?
110
- start_part_rel = root_rels.select { |rel| rel[:type] == Document::MODEL_TYPE }.first
111
- if start_part_rel
112
- start_part_target = start_part_rel[:target]
113
- start_part_types = m.relationships.flat_map { |k, v| v }.select { |rel| rel[:type] == Document::MODEL_TYPE && rel[:target] == start_part_target }
114
- l.error :invalid_startpart_target, :target => start_part_target if start_part_types.size > 1
115
- end
116
- end
117
-
118
- end
119
-
120
- l.context "print tickets" do |l|
121
- print_ticket_types = m.relationships.flat_map { |k, v| v }.select { |rel| rel[:type] == PRINT_TICKET_TYPE }
122
- l.error :multiple_print_tickets if print_ticket_types.size > 1
123
- end
124
-
125
- l.context "relationship elements" do |l|
126
- m.relationships.each do |file_name, rels|
127
- rels.each do |rel|
128
- l.context rel[:target] do |l|
129
- next unless u = parse_uri(l, rel[:target])
130
-
131
- l.error :err_uri_relative_path unless u.to_s.start_with? '/'
132
-
133
- target = rel[:target].gsub(/^\//, "")
134
- l.error :err_uri_empty_segment if target.end_with? '/' or target.include? '//'
135
- l.error :err_uri_relative_path if target.include? '/../'
136
-
137
- # necessary since it has been observed that rubyzip treats all zip entry names
138
- # as ASCII-8BIT regardless if they really contain unicode chars. Without forcing
139
- # the encoding we are unable to find the target within the zip if the target contains
140
- # unicode chars.
141
- zip_target = target
142
- zip_target.force_encoding('ASCII-8BIT')
143
- relationship_file = zip_file.glob(zip_target).first
144
-
145
- rel_type = rel[:type]
146
- if relationship_file
147
- relationship_type = RELATIONSHIP_TYPES[rel_type]
148
- if relationship_type.nil?
149
- l.warning :unsupported_relationship_type, type: rel[:type], target: rel[:target]
150
- else
151
- # check that relationships are valid; extensions and relationship types must jive
152
- content_type = m.types.get_type(target)
153
- expected_content_type = relationship_type[:valid_types]
154
-
155
- if (expected_content_type)
156
- l.error :missing_content_type, part: target unless content_type
157
- l.error :resource_contentype_invalid, bt: content_type, rt: rel[:target] unless (content_type.nil? || expected_content_type.include?(content_type))
158
- else
159
- l.info "found unrecognized relationship type: #{rel_type}"
160
- end
161
-
162
- unless relationship_type[:klass].nil?
163
- m.send(relationship_type[:collection]) << {
164
- rel_id: rel[:id],
165
- target: rel[:target],
166
- object: Object.const_get(relationship_type[:klass]).parse(m, relationship_file)
167
- }
168
- end
169
- end
170
- else
171
- l.error :rel_file_not_found, mf: "#{rel[:target]}"
172
- end
173
- end
174
- end
175
- end
176
- end
177
-
178
- validate_texture_parts(m, l)
179
- end
180
-
181
- return m
182
- rescue Zip::Error
183
- l.fatal_error :not_a_zip
184
- end
185
- end
186
- rescue Log3mf::FatalError
187
- #puts "HALTING PROCESSING DUE TO FATAL ERROR"
188
- return nil
189
- end
190
- end
191
-
192
- def write(output_file = nil)
193
- output_file = zip_filename if output_file.nil?
194
-
195
- Zip::File.open(zip_filename) do |input_zip_file|
196
-
197
- buffer = Zip::OutputStream.write_buffer do |out|
198
- input_zip_file.entries.each do |e|
199
- if e.directory?
200
- out.copy_raw_entry(e)
201
- else
202
- out.put_next_entry(e.name)
203
- if objects[e.name]
204
- out.write objects[e.name]
205
- else
206
- out.write e.get_input_stream.read
207
- end
208
- end
209
- end
210
- end
211
-
212
- File.open(output_file, 'wb') { |f| f.write(buffer.string) }
213
-
214
- end
215
-
216
- end
217
-
218
- def contents_for(path)
219
- Zip::File.open(zip_filename) do |zip_file|
220
- zip_file.glob(path).first.get_input_stream.read
221
- end
222
- end
223
-
224
- private
225
-
226
- def self.parse_uri(logger, uri)
227
- begin
228
- reserved_chars = /[\[\]]/
229
-
230
- if uri =~ reserved_chars
231
- logger.error :err_uri_bad
232
- return nil
233
- end
234
-
235
- u = Addressable::URI.parse uri
236
- rescue ArgumentError, Addressable::URI::InvalidURIError
237
- logger.error :err_uri_bad
238
- end
239
- u
240
- end
241
-
242
- end
1
+ class Document
2
+
3
+ attr_accessor :types
4
+ attr_accessor :relationships
5
+ attr_accessor :models
6
+ attr_accessor :thumbnails
7
+ attr_accessor :textures
8
+ attr_accessor :objects
9
+ attr_accessor :parts
10
+ attr_accessor :zip_filename
11
+
12
+ # Relationship schemas
13
+ MODEL_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel'
14
+ THUMBNAIL_TYPE = 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail'
15
+ TEXTURE_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dtexture'
16
+ PRINT_TICKET_TYPE = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/printticket'
17
+
18
+ # Image Content Types
19
+ THUMBNAIL_TYPES = %w[image/jpeg image/png].freeze
20
+ TEXTURE_TYPES = %w[image/jpeg image/png application/vnd.ms-package.3dmanufacturing-3dmodeltexture].freeze
21
+
22
+ # Relationship Type => Class validating relationship type
23
+ RELATIONSHIP_TYPES = {
24
+ MODEL_TYPE => {klass: 'Model3mf', collection: :models, valid_types: ['application/vnd.ms-package.3dmanufacturing-3dmodel+xml']},
25
+ THUMBNAIL_TYPE => {klass: 'Thumbnail3mf', collection: :thumbnails, valid_types: THUMBNAIL_TYPES},
26
+ TEXTURE_TYPE => {klass: 'Texture3mf', collection: :textures, valid_types: TEXTURE_TYPES},
27
+ PRINT_TICKET_TYPE => {valid_types: ['application/vnd.ms-printing.printticket+xml']}
28
+ }
29
+
30
+ def initialize(zip_filename)
31
+ self.models=[]
32
+ self.thumbnails=[]
33
+ self.textures=[]
34
+ self.objects={}
35
+ self.relationships={}
36
+ self.types=nil
37
+ self.parts=[]
38
+ @zip_filename = zip_filename
39
+ end
40
+
41
+ #verify that each texture part in the 3MF is related to the model through a texture relationship in a rels file
42
+ def self.validate_texture_parts(document, log)
43
+ unless document.types.empty?
44
+ document.parts.select { |part| TEXTURE_TYPES.include?(document.types.get_type(part)) }.each do |tfile|
45
+ if document.textures.select { |f| f[:target] == tfile }.size == 0
46
+ if document.thumbnails.select { |t| t[:target] == tfile }.size == 0
47
+ log.context "part names" do |l|
48
+ l.warning "#{tfile} appears to be a texture file but no rels file declares any relationship to the model", page: 13
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def self.read(input_file)
57
+
58
+ m = new(input_file)
59
+ begin
60
+ Log3mf.context 'zip' do |l|
61
+ begin
62
+ Zip.warn_invalid_date = false
63
+ Zip.unicode_names = true
64
+
65
+ # check for the general purpose flag set - if so, warn that 3mf may not work on some systems
66
+ File.open(input_file, "r") do |file|
67
+ if file.read[6] == "\b"
68
+ l.warning 'File format: this file may not open on all systems'
69
+ end
70
+ end
71
+
72
+ Zip::File.open(input_file) do |zip_file|
73
+
74
+ l.info 'Zip file is valid'
75
+
76
+ # check for valid, absolute URI's for each path name
77
+
78
+ zip_file.each do |part|
79
+ l.context "part names /#{part.name}" do |l|
80
+ unless part.name.end_with? '[Content_Types].xml'
81
+ next unless u = parse_uri(l, part.name)
82
+
83
+ u.path.split('/').each do |segment|
84
+ l.error :err_uri_hidden_file if (segment.start_with? '.') && !(segment.end_with? '.rels')
85
+ end
86
+ m.parts << '/' + part.name unless part.directory?
87
+ end
88
+ end
89
+ end
90
+
91
+ l.context 'content types' do |l|
92
+ content_type_match = zip_file.glob('\[Content_Types\].xml').first
93
+ if content_type_match
94
+ m.types = ContentTypes.parse(content_type_match)
95
+ else
96
+ l.fatal_error :missing_content_types
97
+ end
98
+ end
99
+
100
+ l.context 'relationships' do |l|
101
+ rel_file = zip_file.glob('_rels/.rels').first
102
+ l.fatal_error :missing_dot_rels_file unless rel_file
103
+
104
+ zip_file.glob('**/*.rels').each do |rel|
105
+ m.relationships[rel.name] = Relationships.parse(rel)
106
+ end
107
+
108
+ root_rels = m.relationships['_rels/.rels']
109
+ unless root_rels.nil?
110
+ start_part_rel = root_rels.select { |rel| rel[:type] == Document::MODEL_TYPE }.first
111
+ if start_part_rel
112
+ start_part_target = start_part_rel[:target]
113
+ start_part_types = m.relationships.flat_map { |k, v| v }.select { |rel| rel[:type] == Document::MODEL_TYPE && rel[:target] == start_part_target }
114
+ l.error :invalid_startpart_target, :target => start_part_target if start_part_types.size > 1
115
+ end
116
+ end
117
+
118
+ end
119
+
120
+ l.context "print tickets" do |l|
121
+ print_ticket_types = m.relationships.flat_map { |k, v| v }.select { |rel| rel[:type] == PRINT_TICKET_TYPE }
122
+ l.error :multiple_print_tickets if print_ticket_types.size > 1
123
+ end
124
+
125
+ l.context "relationship elements" do |l|
126
+ m.relationships.each do |file_name, rels|
127
+ rels.each do |rel|
128
+ l.context rel[:target] do |l|
129
+ next unless u = parse_uri(l, rel[:target])
130
+
131
+ l.error :err_uri_relative_path unless u.to_s.start_with? '/'
132
+
133
+ target = rel[:target].gsub(/^\//, "")
134
+ l.error :err_uri_empty_segment if target.end_with? '/' or target.include? '//'
135
+ l.error :err_uri_relative_path if target.include? '/../'
136
+
137
+ # necessary since it has been observed that rubyzip treats all zip entry names
138
+ # as ASCII-8BIT regardless if they really contain unicode chars. Without forcing
139
+ # the encoding we are unable to find the target within the zip if the target contains
140
+ # unicode chars.
141
+ zip_target = target
142
+ zip_target.force_encoding('ASCII-8BIT')
143
+ relationship_file = zip_file.glob(zip_target).first
144
+
145
+ rel_type = rel[:type]
146
+ if relationship_file
147
+ relationship_type = RELATIONSHIP_TYPES[rel_type]
148
+ if relationship_type.nil?
149
+ l.warning :unsupported_relationship_type, type: rel[:type], target: rel[:target]
150
+ else
151
+ # check that relationships are valid; extensions and relationship types must jive
152
+ content_type = m.types.get_type(target)
153
+ expected_content_type = relationship_type[:valid_types]
154
+
155
+ if (expected_content_type)
156
+ l.error :missing_content_type, part: target unless content_type
157
+ l.error :resource_contentype_invalid, bt: content_type, rt: rel[:target] unless (content_type.nil? || expected_content_type.include?(content_type))
158
+ else
159
+ l.info "found unrecognized relationship type: #{rel_type}"
160
+ end
161
+
162
+ unless relationship_type[:klass].nil?
163
+ m.send(relationship_type[:collection]) << {
164
+ rel_id: rel[:id],
165
+ target: rel[:target],
166
+ object: Object.const_get(relationship_type[:klass]).parse(m, relationship_file)
167
+ }
168
+ end
169
+ end
170
+ else
171
+ l.error :rel_file_not_found, mf: "#{rel[:target]}"
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ validate_texture_parts(m, l)
179
+ end
180
+
181
+ return m
182
+ rescue Zip::Error
183
+ l.fatal_error :not_a_zip
184
+ end
185
+ end
186
+ rescue Log3mf::FatalError
187
+ #puts "HALTING PROCESSING DUE TO FATAL ERROR"
188
+ return nil
189
+ end
190
+ end
191
+
192
+ def write(output_file = nil)
193
+ output_file = zip_filename if output_file.nil?
194
+
195
+ Zip::File.open(zip_filename) do |input_zip_file|
196
+
197
+ buffer = Zip::OutputStream.write_buffer do |out|
198
+ input_zip_file.entries.each do |e|
199
+ if e.directory?
200
+ out.copy_raw_entry(e)
201
+ else
202
+ out.put_next_entry(e.name)
203
+ if objects[e.name]
204
+ out.write objects[e.name]
205
+ else
206
+ out.write e.get_input_stream.read
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ File.open(output_file, 'wb') { |f| f.write(buffer.string) }
213
+
214
+ end
215
+
216
+ end
217
+
218
+ def contents_for(path)
219
+ Zip::File.open(zip_filename) do |zip_file|
220
+ zip_file.glob(path).first.get_input_stream.read
221
+ end
222
+ end
223
+
224
+ private
225
+
226
+ def self.parse_uri(logger, uri)
227
+ begin
228
+ reserved_chars = /[\[\]]/
229
+
230
+ if uri =~ reserved_chars
231
+ logger.error :err_uri_bad
232
+ return nil
233
+ end
234
+
235
+ u = Addressable::URI.parse uri
236
+ rescue ArgumentError, Addressable::URI::InvalidURIError
237
+ logger.error :err_uri_bad
238
+ end
239
+ u
240
+ end
241
+
242
+ end