uniword 1.2.4 → 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 (197) 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 +21 -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/has_borders.rb +8 -1
  15. data/lib/uniword/builder/image_builder.rb +6 -5
  16. data/lib/uniword/builder/run_utils.rb +2 -0
  17. data/lib/uniword/builder.rb +1 -0
  18. data/lib/uniword/cli/fonts_cli.rb +52 -0
  19. data/lib/uniword/cli/main.rb +110 -24
  20. data/lib/uniword/cli/page_cli.rb +85 -0
  21. data/lib/uniword/cli/styles_cli.rb +204 -0
  22. data/lib/uniword/cli/theme_cli.rb +88 -0
  23. data/lib/uniword/cli/toc_cli.rb +22 -2
  24. data/lib/uniword/comment.rb +20 -15
  25. data/lib/uniword/comment_range.rb +3 -3
  26. data/lib/uniword/comments_part.rb +51 -19
  27. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  28. data/lib/uniword/configuration.rb +93 -1
  29. data/lib/uniword/content_types.rb +19 -39
  30. data/lib/uniword/document_factory.rb +9 -1
  31. data/lib/uniword/document_writer.rb +16 -5
  32. data/lib/uniword/docx/chart_part.rb +34 -0
  33. data/lib/uniword/docx/custom_xml_item.rb +83 -0
  34. data/lib/uniword/docx/header_footer_part.rb +140 -0
  35. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  36. data/lib/uniword/docx/header_footer_view.rb +225 -0
  37. data/lib/uniword/docx/id_allocator.rb +132 -38
  38. data/lib/uniword/docx/package.rb +223 -35
  39. data/lib/uniword/docx/package_defaults.rb +58 -78
  40. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  41. data/lib/uniword/docx/package_serialization.rb +116 -234
  42. data/lib/uniword/docx/part.rb +106 -0
  43. data/lib/uniword/docx/part_collection.rb +123 -0
  44. data/lib/uniword/docx/reconciler/body.rb +59 -68
  45. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  46. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  47. data/lib/uniword/docx/reconciler/helpers.rb +9 -1
  48. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  49. data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
  50. data/lib/uniword/docx/reconciler/parts.rb +42 -16
  51. data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
  52. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  53. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  54. data/lib/uniword/docx/reconciler.rb +40 -15
  55. data/lib/uniword/docx.rb +10 -0
  56. data/lib/uniword/drawingml/blip.rb +0 -1
  57. data/lib/uniword/drawingml/color_scheme.rb +5 -7
  58. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  59. data/lib/uniword/drawingml/theme.rb +12 -7
  60. data/lib/uniword/errors.rb +8 -1
  61. data/lib/uniword/footer.rb +4 -0
  62. data/lib/uniword/header.rb +4 -0
  63. data/lib/uniword/hyperlink.rb +1 -1
  64. data/lib/uniword/image.rb +6 -6
  65. data/lib/uniword/lazy_loader.rb +1 -2
  66. data/lib/uniword/model_attribute_access.rb +20 -4
  67. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  68. data/lib/uniword/ooxml/part_definition.rb +171 -0
  69. data/lib/uniword/ooxml/part_registry.rb +311 -0
  70. data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
  71. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  72. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -4
  73. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  74. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  75. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  76. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  77. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  78. data/lib/uniword/ooxml/types.rb +10 -0
  79. data/lib/uniword/ooxml.rb +2 -0
  80. data/lib/uniword/properties/alignment.rb +8 -3
  81. data/lib/uniword/properties/border.rb +52 -6
  82. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  83. data/lib/uniword/properties/color_value.rb +8 -3
  84. data/lib/uniword/properties/highlight.rb +9 -5
  85. data/lib/uniword/properties/shading.rb +4 -3
  86. data/lib/uniword/properties/tab_stop.rb +6 -1
  87. data/lib/uniword/properties/table_justification.rb +4 -1
  88. data/lib/uniword/properties/underline.rb +4 -2
  89. data/lib/uniword/properties/vertical_align.rb +6 -3
  90. data/lib/uniword/resource/theme_processor.rb +1 -1
  91. data/lib/uniword/review/review_manager.rb +23 -9
  92. data/lib/uniword/revision.rb +9 -9
  93. data/lib/uniword/schema/model_generator.rb +31 -15
  94. data/lib/uniword/shared_types/hex_color.rb +4 -1
  95. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  96. data/lib/uniword/shared_types/point_measure.rb +4 -1
  97. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  98. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  99. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  100. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  101. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  102. data/lib/uniword/template/variable_resolver.rb +40 -3
  103. data/lib/uniword/themes/theme_transformation.rb +56 -42
  104. data/lib/uniword/toc/toc_generator.rb +2 -0
  105. data/lib/uniword/validation/checkers/footnote_reference_checker.rb +4 -1
  106. data/lib/uniword/validation/engine.rb +35 -0
  107. data/lib/uniword/validation/link_checker.rb +1 -1
  108. data/lib/uniword/validation/opc_validator.rb +0 -1
  109. data/lib/uniword/validation/report/layer_result.rb +0 -1
  110. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  111. data/lib/uniword/validation/report/verification_report.rb +0 -2
  112. data/lib/uniword/validation/report.rb +20 -0
  113. data/lib/uniword/validation/rules/base.rb +10 -2
  114. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  115. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  116. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  117. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  118. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  119. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  120. data/lib/uniword/validation/rules/document_context.rb +8 -1
  121. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  122. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  123. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  124. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  125. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  126. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  127. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  128. data/lib/uniword/validation/rules/model_context.rb +32 -0
  129. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  130. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  133. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  134. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  135. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  136. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  137. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  139. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  140. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  141. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  142. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  143. data/lib/uniword/validation/rules.rb +61 -23
  144. data/lib/uniword/validation/schema_registry.rb +11 -0
  145. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  146. data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
  147. data/lib/uniword/validation/validators.rb +0 -9
  148. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  149. data/lib/uniword/validation.rb +7 -2
  150. data/lib/uniword/version.rb +1 -1
  151. data/lib/uniword/vml/imagedata.rb +0 -1
  152. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  153. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  154. data/lib/uniword/wordprocessingml/document_root.rb +211 -21
  155. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  156. data/lib/uniword/wordprocessingml/font.rb +1 -1
  157. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  158. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  159. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  160. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  161. data/lib/uniword/wordprocessingml/level.rb +6 -20
  162. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  163. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  164. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  165. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  166. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  167. data/lib/uniword/wordprocessingml/run.rb +3 -0
  168. data/lib/uniword/wordprocessingml/run_properties.rb +20 -50
  169. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  170. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  171. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  172. data/lib/uniword/wordprocessingml/style.rb +4 -3
  173. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  174. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  175. data/lib/uniword/wordprocessingml/table_properties.rb +10 -15
  176. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  177. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  178. data/lib/uniword/wordprocessingml.rb +4 -0
  179. data/lib/uniword.rb +27 -2
  180. metadata +38 -19
  181. data/config/validation_rules.yml +0 -60
  182. data/config/warning_rules.yml +0 -57
  183. data/lib/uniword/validation/document_validator.rb +0 -272
  184. data/lib/uniword/validation/structural_validator.rb +0 -116
  185. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  186. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  187. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  188. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  189. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  190. data/lib/uniword/validators/element_validator.rb +0 -93
  191. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  192. data/lib/uniword/validators/table_validator.rb +0 -134
  193. data/lib/uniword/validators.rb +0 -9
  194. data/lib/uniword/warnings/warning.rb +0 -130
  195. data/lib/uniword/warnings/warning_collector.rb +0 -229
  196. data/lib/uniword/warnings/warning_report.rb +0 -159
  197. 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)
@@ -69,11 +68,11 @@ document_rels)
69
68
  end
70
69
  if app_properties
71
70
  content["docProps/app.xml"] =
72
- app_properties.to_xml(DOCX_PROPS_OPTIONS.dup)
71
+ add_standalone(app_properties.to_xml(DOCX_PROPS_OPTIONS.dup))
73
72
  end
74
73
  if custom_properties
75
74
  content["docProps/custom.xml"] =
76
- custom_properties.to_xml(DOCX_PROPS_OPTIONS.dup)
75
+ add_standalone(custom_properties.to_xml(DOCX_PROPS_OPTIONS.dup))
77
76
  end
78
77
 
79
78
  # Custom XML data items
@@ -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,57 +150,52 @@ 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
161
157
  serialize_embeddings(content)
162
158
  end
163
159
 
164
- # Serialize an OOXML document part with standard encoding, single-line output
160
+ # Serialize an OOXML document part with standard encoding, single-line output.
161
+ # All DOCX parts require standalone="yes" in the XML declaration for Word compatibility.
165
162
  def serialize_part(model)
166
- model.to_xml(DOCX_PART_OPTIONS.dup)
163
+ add_standalone(model.to_xml(DOCX_PART_OPTIONS.dup))
167
164
  end
168
165
 
169
166
  # Serialize package infrastructure (rels, content types) with declaration, single-line output
170
167
  def serialize_infrastructure(model)
171
- model.to_xml(DOCX_INFRA_OPTIONS.dup)
168
+ add_standalone(model.to_xml(DOCX_INFRA_OPTIONS.dup))
172
169
  end
173
170
 
174
- private
175
-
176
- # Generate the next numeric relationship ID for a Relationships object.
177
- def next_rid(relationships)
178
- Ooxml::Relationships::PackageRelationships.next_available_rid(
179
- relationships,
171
+ # Ensure the XML declaration includes standalone="yes".
172
+ # lutaml-model omits this by default; Word's strict OPC validation requires it.
173
+ def add_standalone(xml)
174
+ xml.sub(
175
+ %r{<\?xml version="1.0" encoding="UTF-8"\?>},
176
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
180
177
  )
181
178
  end
182
179
 
183
- # Shared pattern: ensure a part has both a content type override and
184
- # a document relationship. Idempotent — skips if already present.
185
- # When allocator is present, only adds content type (rels handled by allocator).
186
- def ensure_content_type(content_types, rels, part_name:,
187
- content_type:, rel_type:, target:)
188
- unless content_types.overrides.any? { |o| o.part_name == part_name }
189
- content_types.overrides << Uniword::ContentTypes::Override.new(
190
- part_name: part_name, content_type: content_type,
191
- )
192
- end
180
+ private
193
181
 
194
- return if allocator
195
- return if rels.relationships.any? { |r| r.target == target }
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 }
196
189
 
197
- rels.relationships << Ooxml::Relationships::Relationship.new(
198
- 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,
199
192
  )
200
193
  end
201
194
 
202
195
  # Legacy alias — calls ensure_content_type (same behavior)
203
196
  alias ensure_part_registered ensure_content_type
204
197
 
205
- def inject_image_parts(content, content_types, document_rels)
198
+ def inject_image_parts(content, content_types, _document_rels)
206
199
  return unless document&.image_parts && !document.image_parts.empty?
207
200
 
208
201
  document.image_parts.each_value do |image_data|
@@ -217,296 +210,185 @@ document_rels)
217
210
  document.image_parts.each_value do |image_data|
218
211
  content["word/#{image_data[:target]}"] = image_data[:data]
219
212
  end
220
-
221
- return if allocator
222
-
223
- document.image_parts.each do |r_id, image_data|
224
- next if document_rels.relationships.any? { |r| r.target == image_data[:target] }
225
-
226
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
227
- id: r_id,
228
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
229
- target: image_data[:target],
230
- )
231
- end
232
213
  end
233
214
 
234
- def inject_chart_parts(content, content_types, document_rels)
215
+ def inject_chart_parts(content, content_types, _document_rels)
235
216
  return unless document&.chart_parts && !document.chart_parts.empty?
236
217
 
218
+ chart = Ooxml::PartRegistry.find_by_key(:chart)
237
219
  unless content_types.overrides.any? do |o|
238
220
  o.part_name&.start_with?("/word/charts/")
239
221
  end
240
222
  content_types.overrides << Uniword::ContentTypes::Override.new(
241
- part_name: "/word/charts/chart1.xml",
242
- content_type: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml",
223
+ part_name: chart.part_name_for(n: 1),
224
+ content_type: chart.content_type,
243
225
  )
244
226
  end
245
227
 
246
- document.chart_parts.each do |r_id, chart_data|
228
+ document.chart_parts.each_value do |chart_data|
247
229
  content["word/#{chart_data[:target]}"] = chart_data[:xml]
248
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
249
- id: r_id,
250
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart",
251
- target: chart_data[:target],
252
- )
253
230
  end
254
231
  end
255
232
 
256
- def inject_bibliography(content_types, document_rels)
233
+ def inject_bibliography(content_types, _document_rels)
257
234
  return unless document&.bibliography_sources
258
235
 
259
- ensure_content_type(content_types, document_rels,
260
- part_name: "/word/sources.xml",
261
- content_type: "application/vnd.openxmlformats-officedocument.bibliography+xml",
262
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/bibliography",
263
- target: "sources.xml")
236
+ ensure_content_type(content_types,
237
+ Ooxml::PartRegistry.find_by_key(:bibliography))
264
238
  end
265
239
 
266
240
  def inject_custom_properties(content_types, package_rels)
267
241
  return unless custom_properties && !custom_properties.properties.empty?
268
242
 
243
+ definition = Ooxml::PartRegistry.find_by_key(:custom_properties)
269
244
  unless content_types.overrides.any? do |o|
270
- o.part_name == "/docProps/custom.xml"
245
+ o.part_name == definition.part_name
271
246
  end
272
247
  content_types.overrides << Uniword::ContentTypes::Override.new(
273
- part_name: "/docProps/custom.xml",
274
- content_type: "application/vnd.openxmlformats-officedocument.custom-properties+xml",
248
+ part_name: definition.part_name,
249
+ content_type: definition.content_type,
275
250
  )
276
251
  end
277
252
 
278
253
  return if package_rels.relationships.any? do |r|
279
- r.type.to_s.include?("officeDocument/2006/relationships/custom-properties")
254
+ r.type.to_s.include?(definition.rel_type)
280
255
  end
281
256
 
257
+ # The reconciler runs before injection; register the rId with
258
+ # the allocator (single authority) and append the rel here.
282
259
  package_rels.relationships << Ooxml::Relationships::Relationship.new(
283
- id: next_rid(package_rels),
284
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
285
- 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,
286
265
  )
287
266
  end
288
267
 
289
268
  def inject_custom_xml(content_types)
290
269
  return unless custom_xml_items && !custom_xml_items.empty?
291
270
 
271
+ props = Ooxml::PartRegistry.find_by_key(:custom_xml_item_props)
292
272
  custom_xml_items.each do |item|
293
273
  idx = item[:index]
274
+ part_name = props.part_name_for(index: idx)
294
275
  next if content_types.overrides.any? do |o|
295
- o.part_name == "/customXml/itemProps#{idx}.xml"
276
+ o.part_name == part_name
296
277
  end
297
278
 
298
279
  content_types.overrides << Uniword::ContentTypes::Override.new(
299
- part_name: "/customXml/itemProps#{idx}.xml",
300
- content_type: "application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
301
- )
302
- end
303
- end
304
-
305
- def inject_headers(content_types, document_rels)
306
- return unless document&.headers && !document.headers.empty?
307
-
308
- counter = 0
309
- document.headers.each_key do |type|
310
- counter += 1
311
- target = "header#{counter}.xml"
312
-
313
- unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
314
- content_types.overrides << Uniword::ContentTypes::Override.new(
315
- part_name: "/word/#{target}",
316
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
317
- )
318
- end
319
-
320
- next if allocator
321
- next if document_rels.relationships.any? { |r| r.target == target }
322
-
323
- r_id = next_rid(document_rels)
324
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
325
- id: r_id,
326
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
327
- target: target,
328
- )
329
-
330
- wire_header_reference(type, r_id)
331
- end
332
- end
333
-
334
- def inject_footers(content_types, document_rels)
335
- return unless document&.footers && !document.footers.empty?
336
-
337
- counter = 0
338
- document.footers.each_key do |type|
339
- counter += 1
340
- target = "footer#{counter}.xml"
341
-
342
- unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
343
- content_types.overrides << Uniword::ContentTypes::Override.new(
344
- part_name: "/word/#{target}",
345
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
346
- )
347
- end
348
-
349
- next if allocator
350
- next if document_rels.relationships.any? { |r| r.target == target }
351
-
352
- r_id = next_rid(document_rels)
353
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
354
- id: r_id,
355
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
356
- target: target,
280
+ part_name: part_name,
281
+ content_type: props.content_type,
357
282
  )
358
-
359
- wire_footer_reference(type, r_id)
360
283
  end
361
284
  end
362
285
 
363
- def inject_header_footer_parts(content_types, document_rels)
364
- 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?
365
293
 
366
- document.header_footer_parts.each do |part|
367
- part_name = "/word/#{part[:target]}"
368
- unless content_types.overrides.any? { |o| o.part_name == part_name }
369
- content_types.overrides << Uniword::ContentTypes::Override.new(
370
- part_name: part_name,
371
- content_type: part[:content_type],
372
- )
373
- end
294
+ parts.each do |part|
295
+ next unless part.target
374
296
 
375
- next if allocator
376
- 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 }
377
299
 
378
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
379
- id: part[:r_id],
380
- type: part[:rel_type],
381
- target: part[:target],
300
+ content_types.overrides << Uniword::ContentTypes::Override.new(
301
+ part_name: part_name, content_type: part.content_type,
382
302
  )
383
303
  end
384
304
  end
385
305
 
386
- def inject_notes(content_types, document_rels)
306
+ def inject_notes(content_types, _document_rels)
387
307
  if footnotes
388
- ensure_content_type(content_types, document_rels,
389
- part_name: "/word/footnotes.xml",
390
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
391
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
392
- target: "footnotes.xml")
308
+ ensure_content_type(content_types,
309
+ Ooxml::PartRegistry.find_by_key(:footnotes))
393
310
  end
394
311
 
395
312
  return unless endnotes
396
313
 
397
- ensure_content_type(content_types, document_rels,
398
- part_name: "/word/endnotes.xml",
399
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
400
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
401
- target: "endnotes.xml")
314
+ ensure_content_type(content_types,
315
+ Ooxml::PartRegistry.find_by_key(:endnotes))
402
316
  end
403
317
 
404
- def inject_theme(content_types, document_rels)
318
+ def inject_theme(content_types, _document_rels)
405
319
  return unless theme
406
320
 
407
- ensure_content_type(content_types, document_rels,
408
- part_name: "/word/theme/theme1.xml",
409
- content_type: "application/vnd.openxmlformats-officedocument.theme+xml",
410
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
411
- target: "theme/theme1.xml")
321
+ ensure_content_type(content_types,
322
+ Ooxml::PartRegistry.find_by_key(:theme))
412
323
  end
413
324
 
414
- 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)
415
333
  return unless numbering
416
334
 
417
- ensure_content_type(content_types, document_rels,
418
- part_name: "/word/numbering.xml",
419
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
420
- rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
421
- target: "numbering.xml")
335
+ ensure_content_type(content_types,
336
+ Ooxml::PartRegistry.find_by_key(:numbering))
422
337
  end
423
338
 
424
- def inject_embeddings(content_types, document_rels)
339
+ def inject_embeddings(content_types, _document_rels)
425
340
  return unless embeddings && !embeddings.empty?
426
341
 
427
- embeddings.each_with_index do |(target, _data), idx|
342
+ ole = Ooxml::PartRegistry.find_by_key(:ole_object)
343
+ embeddings.each_key do |target|
428
344
  part_name = "/word/#{target}"
429
345
  next if content_types.overrides.any? { |o| o.part_name == part_name }
430
346
 
431
347
  content_types.overrides << Uniword::ContentTypes::Override.new(
432
348
  part_name: part_name,
433
- content_type: "application/vnd.openxmlformats-officedocument.oleObject",
434
- )
435
-
436
- next if document_rels.relationships.any? { |r| r.target == target }
437
-
438
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
439
- id: "rIdEmbedding#{idx + 1}",
440
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject",
441
- target: target,
349
+ content_type: ole.content_type,
442
350
  )
443
351
  end
444
352
  end
445
353
 
446
- def wire_header_reference(type, r_id)
447
- return unless document&.body
448
-
449
- sect_pr = document.body.section_properties ||= Wordprocessingml::SectionProperties.new
450
- existing = sect_pr.header_references&.find { |r| r.type == type }
451
- if existing
452
- existing.r_id = r_id
453
- else
454
- sect_pr.header_references << Wordprocessingml::HeaderReference.new(
455
- type: type, r_id: r_id,
456
- )
354
+ def serialize_notes(content)
355
+ if footnotes
356
+ content["word/footnotes.xml"] =
357
+ serialize_part(footnotes)
457
358
  end
458
- end
459
-
460
- def wire_footer_reference(type, r_id)
461
- return unless document&.body
462
-
463
- sect_pr = document.body.section_properties ||= Wordprocessingml::SectionProperties.new
464
- existing = sect_pr.footer_references&.find { |r| r.type == type }
465
- if existing
466
- existing.r_id = r_id
467
- else
468
- sect_pr.footer_references << Wordprocessingml::FooterReference.new(
469
- type: type, r_id: r_id,
470
- )
359
+ if endnotes
360
+ content["word/endnotes.xml"] =
361
+ serialize_part(endnotes)
471
362
  end
472
- end
473
-
474
- def serialize_headers(content)
475
- return unless document&.headers && !document.headers.empty?
476
-
477
- idx = 0
478
- document.headers.each_value do |header_obj|
479
- idx += 1
480
- content["word/header#{idx}.xml"] =
481
- serialize_part(header_obj)
363
+ if footnotes_rels
364
+ content["word/_rels/footnotes.xml.rels"] =
365
+ serialize_infrastructure(footnotes_rels)
482
366
  end
483
- end
484
-
485
- def serialize_footers(content)
486
- return unless document&.footers && !document.footers.empty?
487
-
488
- idx = 0
489
- document.footers.each_value do |footer_obj|
490
- idx += 1
491
- content["word/footer#{idx}.xml"] =
492
- serialize_part(footer_obj)
367
+ if endnotes_rels
368
+ content["word/_rels/endnotes.xml.rels"] =
369
+ serialize_infrastructure(endnotes_rels)
493
370
  end
494
371
  end
495
372
 
373
+ # Emit every header/footer part from the unified store: one part
374
+ # file per part, headers before footers (historic emission order).
496
375
  def serialize_header_footer_parts(content)
497
- 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
498
381
 
499
- document.header_footer_parts.each do |part|
500
- content["word/#{part[:target]}"] =
501
- serialize_part(part[:content])
382
+ content["word/#{part.target}"] =
383
+ serialize_part(part.serializable_content)
502
384
  end
503
385
  end
504
386
 
505
387
  def serialize_embeddings(content)
506
388
  return unless embeddings && !embeddings.empty?
507
389
 
508
- embeddings.each do |target, data|
509
- content["word/#{target}"] = data
390
+ embeddings.each do |target, part|
391
+ content["word/#{target}"] = part.content
510
392
  end
511
393
  end
512
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