uniword 1.3.0 → 1.4.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/lib/uniword/builder/chart_builder.rb +3 -5
  4. data/lib/uniword/builder/image_builder.rb +5 -5
  5. data/lib/uniword/document_factory.rb +10 -24
  6. data/lib/uniword/docx/custom_xml_item.rb +8 -0
  7. data/lib/uniword/docx/image_part.rb +83 -0
  8. data/lib/uniword/docx/package.rb +22 -402
  9. data/lib/uniword/docx/package_defaults.rb +36 -32
  10. data/lib/uniword/docx/package_serialization.rb +46 -7
  11. data/lib/uniword/docx/part.rb +19 -0
  12. data/lib/uniword/docx/part_collection.rb +15 -7
  13. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  14. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  15. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  16. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  17. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  18. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  19. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  20. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  21. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  22. data/lib/uniword/docx/part_loader.rb +92 -0
  23. data/lib/uniword/docx/raw_part.rb +75 -0
  24. data/lib/uniword/docx/reconciler/helpers.rb +9 -30
  25. data/lib/uniword/docx/reconciler/package_structure.rb +4 -5
  26. data/lib/uniword/docx/reconciler/referential_integrity.rb +4 -79
  27. data/lib/uniword/docx.rb +3 -0
  28. data/lib/uniword/images/image_manager.rb +13 -13
  29. data/lib/uniword/ooxml/element_order.rb +94 -0
  30. data/lib/uniword/ooxml/part_definition.rb +164 -6
  31. data/lib/uniword/ooxml/part_registry.rb +238 -45
  32. data/lib/uniword/ooxml/relationships/image_relationship.rb +5 -2
  33. data/lib/uniword/ooxml.rb +1 -0
  34. data/lib/uniword/toc/toc_generator.rb +6 -7
  35. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  36. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  37. data/lib/uniword/validation/opc_validator.rb +16 -1
  38. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -0
  39. data/lib/uniword/version.rb +1 -1
  40. data/lib/uniword/wordprocessingml/document_root.rb +29 -220
  41. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  42. data/lib/uniword/wordprocessingml/endnotes.rb +2 -3
  43. data/lib/uniword/wordprocessingml/footnotes.rb +2 -3
  44. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  45. data/lib/uniword/wordprocessingml.rb +1 -2
  46. metadata +16 -2
@@ -94,7 +94,7 @@ module Uniword
94
94
  attribute :comments, Uniword::CommentsPart
95
95
 
96
96
  # Non-serialized attributes (DOCX packaging helpers)
97
- attr_accessor :chart_parts, :bibliography_sources, :profile
97
+ attr_accessor :profile
98
98
  attr_accessor :settings_rels, :footnotes_rels, :endnotes_rels
99
99
 
100
100
  # OLE/embedded object binaries (word/embeddings/*), keyed by target.
@@ -109,6 +109,22 @@ module Uniword
109
109
  embeddings.replace_all(value)
110
110
  end
111
111
 
112
+ # Raw passthrough parts no registry definition models, keyed by
113
+ # package path ("docProps/meta.xml", "word/glossary/document.xml",
114
+ # ...). Carried byte-for-byte from load to save — see
115
+ # PartLoader::RawPartLoader for claiming and
116
+ # PackageSerialization#serialize_raw_parts for emission.
117
+ #
118
+ # @return [PartCollection] package path => RawPart
119
+ def raw_parts
120
+ @raw_parts ||= PartCollection.new(:path, RawPart)
121
+ end
122
+
123
+ # Bulk-assign raw parts (Hash of path => RawPart/hash; nil clears).
124
+ def raw_parts=(value)
125
+ raw_parts.replace_all(value)
126
+ end
127
+
112
128
  # Central ID allocator — owns all rId, footnote, bookmark, etc. assignment.
113
129
  # Seeded from template rels on load; used by builders during construction.
114
130
  attr_accessor :allocator
@@ -144,374 +160,19 @@ module Uniword
144
160
 
145
161
  # Create package from extracted ZIP content
146
162
  #
163
+ # Parts load by iterating Ooxml::PartRegistry.loadable and
164
+ # dispatching each definition to its Docx::PartLoader strategy —
165
+ # see PartLoader for the load order and strategy registry.
166
+ #
147
167
  # @param zip_content [Hash] Extracted ZIP files
148
168
  # @param zip_path [String, nil] Original ZIP path for binary re-extraction
149
169
  # @return [Package] Package object
150
170
  def self.from_zip_content(zip_content, zip_path = nil)
151
171
  package = new
152
-
153
- # Parse Content Types
154
- if zip_content["[Content_Types].xml"]
155
- package.content_types = Uniword::ContentTypes::Types.from_xml(
156
- zip_content["[Content_Types].xml"]
157
- )
158
- end
159
-
160
- # Parse Package Relationships
161
- if zip_content["_rels/.rels"]
162
- package.package_rels = Ooxml::Relationships::PackageRelationships.from_xml(
163
- zip_content["_rels/.rels"]
164
- )
165
- end
166
-
167
- # Find the main document path from officeDocument relationship
168
- main_doc_path = find_main_document_path(package.package_rels)
169
- main_doc_rels_path = find_document_rels_path(main_doc_path)
170
-
171
- # Parse Document Properties
172
- if zip_content["docProps/core.xml"]
173
- package.core_properties = Ooxml::CoreProperties.from_xml(
174
- zip_content["docProps/core.xml"]
175
- )
176
- end
177
-
178
- if zip_content["docProps/app.xml"]
179
- package.app_properties = Ooxml::AppProperties.from_xml(
180
- zip_content["docProps/app.xml"]
181
- )
182
- end
183
-
184
- # Parse Custom Properties
185
- if zip_content["docProps/custom.xml"]
186
- package.custom_properties = Ooxml::CustomProperties.from_xml(
187
- zip_content["docProps/custom.xml"]
188
- )
189
- end
190
-
191
- # Parse Custom XML Data items (customXml/item*.xml)
192
- custom_xml_files = zip_content.keys.grep(%r{^customXml/item(\d+)\.xml$})
193
- if custom_xml_files.any?
194
- package.custom_xml_items = []
195
- custom_xml_files.sort_by { |f| f[/item(\d+)/, 1].to_i }.each do |item_path|
196
- index = item_path[/item(\d+)/, 1].to_i
197
- props_path = "customXml/itemProps#{index}.xml"
198
- rels_path = "customXml/_rels/item#{index}.xml.rels"
199
-
200
- package.custom_xml_items << CustomXmlItem.new(
201
- index: index,
202
- xml_content: zip_content[item_path],
203
- props_xml: zip_content[props_path],
204
- rels_xml: zip_content[rels_path],
205
- )
206
- end
207
- end
208
-
209
- # Parse Document Parts - use dynamic path from package relationships
210
- if main_doc_path && zip_content[main_doc_path]
211
- package.document = Uniword::Wordprocessingml::DocumentRoot.from_xml(
212
- zip_content[main_doc_path]
213
- )
214
- elsif zip_content["word/document.xml"]
215
- package.document = Uniword::Wordprocessingml::DocumentRoot.from_xml(
216
- zip_content["word/document.xml"]
217
- )
218
- end
219
-
220
- if zip_content["word/styles.xml"]
221
- package.styles = Uniword::Wordprocessingml::StylesConfiguration.from_xml(
222
- zip_content["word/styles.xml"]
223
- )
224
- end
225
-
226
- if zip_content["word/numbering.xml"]
227
- package.numbering = Uniword::Wordprocessingml::NumberingConfiguration.from_xml(
228
- zip_content["word/numbering.xml"]
229
- )
230
- end
231
-
232
- if zip_content["word/settings.xml"]
233
- package.settings = Uniword::Wordprocessingml::Settings.from_xml(
234
- zip_content["word/settings.xml"]
235
- )
236
- end
237
-
238
- if zip_content["word/_rels/settings.xml.rels"]
239
- package.settings_rels =
240
- Ooxml::Relationships::PackageRelationships.from_xml(
241
- zip_content["word/_rels/settings.xml.rels"]
242
- )
243
- end
244
-
245
- if zip_content["word/fontTable.xml"]
246
- package.font_table = Uniword::Wordprocessingml::FontTable.from_xml(
247
- zip_content["word/fontTable.xml"]
248
- )
249
- end
250
-
251
- if zip_content["word/webSettings.xml"]
252
- package.web_settings = Uniword::Wordprocessingml::WebSettings.from_xml(
253
- zip_content["word/webSettings.xml"]
254
- )
255
- end
256
-
257
- # Parse document relationships - use dynamic path based on main document
258
- if main_doc_rels_path && zip_content[main_doc_rels_path]
259
- package.document_rels = Ooxml::Relationships::PackageRelationships.from_xml(
260
- zip_content[main_doc_rels_path]
261
- )
262
- elsif zip_content["word/_rels/document.xml.rels"]
263
- package.document_rels = Ooxml::Relationships::PackageRelationships.from_xml(
264
- zip_content["word/_rels/document.xml.rels"]
265
- )
266
- end
267
-
268
- # Parse Theme
269
- if zip_content["word/theme/theme1.xml"]
270
- package.theme = Drawingml::Theme.from_xml(
271
- zip_content["word/theme/theme1.xml"]
272
- )
273
-
274
- theme_media = extract_theme_media(zip_content)
275
- package.theme.media_files = theme_media if theme_media.any?
276
- end
277
-
278
- if zip_content["word/theme/_rels/theme1.xml.rels"]
279
- package.theme_rels = Ooxml::Relationships::PackageRelationships.from_xml(
280
- zip_content["word/theme/_rels/theme1.xml.rels"]
281
- )
282
- end
283
-
284
- # Parse Footnotes
285
- if zip_content["word/footnotes.xml"]
286
- package.footnotes = Uniword::Wordprocessingml::Footnotes.from_xml(
287
- zip_content["word/footnotes.xml"]
288
- )
289
- end
290
-
291
- # Parse Endnotes
292
- if zip_content["word/endnotes.xml"]
293
- package.endnotes = Uniword::Wordprocessingml::Endnotes.from_xml(
294
- zip_content["word/endnotes.xml"]
295
- )
296
- end
297
-
298
- # Parse Comments
299
- if zip_content["word/comments.xml"]
300
- package.comments = Uniword::CommentsPart.from_xml(
301
- zip_content["word/comments.xml"]
302
- )
303
- end
304
-
305
- # Parse note part relationships (r:id refs inside notes, e.g.
306
- # hyperlinks in footnotes) so they survive the round-trip.
307
- if zip_content["word/_rels/footnotes.xml.rels"]
308
- package.footnotes_rels =
309
- Ooxml::Relationships::PackageRelationships.from_xml(
310
- zip_content["word/_rels/footnotes.xml.rels"]
311
- )
312
- end
313
-
314
- if zip_content["word/_rels/endnotes.xml.rels"]
315
- package.endnotes_rels =
316
- Ooxml::Relationships::PackageRelationships.from_xml(
317
- zip_content["word/_rels/endnotes.xml.rels"]
318
- )
319
- end
320
-
321
- # Parse Header and Footer parts
322
- extract_header_footer_parts(zip_content, package)
323
-
324
- # Parse Chart parts
325
- chart_files = zip_content.keys.grep(%r{^word/charts/chart\d+\.xml$})
326
- if chart_files.any? && package.document_rels
327
- chart_files.each do |chart_path|
328
- chart_target = chart_path.sub("word/", "")
329
- rel = package.document_rels.relationships.find do |r|
330
- r.target == chart_target &&
331
- r.type.to_s.include?("officeDocument/2006/relationships/chart")
332
- end
333
- next unless rel
334
-
335
- package.document.chart_parts[rel.id] = {
336
- xml: zip_content[chart_path],
337
- target: chart_target
338
- }
339
- end
340
- end
341
-
342
- # Extract image parts from word/media/ directory
343
- extract_image_parts(zip_content, package, zip_path)
344
-
345
- # Extract OLE/embedded object binaries from word/embeddings/
346
- embedding_files = zip_content.keys.grep(%r{^word/embeddings/.+$})
347
- if embedding_files.any?
348
- package.embeddings = {}
349
- embedding_files.each do |emb_path|
350
- target = emb_path.sub("word/", "")
351
- package.embeddings[target] = zip_content[emb_path]
352
- end
353
- end
354
-
172
+ PartLoader.load(zip_content, package, zip_path: zip_path)
355
173
  package
356
174
  end
357
175
 
358
- def self.extract_header_footer_parts(zip_content, package)
359
- return unless package.document && package.document_rels
360
-
361
- header_files = zip_content.keys.grep(%r{^word/header\d+\.xml$})
362
- footer_files = zip_content.keys.grep(%r{^word/footer\d+\.xml$})
363
-
364
- return if header_files.empty? && footer_files.empty?
365
-
366
- store = package.document.header_footer_parts
367
- ref_types = header_footer_reference_types(package.document)
368
-
369
- header_files.sort.each do |path|
370
- target = path.sub("word/", "")
371
- rel = package.document_rels.relationships.find do |r|
372
- r.target == target &&
373
- r.type.to_s.include?("officeDocument/2006/relationships/header")
374
- end
375
- next unless rel
376
-
377
- store << HeaderFooterPart.new(
378
- kind: :header,
379
- r_id: rel.id,
380
- target: target,
381
- rel_type: rel.type,
382
- type: ref_types[rel.id],
383
- content: Uniword::Wordprocessingml::Header.from_xml(zip_content[path]),
384
- loaded: true,
385
- )
386
- end
387
-
388
- footer_files.sort.each do |path|
389
- target = path.sub("word/", "")
390
- rel = package.document_rels.relationships.find do |r|
391
- r.target == target &&
392
- r.type.to_s.include?("officeDocument/2006/relationships/footer")
393
- end
394
- next unless rel
395
-
396
- store << HeaderFooterPart.new(
397
- kind: :footer,
398
- r_id: rel.id,
399
- target: target,
400
- rel_type: rel.type,
401
- type: ref_types[rel.id],
402
- content: Uniword::Wordprocessingml::Footer.from_xml(zip_content[path]),
403
- loaded: true,
404
- )
405
- end
406
- end
407
-
408
- # Map relationship id => sectPr reference type
409
- # ("default"/"first"/"even") from every section properties
410
- # element (body-level and paragraph-level).
411
- def self.header_footer_reference_types(document)
412
- types = {}
413
- section_properties_of(document).each do |sect_pr|
414
- (sect_pr.header_references || []).each do |ref|
415
- types[ref.r_id] = ref.type if ref.r_id
416
- end
417
- (sect_pr.footer_references || []).each do |ref|
418
- types[ref.r_id] = ref.type if ref.r_id
419
- end
420
- end
421
- types
422
- end
423
-
424
- def self.section_properties_of(document)
425
- body = document.body
426
- return [] unless body
427
-
428
- sect_prs = []
429
- sect_prs << body.section_properties if body.section_properties
430
- (body.paragraphs || []).each do |para|
431
- sect_pr = para.properties&.section_properties
432
- sect_prs << sect_pr if sect_pr
433
- end
434
- sect_prs
435
- end
436
-
437
- # Extract image files from word/media/ directory in DOCX
438
- #
439
- # @param zip_content [Hash] Extracted ZIP content (may have corrupted binary)
440
- # @param package [Package] Package to populate
441
- # @param zip_path [String, nil] Original ZIP path for binary re-extraction
442
- def self.extract_image_parts(zip_content, package, zip_path = nil)
443
- return unless package.document
444
-
445
- media_files = zip_content.keys.grep(%r{^word/media/.+$})
446
- return if media_files.empty?
447
-
448
- package.document.image_parts ||= {}
449
-
450
- media_files.each do |media_path|
451
- filename = File.basename(media_path)
452
- ext = File.extname(filename).delete(".").downcase
453
- content_type = case ext
454
- when "jpg", "jpeg" then "image/jpeg"
455
- when "png" then "image/png"
456
- when "gif" then "image/gif"
457
- when "bmp" then "image/bmp"
458
- when "tiff", "tif" then "image/tiff"
459
- when "svg" then "image/svg+xml"
460
- else "image/#{ext}"
461
- end
462
-
463
- # Key the part by the rId the loaded document rels assign to
464
- # this target, so downstream consumers (MHTML rendering, image
465
- # manager) resolve r:embed references correctly. Media with no
466
- # document-level rel (e.g. theme-only images) gets a synthetic
467
- # key; it never becomes a relationship.
468
- r_id = loaded_image_rid(package, filename) ||
469
- next_synthetic_image_key(package)
470
-
471
- binary_data = if zip_path
472
- read_binary_from_zip(zip_path, media_path)
473
- else
474
- zip_content[media_path]
475
- end
476
-
477
- package.document.image_parts[r_id] = {
478
- data: binary_data,
479
- target: "media/#{filename}",
480
- content_type: content_type
481
- }
482
- end
483
- end
484
-
485
- # The rId a loaded document relationship assigns to
486
- # "media/<filename>", or nil when no image rel targets it.
487
- def self.loaded_image_rid(package, filename)
488
- image_type = Ooxml::PartRegistry.find_by_key(:image).rel_type
489
- rel = package.document_rels&.relationships&.find do |r|
490
- r.target == "media/#{filename}" && r.type.to_s == image_type
491
- end
492
- rel&.id
493
- end
494
-
495
- # Synthetic image-part key that cannot collide with a loaded
496
- # relationship id or an already-keyed image part.
497
- def self.next_synthetic_image_key(package)
498
- taken = package.document_rels&.relationships&.map(&:id) || []
499
- n = package.document.image_parts.size + 1
500
- n += 1 while taken.include?("rId#{n}")
501
- "rId#{n}"
502
- end
503
-
504
- # Read binary data directly from ZIP file without UTF-8 encoding
505
- def self.read_binary_from_zip(zip_path, entry_path)
506
- require "zip"
507
- Zip::File.open(zip_path) do |zip_file|
508
- entry = zip_file.find_entry(entry_path)
509
- return nil unless entry
510
-
511
- entry.get_input_stream.read
512
- end
513
- end
514
-
515
176
  # Save document to file (class method for DocumentWriter compatibility)
516
177
  #
517
178
  # @param document [Wordprocessingml::DocumentRoot] Document to save
@@ -554,24 +215,6 @@ module Uniword
554
215
  allocator
555
216
  end
556
217
 
557
- # Extract media files from word/theme/media/ directory
558
- def self.extract_theme_media(zip_content)
559
- media = {}
560
-
561
- zip_content.each_key do |file_path|
562
- next unless file_path =~ %r{^word/theme/media/(.+)$}
563
-
564
- filename = Regexp.last_match(1)
565
- media[filename] = Uniword::Themes::MediaFile.new(
566
- filename: filename,
567
- content: zip_content[file_path],
568
- source_path: file_path
569
- )
570
- end
571
-
572
- media
573
- end
574
-
575
218
  # Save package to file
576
219
  #
577
220
  # @param path [String] Output file path
@@ -673,29 +316,6 @@ module Uniword
673
316
  document&.styles_configuration
674
317
  end
675
318
 
676
- # Find the main document path from package relationships
677
- def self.find_main_document_path(package_rels)
678
- return nil unless package_rels&.relationships
679
-
680
- rel = package_rels.relationships.find do |r|
681
- r.type.to_s.include?("officeDocument/2006/relationships/officeDocument")
682
- end
683
- return nil unless rel&.target
684
-
685
- path = rel.target.dup
686
- path.sub!(%r{^/}, "")
687
- path
688
- end
689
-
690
- # Find the document relationships path from the main document path
691
- def self.find_document_rels_path(doc_path)
692
- return nil unless doc_path
693
-
694
- dir = File.dirname(doc_path)
695
- basename = File.basename(doc_path)
696
- File.join(dir, "_rels", "#{basename}.rels")
697
- end
698
-
699
319
  private
700
320
 
701
321
  # Log each applied fix via Uniword.logger when policy allows.
@@ -31,43 +31,20 @@ module Uniword
31
31
  %i[styles settings web_settings font_table].freeze
32
32
 
33
33
  # Copy parts from document to package for round-trip preservation
34
+ #
35
+ # Registry-driven: every PartDefinition naming both a document
36
+ # and a package attribute is mirrored, honoring the
37
+ # definition's optional +to_package_guard+ predicate (e.g. the
38
+ # lazily-initialized numbering configuration) and
39
+ # +to_package_type+ value constraint (e.g. comments).
34
40
  def copy_document_parts_to_package(document, package)
35
41
  return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
36
42
 
37
- package.styles = document.styles_configuration if document.styles_configuration
38
- package.settings = document.settings if document.settings
39
- package.font_table = document.font_table if document.font_table
40
- package.web_settings = document.web_settings if document.web_settings
41
- package.theme = document.theme if document.theme
42
- package.core_properties = document.core_properties if document.core_properties
43
- package.app_properties = document.app_properties if document.app_properties
44
- package.custom_properties = document.custom_properties if document.custom_properties
45
- package.document_rels = document.document_rels if document.document_rels
46
- package.theme_rels = document.theme_rels if document.theme_rels
47
- package.package_rels = document.package_rels if document.package_rels
48
- if document.settings_rels
49
- package.settings_rels = document.settings_rels
43
+ Ooxml::PartRegistry.copied_to_package.each do |definition|
44
+ copy_part_to_package(document, package, definition)
50
45
  end
51
- if document.footnotes_rels
52
- package.footnotes_rels = document.footnotes_rels
53
- end
54
- if document.endnotes_rels
55
- package.endnotes_rels = document.endnotes_rels
56
- end
57
- package.content_types = document.content_types if document.content_types
58
- package.footnotes = document.footnotes if document.footnotes
59
- package.endnotes = document.endnotes if document.endnotes
60
- if document.comments.is_a?(Uniword::CommentsPart)
61
- package.comments = document.comments
62
- end
63
- package.allocator = document.allocator if document.allocator
64
46
 
65
- package.numbering = document.numbering_configuration if document.numbering_configuration_loaded?
66
-
67
- package.chart_parts = document.chart_parts if document.chart_parts
68
- package.custom_xml_items = document.custom_xml_items if document.custom_xml_items
69
- package.bibliography_sources = document.bibliography_sources if document.bibliography_sources
70
- package.embeddings = document.embeddings if document.embeddings
47
+ package.allocator = document.allocator if document.allocator
71
48
  end
72
49
 
73
50
  # Create minimal content types for a valid DOCX
@@ -102,6 +79,33 @@ module Uniword
102
79
 
103
80
  private
104
81
 
82
+ # Copy one document part to the package when the definition's
83
+ # guard predicate and value type constraint allow it.
84
+ def copy_part_to_package(document, package, definition)
85
+ return unless copyable_to_package?(document, definition)
86
+
87
+ value = document.method(definition.document_attribute).call
88
+ return if value.nil?
89
+ return unless copyable_type?(definition, value)
90
+
91
+ package.method(:"#{definition.package_attribute}=").call(value)
92
+ end
93
+
94
+ # The guard predicate named by the definition (a method on the
95
+ # document, e.g. :numbering_configuration_loaded?) decides;
96
+ # without a guard the part copies.
97
+ def copyable_to_package?(document, definition)
98
+ guard = definition.to_package_guard
99
+ guard.nil? || document.method(guard).call
100
+ end
101
+
102
+ # When the definition constrains the value type (e.g.
103
+ # CommentsPart for comments), the value must satisfy it.
104
+ def copyable_type?(definition, value)
105
+ type = definition.to_package_type
106
+ type.nil? || value.is_a?(type)
107
+ end
108
+
105
109
  # Build a Relationships part with sequential rIds (rId1..rIdN)
106
110
  # for the given registry part keys, in the given order.
107
111
  def build_minimal_rels(part_keys)
@@ -50,6 +50,7 @@ document_rels)
50
50
  inject_theme(content_types, document_rels)
51
51
  inject_numbering(content_types, document_rels)
52
52
  inject_embeddings(content_types, document_rels)
53
+ inject_raw_part_content_types(content_types)
53
54
  end
54
55
 
55
56
  # Serialize all package parts to XML and add to content hash
@@ -155,6 +156,9 @@ document_rels)
155
156
 
156
157
  # OLE/embedded object binaries
157
158
  serialize_embeddings(content)
159
+
160
+ # Raw passthrough parts (unmodelled; byte-for-byte)
161
+ serialize_raw_parts(content)
158
162
  end
159
163
 
160
164
  # Serialize an OOXML document part with standard encoding, single-line output.
@@ -198,17 +202,17 @@ document_rels)
198
202
  def inject_image_parts(content, content_types, _document_rels)
199
203
  return unless document&.image_parts && !document.image_parts.empty?
200
204
 
201
- document.image_parts.each_value do |image_data|
202
- ext = File.extname(image_data[:target]).delete(".")
205
+ document.image_parts.each_value do |part|
206
+ ext = File.extname(part.target.to_s).delete(".")
203
207
  next if content_types.defaults.any? { |d| d.extension == ext }
204
208
 
205
209
  content_types.defaults << Uniword::ContentTypes::Default.new(
206
- extension: ext, content_type: image_data[:content_type],
210
+ extension: ext, content_type: part.content_type,
207
211
  )
208
212
  end
209
213
 
210
- document.image_parts.each_value do |image_data|
211
- content["word/#{image_data[:target]}"] = image_data[:data]
214
+ document.image_parts.each_value do |part|
215
+ content["word/#{part.target}"] = part.data
212
216
  end
213
217
  end
214
218
 
@@ -225,8 +229,8 @@ document_rels)
225
229
  )
226
230
  end
227
231
 
228
- document.chart_parts.each_value do |chart_data|
229
- content["word/#{chart_data[:target]}"] = chart_data[:xml]
232
+ document.chart_parts.each_value do |part|
233
+ content["word/#{part.target}"] = part.xml
230
234
  end
231
235
  end
232
236
 
@@ -391,6 +395,41 @@ document_rels)
391
395
  content["word/#{target}"] = part.content
392
396
  end
393
397
  end
398
+
399
+ # Content types for raw-carried parts. Source Overrides survive
400
+ # reconciliation untouched (non-standard entries are preserved);
401
+ # a part whose content type came from a Default entry needs an
402
+ # Override once the reconciler rebuilds the standard Defaults.
403
+ def inject_raw_part_content_types(content_types)
404
+ return if raw_parts.empty?
405
+
406
+ raw_parts.each_value do |part|
407
+ next unless part.path && part.content_type
408
+
409
+ ext = File.extname(part.path)[1..]
410
+ next if ext && content_types.defaults.any? do |d|
411
+ d.extension == ext
412
+ end
413
+
414
+ part_name = "/#{part.path}"
415
+ next if content_types.overrides.any? { |o| o.part_name == part_name }
416
+
417
+ content_types.overrides << Uniword::ContentTypes::Override.new(
418
+ part_name: part_name, content_type: part.content_type,
419
+ )
420
+ end
421
+ end
422
+
423
+ # Emit raw-carried parts byte-for-byte. Model emission always
424
+ # wins: raw claiming already excludes registry paths, and the
425
+ # key guard makes double emission impossible by construction.
426
+ def serialize_raw_parts(content)
427
+ return if raw_parts.empty?
428
+
429
+ raw_parts.each do |path, part|
430
+ content[path] = part.content unless content.key?(path)
431
+ end
432
+ end
394
433
  end
395
434
  end
396
435
  end
@@ -60,6 +60,17 @@ module Uniword
60
60
  end
61
61
  # rubocop:enable Metrics/ParameterLists
62
62
 
63
+ # Wrap a legacy raw-hash entry ({ content:/xml:, target: }) into
64
+ # a Part. Subclasses override for family-specific keys (e.g.
65
+ # ImagePart's { data:, content_type:, path: }). Used by
66
+ # PartCollection to normalize hash assignments.
67
+ #
68
+ # @param hash [Hash] legacy hash entry
69
+ # @return [Part]
70
+ def self.from_hash(hash)
71
+ new(target: hash[:target], content: hash[:content] || hash[:xml])
72
+ end
73
+
63
74
  # @return [String, nil] relationship type URI
64
75
  def rel_type
65
76
  @rel_type || definition&.rel_type
@@ -79,6 +90,14 @@ module Uniword
79
90
  target && "word/#{target}"
80
91
  end
81
92
 
93
+ # Package paths this part emits (one for plain parts; subclasses
94
+ # with sidecar parts override).
95
+ #
96
+ # @return [Array<String>] emitted package paths
97
+ def package_paths
98
+ [path].compact
99
+ end
100
+
82
101
  # Verbatim overrides carried from a loaded package.
83
102
  attr_writer :rel_type, :content_type
84
103