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,10 +44,9 @@ document_rels)
44
44
  inject_bibliography(content_types, document_rels)
45
45
  inject_custom_properties(content_types, package_rels)
46
46
  inject_custom_xml(content_types)
47
- inject_headers(content_types, document_rels)
48
- inject_footers(content_types, document_rels)
49
- inject_header_footer_parts(content_types, document_rels)
47
+ inject_header_footer_content_types(content_types)
50
48
  inject_notes(content_types, document_rels)
49
+ inject_comments(content_types, document_rels)
51
50
  inject_theme(content_types, document_rels)
52
51
  inject_numbering(content_types, document_rels)
53
52
  inject_embeddings(content_types, document_rels)
@@ -137,13 +136,12 @@ document_rels)
137
136
  end
138
137
 
139
138
  # Notes
140
- if footnotes
141
- content["word/footnotes.xml"] =
142
- serialize_part(footnotes)
143
- end
144
- if endnotes
145
- content["word/endnotes.xml"] =
146
- serialize_part(endnotes)
139
+ serialize_notes(content)
140
+
141
+ # Comments
142
+ if comments
143
+ content["word/comments.xml"] =
144
+ serialize_part(comments)
147
145
  end
148
146
 
149
147
  # Bibliography sources
@@ -152,9 +150,7 @@ document_rels)
152
150
  serialize_infrastructure(document.bibliography_sources)
153
151
  end
154
152
 
155
- # Headers and footers
156
- serialize_headers(content)
157
- serialize_footers(content)
153
+ # Headers and footers (unified store)
158
154
  serialize_header_footer_parts(content)
159
155
 
160
156
  # OLE/embedded object binaries
@@ -183,36 +179,23 @@ document_rels)
183
179
 
184
180
  private
185
181
 
186
- # Generate the next numeric relationship ID for a Relationships object.
187
- def next_rid(relationships)
188
- Ooxml::Relationships::PackageRelationships.next_available_rid(
189
- relationships,
190
- )
191
- end
192
-
193
- # Shared pattern: ensure a part has both a content type override and
194
- # a document relationship. Idempotent — skips if already present.
195
- # When allocator is present, only adds content type (rels handled by allocator).
196
- def ensure_content_type(content_types, rels, part_name:,
197
- content_type:, rel_type:, target:)
198
- unless content_types.overrides.any? { |o| o.part_name == part_name }
199
- content_types.overrides << Uniword::ContentTypes::Override.new(
200
- part_name: part_name, content_type: content_type,
201
- )
202
- end
182
+ # Shared pattern: ensure a part has a content type override.
183
+ # Idempotent — skips if already present. Relationships are owned
184
+ # by the Reconciler (assembled from the IdAllocator), never by
185
+ # the serializer. Part metadata comes from Ooxml::PartRegistry.
186
+ def ensure_content_type(content_types, definition)
187
+ part_name = definition.part_name
188
+ return if content_types.overrides.any? { |o| o.part_name == part_name }
203
189
 
204
- return if allocator
205
- return if rels.relationships.any? { |r| r.target == target }
206
-
207
- rels.relationships << Ooxml::Relationships::Relationship.new(
208
- id: next_rid(rels), type: rel_type, target: target,
190
+ content_types.overrides << Uniword::ContentTypes::Override.new(
191
+ part_name: part_name, content_type: definition.content_type,
209
192
  )
210
193
  end
211
194
 
212
195
  # Legacy alias — calls ensure_content_type (same behavior)
213
196
  alias ensure_part_registered ensure_content_type
214
197
 
215
- def inject_image_parts(content, content_types, document_rels)
198
+ def inject_image_parts(content, content_types, _document_rels)
216
199
  return unless document&.image_parts && !document.image_parts.empty?
217
200
 
218
201
  document.image_parts.each_value do |image_data|
@@ -227,296 +210,185 @@ document_rels)
227
210
  document.image_parts.each_value do |image_data|
228
211
  content["word/#{image_data[:target]}"] = image_data[:data]
229
212
  end
230
-
231
- return if allocator
232
-
233
- document.image_parts.each do |r_id, image_data|
234
- next if document_rels.relationships.any? { |r| r.target == image_data[:target] }
235
-
236
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
237
- id: r_id,
238
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
239
- target: image_data[:target],
240
- )
241
- end
242
213
  end
243
214
 
244
- def inject_chart_parts(content, content_types, document_rels)
215
+ def inject_chart_parts(content, content_types, _document_rels)
245
216
  return unless document&.chart_parts && !document.chart_parts.empty?
246
217
 
218
+ chart = Ooxml::PartRegistry.find_by_key(:chart)
247
219
  unless content_types.overrides.any? do |o|
248
220
  o.part_name&.start_with?("/word/charts/")
249
221
  end
250
222
  content_types.overrides << Uniword::ContentTypes::Override.new(
251
- part_name: "/word/charts/chart1.xml",
252
- content_type: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml",
223
+ part_name: chart.part_name_for(n: 1),
224
+ content_type: chart.content_type,
253
225
  )
254
226
  end
255
227
 
256
- document.chart_parts.each do |r_id, chart_data|
228
+ document.chart_parts.each_value do |chart_data|
257
229
  content["word/#{chart_data[:target]}"] = chart_data[:xml]
258
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
259
- id: r_id,
260
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart",
261
- target: chart_data[:target],
262
- )
263
230
  end
264
231
  end
265
232
 
266
- def inject_bibliography(content_types, document_rels)
233
+ def inject_bibliography(content_types, _document_rels)
267
234
  return unless document&.bibliography_sources
268
235
 
269
- ensure_content_type(content_types, document_rels,
270
- part_name: "/word/sources.xml",
271
- content_type: "application/vnd.openxmlformats-officedocument.bibliography+xml",
272
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/bibliography",
273
- target: "sources.xml")
236
+ ensure_content_type(content_types,
237
+ Ooxml::PartRegistry.find_by_key(:bibliography))
274
238
  end
275
239
 
276
240
  def inject_custom_properties(content_types, package_rels)
277
241
  return unless custom_properties && !custom_properties.properties.empty?
278
242
 
243
+ definition = Ooxml::PartRegistry.find_by_key(:custom_properties)
279
244
  unless content_types.overrides.any? do |o|
280
- o.part_name == "/docProps/custom.xml"
245
+ o.part_name == definition.part_name
281
246
  end
282
247
  content_types.overrides << Uniword::ContentTypes::Override.new(
283
- part_name: "/docProps/custom.xml",
284
- content_type: "application/vnd.openxmlformats-officedocument.custom-properties+xml",
248
+ part_name: definition.part_name,
249
+ content_type: definition.content_type,
285
250
  )
286
251
  end
287
252
 
288
253
  return if package_rels.relationships.any? do |r|
289
- r.type.to_s.include?("officeDocument/2006/relationships/custom-properties")
254
+ r.type.to_s.include?(definition.rel_type)
290
255
  end
291
256
 
257
+ # The reconciler runs before injection; register the rId with
258
+ # the allocator (single authority) and append the rel here.
292
259
  package_rels.relationships << Ooxml::Relationships::Relationship.new(
293
- id: next_rid(package_rels),
294
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
295
- target: "docProps/custom.xml",
260
+ id: allocator.alloc_rid(target: definition.target,
261
+ type: definition.rel_type,
262
+ scope: :package),
263
+ type: definition.rel_type,
264
+ target: definition.target,
296
265
  )
297
266
  end
298
267
 
299
268
  def inject_custom_xml(content_types)
300
269
  return unless custom_xml_items && !custom_xml_items.empty?
301
270
 
271
+ props = Ooxml::PartRegistry.find_by_key(:custom_xml_item_props)
302
272
  custom_xml_items.each do |item|
303
273
  idx = item[:index]
274
+ part_name = props.part_name_for(index: idx)
304
275
  next if content_types.overrides.any? do |o|
305
- o.part_name == "/customXml/itemProps#{idx}.xml"
276
+ o.part_name == part_name
306
277
  end
307
278
 
308
279
  content_types.overrides << Uniword::ContentTypes::Override.new(
309
- part_name: "/customXml/itemProps#{idx}.xml",
310
- content_type: "application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
311
- )
312
- end
313
- end
314
-
315
- def inject_headers(content_types, document_rels)
316
- return unless document&.headers && !document.headers.empty?
317
-
318
- counter = 0
319
- document.headers.each_key do |type|
320
- counter += 1
321
- target = "header#{counter}.xml"
322
-
323
- unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
324
- content_types.overrides << Uniword::ContentTypes::Override.new(
325
- part_name: "/word/#{target}",
326
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
327
- )
328
- end
329
-
330
- next if allocator
331
- next if document_rels.relationships.any? { |r| r.target == target }
332
-
333
- r_id = next_rid(document_rels)
334
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
335
- id: r_id,
336
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
337
- target: target,
338
- )
339
-
340
- wire_header_reference(type, r_id)
341
- end
342
- end
343
-
344
- def inject_footers(content_types, document_rels)
345
- return unless document&.footers && !document.footers.empty?
346
-
347
- counter = 0
348
- document.footers.each_key do |type|
349
- counter += 1
350
- target = "footer#{counter}.xml"
351
-
352
- unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
353
- content_types.overrides << Uniword::ContentTypes::Override.new(
354
- part_name: "/word/#{target}",
355
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
356
- )
357
- end
358
-
359
- next if allocator
360
- next if document_rels.relationships.any? { |r| r.target == target }
361
-
362
- r_id = next_rid(document_rels)
363
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
364
- id: r_id,
365
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
366
- target: target,
280
+ part_name: part_name,
281
+ content_type: props.content_type,
367
282
  )
368
-
369
- wire_footer_reference(type, r_id)
370
283
  end
371
284
  end
372
285
 
373
- def inject_header_footer_parts(content_types, document_rels)
374
- return unless document&.header_footer_parts && !document.header_footer_parts.empty?
286
+ # Register content-type overrides for every header/footer part in
287
+ # the unified store. Relationships and sectPr references are owned
288
+ # by the Reconciler (single wiring implementation); the serializer
289
+ # only ensures each emitted part has exactly one override.
290
+ def inject_header_footer_content_types(content_types)
291
+ parts = document&.header_footer_parts
292
+ return unless parts && !parts.empty?
375
293
 
376
- document.header_footer_parts.each do |part|
377
- part_name = "/word/#{part[:target]}"
378
- unless content_types.overrides.any? { |o| o.part_name == part_name }
379
- content_types.overrides << Uniword::ContentTypes::Override.new(
380
- part_name: part_name,
381
- content_type: part[:content_type],
382
- )
383
- end
294
+ parts.each do |part|
295
+ next unless part.target
384
296
 
385
- next if allocator
386
- next if document_rels.relationships.any? { |r| r.id == part[:r_id] }
297
+ part_name = "/word/#{part.target}"
298
+ next if content_types.overrides.any? { |o| o.part_name == part_name }
387
299
 
388
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
389
- id: part[:r_id],
390
- type: part[:rel_type],
391
- target: part[:target],
300
+ content_types.overrides << Uniword::ContentTypes::Override.new(
301
+ part_name: part_name, content_type: part.content_type,
392
302
  )
393
303
  end
394
304
  end
395
305
 
396
- def inject_notes(content_types, document_rels)
306
+ def inject_notes(content_types, _document_rels)
397
307
  if footnotes
398
- ensure_content_type(content_types, document_rels,
399
- part_name: "/word/footnotes.xml",
400
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
401
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
402
- target: "footnotes.xml")
308
+ ensure_content_type(content_types,
309
+ Ooxml::PartRegistry.find_by_key(:footnotes))
403
310
  end
404
311
 
405
312
  return unless endnotes
406
313
 
407
- ensure_content_type(content_types, document_rels,
408
- part_name: "/word/endnotes.xml",
409
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
410
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
411
- target: "endnotes.xml")
314
+ ensure_content_type(content_types,
315
+ Ooxml::PartRegistry.find_by_key(:endnotes))
412
316
  end
413
317
 
414
- def inject_theme(content_types, document_rels)
318
+ def inject_theme(content_types, _document_rels)
415
319
  return unless theme
416
320
 
417
- ensure_content_type(content_types, document_rels,
418
- part_name: "/word/theme/theme1.xml",
419
- content_type: "application/vnd.openxmlformats-officedocument.theme+xml",
420
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
421
- target: "theme/theme1.xml")
321
+ ensure_content_type(content_types,
322
+ Ooxml::PartRegistry.find_by_key(:theme))
422
323
  end
423
324
 
424
- def inject_numbering(content_types, document_rels)
325
+ def inject_comments(content_types, _document_rels)
326
+ return unless comments
327
+
328
+ ensure_content_type(content_types,
329
+ Ooxml::PartRegistry.find_by_key(:comments))
330
+ end
331
+
332
+ def inject_numbering(content_types, _document_rels)
425
333
  return unless numbering
426
334
 
427
- ensure_content_type(content_types, document_rels,
428
- part_name: "/word/numbering.xml",
429
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
430
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
431
- target: "numbering.xml")
335
+ ensure_content_type(content_types,
336
+ Ooxml::PartRegistry.find_by_key(:numbering))
432
337
  end
433
338
 
434
- def inject_embeddings(content_types, document_rels)
339
+ def inject_embeddings(content_types, _document_rels)
435
340
  return unless embeddings && !embeddings.empty?
436
341
 
437
- embeddings.each_with_index do |(target, _data), idx|
342
+ ole = Ooxml::PartRegistry.find_by_key(:ole_object)
343
+ embeddings.each_key do |target|
438
344
  part_name = "/word/#{target}"
439
345
  next if content_types.overrides.any? { |o| o.part_name == part_name }
440
346
 
441
347
  content_types.overrides << Uniword::ContentTypes::Override.new(
442
348
  part_name: part_name,
443
- content_type: "application/vnd.openxmlformats-officedocument.oleObject",
444
- )
445
-
446
- next if document_rels.relationships.any? { |r| r.target == target }
447
-
448
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
449
- id: "rIdEmbedding#{idx + 1}",
450
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject",
451
- target: target,
349
+ content_type: ole.content_type,
452
350
  )
453
351
  end
454
352
  end
455
353
 
456
- def wire_header_reference(type, r_id)
457
- return unless document&.body
458
-
459
- sect_pr = document.body.section_properties ||= Wordprocessingml::SectionProperties.new
460
- existing = sect_pr.header_references&.find { |r| r.type == type }
461
- if existing
462
- existing.r_id = r_id
463
- else
464
- sect_pr.header_references << Wordprocessingml::HeaderReference.new(
465
- type: type, r_id: r_id,
466
- )
354
+ def serialize_notes(content)
355
+ if footnotes
356
+ content["word/footnotes.xml"] =
357
+ serialize_part(footnotes)
467
358
  end
468
- end
469
-
470
- def wire_footer_reference(type, r_id)
471
- return unless document&.body
472
-
473
- sect_pr = document.body.section_properties ||= Wordprocessingml::SectionProperties.new
474
- existing = sect_pr.footer_references&.find { |r| r.type == type }
475
- if existing
476
- existing.r_id = r_id
477
- else
478
- sect_pr.footer_references << Wordprocessingml::FooterReference.new(
479
- type: type, r_id: r_id,
480
- )
359
+ if endnotes
360
+ content["word/endnotes.xml"] =
361
+ serialize_part(endnotes)
481
362
  end
482
- end
483
-
484
- def serialize_headers(content)
485
- return unless document&.headers && !document.headers.empty?
486
-
487
- idx = 0
488
- document.headers.each_value do |header_obj|
489
- idx += 1
490
- content["word/header#{idx}.xml"] =
491
- serialize_part(header_obj)
363
+ if footnotes_rels
364
+ content["word/_rels/footnotes.xml.rels"] =
365
+ serialize_infrastructure(footnotes_rels)
492
366
  end
493
- end
494
-
495
- def serialize_footers(content)
496
- return unless document&.footers && !document.footers.empty?
497
-
498
- idx = 0
499
- document.footers.each_value do |footer_obj|
500
- idx += 1
501
- content["word/footer#{idx}.xml"] =
502
- serialize_part(footer_obj)
367
+ if endnotes_rels
368
+ content["word/_rels/endnotes.xml.rels"] =
369
+ serialize_infrastructure(endnotes_rels)
503
370
  end
504
371
  end
505
372
 
373
+ # Emit every header/footer part from the unified store: one part
374
+ # file per part, headers before footers (historic emission order).
506
375
  def serialize_header_footer_parts(content)
507
- return unless document&.header_footer_parts && !document.header_footer_parts.empty?
376
+ parts = document&.header_footer_parts
377
+ return unless parts && !parts.empty?
378
+
379
+ (parts.of_kind(:header) + parts.of_kind(:footer)).each do |part|
380
+ next unless part.target && part.content
508
381
 
509
- document.header_footer_parts.each do |part|
510
- content["word/#{part[:target]}"] =
511
- serialize_part(part[:content])
382
+ content["word/#{part.target}"] =
383
+ serialize_part(part.serializable_content)
512
384
  end
513
385
  end
514
386
 
515
387
  def serialize_embeddings(content)
516
388
  return unless embeddings && !embeddings.empty?
517
389
 
518
- embeddings.each do |target, data|
519
- content["word/#{target}"] = data
390
+ embeddings.each do |target, part|
391
+ content["word/#{target}"] = part.content
520
392
  end
521
393
  end
522
394
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Value object for one package-held part (chart, embedding,
6
+ # header, footer, ...).
7
+ #
8
+ # A Part couples the part's content (a lutaml-model object or a
9
+ # raw String) with the packaging metadata the OPC layer needs to
10
+ # emit it: relationship target, relationship id, content type and
11
+ # relationship type. Defaults for the metadata come from the
12
+ # part's Ooxml::PartDefinition (the single source of truth);
13
+ # loaded documents may carry verbatim overrides (e.g. strict-OOXML
14
+ # relationship URIs).
15
+ #
16
+ # Replaces the raw Hash entries previously stored in
17
+ # +chart_parts+, +embeddings+ and +header_footer_parts+. Hash-style
18
+ # read access (+part[:target]+) is kept for backward compatibility.
19
+ #
20
+ # @example
21
+ # part = Part.new(
22
+ # definition: Ooxml::PartRegistry.find_by_key(:ole_object),
23
+ # target: "embeddings/file.xlsx",
24
+ # content: binary_data,
25
+ # )
26
+ # part.path # => "word/embeddings/file.xlsx"
27
+ # part.content_type # => definition's content type
28
+ class Part
29
+ # @return [Ooxml::PartDefinition, nil] registry definition for
30
+ # this part kind
31
+ attr_reader :definition
32
+
33
+ # @return [String, nil] relationship id (nil until wired)
34
+ attr_accessor :r_id
35
+
36
+ # @return [String, nil] relationship target relative to word/
37
+ attr_accessor :target
38
+
39
+ # @return [Object] part content (model object or raw String)
40
+ attr_accessor :content
41
+
42
+ # @param definition [Ooxml::PartDefinition, nil] registry entry
43
+ # @param r_id [String, nil] relationship id
44
+ # @param target [String, nil] relationship target (relative to
45
+ # the owning part, e.g. "charts/chart1.xml")
46
+ # @param content [Object, nil] part content
47
+ # @param rel_type [String, nil] explicit relationship type
48
+ # override (verbatim value from a loaded package)
49
+ # @param content_type [String, nil] explicit content type
50
+ # override (verbatim value from a loaded package)
51
+ # rubocop:disable Metrics/ParameterLists
52
+ def initialize(definition: nil, r_id: nil, target: nil, content: nil,
53
+ rel_type: nil, content_type: nil)
54
+ @definition = definition
55
+ @r_id = r_id
56
+ @target = target
57
+ @content = content
58
+ @rel_type = rel_type
59
+ @content_type = content_type
60
+ end
61
+ # rubocop:enable Metrics/ParameterLists
62
+
63
+ # @return [String, nil] relationship type URI
64
+ def rel_type
65
+ @rel_type || definition&.rel_type
66
+ end
67
+
68
+ # @return [String, nil] MIME content type
69
+ def content_type
70
+ @content_type || definition&.content_type
71
+ end
72
+
73
+ # Package-relative path of the emitted part. Document-scoped
74
+ # parts live under word/; their target is already relative to
75
+ # word/.
76
+ #
77
+ # @return [String, nil] e.g. "word/charts/chart1.xml"
78
+ def path
79
+ target && "word/#{target}"
80
+ end
81
+
82
+ # Verbatim overrides carried from a loaded package.
83
+ attr_writer :rel_type, :content_type
84
+
85
+ # Key → reader lambdas backing hash-style access (kept as data
86
+ # so the dispatch stays declarative).
87
+ HASH_LOOKUP = {
88
+ r_id: lambda(&:r_id),
89
+ target: lambda(&:target),
90
+ rel_type: lambda(&:rel_type),
91
+ content_type: lambda(&:content_type),
92
+ content: lambda(&:content),
93
+ path: lambda(&:path),
94
+ }.freeze
95
+
96
+ # Hash-style read compatibility for former hash entries.
97
+ #
98
+ # @param key [Symbol, String] one of :r_id, :target, :rel_type,
99
+ # :content_type, :content, :path
100
+ # @return [Object, nil]
101
+ def [](key)
102
+ HASH_LOOKUP[key.to_sym]&.call(self)
103
+ end
104
+ end
105
+ end
106
+ end