uniword 1.2.5 → 1.3.0

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +223 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +19 -9
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +6 -5
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +9 -1
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +83 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/package.rb +223 -35
  38. data/lib/uniword/docx/package_defaults.rb +58 -78
  39. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  40. data/lib/uniword/docx/package_serialization.rb +103 -231
  41. data/lib/uniword/docx/part.rb +106 -0
  42. data/lib/uniword/docx/part_collection.rb +123 -0
  43. data/lib/uniword/docx/reconciler/body.rb +59 -68
  44. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  45. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  46. data/lib/uniword/docx/reconciler/helpers.rb +9 -1
  47. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  48. data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
  49. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  50. data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
  51. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  52. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  53. data/lib/uniword/docx/reconciler.rb +36 -14
  54. data/lib/uniword/docx.rb +10 -0
  55. data/lib/uniword/drawingml/blip.rb +0 -1
  56. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  57. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  58. data/lib/uniword/drawingml/theme.rb +12 -6
  59. data/lib/uniword/errors.rb +8 -1
  60. data/lib/uniword/footer.rb +4 -0
  61. data/lib/uniword/header.rb +4 -0
  62. data/lib/uniword/hyperlink.rb +1 -1
  63. data/lib/uniword/image.rb +6 -6
  64. data/lib/uniword/lazy_loader.rb +1 -2
  65. data/lib/uniword/model_attribute_access.rb +20 -4
  66. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  67. data/lib/uniword/ooxml/part_definition.rb +171 -0
  68. data/lib/uniword/ooxml/part_registry.rb +311 -0
  69. data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
  70. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  71. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  72. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  73. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  74. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  75. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  76. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  77. data/lib/uniword/ooxml/types.rb +10 -0
  78. data/lib/uniword/ooxml.rb +2 -0
  79. data/lib/uniword/properties/alignment.rb +8 -3
  80. data/lib/uniword/properties/border.rb +52 -6
  81. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  82. data/lib/uniword/properties/color_value.rb +8 -3
  83. data/lib/uniword/properties/highlight.rb +9 -5
  84. data/lib/uniword/properties/shading.rb +4 -3
  85. data/lib/uniword/properties/tab_stop.rb +6 -1
  86. data/lib/uniword/properties/table_justification.rb +4 -1
  87. data/lib/uniword/properties/underline.rb +4 -2
  88. data/lib/uniword/properties/vertical_align.rb +6 -3
  89. data/lib/uniword/review/review_manager.rb +23 -9
  90. data/lib/uniword/revision.rb +9 -9
  91. data/lib/uniword/schema/model_generator.rb +31 -15
  92. data/lib/uniword/shared_types/hex_color.rb +4 -1
  93. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  94. data/lib/uniword/shared_types/point_measure.rb +4 -1
  95. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  96. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  97. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  98. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  99. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  100. data/lib/uniword/template/variable_resolver.rb +40 -7
  101. data/lib/uniword/themes/theme_transformation.rb +55 -41
  102. data/lib/uniword/toc/toc_generator.rb +2 -0
  103. data/lib/uniword/validation/engine.rb +35 -0
  104. data/lib/uniword/validation/link_checker.rb +1 -1
  105. data/lib/uniword/validation/opc_validator.rb +0 -1
  106. data/lib/uniword/validation/report/layer_result.rb +0 -1
  107. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  108. data/lib/uniword/validation/report/verification_report.rb +0 -2
  109. data/lib/uniword/validation/report.rb +20 -0
  110. data/lib/uniword/validation/rules/base.rb +10 -2
  111. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  112. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  113. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  114. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  115. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  116. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  117. data/lib/uniword/validation/rules/document_context.rb +7 -0
  118. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  119. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  120. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  121. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  122. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  123. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  124. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  125. data/lib/uniword/validation/rules/model_context.rb +32 -0
  126. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  127. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  128. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  129. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  130. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  133. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  134. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  135. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  136. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  137. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  140. data/lib/uniword/validation/rules.rb +61 -23
  141. data/lib/uniword/validation/schema_registry.rb +11 -0
  142. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  143. data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
  144. data/lib/uniword/validation/validators.rb +0 -9
  145. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  146. data/lib/uniword/validation.rb +7 -2
  147. data/lib/uniword/version.rb +1 -1
  148. data/lib/uniword/vml/imagedata.rb +0 -1
  149. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  150. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  151. data/lib/uniword/wordprocessingml/document_root.rb +211 -21
  152. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  153. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  154. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  155. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  156. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  157. data/lib/uniword/wordprocessingml/level.rb +6 -20
  158. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  159. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  160. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  161. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  162. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  163. data/lib/uniword/wordprocessingml/run.rb +3 -0
  164. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  165. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  166. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  167. data/lib/uniword/wordprocessingml/style.rb +2 -1
  168. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  169. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  170. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  171. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  172. data/lib/uniword/wordprocessingml.rb +4 -0
  173. data/lib/uniword.rb +27 -2
  174. metadata +38 -19
  175. data/config/validation_rules.yml +0 -60
  176. data/config/warning_rules.yml +0 -57
  177. data/lib/uniword/validation/document_validator.rb +0 -272
  178. data/lib/uniword/validation/structural_validator.rb +0 -116
  179. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  180. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  181. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  182. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  183. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  184. data/lib/uniword/validators/element_validator.rb +0 -93
  185. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  186. data/lib/uniword/validators/table_validator.rb +0 -134
  187. data/lib/uniword/validators.rb +0 -9
  188. data/lib/uniword/warnings/warning.rb +0 -130
  189. data/lib/uniword/warnings/warning_collector.rb +0 -234
  190. data/lib/uniword/warnings/warning_report.rb +0 -159
  191. data/lib/uniword/warnings.rb +0 -9
@@ -44,7 +44,15 @@ module Uniword
44
44
  attribute :custom_properties, Ooxml::CustomProperties
45
45
 
46
46
  # Custom XML data items (customXml/item*.xml)
47
- attr_accessor :custom_xml_items
47
+ #
48
+ # @return [Array<CustomXmlItem>, nil]
49
+ attr_reader :custom_xml_items
50
+
51
+ # Assign custom XML items; accepts CustomXmlItem objects and
52
+ # legacy hashes ({ index:, xml_content:, props_xml:, rels_xml: }).
53
+ def custom_xml_items=(items)
54
+ @custom_xml_items = items && items.map { |i| CustomXmlItem.wrap(i) }
55
+ end
48
56
 
49
57
  # === Document Parts (word/) ===
50
58
  # Main document content (word/document.xml)
@@ -82,9 +90,24 @@ module Uniword
82
90
  # Endnotes (word/endnotes.xml)
83
91
  attribute :endnotes, Uniword::Wordprocessingml::Endnotes
84
92
 
93
+ # Comments (word/comments.xml)
94
+ attribute :comments, Uniword::CommentsPart
95
+
85
96
  # Non-serialized attributes (DOCX packaging helpers)
86
97
  attr_accessor :chart_parts, :bibliography_sources, :profile
87
- attr_accessor :settings_rels, :embeddings
98
+ attr_accessor :settings_rels, :footnotes_rels, :endnotes_rels
99
+
100
+ # OLE/embedded object binaries (word/embeddings/*), keyed by target.
101
+ #
102
+ # @return [PartCollection] target => Part
103
+ def embeddings
104
+ @embeddings ||= PartCollection.new(:target, Part)
105
+ end
106
+
107
+ # Bulk-assign embeddings (Hash of target => Part/binary; nil clears).
108
+ def embeddings=(value)
109
+ embeddings.replace_all(value)
110
+ end
88
111
 
89
112
  # Central ID allocator — owns all rId, footnote, bookmark, etc. assignment.
90
113
  # Seeded from template rels on load; used by builders during construction.
@@ -98,6 +121,15 @@ module Uniword
98
121
  # Paths that have been modified and should not use raw XML passthrough.
99
122
  attr_accessor :modified_part_paths
100
123
 
124
+ # Audit trail of repairs applied by the Reconciler during the most
125
+ # recent save (to_zip_content). Empty before the first save and when
126
+ # the package needed no repairs.
127
+ #
128
+ # @return [Array<Reconciler::Fix>] Fixes from the most recent save
129
+ def applied_fixes
130
+ @applied_fixes ||= []
131
+ end
132
+
101
133
  # Load DOCX package from file
102
134
  #
103
135
  # @param path [String] Path to .docx file
@@ -162,18 +194,15 @@ module Uniword
162
194
  package.custom_xml_items = []
163
195
  custom_xml_files.sort_by { |f| f[/item(\d+)/, 1].to_i }.each do |item_path|
164
196
  index = item_path[/item(\d+)/, 1].to_i
165
- item = {
166
- index: index,
167
- xml_content: zip_content[item_path]
168
- }
169
-
170
197
  props_path = "customXml/itemProps#{index}.xml"
171
- item[:props_xml] = zip_content[props_path] if zip_content[props_path]
172
-
173
198
  rels_path = "customXml/_rels/item#{index}.xml.rels"
174
- item[:rels_xml] = zip_content[rels_path] if zip_content[rels_path]
175
199
 
176
- package.custom_xml_items << item
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
+ )
177
206
  end
178
207
  end
179
208
 
@@ -266,13 +295,35 @@ module Uniword
266
295
  )
267
296
  end
268
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
+
269
321
  # Parse Header and Footer parts
270
322
  extract_header_footer_parts(zip_content, package)
271
323
 
272
324
  # Parse Chart parts
273
325
  chart_files = zip_content.keys.grep(%r{^word/charts/chart\d+\.xml$})
274
326
  if chart_files.any? && package.document_rels
275
- package.document.chart_parts ||= {}
276
327
  chart_files.each do |chart_path|
277
328
  chart_target = chart_path.sub("word/", "")
278
329
  rel = package.document_rels.relationships.find do |r|
@@ -312,7 +363,8 @@ module Uniword
312
363
 
313
364
  return if header_files.empty? && footer_files.empty?
314
365
 
315
- package.document.header_footer_parts ||= []
366
+ store = package.document.header_footer_parts
367
+ ref_types = header_footer_reference_types(package.document)
316
368
 
317
369
  header_files.sort.each do |path|
318
370
  target = path.sub("word/", "")
@@ -322,13 +374,15 @@ module Uniword
322
374
  end
323
375
  next unless rel
324
376
 
325
- package.document.header_footer_parts << {
377
+ store << HeaderFooterPart.new(
378
+ kind: :header,
326
379
  r_id: rel.id,
327
380
  target: target,
328
381
  rel_type: rel.type,
329
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
382
+ type: ref_types[rel.id],
330
383
  content: Uniword::Wordprocessingml::Header.from_xml(zip_content[path]),
331
- }
384
+ loaded: true,
385
+ )
332
386
  end
333
387
 
334
388
  footer_files.sort.each do |path|
@@ -339,14 +393,45 @@ module Uniword
339
393
  end
340
394
  next unless rel
341
395
 
342
- package.document.header_footer_parts << {
396
+ store << HeaderFooterPart.new(
397
+ kind: :footer,
343
398
  r_id: rel.id,
344
399
  target: target,
345
400
  rel_type: rel.type,
346
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
401
+ type: ref_types[rel.id],
347
402
  content: Uniword::Wordprocessingml::Footer.from_xml(zip_content[path]),
348
- }
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
349
433
  end
434
+ sect_prs
350
435
  end
351
436
 
352
437
  # Extract image files from word/media/ directory in DOCX
@@ -375,14 +460,13 @@ module Uniword
375
460
  else "image/#{ext}"
376
461
  end
377
462
 
378
- r_id = if package.allocator
379
- package.allocator.alloc_rid(
380
- target: "media/#{filename}",
381
- type: IdAllocator::IMAGE_REL_TYPE,
382
- )
383
- else
384
- "rId#{package.document.image_parts.size + 1}"
385
- end
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)
386
470
 
387
471
  binary_data = if zip_path
388
472
  read_binary_from_zip(zip_path, media_path)
@@ -398,6 +482,25 @@ module Uniword
398
482
  end
399
483
  end
400
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
+
401
504
  # Read binary data directly from ZIP file without UTF-8 encoding
402
505
  def self.read_binary_from_zip(zip_path, entry_path)
403
506
  require "zip"
@@ -410,7 +513,14 @@ module Uniword
410
513
  end
411
514
 
412
515
  # Save document to file (class method for DocumentWriter compatibility)
413
- def self.to_file(document, path, profile: nil)
516
+ #
517
+ # @param document [Wordprocessingml::DocumentRoot] Document to save
518
+ # @param path [String] Output file path
519
+ # @param profile [Profile, nil] Reconciliation profile
520
+ # @param validate [Boolean, nil] Run the package integrity gate before
521
+ # writing; nil falls back to Uniword.configuration.validate_on_save
522
+ # @return [void]
523
+ def self.to_file(document, path, profile: nil, validate: nil)
414
524
  package = new
415
525
  package.document = document
416
526
  package.profile = profile || Profile.defaults
@@ -421,7 +531,7 @@ module Uniword
421
531
  package.settings ||= Uniword::Wordprocessingml::Settings.new
422
532
  package.font_table ||= Uniword::Wordprocessingml::FontTable.new
423
533
  package.web_settings ||= Uniword::Wordprocessingml::WebSettings.new
424
- package.to_file(path)
534
+ package.to_file(path, validate: validate)
425
535
  end
426
536
 
427
537
  # Populate the allocator from all existing template data.
@@ -430,6 +540,20 @@ module Uniword
430
540
  @allocator = IdAllocator.populate_from_package(self)
431
541
  end
432
542
 
543
+ # Guarantee the single rId authority before reconciliation: reuse
544
+ # the package's allocator (loaded/builder-seeded) or start one,
545
+ # then seed it from the package's current relationships. Seeding
546
+ # preserves loaded rIds verbatim; earlier builder allocations keep
547
+ # their ids — a seeded rel whose id collides is reallocated
548
+ # deterministically (IdAllocator#seed_from_rels).
549
+ def prepare_allocator
550
+ self.allocator ||= IdAllocator.new
551
+ allocator.seed_from_rels(package_rels&.relationships,
552
+ scope: :package)
553
+ allocator.seed_from_rels(document_rels&.relationships)
554
+ allocator
555
+ end
556
+
433
557
  # Extract media files from word/theme/media/ directory
434
558
  def self.extract_theme_media(zip_content)
435
559
  media = {}
@@ -449,14 +573,31 @@ module Uniword
449
573
  end
450
574
 
451
575
  # Save package to file
452
- def to_file(path)
453
- zip_content = to_zip_content
576
+ #
577
+ # @param path [String] Output file path
578
+ # @param validate [Boolean, nil] Run the package integrity gate before
579
+ # writing; nil falls back to Uniword.configuration.validate_on_save
580
+ # @return [void]
581
+ # @raise [Uniword::ValidationError] when the gate is enabled and the
582
+ # generated package content is invalid
583
+ def to_file(path, validate: nil)
584
+ zip_content = to_zip_content(validate: validate)
454
585
  packager = Infrastructure::ZipPackager.new
455
586
  packager.package(zip_content, path)
456
587
  end
457
588
 
458
589
  # Generate ZIP content hash
459
- def to_zip_content
590
+ #
591
+ # Runs the Reconciler (the only mutating pass), records its repair
592
+ # report on #applied_fixes, and — unless validation is disabled —
593
+ # refuses invalid output via the PackageIntegrityChecker gate.
594
+ #
595
+ # @param validate [Boolean, nil] Run the package integrity gate;
596
+ # nil falls back to Uniword.configuration.validate_on_save
597
+ # @return [Hash] File paths => content
598
+ # @raise [Uniword::ValidationError] when the gate is enabled and the
599
+ # generated package content is invalid
600
+ def to_zip_content(validate: nil)
460
601
  content = {}
461
602
 
462
603
  self.content_types ||= self.class.minimal_content_types
@@ -467,15 +608,28 @@ module Uniword
467
608
  self.font_table ||= Uniword::Wordprocessingml::FontTable.new
468
609
  self.web_settings ||= Uniword::Wordprocessingml::WebSettings.new
469
610
 
470
- Reconciler.new(self,
471
- profile: profile || Profile.defaults,
472
- allocator: allocator).reconcile
611
+ # An allocator carried into the save (builder-managed document
612
+ # or loaded package) selects light-touch repairs; documents
613
+ # without one get the full normalization repertoire. rIds flow
614
+ # through the allocator either way.
615
+ builder_managed = !allocator.nil?
616
+ prepare_allocator
617
+
618
+ reconciler = Reconciler.new(self,
619
+ profile: profile || Profile.defaults,
620
+ allocator: allocator,
621
+ builder_managed: builder_managed)
622
+ reconciler.reconcile
623
+ @applied_fixes = reconciler.applied_fixes
624
+ log_applied_fixes
473
625
 
474
626
  inject_part_relationships(content, content_types, package_rels, document_rels)
475
627
  serialize_package_parts(content, content_types, package_rels, document_rels)
476
628
 
477
629
  # OOXML requires [Content_Types].xml as the first ZIP entry.
478
630
  reorder_content_hash(content)
631
+
632
+ enforce_package_integrity(content, validate)
479
633
  content
480
634
  end
481
635
 
@@ -541,6 +695,40 @@ module Uniword
541
695
  basename = File.basename(doc_path)
542
696
  File.join(dir, "_rels", "#{basename}.rels")
543
697
  end
698
+
699
+ private
700
+
701
+ # Log each applied fix via Uniword.logger when policy allows.
702
+ #
703
+ # @return [void]
704
+ def log_applied_fixes
705
+ return if @applied_fixes.empty?
706
+ return unless Uniword.configuration.log_save_fixes
707
+
708
+ @applied_fixes.each do |fix|
709
+ Uniword.logger&.info { "Reconciler fix #{fix}" }
710
+ end
711
+ end
712
+
713
+ # Write-time integrity gate: refuse invalid package content.
714
+ #
715
+ # @param content [Hash] File paths => content
716
+ # @param validate [Boolean, nil] explicit override; nil reads policy
717
+ # @return [void]
718
+ # @raise [Uniword::ValidationError] listing all integrity issues
719
+ def enforce_package_integrity(content, validate)
720
+ validate = Uniword.configuration.validate_on_save if validate.nil?
721
+ return unless validate
722
+
723
+ issues = PackageIntegrityChecker.new.check(content)
724
+ return if issues.empty?
725
+
726
+ raise Uniword::ValidationError.new(
727
+ self,
728
+ issues.map { |issue| "#{issue.code} (#{issue.part}): #{issue.message}" },
729
+ issues: issues,
730
+ )
731
+ end
544
732
  end
545
733
  end
546
734
  end
@@ -15,6 +15,21 @@ module Uniword
15
15
 
16
16
  # Class methods for package construction
17
17
  module ClassMethods
18
+ # Part kinds in a minimal [Content_Types].xml, emission order.
19
+ MINIMAL_CT_DEFAULT_PARTS = %i[rels xml].freeze
20
+ MINIMAL_CT_OVERRIDE_PARTS =
21
+ %i[document styles font_table settings web_settings
22
+ app_properties core_properties].freeze
23
+
24
+ # Part kinds in a minimal _rels/.rels, in rId order.
25
+ MINIMAL_PACKAGE_REL_PARTS =
26
+ %i[document core_properties app_properties].freeze
27
+
28
+ # Part kinds in a minimal word/_rels/document.xml.rels,
29
+ # in rId order.
30
+ MINIMAL_DOCUMENT_REL_PARTS =
31
+ %i[styles settings web_settings font_table].freeze
32
+
18
33
  # Copy parts from document to package for round-trip preservation
19
34
  def copy_document_parts_to_package(document, package)
20
35
  return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
@@ -30,9 +45,21 @@ module Uniword
30
45
  package.document_rels = document.document_rels if document.document_rels
31
46
  package.theme_rels = document.theme_rels if document.theme_rels
32
47
  package.package_rels = document.package_rels if document.package_rels
48
+ if document.settings_rels
49
+ package.settings_rels = document.settings_rels
50
+ 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
33
57
  package.content_types = document.content_types if document.content_types
34
58
  package.footnotes = document.footnotes if document.footnotes
35
59
  package.endnotes = document.endnotes if document.endnotes
60
+ if document.comments.is_a?(Uniword::CommentsPart)
61
+ package.comments = document.comments
62
+ end
36
63
  package.allocator = document.allocator if document.allocator
37
64
 
38
65
  package.numbering = document.numbering_configuration if document.numbering_configuration_loaded?
@@ -46,94 +73,47 @@ module Uniword
46
73
  # Create minimal content types for a valid DOCX
47
74
  def minimal_content_types
48
75
  ct = Uniword::ContentTypes::Types.new
49
- ct.defaults ||= []
50
- ct.defaults << Uniword::ContentTypes::Default.new(
51
- extension: "rels",
52
- content_type: "application/vnd.openxmlformats-package.relationships+xml",
53
- )
54
- ct.defaults << Uniword::ContentTypes::Default.new(
55
- extension: "xml",
56
- content_type: "application/xml",
57
- )
58
-
59
- ct.overrides ||= []
60
- ct.overrides << Uniword::ContentTypes::Override.new(
61
- part_name: "/word/document.xml",
62
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
63
- )
64
- ct.overrides << Uniword::ContentTypes::Override.new(
65
- part_name: "/word/styles.xml",
66
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
67
- )
68
- ct.overrides << Uniword::ContentTypes::Override.new(
69
- part_name: "/word/fontTable.xml",
70
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml",
71
- )
72
- ct.overrides << Uniword::ContentTypes::Override.new(
73
- part_name: "/word/settings.xml",
74
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
75
- )
76
- ct.overrides << Uniword::ContentTypes::Override.new(
77
- part_name: "/word/webSettings.xml",
78
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml",
79
- )
80
- ct.overrides << Uniword::ContentTypes::Override.new(
81
- part_name: "/docProps/app.xml",
82
- content_type: "application/vnd.openxmlformats-officedocument.extended-properties+xml",
83
- )
84
- ct.overrides << Uniword::ContentTypes::Override.new(
85
- part_name: "/docProps/core.xml",
86
- content_type: "application/vnd.openxmlformats-package.core-properties+xml",
87
- )
76
+ ct.defaults = MINIMAL_CT_DEFAULT_PARTS.map do |key|
77
+ defn = Ooxml::PartRegistry.find_by_key(key)
78
+ Uniword::ContentTypes::Default.new(
79
+ extension: defn.extension,
80
+ content_type: defn.content_type,
81
+ )
82
+ end
83
+ ct.overrides = MINIMAL_CT_OVERRIDE_PARTS.map do |key|
84
+ defn = Ooxml::PartRegistry.find_by_key(key)
85
+ Uniword::ContentTypes::Override.new(
86
+ part_name: defn.part_name,
87
+ content_type: defn.content_type,
88
+ )
89
+ end
88
90
  ct
89
91
  end
90
92
 
91
93
  # Create minimal package relationships for a valid DOCX
92
94
  def minimal_package_rels
93
- rels = Ooxml::Relationships::PackageRelationships.new
94
- rels.relationships ||= []
95
- rels.relationships << Ooxml::Relationships::Relationship.new(
96
- id: "rId1",
97
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
98
- target: "word/document.xml",
99
- )
100
- rels.relationships << Ooxml::Relationships::Relationship.new(
101
- id: "rId2",
102
- type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
103
- target: "docProps/core.xml",
104
- )
105
- rels.relationships << Ooxml::Relationships::Relationship.new(
106
- id: "rId3",
107
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
108
- target: "docProps/app.xml",
109
- )
110
- rels
95
+ build_minimal_rels(MINIMAL_PACKAGE_REL_PARTS)
111
96
  end
112
97
 
113
98
  # Create minimal document relationships for a valid DOCX
114
99
  def minimal_document_rels
100
+ build_minimal_rels(MINIMAL_DOCUMENT_REL_PARTS)
101
+ end
102
+
103
+ private
104
+
105
+ # Build a Relationships part with sequential rIds (rId1..rIdN)
106
+ # for the given registry part keys, in the given order.
107
+ def build_minimal_rels(part_keys)
115
108
  rels = Ooxml::Relationships::PackageRelationships.new
116
- rels.relationships ||= []
117
- rels.relationships << Ooxml::Relationships::Relationship.new(
118
- id: "rId1",
119
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
120
- target: "styles.xml",
121
- )
122
- rels.relationships << Ooxml::Relationships::Relationship.new(
123
- id: "rId2",
124
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings",
125
- target: "settings.xml",
126
- )
127
- rels.relationships << Ooxml::Relationships::Relationship.new(
128
- id: "rId3",
129
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings",
130
- target: "webSettings.xml",
131
- )
132
- rels.relationships << Ooxml::Relationships::Relationship.new(
133
- id: "rId4",
134
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable",
135
- target: "fontTable.xml",
136
- )
109
+ rels.relationships = part_keys.each_with_index.map do |key, idx|
110
+ defn = Ooxml::PartRegistry.find_by_key(key)
111
+ Ooxml::Relationships::Relationship.new(
112
+ id: "rId#{idx + 1}",
113
+ type: defn.rel_type,
114
+ target: defn.target,
115
+ )
116
+ end
137
117
  rels
138
118
  end
139
119
  end