uniword 1.2.5 → 1.4.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +313 -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 +22 -14
  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 +11 -10
  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 +12 -18
  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 +91 -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/image_part.rb +83 -0
  38. data/lib/uniword/docx/package.rb +141 -333
  39. data/lib/uniword/docx/package_defaults.rb +83 -99
  40. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  41. data/lib/uniword/docx/package_serialization.rb +144 -233
  42. data/lib/uniword/docx/part.rb +125 -0
  43. data/lib/uniword/docx/part_collection.rb +131 -0
  44. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  45. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  46. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  47. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  48. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  49. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  50. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  51. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  52. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  53. data/lib/uniword/docx/part_loader.rb +92 -0
  54. data/lib/uniword/docx/raw_part.rb +75 -0
  55. data/lib/uniword/docx/reconciler/body.rb +59 -68
  56. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  57. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  58. data/lib/uniword/docx/reconciler/helpers.rb +18 -31
  59. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  60. data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
  61. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  62. data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -123
  63. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  64. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  65. data/lib/uniword/docx/reconciler.rb +36 -14
  66. data/lib/uniword/docx.rb +13 -0
  67. data/lib/uniword/drawingml/blip.rb +0 -1
  68. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  69. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  70. data/lib/uniword/drawingml/theme.rb +12 -6
  71. data/lib/uniword/errors.rb +8 -1
  72. data/lib/uniword/footer.rb +4 -0
  73. data/lib/uniword/header.rb +4 -0
  74. data/lib/uniword/hyperlink.rb +1 -1
  75. data/lib/uniword/image.rb +6 -6
  76. data/lib/uniword/images/image_manager.rb +13 -13
  77. data/lib/uniword/lazy_loader.rb +1 -2
  78. data/lib/uniword/model_attribute_access.rb +20 -4
  79. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  80. data/lib/uniword/ooxml/element_order.rb +94 -0
  81. data/lib/uniword/ooxml/part_definition.rb +329 -0
  82. data/lib/uniword/ooxml/part_registry.rb +504 -0
  83. data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
  84. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  85. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  86. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  87. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  88. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  89. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  90. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  91. data/lib/uniword/ooxml/types.rb +10 -0
  92. data/lib/uniword/ooxml.rb +3 -0
  93. data/lib/uniword/properties/alignment.rb +8 -3
  94. data/lib/uniword/properties/border.rb +52 -6
  95. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  96. data/lib/uniword/properties/color_value.rb +8 -3
  97. data/lib/uniword/properties/highlight.rb +9 -5
  98. data/lib/uniword/properties/shading.rb +4 -3
  99. data/lib/uniword/properties/tab_stop.rb +6 -1
  100. data/lib/uniword/properties/table_justification.rb +4 -1
  101. data/lib/uniword/properties/underline.rb +4 -2
  102. data/lib/uniword/properties/vertical_align.rb +6 -3
  103. data/lib/uniword/review/review_manager.rb +23 -9
  104. data/lib/uniword/revision.rb +9 -9
  105. data/lib/uniword/schema/model_generator.rb +31 -15
  106. data/lib/uniword/shared_types/hex_color.rb +4 -1
  107. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  108. data/lib/uniword/shared_types/point_measure.rb +4 -1
  109. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  110. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  111. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  112. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  113. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  114. data/lib/uniword/template/variable_resolver.rb +40 -7
  115. data/lib/uniword/themes/theme_transformation.rb +55 -41
  116. data/lib/uniword/toc/toc_generator.rb +6 -5
  117. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  118. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  119. data/lib/uniword/validation/engine.rb +35 -0
  120. data/lib/uniword/validation/link_checker.rb +1 -1
  121. data/lib/uniword/validation/opc_validator.rb +16 -2
  122. data/lib/uniword/validation/report/layer_result.rb +0 -1
  123. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  124. data/lib/uniword/validation/report/verification_report.rb +0 -2
  125. data/lib/uniword/validation/report.rb +20 -0
  126. data/lib/uniword/validation/rules/base.rb +10 -2
  127. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  128. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  129. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  130. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  133. data/lib/uniword/validation/rules/document_context.rb +7 -0
  134. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  135. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  136. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  137. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  140. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  141. data/lib/uniword/validation/rules/model_context.rb +32 -0
  142. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  143. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  144. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  145. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  146. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  147. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  148. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  149. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  150. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  151. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  152. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  153. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  154. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  155. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  156. data/lib/uniword/validation/rules.rb +61 -23
  157. data/lib/uniword/validation/schema_registry.rb +11 -0
  158. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  159. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -2
  160. data/lib/uniword/validation/validators.rb +0 -9
  161. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  162. data/lib/uniword/validation.rb +7 -2
  163. data/lib/uniword/version.rb +1 -1
  164. data/lib/uniword/vml/imagedata.rb +0 -1
  165. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  166. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  167. data/lib/uniword/wordprocessingml/document_root.rb +121 -122
  168. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  169. data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
  170. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  171. data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
  172. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  173. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  174. data/lib/uniword/wordprocessingml/level.rb +6 -20
  175. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  176. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  177. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  178. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  179. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  180. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  181. data/lib/uniword/wordprocessingml/run.rb +3 -0
  182. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  183. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  184. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  185. data/lib/uniword/wordprocessingml/style.rb +2 -1
  186. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  187. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  188. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  189. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  190. data/lib/uniword/wordprocessingml.rb +5 -2
  191. data/lib/uniword.rb +27 -2
  192. metadata +52 -19
  193. data/config/validation_rules.yml +0 -60
  194. data/config/warning_rules.yml +0 -57
  195. data/lib/uniword/validation/document_validator.rb +0 -272
  196. data/lib/uniword/validation/structural_validator.rb +0 -116
  197. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  198. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  199. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  200. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  201. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  202. data/lib/uniword/validators/element_validator.rb +0 -93
  203. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  204. data/lib/uniword/validators/table_validator.rb +0 -134
  205. data/lib/uniword/validators.rb +0 -9
  206. data/lib/uniword/warnings/warning.rb +0 -130
  207. data/lib/uniword/warnings/warning_collector.rb +0 -234
  208. data/lib/uniword/warnings/warning_report.rb +0 -159
  209. data/lib/uniword/warnings.rb +0 -9
@@ -44,13 +44,13 @@ 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)
53
+ inject_raw_part_content_types(content_types)
54
54
  end
55
55
 
56
56
  # Serialize all package parts to XML and add to content hash
@@ -137,13 +137,12 @@ document_rels)
137
137
  end
138
138
 
139
139
  # 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)
140
+ serialize_notes(content)
141
+
142
+ # Comments
143
+ if comments
144
+ content["word/comments.xml"] =
145
+ serialize_part(comments)
147
146
  end
148
147
 
149
148
  # Bibliography sources
@@ -152,13 +151,14 @@ document_rels)
152
151
  serialize_infrastructure(document.bibliography_sources)
153
152
  end
154
153
 
155
- # Headers and footers
156
- serialize_headers(content)
157
- serialize_footers(content)
154
+ # Headers and footers (unified store)
158
155
  serialize_header_footer_parts(content)
159
156
 
160
157
  # OLE/embedded object binaries
161
158
  serialize_embeddings(content)
159
+
160
+ # Raw passthrough parts (unmodelled; byte-for-byte)
161
+ serialize_raw_parts(content)
162
162
  end
163
163
 
164
164
  # Serialize an OOXML document part with standard encoding, single-line output.
@@ -183,340 +183,251 @@ document_rels)
183
183
 
184
184
  private
185
185
 
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
203
-
204
- return if allocator
205
- return if rels.relationships.any? { |r| r.target == target }
186
+ # Shared pattern: ensure a part has a content type override.
187
+ # Idempotent — skips if already present. Relationships are owned
188
+ # by the Reconciler (assembled from the IdAllocator), never by
189
+ # the serializer. Part metadata comes from Ooxml::PartRegistry.
190
+ def ensure_content_type(content_types, definition)
191
+ part_name = definition.part_name
192
+ return if content_types.overrides.any? { |o| o.part_name == part_name }
206
193
 
207
- rels.relationships << Ooxml::Relationships::Relationship.new(
208
- id: next_rid(rels), type: rel_type, target: target,
194
+ content_types.overrides << Uniword::ContentTypes::Override.new(
195
+ part_name: part_name, content_type: definition.content_type,
209
196
  )
210
197
  end
211
198
 
212
199
  # Legacy alias — calls ensure_content_type (same behavior)
213
200
  alias ensure_part_registered ensure_content_type
214
201
 
215
- def inject_image_parts(content, content_types, document_rels)
202
+ def inject_image_parts(content, content_types, _document_rels)
216
203
  return unless document&.image_parts && !document.image_parts.empty?
217
204
 
218
- document.image_parts.each_value do |image_data|
219
- ext = File.extname(image_data[:target]).delete(".")
205
+ document.image_parts.each_value do |part|
206
+ ext = File.extname(part.target.to_s).delete(".")
220
207
  next if content_types.defaults.any? { |d| d.extension == ext }
221
208
 
222
209
  content_types.defaults << Uniword::ContentTypes::Default.new(
223
- extension: ext, content_type: image_data[:content_type],
210
+ extension: ext, content_type: part.content_type,
224
211
  )
225
212
  end
226
213
 
227
- document.image_parts.each_value do |image_data|
228
- content["word/#{image_data[:target]}"] = image_data[:data]
229
- 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
- )
214
+ document.image_parts.each_value do |part|
215
+ content["word/#{part.target}"] = part.data
241
216
  end
242
217
  end
243
218
 
244
- def inject_chart_parts(content, content_types, document_rels)
219
+ def inject_chart_parts(content, content_types, _document_rels)
245
220
  return unless document&.chart_parts && !document.chart_parts.empty?
246
221
 
222
+ chart = Ooxml::PartRegistry.find_by_key(:chart)
247
223
  unless content_types.overrides.any? do |o|
248
224
  o.part_name&.start_with?("/word/charts/")
249
225
  end
250
226
  content_types.overrides << Uniword::ContentTypes::Override.new(
251
- part_name: "/word/charts/chart1.xml",
252
- content_type: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml",
227
+ part_name: chart.part_name_for(n: 1),
228
+ content_type: chart.content_type,
253
229
  )
254
230
  end
255
231
 
256
- document.chart_parts.each do |r_id, chart_data|
257
- 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
- )
232
+ document.chart_parts.each_value do |part|
233
+ content["word/#{part.target}"] = part.xml
263
234
  end
264
235
  end
265
236
 
266
- def inject_bibliography(content_types, document_rels)
237
+ def inject_bibliography(content_types, _document_rels)
267
238
  return unless document&.bibliography_sources
268
239
 
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")
240
+ ensure_content_type(content_types,
241
+ Ooxml::PartRegistry.find_by_key(:bibliography))
274
242
  end
275
243
 
276
244
  def inject_custom_properties(content_types, package_rels)
277
245
  return unless custom_properties && !custom_properties.properties.empty?
278
246
 
247
+ definition = Ooxml::PartRegistry.find_by_key(:custom_properties)
279
248
  unless content_types.overrides.any? do |o|
280
- o.part_name == "/docProps/custom.xml"
249
+ o.part_name == definition.part_name
281
250
  end
282
251
  content_types.overrides << Uniword::ContentTypes::Override.new(
283
- part_name: "/docProps/custom.xml",
284
- content_type: "application/vnd.openxmlformats-officedocument.custom-properties+xml",
252
+ part_name: definition.part_name,
253
+ content_type: definition.content_type,
285
254
  )
286
255
  end
287
256
 
288
257
  return if package_rels.relationships.any? do |r|
289
- r.type.to_s.include?("officeDocument/2006/relationships/custom-properties")
258
+ r.type.to_s.include?(definition.rel_type)
290
259
  end
291
260
 
261
+ # The reconciler runs before injection; register the rId with
262
+ # the allocator (single authority) and append the rel here.
292
263
  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",
264
+ id: allocator.alloc_rid(target: definition.target,
265
+ type: definition.rel_type,
266
+ scope: :package),
267
+ type: definition.rel_type,
268
+ target: definition.target,
296
269
  )
297
270
  end
298
271
 
299
272
  def inject_custom_xml(content_types)
300
273
  return unless custom_xml_items && !custom_xml_items.empty?
301
274
 
275
+ props = Ooxml::PartRegistry.find_by_key(:custom_xml_item_props)
302
276
  custom_xml_items.each do |item|
303
277
  idx = item[:index]
278
+ part_name = props.part_name_for(index: idx)
304
279
  next if content_types.overrides.any? do |o|
305
- o.part_name == "/customXml/itemProps#{idx}.xml"
280
+ o.part_name == part_name
306
281
  end
307
282
 
308
283
  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,
284
+ part_name: part_name,
285
+ content_type: props.content_type,
367
286
  )
368
-
369
- wire_footer_reference(type, r_id)
370
287
  end
371
288
  end
372
289
 
373
- def inject_header_footer_parts(content_types, document_rels)
374
- return unless document&.header_footer_parts && !document.header_footer_parts.empty?
290
+ # Register content-type overrides for every header/footer part in
291
+ # the unified store. Relationships and sectPr references are owned
292
+ # by the Reconciler (single wiring implementation); the serializer
293
+ # only ensures each emitted part has exactly one override.
294
+ def inject_header_footer_content_types(content_types)
295
+ parts = document&.header_footer_parts
296
+ return unless parts && !parts.empty?
375
297
 
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
298
+ parts.each do |part|
299
+ next unless part.target
384
300
 
385
- next if allocator
386
- next if document_rels.relationships.any? { |r| r.id == part[:r_id] }
301
+ part_name = "/word/#{part.target}"
302
+ next if content_types.overrides.any? { |o| o.part_name == part_name }
387
303
 
388
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
389
- id: part[:r_id],
390
- type: part[:rel_type],
391
- target: part[:target],
304
+ content_types.overrides << Uniword::ContentTypes::Override.new(
305
+ part_name: part_name, content_type: part.content_type,
392
306
  )
393
307
  end
394
308
  end
395
309
 
396
- def inject_notes(content_types, document_rels)
310
+ def inject_notes(content_types, _document_rels)
397
311
  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")
312
+ ensure_content_type(content_types,
313
+ Ooxml::PartRegistry.find_by_key(:footnotes))
403
314
  end
404
315
 
405
316
  return unless endnotes
406
317
 
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")
318
+ ensure_content_type(content_types,
319
+ Ooxml::PartRegistry.find_by_key(:endnotes))
412
320
  end
413
321
 
414
- def inject_theme(content_types, document_rels)
322
+ def inject_theme(content_types, _document_rels)
415
323
  return unless theme
416
324
 
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")
325
+ ensure_content_type(content_types,
326
+ Ooxml::PartRegistry.find_by_key(:theme))
327
+ end
328
+
329
+ def inject_comments(content_types, _document_rels)
330
+ return unless comments
331
+
332
+ ensure_content_type(content_types,
333
+ Ooxml::PartRegistry.find_by_key(:comments))
422
334
  end
423
335
 
424
- def inject_numbering(content_types, document_rels)
336
+ def inject_numbering(content_types, _document_rels)
425
337
  return unless numbering
426
338
 
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")
339
+ ensure_content_type(content_types,
340
+ Ooxml::PartRegistry.find_by_key(:numbering))
432
341
  end
433
342
 
434
- def inject_embeddings(content_types, document_rels)
343
+ def inject_embeddings(content_types, _document_rels)
435
344
  return unless embeddings && !embeddings.empty?
436
345
 
437
- embeddings.each_with_index do |(target, _data), idx|
346
+ ole = Ooxml::PartRegistry.find_by_key(:ole_object)
347
+ embeddings.each_key do |target|
438
348
  part_name = "/word/#{target}"
439
349
  next if content_types.overrides.any? { |o| o.part_name == part_name }
440
350
 
441
351
  content_types.overrides << Uniword::ContentTypes::Override.new(
442
352
  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,
353
+ content_type: ole.content_type,
452
354
  )
453
355
  end
454
356
  end
455
357
 
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
- )
358
+ def serialize_notes(content)
359
+ if footnotes
360
+ content["word/footnotes.xml"] =
361
+ serialize_part(footnotes)
362
+ end
363
+ if endnotes
364
+ content["word/endnotes.xml"] =
365
+ serialize_part(endnotes)
366
+ end
367
+ if footnotes_rels
368
+ content["word/_rels/footnotes.xml.rels"] =
369
+ serialize_infrastructure(footnotes_rels)
370
+ end
371
+ if endnotes_rels
372
+ content["word/_rels/endnotes.xml.rels"] =
373
+ serialize_infrastructure(endnotes_rels)
467
374
  end
468
375
  end
469
376
 
470
- def wire_footer_reference(type, r_id)
471
- return unless document&.body
377
+ # Emit every header/footer part from the unified store: one part
378
+ # file per part, headers before footers (historic emission order).
379
+ def serialize_header_footer_parts(content)
380
+ parts = document&.header_footer_parts
381
+ return unless parts && !parts.empty?
382
+
383
+ (parts.of_kind(:header) + parts.of_kind(:footer)).each do |part|
384
+ next unless part.target && part.content
472
385
 
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
- )
386
+ content["word/#{part.target}"] =
387
+ serialize_part(part.serializable_content)
481
388
  end
482
389
  end
483
390
 
484
- def serialize_headers(content)
485
- return unless document&.headers && !document.headers.empty?
391
+ def serialize_embeddings(content)
392
+ return unless embeddings && !embeddings.empty?
486
393
 
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)
394
+ embeddings.each do |target, part|
395
+ content["word/#{target}"] = part.content
492
396
  end
493
397
  end
494
398
 
495
- def serialize_footers(content)
496
- return unless document&.footers && !document.footers.empty?
399
+ # Content types for raw-carried parts. Source Overrides survive
400
+ # reconciliation untouched (non-standard entries are preserved);
401
+ # a part whose content type came from a Default entry needs an
402
+ # Override once the reconciler rebuilds the standard Defaults.
403
+ def inject_raw_part_content_types(content_types)
404
+ return if raw_parts.empty?
497
405
 
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)
503
- end
504
- end
406
+ raw_parts.each_value do |part|
407
+ next unless part.path && part.content_type
505
408
 
506
- def serialize_header_footer_parts(content)
507
- return unless document&.header_footer_parts && !document.header_footer_parts.empty?
409
+ ext = File.extname(part.path)[1..]
410
+ next if ext && content_types.defaults.any? do |d|
411
+ d.extension == ext
412
+ end
413
+
414
+ part_name = "/#{part.path}"
415
+ next if content_types.overrides.any? { |o| o.part_name == part_name }
508
416
 
509
- document.header_footer_parts.each do |part|
510
- content["word/#{part[:target]}"] =
511
- serialize_part(part[:content])
417
+ content_types.overrides << Uniword::ContentTypes::Override.new(
418
+ part_name: part_name, content_type: part.content_type,
419
+ )
512
420
  end
513
421
  end
514
422
 
515
- def serialize_embeddings(content)
516
- return unless embeddings && !embeddings.empty?
423
+ # Emit raw-carried parts byte-for-byte. Model emission always
424
+ # wins: raw claiming already excludes registry paths, and the
425
+ # key guard makes double emission impossible by construction.
426
+ def serialize_raw_parts(content)
427
+ return if raw_parts.empty?
517
428
 
518
- embeddings.each do |target, data|
519
- content["word/#{target}"] = data
429
+ raw_parts.each do |path, part|
430
+ content[path] = part.content unless content.key?(path)
520
431
  end
521
432
  end
522
433
  end
@@ -0,0 +1,125 @@
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
+ # Wrap a legacy raw-hash entry ({ content:/xml:, target: }) into
64
+ # a Part. Subclasses override for family-specific keys (e.g.
65
+ # ImagePart's { data:, content_type:, path: }). Used by
66
+ # PartCollection to normalize hash assignments.
67
+ #
68
+ # @param hash [Hash] legacy hash entry
69
+ # @return [Part]
70
+ def self.from_hash(hash)
71
+ new(target: hash[:target], content: hash[:content] || hash[:xml])
72
+ end
73
+
74
+ # @return [String, nil] relationship type URI
75
+ def rel_type
76
+ @rel_type || definition&.rel_type
77
+ end
78
+
79
+ # @return [String, nil] MIME content type
80
+ def content_type
81
+ @content_type || definition&.content_type
82
+ end
83
+
84
+ # Package-relative path of the emitted part. Document-scoped
85
+ # parts live under word/; their target is already relative to
86
+ # word/.
87
+ #
88
+ # @return [String, nil] e.g. "word/charts/chart1.xml"
89
+ def path
90
+ target && "word/#{target}"
91
+ end
92
+
93
+ # Package paths this part emits (one for plain parts; subclasses
94
+ # with sidecar parts override).
95
+ #
96
+ # @return [Array<String>] emitted package paths
97
+ def package_paths
98
+ [path].compact
99
+ end
100
+
101
+ # Verbatim overrides carried from a loaded package.
102
+ attr_writer :rel_type, :content_type
103
+
104
+ # Key → reader lambdas backing hash-style access (kept as data
105
+ # so the dispatch stays declarative).
106
+ HASH_LOOKUP = {
107
+ r_id: lambda(&:r_id),
108
+ target: lambda(&:target),
109
+ rel_type: lambda(&:rel_type),
110
+ content_type: lambda(&:content_type),
111
+ content: lambda(&:content),
112
+ path: lambda(&:path),
113
+ }.freeze
114
+
115
+ # Hash-style read compatibility for former hash entries.
116
+ #
117
+ # @param key [Symbol, String] one of :r_id, :target, :rel_type,
118
+ # :content_type, :content, :path
119
+ # @return [Object, nil]
120
+ def [](key)
121
+ HASH_LOOKUP[key.to_sym]&.call(self)
122
+ end
123
+ end
124
+ end
125
+ end