uniword 1.0.11 → 1.2.4

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 (200) hide show
  1. checksums.yaml +4 -4
  2. data/config/font_metadata.yml +56 -0
  3. data/lib/uniword/accessibility/rules/descriptive_headings_rule.rb +1 -1
  4. data/lib/uniword/assembly/toc.rb +4 -14
  5. data/lib/uniword/assembly/variable_substitutor.rb +3 -4
  6. data/lib/uniword/batch/stages/normalize_styles_stage.rb +4 -2
  7. data/lib/uniword/builder/base_builder.rb +25 -0
  8. data/lib/uniword/builder/chart_builder.rb +33 -14
  9. data/lib/uniword/builder/deterministic_id.rb +17 -0
  10. data/lib/uniword/builder/document_builder.rb +129 -34
  11. data/lib/uniword/builder/footnote_builder.rb +22 -23
  12. data/lib/uniword/builder/header_footer_builder.rb +10 -10
  13. data/lib/uniword/builder/image_builder.rb +24 -16
  14. data/lib/uniword/builder/list_builder.rb +2 -1
  15. data/lib/uniword/builder/paragraph_builder.rb +95 -20
  16. data/lib/uniword/builder/run_utils.rb +97 -0
  17. data/lib/uniword/builder/section_builder.rb +40 -67
  18. data/lib/uniword/builder/table_builder.rb +32 -13
  19. data/lib/uniword/builder/table_cell_builder.rb +19 -0
  20. data/lib/uniword/builder.rb +27 -19
  21. data/lib/uniword/cli/diff_cli.rb +0 -1
  22. data/lib/uniword/cli/generate_cli.rb +0 -1
  23. data/lib/uniword/cli/headers_cli.rb +0 -1
  24. data/lib/uniword/cli/images_cli.rb +0 -1
  25. data/lib/uniword/cli/main.rb +6 -7
  26. data/lib/uniword/cli/protect_cli.rb +0 -1
  27. data/lib/uniword/cli/resources_cli.rb +0 -1
  28. data/lib/uniword/cli/review_cli.rb +0 -1
  29. data/lib/uniword/cli/spellcheck_cli.rb +0 -1
  30. data/lib/uniword/cli/styleset_cli.rb +0 -1
  31. data/lib/uniword/cli/template_cli.rb +0 -1
  32. data/lib/uniword/cli/theme_cli.rb +0 -1
  33. data/lib/uniword/cli/toc_cli.rb +0 -1
  34. data/lib/uniword/cli/watermark_cli.rb +0 -1
  35. data/lib/uniword/cli.rb +6 -17
  36. data/lib/uniword/diff/document_differ.rb +23 -5
  37. data/lib/uniword/diff/package_differ.rb +0 -1
  38. data/lib/uniword/diff.rb +4 -0
  39. data/lib/uniword/document_factory.rb +16 -25
  40. data/lib/uniword/docx/document_statistics.rb +42 -54
  41. data/lib/uniword/docx/id_allocator.rb +143 -0
  42. data/lib/uniword/docx/package.rb +42 -6
  43. data/lib/uniword/docx/package_defaults.rb +15 -25
  44. data/lib/uniword/docx/package_serialization.rb +131 -127
  45. data/lib/uniword/docx/reconciler/body.rb +197 -0
  46. data/lib/uniword/docx/reconciler/fix_codes.rb +74 -0
  47. data/lib/uniword/docx/reconciler/helpers.rb +337 -0
  48. data/lib/uniword/docx/reconciler/notes.rb +301 -0
  49. data/lib/uniword/docx/reconciler/package_structure.rb +322 -0
  50. data/lib/uniword/docx/reconciler/parts.rb +484 -0
  51. data/lib/uniword/docx/reconciler/referential_integrity.rb +449 -0
  52. data/lib/uniword/docx/reconciler/tables.rb +162 -0
  53. data/lib/uniword/docx/reconciler/theme.rb +141 -0
  54. data/lib/uniword/docx/reconciler.rb +44 -1097
  55. data/lib/uniword/docx.rb +3 -0
  56. data/lib/uniword/drawingml/color_scheme.rb +38 -5
  57. data/lib/uniword/hyperlink.rb +27 -26
  58. data/lib/uniword/infrastructure/zip_packager.rb +41 -38
  59. data/lib/uniword/lazy_loader.rb +36 -62
  60. data/lib/uniword/math.rb +3 -86
  61. data/lib/uniword/mhtml/document.rb +1 -3
  62. data/lib/uniword/model_attribute_access.rb +27 -0
  63. data/lib/uniword/ooxml/namespaces.rb +62 -2
  64. data/lib/uniword/ooxml/relationships/package_relationships.rb +7 -0
  65. data/lib/uniword/presentationml/slide_id.rb +0 -1
  66. data/lib/uniword/presentationml/slide_master_id.rb +0 -1
  67. data/lib/uniword/properties/bold.rb +0 -2
  68. data/lib/uniword/properties/boolean_formatting.rb +0 -2
  69. data/lib/uniword/properties/borders.rb +1 -1
  70. data/lib/uniword/properties/italic.rb +0 -2
  71. data/lib/uniword/properties/numbering_properties.rb +1 -1
  72. data/lib/uniword/properties/style_reference.rb +13 -0
  73. data/lib/uniword/quality/rules/heading_hierarchy_rule.rb +1 -1
  74. data/lib/uniword/quality/rules/style_consistency_rule.rb +1 -1
  75. data/lib/uniword/review/review_manager.rb +0 -2
  76. data/lib/uniword/template/helpers/loop_helper.rb +2 -2
  77. data/lib/uniword/template/variable_resolver.rb +14 -1
  78. data/lib/uniword/theme/theme_xml_parser.rb +1 -1
  79. data/lib/uniword/toc/toc_generator.rb +3 -14
  80. data/lib/uniword/transformation/mhtml_element_renderer.rb +94 -37
  81. data/lib/uniword/transformation/mhtml_metadata_builder.rb +1 -1
  82. data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
  83. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  84. data/lib/uniword/version.rb +1 -1
  85. data/lib/uniword/visitor/text_extractor.rb +2 -1
  86. data/lib/uniword/watermark/manager.rb +2 -2
  87. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  88. data/lib/uniword/wordprocessingml/body.rb +51 -34
  89. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  90. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  91. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  92. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  93. data/lib/uniword/wordprocessingml/document_root.rb +4 -3
  94. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  95. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  96. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  97. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  98. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  99. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  100. data/lib/uniword/wordprocessingml/header.rb +4 -0
  101. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  102. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  103. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  104. data/lib/uniword/wordprocessingml/level.rb +2 -0
  105. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  106. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  107. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  108. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  109. data/lib/uniword/wordprocessingml/num.rb +4 -0
  110. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  111. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  112. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  113. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  114. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  115. data/lib/uniword/wordprocessingml/run.rb +32 -36
  116. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  117. data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
  118. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  119. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  120. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  121. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  122. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  123. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  124. data/lib/uniword/wordprocessingml/text.rb +8 -2
  125. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  126. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  127. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  128. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  129. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  130. data/lib/uniword/wordprocessingml.rb +23 -0
  131. data/lib/uniword.rb +27 -0
  132. metadata +46 -70
  133. data/lib/uniword/math/accent.rb +0 -25
  134. data/lib/uniword/math/accent_properties.rb +0 -25
  135. data/lib/uniword/math/argument_properties.rb +0 -22
  136. data/lib/uniword/math/bar.rb +0 -25
  137. data/lib/uniword/math/bar_properties.rb +0 -25
  138. data/lib/uniword/math/begin_char.rb +0 -22
  139. data/lib/uniword/math/border_box.rb +0 -25
  140. data/lib/uniword/math/border_box_properties.rb +0 -39
  141. data/lib/uniword/math/box.rb +0 -25
  142. data/lib/uniword/math/box_properties.rb +0 -33
  143. data/lib/uniword/math/char.rb +0 -22
  144. data/lib/uniword/math/control_properties.rb +0 -27
  145. data/lib/uniword/math/degree.rb +0 -26
  146. data/lib/uniword/math/delimiter.rb +0 -25
  147. data/lib/uniword/math/delimiter_properties.rb +0 -33
  148. data/lib/uniword/math/denominator.rb +0 -26
  149. data/lib/uniword/math/element.rb +0 -61
  150. data/lib/uniword/math/end_char.rb +0 -22
  151. data/lib/uniword/math/equation_array.rb +0 -25
  152. data/lib/uniword/math/equation_array_properties.rb +0 -33
  153. data/lib/uniword/math/fraction.rb +0 -27
  154. data/lib/uniword/math/fraction_properties.rb +0 -25
  155. data/lib/uniword/math/function.rb +0 -27
  156. data/lib/uniword/math/function_name.rb +0 -26
  157. data/lib/uniword/math/function_properties.rb +0 -23
  158. data/lib/uniword/math/group_char.rb +0 -25
  159. data/lib/uniword/math/group_char_properties.rb +0 -29
  160. data/lib/uniword/math/lim.rb +0 -26
  161. data/lib/uniword/math/lower_limit.rb +0 -27
  162. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  163. data/lib/uniword/math/math_break.rb +0 -22
  164. data/lib/uniword/math/math_font.rb +0 -22
  165. data/lib/uniword/math/math_properties.rb +0 -53
  166. data/lib/uniword/math/math_run.rb +0 -42
  167. data/lib/uniword/math/math_run_properties.rb +0 -37
  168. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  169. data/lib/uniword/math/math_simple_val.rb +0 -24
  170. data/lib/uniword/math/math_style.rb +0 -22
  171. data/lib/uniword/math/math_text.rb +0 -23
  172. data/lib/uniword/math/matrix.rb +0 -25
  173. data/lib/uniword/math/matrix_column.rb +0 -23
  174. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  175. data/lib/uniword/math/matrix_columns.rb +0 -23
  176. data/lib/uniword/math/matrix_properties.rb +0 -37
  177. data/lib/uniword/math/matrix_row.rb +0 -23
  178. data/lib/uniword/math/nary.rb +0 -29
  179. data/lib/uniword/math/nary_properties.rb +0 -33
  180. data/lib/uniword/math/numerator.rb +0 -26
  181. data/lib/uniword/math/o_math.rb +0 -65
  182. data/lib/uniword/math/o_math_para.rb +0 -25
  183. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  184. data/lib/uniword/math/phantom.rb +0 -25
  185. data/lib/uniword/math/phantom_properties.rb +0 -33
  186. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  187. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  188. data/lib/uniword/math/radical.rb +0 -27
  189. data/lib/uniword/math/radical_properties.rb +0 -25
  190. data/lib/uniword/math/separator_char.rb +0 -22
  191. data/lib/uniword/math/sub.rb +0 -26
  192. data/lib/uniword/math/sub_superscript.rb +0 -29
  193. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  194. data/lib/uniword/math/subscript.rb +0 -27
  195. data/lib/uniword/math/subscript_properties.rb +0 -23
  196. data/lib/uniword/math/sup.rb +0 -26
  197. data/lib/uniword/math/superscript.rb +0 -27
  198. data/lib/uniword/math/superscript_properties.rb +0 -23
  199. data/lib/uniword/math/upper_limit.rb +0 -27
  200. data/lib/uniword/math/upper_limit_properties.rb +0 -23
@@ -0,0 +1,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Cross-part referential integrity enforcement.
7
+ #
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
12
+ # - For relationship refs (rId): reconcile against actual parts
13
+ # - For uniqueness constraints: deduplicate with deterministic resolution
14
+ module ReferentialIntegrity
15
+ def reconcile_referential_integrity
16
+ return unless package.document&.body
17
+
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
+ reconcile_note_body_references(:footnote)
47
+ reconcile_note_body_references(:endnote)
48
+ reconcile_sect_pr_references
49
+ reconcile_image_references
50
+ reconcile_hyperlink_references
51
+ ensure_para_id_uniqueness
52
+ 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
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)")
86
+ end
87
+
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
119
+
120
+ # -- Note references (body → footnotes.xml / endnotes.xml) --
121
+
122
+ def reconcile_note_body_references(type)
123
+ notes = notes_collection_for(type)
124
+ return unless notes
125
+
126
+ entries = note_entries_for(notes, type)
127
+ defined_ids = entries
128
+ .reject { |e| VALID_NOTE_TYPES.include?(e.type) }
129
+ .filter_map(&:id).to_set
130
+
131
+ removed = 0
132
+
133
+ walk_body_paragraphs(package.document.body) do |para|
134
+ para.runs.reject! do |run|
135
+ ref = note_reference_from_run(run, type)
136
+ next false unless ref&.id
137
+ next false if defined_ids.include?(ref.id)
138
+
139
+ removed += 1
140
+ true
141
+ end
142
+ end
143
+
144
+ return unless removed.positive?
145
+
146
+ record_fix(FixCodes::DANGLING_NOTE_REFERENCE_REMOVED,
147
+ "Removed #{removed} dangling #{type}note reference(s) in body")
148
+ end
149
+
150
+ # -- Section property references (sectPr → document.xml.rels) --
151
+
152
+ def reconcile_sect_pr_references
153
+ sect_pr = package.document&.body&.section_properties
154
+ return unless sect_pr
155
+
156
+ valid_rids = collect_valid_header_footer_rids
157
+ return if valid_rids.empty?
158
+
159
+ removed = 0
160
+ [sect_pr.header_references, sect_pr.footer_references].each do |refs|
161
+ next unless refs
162
+
163
+ removed += refs.count { |r| r.r_id && !valid_rids.include?(r.r_id) }
164
+ refs.reject! { |r| r.r_id && !valid_rids.include?(r.r_id) }
165
+ end
166
+
167
+ return unless removed.positive?
168
+
169
+ record_fix(FixCodes::DANGLING_HEADER_FOOTER_REMOVED,
170
+ "Removed #{removed} dangling header/footer reference(s) from sectPr")
171
+ end
172
+
173
+ def collect_valid_header_footer_rids
174
+ rids = Set.new
175
+
176
+ (package.document&.header_footer_parts || []).each do |part|
177
+ rids << part[:r_id] if part[:r_id]
178
+ end
179
+
180
+ collect_rels_by_type(rids,
181
+ "officeDocument/2006/relationships/header",
182
+ "officeDocument/2006/relationships/footer")
183
+
184
+ rids
185
+ end
186
+
187
+ # -- Style references (body → styles.xml) --
188
+
189
+ def reconcile_style_references
190
+ styles = package.styles
191
+ return unless styles
192
+
193
+ defined_ids = styles.styles.to_set(&:id)
194
+ style_names = styles.styles.to_set { |s| s.name&.val }.compact
195
+ removed = 0
196
+
197
+ walk_body_paragraphs(package.document.body) do |para|
198
+ removed += reconcile_paragraph_style(para, defined_ids, style_names)
199
+ removed += reconcile_run_styles(para, defined_ids, style_names)
200
+ end
201
+
202
+ walk_body_tables(package.document.body) do |tbl|
203
+ tbl_ref = tbl.properties&.style
204
+ next unless tbl_ref&.val
205
+ next if defined_ids.include?(tbl_ref.val)
206
+ next if style_names.include?(tbl_ref.val)
207
+
208
+ tbl.properties.style = nil
209
+ removed += 1
210
+ end
211
+
212
+ return unless removed.positive?
213
+
214
+ record_fix(FixCodes::DANGLING_STYLE_REFERENCE_REMOVED,
215
+ "Removed #{removed} dangling style reference(s) from body")
216
+ end
217
+
218
+ # -- Style inheritance (styles.xml self-references) --
219
+
220
+ def reconcile_style_inheritance
221
+ styles = package.styles
222
+ return unless styles
223
+
224
+ defined_ids = styles.styles.to_set(&:id)
225
+ stripped = 0
226
+
227
+ styles.styles.each do |style|
228
+ based_on = style.basedOn
229
+ if based_on&.val && !defined_ids.include?(based_on.val)
230
+ style.basedOn = nil
231
+ stripped += 1
232
+ end
233
+
234
+ link = style.link
235
+ if link&.val && !defined_ids.include?(link.val)
236
+ style.link = nil
237
+ stripped += 1
238
+ end
239
+ end
240
+
241
+ return unless stripped.positive?
242
+
243
+ record_fix(FixCodes::DANGLING_BASED_ON_REMOVED,
244
+ "Removed #{stripped} dangling basedOn/link reference(s) in styles")
245
+ end
246
+
247
+ # -- Numbering references (body → numbering.xml) --
248
+
249
+ def reconcile_numbering_body_references
250
+ numbering = package.numbering
251
+ return unless numbering
252
+
253
+ defined_num_ids = numbering.instances.to_set(&:num_id)
254
+ return if defined_num_ids.empty?
255
+
256
+ removed = 0
257
+
258
+ walk_body_paragraphs(package.document.body) do |para|
259
+ num_pr = para.properties&.numbering_properties
260
+ next unless num_pr
261
+
262
+ num_id = numbering_num_id_value(num_pr)
263
+ next unless num_id
264
+ next if num_id.zero?
265
+ next if defined_num_ids.include?(num_id)
266
+
267
+ para.properties.numbering_properties = nil
268
+ removed += 1
269
+ end
270
+
271
+ return unless removed.positive?
272
+
273
+ record_fix(FixCodes::DANGLING_NUMBERING_REMOVED,
274
+ "Removed #{removed} dangling numbering reference(s) from body")
275
+ end
276
+
277
+ # -- Image references (blip/@r:embed → document.xml.rels) --
278
+
279
+ def reconcile_image_references
280
+ rels = package.document_rels
281
+ return unless rels
282
+
283
+ valid_rids = rels.relationships.to_set(&:id)
284
+ return if valid_rids.empty?
285
+
286
+ dangling = 0
287
+
288
+ walk_body_paragraphs(package.document.body) do |para|
289
+ (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)
293
+
294
+ dangling += 1
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ return unless dangling.positive?
301
+
302
+ record_fix(FixCodes::DANGLING_DRAWING_REMOVED,
303
+ "Found #{dangling} drawing(s) with dangling image reference(s)")
304
+ end
305
+
306
+ # -- Hyperlink references (hyperlink/@r:id → document.xml.rels) --
307
+
308
+ def reconcile_hyperlink_references
309
+ rels = package.document_rels
310
+ return unless rels
311
+
312
+ valid_rids = rels.relationships.to_set(&:id)
313
+ return if valid_rids.empty?
314
+
315
+ removed = 0
316
+
317
+ walk_body_paragraphs(package.document.body) do |para|
318
+ (para.hyperlinks || []).reject! do |hl|
319
+ next false if hl.anchor
320
+ next false unless hl.id
321
+ next false unless hl.id.match?(/\ArId\d+\z/i)
322
+ next false if valid_rids.include?(hl.id)
323
+
324
+ removed += 1
325
+ true
326
+ end
327
+ end
328
+
329
+ return unless removed.positive?
330
+
331
+ record_fix(FixCodes::DANGLING_HYPERLINK_REMOVED,
332
+ "Removed #{removed} dangling hyperlink reference(s) from body")
333
+ end
334
+
335
+ # -- Uniqueness constraints --
336
+
337
+ def ensure_para_id_uniqueness
338
+ seen = {}
339
+ collisions = 0
340
+
341
+ walk_all_paragraphs do |para|
342
+ pid = para.para_id
343
+ next unless pid
344
+
345
+ if seen.key?(pid)
346
+ para.para_id = generate_hex_id("collision:#{collisions}")
347
+ collisions += 1
348
+ else
349
+ seen[pid] = true
350
+ end
351
+ end
352
+
353
+ return unless collisions.positive?
354
+
355
+ record_fix(FixCodes::PARAGRAPH_BACKFILL,
356
+ "Resolved #{collisions} paraId collision(s)")
357
+ end
358
+
359
+ def ensure_rid_uniqueness
360
+ rels = package.document_rels
361
+ return unless rels
362
+
363
+ seen = {}
364
+ duplicates = []
365
+
366
+ rels.relationships.each do |rel|
367
+ next unless rel.id
368
+
369
+ if seen[rel.id]
370
+ duplicates << rel
371
+ else
372
+ seen[rel.id] = true
373
+ end
374
+ end
375
+
376
+ return if duplicates.empty?
377
+
378
+ duplicates.each do |rel|
379
+ old_id = rel.id
380
+ rel.id = derive_unique_rid(rels, old_id)
381
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
382
+ "Deduplicated rId #{old_id} → #{rel.id}")
383
+ end
384
+
385
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Resolved #{duplicates.size} rId collision(s)")
386
+ end
387
+
388
+ # -- Traversal helpers --
389
+
390
+ def reconcile_paragraph_style(para, defined_ids, style_names)
391
+ style_ref = Array(para.properties&.style).first
392
+ return 0 unless style_ref&.value
393
+ return 0 if defined_ids.include?(style_ref.value)
394
+ return 0 if style_names.include?(style_ref.value)
395
+
396
+ para.properties.style = nil
397
+ 1
398
+ end
399
+
400
+ def reconcile_run_styles(para, defined_ids, style_names)
401
+ removed = 0
402
+ (para.runs || []).each do |run|
403
+ r_style = run.properties&.style
404
+ next unless r_style&.value
405
+ next if defined_ids.include?(r_style.value)
406
+ next if style_names.include?(r_style.value)
407
+
408
+ run.properties.style = nil
409
+ removed += 1
410
+ end
411
+ removed
412
+ end
413
+
414
+ # Extract rId from the blip chain: drawing → inline/anchor → graphic →
415
+ # graphicData → picture → blipFill → blip → embed
416
+ def drawing_embed_rids(drawing)
417
+ rids = []
418
+ [drawing.inline, drawing.anchor].compact.each do |container|
419
+ blip = container.graphic&.graphic_data&.picture&.blip_fill&.blip
420
+ rids << blip.embed.to_s if blip&.embed
421
+ end
422
+ rids
423
+ end
424
+
425
+ def numbering_num_id_value(num_pr)
426
+ val = num_pr.num_id
427
+ return nil unless val
428
+
429
+ val.is_a?(Integer) ? val : val.value
430
+ end
431
+
432
+ def collect_rels_by_type(rids, *type_fragments)
433
+ return unless package.document_rels
434
+
435
+ package.document_rels.relationships.each do |rel|
436
+ t = rel.type.to_s
437
+ rids << rel.id if type_fragments.any? { |frag| t.include?(frag) }
438
+ end
439
+ end
440
+
441
+ def derive_unique_rid(relationships, _current_rel)
442
+ Ooxml::Relationships::PackageRelationships.next_available_rid(
443
+ relationships,
444
+ )
445
+ end
446
+ end
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Table structure reconciliation.
7
+ #
8
+ # Ensures tables have required properties, grid, and cell widths.
9
+ # Adds gridAfter for rows that don't cover all grid columns.
10
+ module Tables
11
+ def reconcile_tables
12
+ return unless package.document&.body
13
+
14
+ tables = package.document.body.tables
15
+ return unless tables
16
+
17
+ tables.each_with_index do |tbl, idx|
18
+ if allocator
19
+ reconcile_table_cell_order_only(tbl, idx)
20
+ else
21
+ reconcile_single_table(tbl, idx)
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Allocator path: builders create complete tables.
29
+ # Only fix element order issues and calculate gridAfter.
30
+ def reconcile_table_cell_order_only(tbl, idx)
31
+ tbl.rows&.each do |row|
32
+ row.cells&.each do |cell|
33
+ reconcile_table_cell_order(cell)
34
+ end
35
+ end
36
+ reconcile_grid_after(tbl)
37
+ end
38
+
39
+ def reconcile_single_table(tbl, idx)
40
+ fixed = false
41
+
42
+ unless tbl.properties
43
+ tbl.properties = Wordprocessingml::TableProperties.new
44
+ fixed = true
45
+ end
46
+
47
+ tw = tbl.properties.table_width
48
+ if tw.nil?
49
+ tbl.properties.table_width = Properties::TableWidth.new(w: 0, type: "auto")
50
+ fixed = true
51
+ elsif tw.w.nil? || tw.type.nil?
52
+ tw.w ||= 0
53
+ tw.type ||= "auto"
54
+ fixed = true
55
+ end
56
+
57
+ if tbl.properties.table_look.nil?
58
+ tbl.properties.table_look = Wordprocessingml::TableDefaults.default_table_look
59
+ fixed = true
60
+ elsif tbl.properties.table_look.val.nil?
61
+ Wordprocessingml::TableDefaults.fill_missing_table_look(tbl.properties.table_look)
62
+ fixed = true
63
+ end
64
+
65
+ col_count = tbl.column_count
66
+ if tbl.grid.nil?
67
+ grid_cols = Array.new(col_count) { Wordprocessingml::GridCol.new }
68
+ tbl.grid = Wordprocessingml::TableGrid.new(columns: grid_cols)
69
+ fixed = true
70
+ elsif tbl.grid.columns.size != col_count
71
+ current = tbl.grid.columns.size
72
+ if current < col_count
73
+ (col_count - current).times do
74
+ tbl.grid.columns << Wordprocessingml::GridCol.new
75
+ end
76
+ else
77
+ tbl.grid.columns = tbl.grid.columns.first(col_count)
78
+ end
79
+ fixed = true
80
+ end
81
+
82
+ tbl.grid&.columns&.each_with_index do |col, ci|
83
+ next if col.width
84
+
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
92
+
93
+ tbl.rows&.each do |row|
94
+ row.cells&.each do |cell|
95
+ tc_pr = cell.properties
96
+ if tc_pr.nil?
97
+ cell.properties = Wordprocessingml::TableCellProperties.new(
98
+ cell_width: Uniword::Properties::CellWidth.new(w: 0, type: "auto"),
99
+ )
100
+ insert_element_order(cell, "tcPr", 0)
101
+ record_fix(FixCodes::TABLE_CELL_DEFAULTS, "Added missing tcPr with tcW to table cell")
102
+ elsif tc_pr.cell_width.nil?
103
+ 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")
105
+ end
106
+
107
+ reconcile_table_cell_order(cell)
108
+ end
109
+ end
110
+
111
+ reconcile_grid_after(tbl)
112
+ end
113
+
114
+ def reconcile_table_cell_order(cell)
115
+ order = cell.element_order
116
+ return unless order && !order.empty?
117
+
118
+ tcPr_idx = order.index { |e| e.name == "tcPr" }
119
+ first_p_idx = order.index { |e| e.name == "p" }
120
+
121
+ return if tcPr_idx.nil? || first_p_idx.nil?
122
+ return if tcPr_idx < first_p_idx
123
+
124
+ tcPr_entry = order.delete_at(tcPr_idx)
125
+ order.insert(first_p_idx, tcPr_entry)
126
+
127
+ record_fix(FixCodes::TABLE_CELL_PR_REORDERED, "Moved tcPr before p in table cell")
128
+ end
129
+
130
+ def reconcile_grid_after(tbl)
131
+ grid_col_count = tbl.grid&.columns&.size || 0
132
+ return unless grid_col_count.positive?
133
+
134
+ tbl.rows&.each do |row|
135
+ cells = row.cells
136
+ next unless cells && !cells.empty?
137
+
138
+ covered = cells.sum do |cell|
139
+ span = cell.properties&.grid_span&.value
140
+ span && span > 0 ? span : 1
141
+ end
142
+
143
+ missing = grid_col_count - covered
144
+ next if missing <= 0
145
+
146
+ row_props = row.properties
147
+ if row_props.nil?
148
+ row.properties = Wordprocessingml::TableRowProperties.new(
149
+ grid_after: Wordprocessingml::GridAfter.new(value: missing),
150
+ )
151
+ insert_element_order(row, "trPr", 0)
152
+ elsif row_props.grid_after.nil? || row_props.grid_after.value != missing
153
+ row_props.grid_after ||= Wordprocessingml::GridAfter.new
154
+ row_props.grid_after.value = missing
155
+ end
156
+ record_fix(FixCodes::TABLE_ROW_GRID_AFTER, "Added gridAfter=#{missing} to table row (grid has #{grid_col_count} cols, row covers #{covered})")
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end