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
@@ -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
31
+ reconcile_relationship_targets
60
32
 
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
80
-
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,117 @@ 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")
383
400
  end
401
+ end
384
402
 
385
- record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Resolved #{duplicates.size} rId collision(s)")
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 — answered by the
487
+ # PartRegistry (single authority), derived from model presence
488
+ # and part collections.
489
+ def carried_part_paths
490
+ Ooxml::PartRegistry.emitted_paths(package)
386
491
  end
387
492
 
388
493
  # -- Traversal helpers --
@@ -437,12 +542,6 @@ module Uniword
437
542
  rids << rel.id if type_fragments.any? { |frag| t.include?(frag) }
438
543
  end
439
544
  end
440
-
441
- def derive_unique_rid(relationships, _current_rel)
442
- Ooxml::Relationships::PackageRelationships.next_available_rid(
443
- relationships,
444
- )
445
- end
446
545
  end
447
546
  end
448
547
  end
@@ -15,7 +15,7 @@ module Uniword
15
15
  return unless tables
16
16
 
17
17
  tables.each_with_index do |tbl, idx|
18
- if allocator
18
+ if builder_managed?
19
19
  reconcile_table_cell_order_only(tbl, idx)
20
20
  else
21
21
  reconcile_single_table(tbl, idx)
@@ -25,14 +25,16 @@ module Uniword
25
25
 
26
26
  private
27
27
 
28
- # Allocator path: builders create complete tables.
29
- # Only fix element order issues and calculate gridAfter.
28
+ # Builder-managed path: builders create complete tables.
29
+ # Only fix element order issues, default gridCol widths, and
30
+ # calculate gridAfter.
30
31
  def reconcile_table_cell_order_only(tbl, idx)
31
32
  tbl.rows&.each do |row|
32
33
  row.cells&.each do |cell|
33
34
  reconcile_table_cell_order(cell)
34
35
  end
35
36
  end
37
+ reconcile_grid_col_widths(tbl)
36
38
  reconcile_grid_after(tbl)
37
39
  end
38
40
 
@@ -79,16 +81,11 @@ module Uniword
79
81
  fixed = true
80
82
  end
81
83
 
82
- tbl.grid&.columns&.each_with_index do |col, ci|
83
- next if col.width
84
+ reconcile_grid_col_widths(tbl)
84
85
 
85
- Uniword.logger&.warn do
86
- "Table #{idx}: gridCol[#{ci}] has no w:w — " \
87
- "table may render with incorrect column widths"
88
- end
89
- end
90
-
91
- record_fix(FixCodes::TABLE_STRUCTURE_RECONCILED, "Reconciled table structure for table #{idx}") if fixed
86
+ record_fix(FixCodes::TABLE_STRUCTURE_RECONCILED,
87
+ "Reconciled table structure for table #{idx}",
88
+ part: "word/document.xml") if fixed
92
89
 
93
90
  tbl.rows&.each do |row|
94
91
  row.cells&.each do |cell|
@@ -98,10 +95,14 @@ module Uniword
98
95
  cell_width: Uniword::Properties::CellWidth.new(w: 0, type: "auto"),
99
96
  )
100
97
  insert_element_order(cell, "tcPr", 0)
101
- record_fix(FixCodes::TABLE_CELL_DEFAULTS, "Added missing tcPr with tcW to table cell")
98
+ record_fix(FixCodes::TABLE_CELL_DEFAULTS,
99
+ "Added missing tcPr with tcW to table cell",
100
+ part: "word/document.xml")
102
101
  elsif tc_pr.cell_width.nil?
103
102
  tc_pr.cell_width = Uniword::Properties::CellWidth.new(w: 0, type: "auto")
104
- record_fix(FixCodes::TABLE_CELL_DEFAULTS, "Added missing tcW to table cell")
103
+ record_fix(FixCodes::TABLE_CELL_DEFAULTS,
104
+ "Added missing tcW to table cell",
105
+ part: "word/document.xml")
105
106
  end
106
107
 
107
108
  reconcile_table_cell_order(cell)
@@ -111,6 +112,63 @@ module Uniword
111
112
  reconcile_grid_after(tbl)
112
113
  end
113
114
 
115
+ # Default missing gridCol widths to an equal share of the
116
+ # section's content width (page width minus left/right margins) —
117
+ # the same fallback Word applies, keeping the package free of
118
+ # width-less grids. Explicit widths are kept; width-less columns
119
+ # share the remaining width equally.
120
+ def reconcile_grid_col_widths(tbl)
121
+ columns = tbl.grid&.columns
122
+ return unless columns&.any?
123
+
124
+ missing = columns.count { |col| col.width.nil? }
125
+ return if missing.zero?
126
+
127
+ default_missing_widths(columns, missing)
128
+ end
129
+
130
+ def default_missing_widths(columns, missing)
131
+ width = shared_column_width(columns, missing)
132
+ columns.each { |col| col.width ||= width }
133
+ record_fix(FixCodes::TABLE_GRID_COL_WIDTHS_DEFAULTED,
134
+ "Defaulted #{missing} gridCol width(s) to " \
135
+ "#{width} twips",
136
+ part: "word/document.xml")
137
+ end
138
+
139
+ # Equal content-width share for `missing` width-less columns
140
+ # after explicit widths take their part.
141
+ def shared_column_width(columns, missing)
142
+ content = section_content_width
143
+ explicit = columns.sum { |col| col.width || 0 }
144
+ remainder = [content - explicit, 0].max
145
+ (remainder.positive? ? remainder : content) / missing
146
+ end
147
+
148
+ # Content width (page width minus left/right margins) in twips.
149
+ # Tables do not track section ownership, so the body-level sectPr
150
+ # governs; falls back to US Letter defaults.
151
+ def section_content_width
152
+ left, right = section_side_margins
153
+ [section_page_width - left - right, 1].max
154
+ end
155
+
156
+ def section_page_width
157
+ sect_pr = package.document&.body&.section_properties
158
+ sect_pr&.page_size&.width ||
159
+ Wordprocessingml::PageDefaults.default_page_size.width
160
+ end
161
+
162
+ def section_side_margins
163
+ margins = section_properties_margins
164
+ defaults = Wordprocessingml::PageDefaults.default_page_margins
165
+ [margins&.left || defaults.left, margins&.right || defaults.right]
166
+ end
167
+
168
+ def section_properties_margins
169
+ package.document&.body&.section_properties&.page_margins
170
+ end
171
+
114
172
  def reconcile_table_cell_order(cell)
115
173
  order = cell.element_order
116
174
  return unless order && !order.empty?
@@ -124,7 +182,9 @@ module Uniword
124
182
  tcPr_entry = order.delete_at(tcPr_idx)
125
183
  order.insert(first_p_idx, tcPr_entry)
126
184
 
127
- record_fix(FixCodes::TABLE_CELL_PR_REORDERED, "Moved tcPr before p in table cell")
185
+ record_fix(FixCodes::TABLE_CELL_PR_REORDERED,
186
+ "Moved tcPr before p in table cell",
187
+ part: "word/document.xml")
128
188
  end
129
189
 
130
190
  def reconcile_grid_after(tbl)
@@ -153,7 +213,9 @@ module Uniword
153
213
  row_props.grid_after ||= Wordprocessingml::GridAfter.new
154
214
  row_props.grid_after.value = missing
155
215
  end
156
- record_fix(FixCodes::TABLE_ROW_GRID_AFTER, "Added gridAfter=#{missing} to table row (grid has #{grid_col_count} cols, row covers #{covered})")
216
+ record_fix(FixCodes::TABLE_ROW_GRID_AFTER,
217
+ "Added gridAfter=#{missing} to table row (grid has #{grid_col_count} cols, row covers #{covered})",
218
+ part: "word/document.xml")
157
219
  end
158
220
  end
159
221
  end
@@ -20,7 +20,9 @@ module Uniword
20
20
  word_theme = friendly.to_word_theme
21
21
  word_theme.name = "Office Theme"
22
22
  package.theme = word_theme
23
- record_fix(FixCodes::THEME_CREATED, "Created default theme with complete fmtScheme")
23
+ record_fix(FixCodes::THEME_CREATED,
24
+ "Created default theme with complete fmtScheme",
25
+ part: "word/theme/theme1.xml")
24
26
  rescue ArgumentError
25
27
  nil
26
28
  end
@@ -58,7 +60,8 @@ module Uniword
58
60
  return unless repaired
59
61
 
60
62
  record_fix(FixCodes::THEME_CREATED,
61
- "Repaired theme fmtScheme with minimum required content")
63
+ "Repaired theme fmtScheme with minimum required content",
64
+ part: "word/theme/theme1.xml")
62
65
  end
63
66
 
64
67
  private
@@ -100,7 +103,7 @@ module Uniword
100
103
  fills = Array(lst.solid_fills).dup
101
104
  while fills.size < 2
102
105
  fills << Drawingml::SolidFill.new(
103
- scheme_color: Drawingml::SchemeColor.new(
106
+ scheme_clr: Drawingml::SchemeColor.new(
104
107
  val: "accent#{fills.size + 1}"
105
108
  ),
106
109
  )
@@ -118,7 +121,7 @@ module Uniword
118
121
  lines << Drawingml::LineProperties.new(
119
122
  width: widths[idx] || 9525,
120
123
  solid_fill: Drawingml::SolidFill.new(
121
- scheme_color: Drawingml::SchemeColor.new(val: "accent#{idx + 1}"),
124
+ scheme_clr: Drawingml::SchemeColor.new(val: "accent#{idx + 1}"),
122
125
  ),
123
126
  )
124
127
  end