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
@@ -6,43 +6,21 @@ module Uniword
6
6
  # Cross-part referential integrity enforcement.
7
7
  #
8
8
  # Verifies and repairs references between document.xml and other
9
- # package parts. Each method follows the principle of least surprise:
10
- # - For removable inline refs (notes): strip dangling silently
11
- # - For structural refs (styles, numbering): warn and strip
9
+ # package parts. Both the allocator (builder) path and the legacy
10
+ # (template-loaded) path repair by stripping: identical input yields
11
+ # identical referential outcome regardless of path. Unrepairable
12
+ # leftovers are rejected by the write-time PackageIntegrityChecker.
13
+ # - For removable inline refs (notes, hyperlinks, drawings): strip
14
+ # - For structural refs (styles, numbering): strip
12
15
  # - For relationship refs (rId): reconcile against actual parts
16
+ # - For relationship targets: strip rels whose target part the
17
+ # package does not carry (the part is never re-emitted, so the
18
+ # rel would dangle in the saved package)
13
19
  # - For uniqueness constraints: deduplicate with deterministic resolution
14
20
  module ReferentialIntegrity
15
21
  def reconcile_referential_integrity
16
22
  return unless package.document&.body
17
23
 
18
- if allocator
19
- validate_builder_references
20
- else
21
- repair_all_references
22
- end
23
-
24
- reconcile_style_references
25
- reconcile_style_inheritance
26
- reconcile_numbering_body_references
27
- end
28
-
29
- private
30
-
31
- # Allocator present: builders produce correct output.
32
- # Validate builder-constrained references — warn on issues
33
- # but don't silently strip (indicates a builder bug).
34
- def validate_builder_references
35
- validate_note_references(:footnote)
36
- validate_note_references(:endnote)
37
- reconcile_image_references
38
- validate_hyperlink_references
39
- ensure_para_id_uniqueness
40
- ensure_rid_uniqueness
41
- reconcile_sect_pr_references
42
- end
43
-
44
- # No allocator: legacy repair path for template-loaded content.
45
- def repair_all_references
46
24
  reconcile_note_body_references(:footnote)
47
25
  reconcile_note_body_references(:endnote)
48
26
  reconcile_sect_pr_references
@@ -50,72 +28,14 @@ module Uniword
50
28
  reconcile_hyperlink_references
51
29
  ensure_para_id_uniqueness
52
30
  ensure_rid_uniqueness
53
- end
54
-
55
- # Validate that note references in the body match existing notes.
56
- # Logs warnings but does NOT strip — builder should produce correct refs.
57
- def validate_note_references(type)
58
- notes = notes_collection_for(type)
59
- return unless notes
60
-
61
- entries = note_entries_for(notes, type)
62
- defined_ids = entries
63
- .reject { |e| VALID_NOTE_TYPES.include?(e.type) }
64
- .filter_map(&:id).to_set
65
-
66
- dangling = 0
67
- walk_body_paragraphs(package.document.body) do |para|
68
- para.runs.each do |run|
69
- ref = note_reference_from_run(run, type)
70
- next unless ref&.id
71
- next if defined_ids.include?(ref.id)
72
-
73
- dangling += 1
74
- Uniword.logger&.warn do
75
- "Dangling #{type}note reference id=#{ref.id} in body — " \
76
- "builder produced invalid reference"
77
- end
78
- end
79
- end
31
+ reconcile_relationship_targets
80
32
 
81
- return unless dangling.positive?
82
-
83
- record_fix(FixCodes::DANGLING_NOTE_REFERENCE_WARNING,
84
- "WARNING: Found #{dangling} dangling #{type}note reference(s) " \
85
- "in body (allocator path — builder bug)")
33
+ reconcile_style_references
34
+ reconcile_style_inheritance
35
+ reconcile_numbering_body_references
86
36
  end
87
37
 
88
- # Validate that hyperlink references match existing rels.
89
- # Logs warnings but does NOT strip — builder should register hyperlinks.
90
- def validate_hyperlink_references
91
- rels = package.document_rels
92
- return unless rels
93
-
94
- valid_rids = rels.relationships.to_set(&:id)
95
- return if valid_rids.empty?
96
-
97
- dangling = 0
98
- walk_body_paragraphs(package.document.body) do |para|
99
- (para.hyperlinks || []).each do |hl|
100
- next if hl.anchor
101
- next unless hl.id
102
- next unless hl.id.match?(/\ArId\d+\z/i)
103
- next if valid_rids.include?(hl.id)
104
-
105
- dangling += 1
106
- Uniword.logger&.warn do
107
- "Dangling hyperlink r:id=#{hl.id} in body — " \
108
- "builder failed to register hyperlink with allocator"
109
- end
110
- end
111
- end
112
-
113
- return unless dangling.positive?
114
-
115
- record_fix(FixCodes::DANGLING_HYPERLINK_WARNING,
116
- "WARNING: Found #{dangling} dangling hyperlink reference(s) " \
117
- "in body (allocator path — builder bug)")
118
- end
38
+ private
119
39
 
120
40
  # -- Note references (body → footnotes.xml / endnotes.xml) --
121
41
 
@@ -144,17 +64,21 @@ module Uniword
144
64
  return unless removed.positive?
145
65
 
146
66
  record_fix(FixCodes::DANGLING_NOTE_REFERENCE_REMOVED,
147
- "Removed #{removed} dangling #{type}note reference(s) in body")
67
+ "Removed #{removed} dangling #{type}note reference(s) in body",
68
+ part: "word/document.xml")
148
69
  end
149
70
 
150
71
  # -- Section property references (sectPr → document.xml.rels) --
151
72
 
73
+ # Strip header/footer references whose rId does not resolve.
74
+ # Builder-registered headers/footers are wired into document_rels
75
+ # during Group 1 (wire_header_footer_parts), so any reference
76
+ # still dangling here has no backing part in the unified store.
152
77
  def reconcile_sect_pr_references
153
78
  sect_pr = package.document&.body&.section_properties
154
79
  return unless sect_pr
155
80
 
156
81
  valid_rids = collect_valid_header_footer_rids
157
- return if valid_rids.empty?
158
82
 
159
83
  removed = 0
160
84
  [sect_pr.header_references, sect_pr.footer_references].each do |refs|
@@ -167,14 +91,15 @@ module Uniword
167
91
  return unless removed.positive?
168
92
 
169
93
  record_fix(FixCodes::DANGLING_HEADER_FOOTER_REMOVED,
170
- "Removed #{removed} dangling header/footer reference(s) from sectPr")
94
+ "Removed #{removed} dangling header/footer reference(s) from sectPr",
95
+ part: "word/document.xml")
171
96
  end
172
97
 
173
98
  def collect_valid_header_footer_rids
174
99
  rids = Set.new
175
100
 
176
101
  (package.document&.header_footer_parts || []).each do |part|
177
- rids << part[:r_id] if part[:r_id]
102
+ rids << part.r_id if part.r_id
178
103
  end
179
104
 
180
105
  collect_rels_by_type(rids,
@@ -212,7 +137,8 @@ module Uniword
212
137
  return unless removed.positive?
213
138
 
214
139
  record_fix(FixCodes::DANGLING_STYLE_REFERENCE_REMOVED,
215
- "Removed #{removed} dangling style reference(s) from body")
140
+ "Removed #{removed} dangling style reference(s) from body",
141
+ part: "word/document.xml")
216
142
  end
217
143
 
218
144
  # -- Style inheritance (styles.xml self-references) --
@@ -241,7 +167,8 @@ module Uniword
241
167
  return unless stripped.positive?
242
168
 
243
169
  record_fix(FixCodes::DANGLING_BASED_ON_REMOVED,
244
- "Removed #{stripped} dangling basedOn/link reference(s) in styles")
170
+ "Removed #{stripped} dangling basedOn/link reference(s) in styles",
171
+ part: "word/styles.xml")
245
172
  end
246
173
 
247
174
  # -- Numbering references (body → numbering.xml) --
@@ -271,11 +198,16 @@ module Uniword
271
198
  return unless removed.positive?
272
199
 
273
200
  record_fix(FixCodes::DANGLING_NUMBERING_REMOVED,
274
- "Removed #{removed} dangling numbering reference(s) from body")
201
+ "Removed #{removed} dangling numbering reference(s) from body",
202
+ part: "word/document.xml")
275
203
  end
276
204
 
277
205
  # -- Image references (blip/@r:embed → document.xml.rels) --
278
206
 
207
+ # Remove drawings whose r:embed has no backing image: either no
208
+ # matching relationship, or a relationship whose target part the
209
+ # package does not carry (the rel is R32-stripped below, so both
210
+ # repairs complete in a single pass regardless of order).
279
211
  def reconcile_image_references
280
212
  rels = package.document_rels
281
213
  return unless rels
@@ -283,28 +215,57 @@ module Uniword
283
215
  valid_rids = rels.relationships.to_set(&:id)
284
216
  return if valid_rids.empty?
285
217
 
286
- dangling = 0
218
+ carried = carried_part_paths
219
+ targets_by_id = rels.relationships.to_h { |r| [r.id, r.target] }
220
+ removed = 0
287
221
 
288
222
  walk_body_paragraphs(package.document.body) do |para|
289
223
  (para.runs || []).each do |run|
290
- (run.drawings || []).each do |drawing|
291
- drawing_embed_rids(drawing).each do |rid|
292
- next if valid_rids.include?(rid)
224
+ next unless run.drawings
293
225
 
294
- dangling += 1
295
- end
226
+ kept = run.drawings.reject do |drawing|
227
+ dangling_drawing?(drawing, valid_rids, targets_by_id,
228
+ carried)
296
229
  end
230
+ removed += run.drawings.size - kept.size
231
+ run.drawings = kept
297
232
  end
298
233
  end
299
234
 
300
- return unless dangling.positive?
235
+ return unless removed.positive?
301
236
 
302
237
  record_fix(FixCodes::DANGLING_DRAWING_REMOVED,
303
- "Found #{dangling} drawing(s) with dangling image reference(s)")
238
+ "Removed #{removed} drawing(s) with dangling image reference(s)",
239
+ part: "word/document.xml")
240
+ end
241
+
242
+ # A drawing is dangling when it carries embed references and
243
+ # none of them resolves to a relationship with a carried target.
244
+ def dangling_drawing?(drawing, valid_rids, targets_by_id, carried)
245
+ rids = drawing_embed_rids(drawing)
246
+ return false if rids.empty?
247
+
248
+ rids.none? do |rid|
249
+ valid_rids.include?(rid) &&
250
+ carried_relationship_target?(targets_by_id[rid], carried)
251
+ end
252
+ end
253
+
254
+ # A rel target backs the rId only when the package emits it
255
+ # (External and fragment targets do not need a part).
256
+ def carried_relationship_target?(target, carried)
257
+ target = target.to_s
258
+ return true if target.empty? || target.start_with?("#")
259
+
260
+ carried.include?(resolve_relationship_target("word", target))
304
261
  end
305
262
 
306
263
  # -- Hyperlink references (hyperlink/@r:id → document.xml.rels) --
307
264
 
265
+ # Repair hyperlink references: dangling rIds are stripped (like
266
+ # other referential repairs); literal URLs placed in r:id by
267
+ # Builder.hyperlink's target= are promoted to proper External
268
+ # relationships so the package stays valid and the link survives.
308
269
  def reconcile_hyperlink_references
309
270
  rels = package.document_rels
310
271
  return unless rels
@@ -313,23 +274,58 @@ module Uniword
313
274
  return if valid_rids.empty?
314
275
 
315
276
  removed = 0
277
+ promoted = 0
316
278
 
317
279
  walk_body_paragraphs(package.document.body) do |para|
318
280
  (para.hyperlinks || []).reject! do |hl|
319
281
  next false if hl.anchor
320
282
  next false unless hl.id
321
- next false unless hl.id.match?(/\ArId\d+\z/i)
322
283
  next false if valid_rids.include?(hl.id)
323
284
 
324
- removed += 1
325
- true
285
+ if hl.id.match?(/\ArId\d+\z/i)
286
+ removed += 1
287
+ true
288
+ else
289
+ promote_literal_hyperlink(rels, hl, valid_rids)
290
+ promoted += 1
291
+ false
292
+ end
326
293
  end
327
294
  end
328
295
 
296
+ if promoted.positive?
297
+ record_fix(FixCodes::HYPERLINK_RELATIONSHIP_CREATED,
298
+ "Promoted #{promoted} literal hyperlink target(s) " \
299
+ "to external relationship(s)",
300
+ part: "word/_rels/document.xml.rels")
301
+ end
302
+
329
303
  return unless removed.positive?
330
304
 
331
305
  record_fix(FixCodes::DANGLING_HYPERLINK_REMOVED,
332
- "Removed #{removed} dangling hyperlink reference(s) from body")
306
+ "Removed #{removed} dangling hyperlink reference(s) from body",
307
+ part: "word/document.xml")
308
+ end
309
+
310
+ # Promote a hyperlink whose r:id holds a literal URL to a proper
311
+ # External relationship, repointing the hyperlink at the new rId.
312
+ # The rId comes from the allocator (single authority); repeated
313
+ # literals for the same URL share one relationship.
314
+ def promote_literal_hyperlink(rels, hyperlink, valid_rids)
315
+ url = hyperlink.id.to_s
316
+ rel_type = Ooxml::PartRegistry.find_by_key(:hyperlink).rel_type
317
+ new_rid = allocator.alloc_rid(target: url, type: rel_type,
318
+ target_mode: "External")
319
+ unless valid_rids.include?(new_rid)
320
+ rels.relationships << Ooxml::Relationships::Relationship.new(
321
+ id: new_rid,
322
+ type: rel_type,
323
+ target: url,
324
+ target_mode: "External",
325
+ )
326
+ valid_rids << new_rid
327
+ end
328
+ hyperlink.id = new_rid
333
329
  end
334
330
 
335
331
  # -- Uniqueness constraints --
@@ -353,9 +349,15 @@ module Uniword
353
349
  return unless collisions.positive?
354
350
 
355
351
  record_fix(FixCodes::PARAGRAPH_BACKFILL,
356
- "Resolved #{collisions} paraId collision(s)")
352
+ "Resolved #{collisions} paraId collision(s)",
353
+ part: "word/document.xml")
357
354
  end
358
355
 
356
+ # rId uniqueness safety net. With the allocator as single
357
+ # authority, duplicate rIds are impossible by construction; this
358
+ # pass only fires on pathological source input (duplicate ids
359
+ # in the loaded rels). Duplicates are renamed — never
360
+ # renumbered wholesale — with fresh ids from the allocator.
359
361
  def ensure_rid_uniqueness
360
362
  rels = package.document_rels
361
363
  return unless rels
@@ -375,14 +377,192 @@ module Uniword
375
377
 
376
378
  return if duplicates.empty?
377
379
 
380
+ rename_duplicate_rids(duplicates)
381
+
382
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
383
+ "Resolved #{duplicates.size} rId collision(s)",
384
+ part: "word/_rels/document.xml.rels")
385
+ end
386
+
387
+ # Rename duplicate rels with fresh allocator ids and register
388
+ # the new bindings so later lookups stay consistent.
389
+ def rename_duplicate_rids(duplicates)
378
390
  duplicates.each do |rel|
379
391
  old_id = rel.id
380
- rel.id = derive_unique_rid(rels, old_id)
392
+ rel.id = allocator.register_rid(
393
+ allocator.next_free_rid,
394
+ target: rel.target, type: rel.type,
395
+ target_mode: rel.target_mode
396
+ )
381
397
  record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
382
- "Deduplicated rId #{old_id} → #{rel.id}")
398
+ "Deduplicated rId #{old_id} → #{rel.id}",
399
+ part: "word/_rels/document.xml.rels")
400
+ end
401
+ end
402
+
403
+ # -- Relationship targets (rels → emitted parts) --
404
+
405
+ # Strip relationships whose target part the package does not
406
+ # carry. Group 3 preserves non-standard source rels verbatim,
407
+ # including rels to parts uniword does not model (e.g.
408
+ # docProps/meta.xml); those parts are never re-emitted, so the
409
+ # rel would dangle (OPC-006) in the saved package.
410
+ def reconcile_relationship_targets
411
+ carried = carried_part_paths
412
+
413
+ rels_collections.each do |rels, base_dir, part|
414
+ strip_dangling_relationship_targets(rels, base_dir, carried,
415
+ part)
416
+ end
417
+ end
418
+
419
+ # [rels, base directory, .rels part path] for every relationships
420
+ # part the package serializes.
421
+ def rels_collections
422
+ [
423
+ [package.package_rels, "", "_rels/.rels"],
424
+ [package.document_rels, "word", "word/_rels/document.xml.rels"],
425
+ [package.settings_rels, "word", "word/_rels/settings.xml.rels"],
426
+ [package.theme_rels, "word/theme",
427
+ "word/theme/_rels/theme1.xml.rels"],
428
+ [package.footnotes_rels, "word", "word/_rels/footnotes.xml.rels"],
429
+ [package.endnotes_rels, "word", "word/_rels/endnotes.xml.rels"],
430
+ ]
431
+ end
432
+
433
+ def strip_dangling_relationship_targets(rels, base_dir, carried,
434
+ part)
435
+ return unless rels&.relationships
436
+
437
+ dangling = rels.relationships.select do |rel|
438
+ dangling_relationship_target?(rel, base_dir, carried)
439
+ end
440
+ return if dangling.empty?
441
+
442
+ dangling.each { |rel| rels.relationships.delete(rel) }
443
+ record_target_removal(dangling, part)
444
+ end
445
+
446
+ def record_target_removal(dangling, part)
447
+ targets = dangling.map { |rel| rel.target.to_s }.join(", ")
448
+ record_fix(FixCodes::DANGLING_RELATIONSHIP_TARGET_REMOVED,
449
+ "Removed #{dangling.size} relationship(s) to parts " \
450
+ "not carried by the package: #{targets}",
451
+ part: part)
452
+ end
453
+
454
+ # A relationship dangles when it is internal and its resolved
455
+ # target is not among the parts the save path emits.
456
+ def dangling_relationship_target?(rel, base_dir, carried)
457
+ return false if rel.target_mode.to_s == "External"
458
+
459
+ target = rel.target.to_s
460
+ return false if target.empty? || target.start_with?("#")
461
+
462
+ !carried.include?(resolve_relationship_target(base_dir, target))
463
+ end
464
+
465
+ # Resolve a relationship target to a normalized package path,
466
+ # handling package-absolute (leading slash) targets and "..".
467
+ def resolve_relationship_target(base_dir, target)
468
+ return target[1..] if target.start_with?("/")
469
+
470
+ normalize_package_path(File.join(base_dir, target))
471
+ end
472
+
473
+ # Lexically normalize a package-relative path (resolve "." and
474
+ # ".." segments). Deliberately avoids File.expand_path, which
475
+ # prepends a drive letter on Windows.
476
+ def normalize_package_path(path)
477
+ segments = []
478
+ path.split("/").each do |segment|
479
+ next if segment.empty? || segment == "."
480
+
481
+ segment == ".." ? segments.pop : segments << segment
482
+ end
483
+ segments.join("/")
484
+ end
485
+
486
+ # Package paths the save path emits, derived from the model —
487
+ # mirrors serialize_package_parts / inject_* emission.
488
+ def carried_part_paths
489
+ paths = carried_word_parts + carried_docprops_parts
490
+ paths.concat(custom_xml_item_paths)
491
+ paths.concat(document_part_paths)
492
+ paths.to_set
493
+ end
494
+
495
+ # [model, package path] pairs for single-instance word/ parts.
496
+ def carried_word_parts
497
+ pairs = core_word_pairs + note_word_pairs
498
+ pairs.filter_map { |model, path| path if model }
499
+ end
500
+
501
+ def core_word_pairs
502
+ [
503
+ [package.document, "word/document.xml"],
504
+ [package.styles, "word/styles.xml"],
505
+ [package.numbering, "word/numbering.xml"],
506
+ [package.settings, "word/settings.xml"],
507
+ [package.font_table, "word/fontTable.xml"],
508
+ [package.web_settings, "word/webSettings.xml"],
509
+ ]
510
+ end
511
+
512
+ def note_word_pairs
513
+ [
514
+ [package.theme, "word/theme/theme1.xml"],
515
+ [package.footnotes, "word/footnotes.xml"],
516
+ [package.endnotes, "word/endnotes.xml"],
517
+ [package.comments, "word/comments.xml"],
518
+ [package.document&.bibliography_sources, "word/sources.xml"],
519
+ ]
520
+ end
521
+
522
+ # [model, package path] pairs for single-instance docProps/ parts.
523
+ def carried_docprops_parts
524
+ pairs = [
525
+ [package.core_properties, "docProps/core.xml"],
526
+ [package.app_properties, "docProps/app.xml"],
527
+ [package.custom_properties, "docProps/custom.xml"],
528
+ ]
529
+ pairs.filter_map { |model, path| path if model }
530
+ end
531
+
532
+ def custom_xml_item_paths
533
+ (package.custom_xml_items || []).flat_map do |item|
534
+ paths = ["customXml/item#{item[:index]}.xml"]
535
+ if item[:props_xml]
536
+ paths << "customXml/itemProps#{item[:index]}.xml"
537
+ end
538
+ paths
383
539
  end
540
+ end
384
541
 
385
- record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Resolved #{duplicates.size} rId collision(s)")
542
+ # Paths emitted from the document model: media, charts,
543
+ # embeddings, headers and footers (unified part store).
544
+ def document_part_paths
545
+ doc = package.document
546
+ return [] unless doc
547
+
548
+ media_chart_paths(doc) + header_footer_paths(doc)
549
+ end
550
+
551
+ def media_chart_paths(doc)
552
+ paths = [doc.image_parts, doc.chart_parts].compact.flat_map do |parts|
553
+ parts.values.map { |data| "word/#{data[:target]}" }
554
+ end
555
+ paths.concat(embedding_paths)
556
+ end
557
+
558
+ def embedding_paths
559
+ (package.embeddings || {}).keys.map { |target| "word/#{target}" }
560
+ end
561
+
562
+ def header_footer_paths(doc)
563
+ doc.header_footer_parts.filter_map do |part|
564
+ part.target && "word/#{part.target}"
565
+ end
386
566
  end
387
567
 
388
568
  # -- Traversal helpers --
@@ -437,12 +617,6 @@ module Uniword
437
617
  rids << rel.id if type_fragments.any? { |frag| t.include?(frag) }
438
618
  end
439
619
  end
440
-
441
- def derive_unique_rid(relationships, _current_rel)
442
- Ooxml::Relationships::PackageRelationships.next_available_rid(
443
- relationships,
444
- )
445
- end
446
620
  end
447
621
  end
448
622
  end