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,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Footnote and endnote reconciliation.
7
+ #
8
+ # Ensures notes are structurally valid with separators, proper ordering
9
+ # and sequential IDs. Creates missing notes/settings pairs and strips
10
+ # invalid types.
11
+ module Notes
12
+ def reconcile_note_references
13
+ [:footnote, :endnote].each do |type|
14
+ ref_ids = collect_note_ids(type)
15
+ next if ref_ids.empty?
16
+
17
+ current = notes_collection_for(type)
18
+
19
+ if current.nil?
20
+ ensure_notes_part(type)
21
+ current = notes_collection_for(type)
22
+ end
23
+
24
+ defined_set = note_entries_for(current, type).map(&:id).to_set
25
+
26
+ missing = ref_ids.reject { |id| defined_set.include?(id) }
27
+ next if missing.empty?
28
+
29
+ entries = note_entries_for(current, type)
30
+ missing.each do |id|
31
+ entries << entry_class(type).new(
32
+ id: id,
33
+ paragraphs: [Wordprocessingml::Paragraph.new(
34
+ runs: [Wordprocessingml::Run.new(
35
+ text: Wordprocessingml::Text.new(content: " ")
36
+ )]
37
+ )],
38
+ )
39
+ end
40
+ record_fix(FixCodes::NOTE_DEFINITION_CREATED,
41
+ "Created missing #{type}note definitions for ids=#{missing.join(', ')}")
42
+ end
43
+ end
44
+
45
+ def reconcile_footnotes
46
+ reconcile_notes_type(
47
+ notes: package.footnotes,
48
+ notes_setter: ->(v) { package.footnotes = v },
49
+ has_pr: package.settings&.footnote_pr,
50
+ type: :footnote,
51
+ create_message: "Created footnotes.xml to match footnotePr in settings",
52
+ pr_message: "Added footnotePr to settings to match footnotes.xml",
53
+ )
54
+ end
55
+
56
+ def reconcile_endnotes
57
+ reconcile_notes_type(
58
+ notes: package.endnotes,
59
+ notes_setter: ->(v) { package.endnotes = v },
60
+ has_pr: package.settings&.endnote_pr,
61
+ type: :endnote,
62
+ create_message: "Created endnotes.xml to match endnotePr in settings",
63
+ pr_message: "Added endnotePr to settings to match endnotes.xml",
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def reconcile_notes_type(notes:, notes_setter:, has_pr:, type:,
70
+ create_message:, pr_message:)
71
+ if has_pr && !notes
72
+ notes_setter.call(minimal_notes(type))
73
+ record_fix(FixCodes::NOTE_PAIR_CREATED, create_message)
74
+ elsif notes && !has_pr
75
+ package.settings ||= Wordprocessingml::Settings.new
76
+ assign_note_pr(package.settings, type)
77
+ record_fix(FixCodes::NOTE_PAIR_CREATED, pr_message)
78
+ end
79
+
80
+ current = notes_collection_for(type)
81
+ return unless current
82
+
83
+ entries = note_entries_for(current, type)
84
+
85
+ finalize_notes(current, entries, type)
86
+ end
87
+
88
+ def finalize_notes(notes, entries, type)
89
+ ensure_separators(notes, type, entries)
90
+ strip_invalid_note_types(entries, type)
91
+ deduplicate_note_ids(entries, type)
92
+ strip_empty_runs_from_notes(entries)
93
+ reorder_notes_by_reference(entries, type)
94
+
95
+ unless allocator
96
+ prefix = type == :footnote ? "fn" : "en"
97
+
98
+ entries.each_with_index do |entry, eidx|
99
+ backfill_paragraphs(entry.paragraphs, generate_rsid,
100
+ "#{prefix}:#{eidx}")
101
+ end
102
+
103
+ renumber_notes(entries, type)
104
+ end
105
+
106
+ set_mc_ignorable(notes, prefixes: FULL_IGNORABLE)
107
+ end
108
+
109
+ def minimal_notes(type)
110
+ entries = [separator_entry(type), continuation_entry(type)]
111
+ build_notes_collection(type, entries: entries)
112
+ end
113
+
114
+ def entry_class(type)
115
+ type == :footnote ? Wordprocessingml::Footnote : Wordprocessingml::Endnote
116
+ end
117
+
118
+ def separator_entry(type)
119
+ entry_class(type).new(
120
+ id: "-1", type: "separator",
121
+ paragraphs: [separator_paragraph(:separator)]
122
+ )
123
+ end
124
+
125
+ def continuation_entry(type)
126
+ entry_class(type).new(
127
+ id: "0", type: "continuationSeparator",
128
+ paragraphs: [separator_paragraph(:continuation)]
129
+ )
130
+ end
131
+
132
+ def separator_paragraph(kind = :separator)
133
+ sep_attr = kind == :separator ? :separator_char : :continuation_separator_char
134
+ sep_class = kind == :separator ? Wordprocessingml::SeparatorChar : Wordprocessingml::ContinuationSeparatorChar
135
+ sep_run = Wordprocessingml::Run.new(sep_attr => sep_class.new)
136
+ Wordprocessingml::Paragraph.new(
137
+ properties: Wordprocessingml::ParagraphProperties.new(
138
+ spacing: Properties::Spacing.new(after: 0, line: 240,
139
+ line_rule: "auto"),
140
+ ),
141
+ runs: [sep_run]
142
+ )
143
+ end
144
+
145
+ def ensure_separators(notes, type, entries)
146
+ entries.each do |entry|
147
+ next unless %w[separator continuationSeparator].include?(entry.type)
148
+ next if entry.paragraphs.empty?
149
+
150
+ entry.paragraphs.each do |p|
151
+ strip_empty_runs(p)
152
+ ensure_separator_run(p, entry.type)
153
+ end
154
+ end
155
+
156
+ ids = entries.to_set(&:id)
157
+ entries.unshift(separator_entry(type)) unless ids.include?("-1")
158
+ entries.unshift(continuation_entry(type)) unless ids.include?("0")
159
+ end
160
+
161
+ def ensure_separator_run(para, type)
162
+ has_sep = para.runs.any? { |r| r.separator_char || r.continuation_separator_char }
163
+ return if has_sep
164
+
165
+ sep = if type == "separator"
166
+ Wordprocessingml::Run.new(separator_char: Wordprocessingml::SeparatorChar.new)
167
+ else
168
+ Wordprocessingml::Run.new(continuation_separator_char: Wordprocessingml::ContinuationSeparatorChar.new)
169
+ end
170
+ para.runs << sep
171
+ end
172
+
173
+ def strip_invalid_note_types(entries, type)
174
+ stripped = []
175
+ entries.each do |entry|
176
+ next if VALID_NOTE_TYPES.include?(entry.type)
177
+ next unless entry.type
178
+
179
+ stripped << entry.id
180
+ entry.type = nil
181
+ end
182
+ return if stripped.empty?
183
+
184
+ record_fix(FixCodes::NOTE_INVALID_TYPE_STRIPPED,
185
+ "Stripped invalid w:type from #{type}note ids=#{stripped.join(', ')}")
186
+ end
187
+
188
+ def deduplicate_note_ids(entries, type)
189
+ seen = {}
190
+ dup_ids = []
191
+ entries.reject! do |entry|
192
+ if seen[entry.id]
193
+ dup_ids << entry.id
194
+ true
195
+ else
196
+ seen[entry.id] = true
197
+ false
198
+ end
199
+ end
200
+ return if dup_ids.empty?
201
+
202
+ record_fix(FixCodes::NOTE_DUPLICATE_ID_REMOVED,
203
+ "Removed duplicate #{type}note ids=#{dup_ids.join(', ')}")
204
+ end
205
+
206
+ def reorder_notes_by_reference(entries, type)
207
+ body = package.document&.body
208
+ return unless body
209
+
210
+ ref_order = collect_note_reference_order(body, type)
211
+ return if ref_order.empty?
212
+
213
+ structural = entries.select { |e| VALID_NOTE_TYPES.include?(e.type) }
214
+ user_entries = entries.reject { |e| VALID_NOTE_TYPES.include?(e.type) }
215
+ return if user_entries.size <= 1
216
+
217
+ by_id = user_entries.group_by(&:id)
218
+ reordered = ref_order.filter_map { |id| by_id[id]&.first }
219
+
220
+ referenced_ids = ref_order.to_set
221
+ unreferenced = user_entries.reject { |e| referenced_ids.include?(e.id) }
222
+ reordered.concat(unreferenced)
223
+
224
+ return if reordered == user_entries
225
+
226
+ entries.clear
227
+ structural.each { |e| entries << e }
228
+ reordered.each { |e| entries << e }
229
+
230
+ record_fix(FixCodes::NOTE_PAIR_CREATED, "Reordered #{type}notes by first reference in document body")
231
+ end
232
+
233
+ def renumber_notes(entries, type)
234
+ user_entries = entries.reject { |e| VALID_NOTE_TYPES.include?(e.type) }
235
+ return if user_entries.size <= 1
236
+
237
+ id_map = {}
238
+ user_entries.each_with_index do |entry, idx|
239
+ old_id = entry.id
240
+ new_id = (idx + 1).to_s
241
+ next if old_id == new_id
242
+ id_map[old_id] = new_id
243
+ entry.id = new_id
244
+ end
245
+ return if id_map.empty?
246
+
247
+ body = package.document&.body
248
+ return unless body
249
+
250
+ walk_body_paragraphs(body) do |para|
251
+ para.runs.each do |run|
252
+ ref = note_reference_from_run(run, type)
253
+ next unless ref && ref.id && id_map.key?(ref.id)
254
+ ref.id = id_map[ref.id]
255
+ end
256
+ end
257
+
258
+ record_fix(FixCodes::NOTE_PAIR_CREATED, "Renumbered #{type}note IDs sequentially (#{id_map.size} changed)")
259
+ end
260
+
261
+ def collect_note_reference_order(body, type)
262
+ seen = []
263
+
264
+ walk_body_paragraphs(body) do |para|
265
+ para.runs.each do |run|
266
+ ref = note_reference_from_run(run, type)
267
+ next unless ref && ref.id
268
+ seen << ref.id unless seen.include?(ref.id)
269
+ end
270
+ end
271
+
272
+ seen
273
+ end
274
+
275
+ # note_reference_from_run lives in Helpers (single source for note-type dispatch).
276
+
277
+ def collect_note_ids(type)
278
+ ids = []
279
+ body = package.document&.body
280
+ return ids unless body
281
+
282
+ walk_body_paragraphs(body) do |para|
283
+ para.runs.each do |run|
284
+ ref = note_reference_from_run(run, type)
285
+ ids << ref.id if ref&.id
286
+ end
287
+ end
288
+ ids
289
+ end
290
+
291
+ def ensure_notes_part(type)
292
+ notes = build_notes_collection(
293
+ type,
294
+ entries: [separator_entry(type), continuation_entry(type)],
295
+ )
296
+ set_notes_collection(notes, type)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Package-level consistency enforcement.
7
+ #
8
+ # Rebuilds content types and relationships for standard parts,
9
+ # preserving non-standard entries from the source document.
10
+ module PackageStructure
11
+ # Relationship types for parts we don't model or serialize.
12
+ UNSUPPORTED_REL_TYPES = Set[
13
+ "http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects",
14
+ ].freeze
15
+
16
+ # Rel types that belong in package-level _rels/.rels, not document.xml.rels.
17
+ PACKAGE_LEVEL_REL_TYPES = Set[
18
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
19
+ "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
20
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
21
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
22
+ ].freeze
23
+
24
+ def reconcile_content_types
25
+ ct = package.content_types
26
+ return unless ct
27
+
28
+ ct.defaults = [
29
+ Uniword::ContentTypes::Default.new(
30
+ extension: "rels",
31
+ content_type: "application/vnd.openxmlformats-package.relationships+xml",
32
+ ),
33
+ Uniword::ContentTypes::Default.new(
34
+ extension: "xml",
35
+ content_type: "application/xml",
36
+ ),
37
+ ]
38
+
39
+ standard = content_type_overrides_for_present_parts
40
+ standard_parts = standard.to_set(&:part_name)
41
+ non_standard = ct.overrides.reject do |o|
42
+ standard_parts.include?(o.part_name)
43
+ end
44
+
45
+ ct.overrides = standard + non_standard
46
+ record_fix(FixCodes::CONTENT_TYPES_ASSEMBLED, "Rebuilt content types for standard parts")
47
+ end
48
+
49
+ def reconcile_package_rels
50
+ rels = package.package_rels
51
+ return unless rels
52
+
53
+ base = "http://schemas.openxmlformats.org"
54
+ standard_defs = [
55
+ ["rId1",
56
+ "#{base}/officeDocument/2006/relationships/officeDocument",
57
+ "word/document.xml"],
58
+ ["rId2",
59
+ "#{base}/package/2006/relationships/metadata/core-properties",
60
+ "docProps/core.xml"],
61
+ ["rId3",
62
+ "#{base}/officeDocument/2006/relationships/extended-properties",
63
+ "docProps/app.xml"],
64
+ ]
65
+
66
+ standard_targets = standard_defs.to_set { |_, _, t| t }
67
+ standard_rids = standard_defs.to_set { |rid, _, _| rid }
68
+ non_standard = rels.relationships.reject do |r|
69
+ standard_targets.include?(r.target) || standard_rids.include?(r.id)
70
+ end
71
+
72
+ existing_by_target = rels.relationships.to_h { |r| [r.target, r] }
73
+ standard = standard_defs.map do |rid, type, target|
74
+ existing = existing_by_target[target]
75
+ build_rel(existing ? existing.id : rid, type, target)
76
+ end
77
+
78
+ rels.relationships = standard + non_standard
79
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Rebuilt package relationships for standard parts")
80
+ end
81
+
82
+ def reconcile_document_rels
83
+ rels = package.document_rels
84
+ return unless rels
85
+
86
+ base = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
87
+ defs = [
88
+ ["styles", "styles.xml", package.styles],
89
+ ["settings", "settings.xml", package.settings],
90
+ ["webSettings", "webSettings.xml", package.web_settings],
91
+ ["fontTable", "fontTable.xml", package.font_table],
92
+ ["theme", "theme/theme1.xml", package.theme],
93
+ ["numbering", "numbering.xml", package.numbering],
94
+ ["footnotes", "footnotes.xml", package.footnotes],
95
+ ["endnotes", "endnotes.xml", package.endnotes],
96
+ ]
97
+
98
+ standard_targets = defs.filter_map { |_, target, obj| target if obj }.to_set
99
+
100
+ # If allocator is present, use it to build rels — preserves existing rIds
101
+ alloc = allocator
102
+ if alloc
103
+ reconcile_document_rels_from_allocator(rels, base, defs, standard_targets, alloc)
104
+ else
105
+ reconcile_document_rels_legacy(rels, base, defs, standard_targets)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def reconcile_document_rels_from_allocator(rels, base, defs, standard_targets, alloc)
112
+ # Collect standard part rels from allocator
113
+ all_rels = []
114
+ defs.each do |suffix, target, obj|
115
+ next unless obj
116
+ r_id = alloc.rid_for(target: target, type: "#{base}/#{suffix}")
117
+ if r_id
118
+ all_rels << build_rel(r_id, "#{base}/#{suffix}", target)
119
+ else
120
+ all_rels << build_rel(
121
+ alloc.alloc_rid(target: target, type: "#{base}/#{suffix}"),
122
+ "#{base}/#{suffix}", target,
123
+ )
124
+ end
125
+ end
126
+
127
+ # Add allocator-managed rels (images, headers, footers, hyperlinks)
128
+ alloc.all_rels.each do |entry|
129
+ next if standard_targets.include?(entry[:target])
130
+ next if all_rels.any? { |r| r.target == entry[:target] }
131
+ next if unsupported_rel_type?(entry[:type])
132
+ next if package_level_rel?(entry[:type])
133
+ next unless header_footer_target_present?(entry[:target])
134
+
135
+ all_rels << build_rel(
136
+ entry[:id], entry[:type], entry[:target],
137
+ target_mode: entry[:target_mode],
138
+ )
139
+ end
140
+
141
+ # Preserve non-standard rels not managed by allocator
142
+ existing_targets = all_rels.to_set(&:target)
143
+ non_standard = rels.relationships.reject do |r|
144
+ existing_targets.include?(r.target) ||
145
+ standard_targets.include?(r.target) ||
146
+ unsupported_rel_type?(r.type) ||
147
+ package_level_rel?(r.type) ||
148
+ !header_footer_target_present?(r.target)
149
+ end
150
+
151
+ rels.relationships = all_rels + non_standard
152
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Assembled document relationships from allocator")
153
+ end
154
+
155
+ def reconcile_document_rels_legacy(rels, base, defs, standard_targets)
156
+ non_standard = rels.relationships.reject do |r|
157
+ standard_targets.include?(r.target) ||
158
+ unsupported_rel_type?(r.type) ||
159
+ package_level_rel?(r.type) ||
160
+ !header_footer_target_present?(r.target)
161
+ end
162
+
163
+ all_rels = []
164
+ rid_mapping = {}
165
+
166
+ defs.each do |suffix, target, obj|
167
+ next unless obj
168
+ rid = "rId#{all_rels.size + 1}"
169
+ all_rels << build_rel(rid, "#{base}/#{suffix}", target)
170
+ end
171
+
172
+ non_standard.each do |rel|
173
+ old_rid = rel.id
174
+ new_rid = "rId#{all_rels.size + 1}"
175
+ rid_mapping[old_rid] = new_rid if old_rid != new_rid
176
+ all_rels << build_rel(new_rid, rel.type, rel.target,
177
+ target_mode: rel.target_mode)
178
+ end
179
+
180
+ rels.relationships = all_rels
181
+ update_sect_pr_rid_references(rid_mapping) unless rid_mapping.empty?
182
+ update_blip_embed_references(rid_mapping) unless rid_mapping.empty?
183
+ update_hyperlink_rid_references(rid_mapping) unless rid_mapping.empty?
184
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Rebuilt document relationships with sequential rIds")
185
+ end
186
+
187
+ def update_sect_pr_rid_references(mapping)
188
+ sect_pr = package.document&.body&.section_properties
189
+ return unless sect_pr
190
+
191
+ [sect_pr.header_references, sect_pr.footer_references].each do |refs|
192
+ next unless refs
193
+
194
+ refs.each do |ref|
195
+ new_rid = mapping[ref.r_id]
196
+ ref.r_id = new_rid if new_rid
197
+ end
198
+ end
199
+ end
200
+
201
+ def update_blip_embed_references(mapping)
202
+ paragraphs = package.document&.body&.paragraphs
203
+ return unless paragraphs
204
+
205
+ paragraphs.each do |para|
206
+ next unless para.runs
207
+
208
+ para.runs.each do |run|
209
+ next unless run.drawings
210
+
211
+ run.drawings.each do |drawing|
212
+ update_drawing_blip(drawing, mapping)
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ def update_drawing_blip(drawing, mapping)
219
+ graphic = drawing.inline&.graphic || drawing.anchor&.graphic
220
+ return unless graphic
221
+
222
+ picture = graphic.graphic_data&.picture
223
+ return unless picture
224
+
225
+ blip = picture.blip_fill&.blip
226
+ return unless blip&.embed
227
+
228
+ new_rid = mapping[blip.embed.to_s]
229
+ blip.embed = new_rid if new_rid
230
+ end
231
+
232
+ def update_hyperlink_rid_references(mapping)
233
+ body = package.document&.body
234
+ return unless body
235
+
236
+ walk_body_paragraphs(body) do |para|
237
+ (para.hyperlinks || []).each do |hl|
238
+ next unless hl.id
239
+ new_rid = mapping[hl.id.to_s]
240
+ hl.id = new_rid if new_rid
241
+ end
242
+ end
243
+ end
244
+
245
+ def unsupported_rel_type?(type)
246
+ UNSUPPORTED_REL_TYPES.include?(type.to_s)
247
+ end
248
+
249
+ def package_level_rel?(type)
250
+ PACKAGE_LEVEL_REL_TYPES.include?(type.to_s)
251
+ end
252
+
253
+ # Check if a header/footer rel target corresponds to a serialized part.
254
+ # Headers/footers may be in two places:
255
+ # - document.headers / document.footers (single-section hash)
256
+ # - document.header_footer_parts (multi-section ordered array)
257
+ # Headers/footers are numbered sequentially: header1.xml, header2.xml...
258
+ # If the model has fewer headers/footers than the target index, it's stale.
259
+ def header_footer_target_present?(target)
260
+ if target.start_with?("header") && target.end_with?(".xml")
261
+ num = target[/header(\d+)\.xml/, 1]&.to_i
262
+ hash_count = package.document&.headers&.size || 0
263
+ parts_count = count_parts_matching("header")
264
+ count = [hash_count, parts_count].max
265
+ return num && num <= count
266
+ end
267
+ if target.start_with?("footer") && target.end_with?(".xml")
268
+ num = target[/footer(\d+)\.xml/, 1]&.to_i
269
+ hash_count = package.document&.footers&.size || 0
270
+ parts_count = count_parts_matching("footer")
271
+ count = [hash_count, parts_count].max
272
+ return num && num <= count
273
+ end
274
+ true
275
+ end
276
+
277
+ def count_parts_matching(prefix)
278
+ parts = package.document&.header_footer_parts
279
+ return 0 unless parts
280
+
281
+ parts.count { |p| p[:target].to_s.start_with?(prefix) }
282
+ end
283
+
284
+ def content_type_overrides_for_present_parts
285
+ checks = [
286
+ [package.document, "/word/document.xml",
287
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"],
288
+ [package.styles, "/word/styles.xml",
289
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"],
290
+ [package.settings, "/word/settings.xml",
291
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"],
292
+ [package.font_table, "/word/fontTable.xml",
293
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"],
294
+ [package.web_settings, "/word/webSettings.xml",
295
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"],
296
+ [package.theme, "/word/theme/theme1.xml",
297
+ "application/vnd.openxmlformats-officedocument.theme+xml"],
298
+ [package.core_properties, "/docProps/core.xml",
299
+ "application/vnd.openxmlformats-package.core-properties+xml"],
300
+ [package.app_properties, "/docProps/app.xml",
301
+ "application/vnd.openxmlformats-officedocument.extended-properties+xml"],
302
+ [package.footnotes, "/word/footnotes.xml",
303
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"],
304
+ [package.endnotes, "/word/endnotes.xml",
305
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"],
306
+ [package.numbering, "/word/numbering.xml",
307
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"],
308
+ ]
309
+
310
+ checks.filter_map do |obj, part_name, content_type|
311
+ next unless obj
312
+
313
+ Uniword::ContentTypes::Override.new(
314
+ part_name: part_name,
315
+ content_type: content_type,
316
+ )
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end