ruby3mf 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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