uniword 1.0.10 → 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 (204) 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 -24
  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 +16 -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 -1069
  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/mhtml_style_builder.rb +9 -2
  83. data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
  84. data/lib/uniword/transformation/ooxml_to_mhtml_converter.rb +7 -15
  85. data/lib/uniword/transformation/yaml_css_generator.rb +815 -0
  86. data/lib/uniword/transformation.rb +2 -0
  87. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  88. data/lib/uniword/version.rb +1 -1
  89. data/lib/uniword/visitor/text_extractor.rb +2 -1
  90. data/lib/uniword/watermark/manager.rb +2 -2
  91. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  92. data/lib/uniword/wordprocessingml/body.rb +53 -18
  93. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  94. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  95. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  96. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  97. data/lib/uniword/wordprocessingml/document_root.rb +6 -3
  98. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  99. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  100. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  101. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  102. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  103. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  104. data/lib/uniword/wordprocessingml/header.rb +4 -0
  105. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  106. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  107. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  108. data/lib/uniword/wordprocessingml/level.rb +2 -0
  109. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  110. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  111. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  112. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  113. data/lib/uniword/wordprocessingml/num.rb +4 -0
  114. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  115. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  116. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  117. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  118. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  119. data/lib/uniword/wordprocessingml/run.rb +32 -36
  120. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  121. data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
  122. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  123. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  124. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  125. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  126. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  127. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  128. data/lib/uniword/wordprocessingml/text.rb +8 -2
  129. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  130. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  131. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  132. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  133. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  134. data/lib/uniword/wordprocessingml.rb +23 -0
  135. data/lib/uniword.rb +27 -0
  136. metadata +47 -70
  137. data/lib/uniword/math/accent.rb +0 -25
  138. data/lib/uniword/math/accent_properties.rb +0 -25
  139. data/lib/uniword/math/argument_properties.rb +0 -22
  140. data/lib/uniword/math/bar.rb +0 -25
  141. data/lib/uniword/math/bar_properties.rb +0 -25
  142. data/lib/uniword/math/begin_char.rb +0 -22
  143. data/lib/uniword/math/border_box.rb +0 -25
  144. data/lib/uniword/math/border_box_properties.rb +0 -39
  145. data/lib/uniword/math/box.rb +0 -25
  146. data/lib/uniword/math/box_properties.rb +0 -33
  147. data/lib/uniword/math/char.rb +0 -22
  148. data/lib/uniword/math/control_properties.rb +0 -27
  149. data/lib/uniword/math/degree.rb +0 -26
  150. data/lib/uniword/math/delimiter.rb +0 -25
  151. data/lib/uniword/math/delimiter_properties.rb +0 -33
  152. data/lib/uniword/math/denominator.rb +0 -26
  153. data/lib/uniword/math/element.rb +0 -61
  154. data/lib/uniword/math/end_char.rb +0 -22
  155. data/lib/uniword/math/equation_array.rb +0 -25
  156. data/lib/uniword/math/equation_array_properties.rb +0 -33
  157. data/lib/uniword/math/fraction.rb +0 -27
  158. data/lib/uniword/math/fraction_properties.rb +0 -25
  159. data/lib/uniword/math/function.rb +0 -27
  160. data/lib/uniword/math/function_name.rb +0 -26
  161. data/lib/uniword/math/function_properties.rb +0 -23
  162. data/lib/uniword/math/group_char.rb +0 -25
  163. data/lib/uniword/math/group_char_properties.rb +0 -29
  164. data/lib/uniword/math/lim.rb +0 -26
  165. data/lib/uniword/math/lower_limit.rb +0 -27
  166. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  167. data/lib/uniword/math/math_break.rb +0 -22
  168. data/lib/uniword/math/math_font.rb +0 -22
  169. data/lib/uniword/math/math_properties.rb +0 -53
  170. data/lib/uniword/math/math_run.rb +0 -42
  171. data/lib/uniword/math/math_run_properties.rb +0 -37
  172. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  173. data/lib/uniword/math/math_simple_val.rb +0 -24
  174. data/lib/uniword/math/math_style.rb +0 -22
  175. data/lib/uniword/math/math_text.rb +0 -23
  176. data/lib/uniword/math/matrix.rb +0 -25
  177. data/lib/uniword/math/matrix_column.rb +0 -23
  178. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  179. data/lib/uniword/math/matrix_columns.rb +0 -23
  180. data/lib/uniword/math/matrix_properties.rb +0 -37
  181. data/lib/uniword/math/matrix_row.rb +0 -23
  182. data/lib/uniword/math/nary.rb +0 -29
  183. data/lib/uniword/math/nary_properties.rb +0 -33
  184. data/lib/uniword/math/numerator.rb +0 -26
  185. data/lib/uniword/math/o_math.rb +0 -65
  186. data/lib/uniword/math/o_math_para.rb +0 -25
  187. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  188. data/lib/uniword/math/phantom.rb +0 -25
  189. data/lib/uniword/math/phantom_properties.rb +0 -33
  190. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  191. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  192. data/lib/uniword/math/radical.rb +0 -27
  193. data/lib/uniword/math/radical_properties.rb +0 -25
  194. data/lib/uniword/math/separator_char.rb +0 -22
  195. data/lib/uniword/math/sub.rb +0 -26
  196. data/lib/uniword/math/sub_superscript.rb +0 -29
  197. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  198. data/lib/uniword/math/subscript.rb +0 -27
  199. data/lib/uniword/math/subscript_properties.rb +0 -23
  200. data/lib/uniword/math/sup.rb +0 -26
  201. data/lib/uniword/math/superscript.rb +0 -27
  202. data/lib/uniword/math/superscript_properties.rb +0 -23
  203. data/lib/uniword/math/upper_limit.rb +0 -27
  204. data/lib/uniword/math/upper_limit_properties.rb +0 -23
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Document body reconciliation.
7
+ #
8
+ # Handles section properties, headers/footers, and their
9
+ # mc:Ignorable and paragraph backfill.
10
+ module Body
11
+ def reconcile_section_properties
12
+ return unless package.document&.body
13
+
14
+ body = package.document.body
15
+
16
+ unless body.section_properties
17
+ body.section_properties = Wordprocessingml::SectionProperties.new(
18
+ page_size: Wordprocessingml::PageDefaults.default_page_size,
19
+ page_margins: Wordprocessingml::PageDefaults.default_page_margins,
20
+ columns: Wordprocessingml::PageDefaults.default_columns,
21
+ doc_grid: Wordprocessingml::PageDefaults.default_doc_grid,
22
+ )
23
+ record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
24
+ "Added default section properties with US Letter page size")
25
+ return
26
+ end
27
+
28
+ sect_pr = body.section_properties
29
+ fixed = false
30
+ unless sect_pr.page_size
31
+ sect_pr.page_size = Wordprocessingml::PageDefaults.default_page_size
32
+ fixed = true
33
+ end
34
+ unless sect_pr.page_margins
35
+ sect_pr.page_margins = Wordprocessingml::PageDefaults.default_page_margins
36
+ fixed = true
37
+ end
38
+ unless sect_pr.columns
39
+ sect_pr.columns = Wordprocessingml::PageDefaults.default_columns
40
+ fixed = true
41
+ end
42
+ if fixed
43
+ record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
44
+ "Filled missing pgSz/pgMar/cols in existing section properties")
45
+ end
46
+ end
47
+
48
+ def reconcile_headers_footers
49
+ ignorable = Ooxml::Types::McIgnorable.new(FULL_IGNORABLE)
50
+ set_header_footer_ignorable(package.document&.headers, ignorable)
51
+ set_header_footer_ignorable(package.document&.footers, ignorable)
52
+
53
+ unless allocator
54
+ rsid = generate_rsid
55
+ backfill_header_footer_parts(package.document&.headers, rsid, "hdr")
56
+ backfill_header_footer_parts(package.document&.footers, rsid, "ftr")
57
+
58
+ parts = package.document&.header_footer_parts
59
+ parts&.each_with_index do |part, pidx|
60
+ backfill_paragraphs(part[:content].paragraphs, rsid, "hfp:#{pidx}")
61
+ end
62
+ end
63
+
64
+ wire_builder_headers_footers
65
+ end
66
+
67
+ private
68
+
69
+ def set_header_footer_ignorable(parts, ignorable)
70
+ return unless parts
71
+
72
+ parts.each_value { |part| part.mc_ignorable = ignorable }
73
+ end
74
+
75
+ def backfill_header_footer_parts(parts, rsid, prefix)
76
+ return unless parts
77
+
78
+ parts.each_with_index do |(_, part), pidx|
79
+ backfill_paragraphs(part.paragraphs, rsid, "#{prefix}:#{pidx}")
80
+ end
81
+ end
82
+
83
+ HEADER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
84
+ FOOTER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
85
+
86
+ # Wire builder-path headers/footers into document_rels and sectPr
87
+ # during reconciliation so referential integrity checks see valid rIds.
88
+ def wire_builder_headers_footers
89
+ doc = package.document
90
+ return unless doc&.body
91
+ return unless doc.headers || doc.footers
92
+
93
+ rels = package.document_rels
94
+ return unless rels
95
+
96
+ counter = 0
97
+ wire_parts_to_rels(doc.headers, rels, HEADER_REL_TYPE,
98
+ "header", counter) do |type, r_id|
99
+ wire_sect_pr_reference(doc, :header, type, r_id)
100
+ end
101
+
102
+ counter = 0
103
+ wire_parts_to_rels(doc.footers, rels, FOOTER_REL_TYPE,
104
+ "footer", counter) do |type, r_id|
105
+ wire_sect_pr_reference(doc, :footer, type, r_id)
106
+ end
107
+
108
+ # Clear element_order so header/footer references serialize correctly.
109
+ # Ordered mode only outputs elements in element_order; the template's
110
+ # order may not include newly-added references.
111
+ sect_pr = doc.body.section_properties
112
+ sect_pr.element_order = nil if sect_pr&.element_order
113
+ end
114
+
115
+ def wire_parts_to_rels(parts, rels, rel_type, file_prefix, counter)
116
+ return unless parts && !parts.empty?
117
+
118
+ parts.each_key do |type|
119
+ counter += 1
120
+ target = "#{file_prefix}#{counter}.xml"
121
+ r_id = if allocator
122
+ allocator.alloc_rid(target: target, type: rel_type)
123
+ else
124
+ find_or_create_rel(rels, rel_type, target)
125
+ end
126
+ yield type, r_id
127
+ end
128
+ end
129
+
130
+ def find_or_create_rel(rels, rel_type, target)
131
+ existing = rels.relationships.find { |r| r.target == target }
132
+ return existing.id if existing
133
+
134
+ r_id = Ooxml::Relationships::PackageRelationships
135
+ .next_available_rid(rels)
136
+ rels.relationships << Ooxml::Relationships::Relationship.new(
137
+ id: r_id, type: rel_type, target: target,
138
+ )
139
+ r_id
140
+ end
141
+
142
+ def wire_sect_pr_reference(doc, kind, type, r_id)
143
+ sect_pr = doc.body.section_properties
144
+ return unless sect_pr
145
+
146
+ refs = case kind
147
+ when :header then sect_pr.header_references
148
+ when :footer then sect_pr.footer_references
149
+ end
150
+ return unless refs
151
+
152
+ existing = refs.find { |r| r.type == type }
153
+ if existing
154
+ existing.r_id = r_id
155
+ else
156
+ ref_class = if kind == :header
157
+ Wordprocessingml::HeaderReference
158
+ else
159
+ Wordprocessingml::FooterReference
160
+ end
161
+ refs << ref_class.new(type: type, r_id: r_id)
162
+ end
163
+ end
164
+
165
+ def reconcile_document_body
166
+ return unless profile
167
+ return unless package.document&.body
168
+
169
+ doc = package.document
170
+ set_mc_ignorable(doc, prefixes: FULL_IGNORABLE)
171
+
172
+ record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body")
173
+
174
+ body = doc.body
175
+ rsid = generate_rsid
176
+
177
+ body.paragraphs.each_with_index do |para, idx|
178
+ strip_empty_runs(para)
179
+ next if allocator
180
+
181
+ para.rsid_r ||= rsid
182
+ para.rsid_r_default ||= "00000000"
183
+ para.para_id ||= generate_hex_id(idx)
184
+ para.text_id ||= "77777777"
185
+ end
186
+
187
+ record_fix(FixCodes::PARAGRAPH_BACKFILL, "Assigned rsid and paraId to paragraphs") unless allocator
188
+
189
+ sect_pr = body.section_properties
190
+ return unless sect_pr
191
+
192
+ sect_pr.rsid_r ||= rsid
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # Single source of truth for record_fix rule codes.
7
+ #
8
+ # Every `record_fix` call site references a constant from this module
9
+ # instead of a bare string literal. Adding a new fix category means
10
+ # adding one constant here, not hunting for the next free letter.
11
+ #
12
+ # Wire format is preserved — external validation rules and audit-log
13
+ # consumers that pattern-match on "R1".."R16" continue to work.
14
+ #
15
+ # Known debt: R10 is overloaded for several distinct concerns
16
+ # (note defs, table structure, style defaults, dangling refs).
17
+ # Splitting requires consumer coordination.
18
+ module FixCodes
19
+ # Settings / package structure
20
+ MC_IGNORABLE = "R1"
21
+ DOC_ID_GENERATED = "R2"
22
+
23
+ # Support parts
24
+ THEME_CREATED = "R3"
25
+ NUMBERING_REFERENCED = "R4"
26
+
27
+ # Package assembly
28
+ RELATIONSHIPS_ASSEMBLED = "R6"
29
+ CONTENT_TYPES_ASSEMBLED = "R7"
30
+ APP_PROPERTIES_ENSURED = "R8"
31
+
32
+ # Notes — pair creation
33
+ NOTE_PAIR_CREATED = "R9"
34
+ NOTE_DEFINITION_CREATED = "R10"
35
+ NOTE_INVALID_TYPE_STRIPPED = "R15"
36
+ NOTE_DUPLICATE_ID_REMOVED = "R16"
37
+
38
+ # Notes — referential integrity warnings and removals
39
+ DANGLING_NOTE_REFERENCE_WARNING = "R9"
40
+ DANGLING_NOTE_REFERENCE_REMOVED = "R10"
41
+ DANGLING_HYPERLINK_WARNING = "R9"
42
+
43
+ # Body
44
+ SECTION_PROPERTIES_DEFAULTED = "R11"
45
+ PARAGRAPH_BACKFILL = "R12"
46
+
47
+ # Parts
48
+ FONT_TABLE_CREATED = "R13"
49
+ CORE_PROPERTIES_REBUILT = "R14"
50
+
51
+ # Styles (currently shares R10 with other concerns — see debt note above)
52
+ STYLE_DEFAULTS_ADDED = "R10"
53
+ SEMI_HIDDEN_ADDED = "R10"
54
+
55
+ # Tables (currently shares R10)
56
+ TABLE_STRUCTURE_RECONCILED = "R10"
57
+ TABLE_CELL_PR_REORDERED = "R10"
58
+ TABLE_CELL_DEFAULTS = "R12"
59
+ TABLE_ROW_GRID_AFTER = "R13"
60
+
61
+ # Referential integrity (currently shares R10)
62
+ DANGLING_STYLE_REFERENCE_REMOVED = "R10"
63
+ DANGLING_BASED_ON_REMOVED = "R10"
64
+ DANGLING_NUMBERING_REMOVED = "R4"
65
+ DANGLING_HYPERLINK_REMOVED = "R10"
66
+ DANGLING_HEADER_FOOTER_REMOVED = "R11"
67
+ DANGLING_DRAWING_REMOVED = "R12"
68
+
69
+ # Run cleanup (currently shares R10)
70
+ EMPTY_RUNS_STRIPPED = "R10"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+ require "yaml"
6
+
7
+ module Uniword
8
+ module Docx
9
+ class Reconciler
10
+ # Shared helpers used across all reconciliation modules.
11
+ #
12
+ # Provides ID generation, document traversal, element_order
13
+ # manipulation, run utilities, and YAML config loading.
14
+ module Helpers
15
+ # -- ID generation --
16
+
17
+ def generate_rsid
18
+ "00#{hex_derive("rsid", 3)}"
19
+ end
20
+
21
+ def generate_hex_id(seed = 0)
22
+ hex_derive("paraId:#{seed}", 4)
23
+ end
24
+
25
+ def hex_derive(seed, byte_count)
26
+ Digest::SHA256.hexdigest(
27
+ "#{document_fingerprint}:#{seed}"
28
+ )[0...(byte_count * 2)].upcase
29
+ end
30
+
31
+ def document_fingerprint
32
+ @document_fingerprint ||= begin
33
+ body = package.document&.body
34
+ return "empty" unless body
35
+
36
+ texts = []
37
+ walk_body_paragraphs(body) do |para|
38
+ texts << (para.runs || []).map(&:text_string).join
39
+ end
40
+ Digest::SHA256.hexdigest(texts.join("|"))
41
+ end
42
+ end
43
+
44
+ # -- Traversal --
45
+
46
+ def walk_body_paragraphs(body)
47
+ if body.element_order && !body.element_order.empty?
48
+ p_idx = 0
49
+ tbl_idx = 0
50
+ body.element_order.each do |entry|
51
+ case entry.name
52
+ when "p"
53
+ yield body.paragraphs[p_idx] if body.paragraphs[p_idx]
54
+ p_idx += 1
55
+ when "tbl"
56
+ if body.tables[tbl_idx]
57
+ walk_table_paragraphs(body.tables[tbl_idx]) { |p| yield p }
58
+ end
59
+ tbl_idx += 1
60
+ end
61
+ end
62
+ else
63
+ body.paragraphs.each { |p| yield p }
64
+ body.tables&.each { |tbl| walk_table_paragraphs(tbl) { |p| yield p } }
65
+ end
66
+ end
67
+
68
+ def walk_body_tables(body)
69
+ return unless body
70
+
71
+ if body.element_order && !body.element_order.empty?
72
+ tbl_idx = 0
73
+ body.element_order.each do |entry|
74
+ next unless entry.name == "tbl"
75
+
76
+ yield body.tables[tbl_idx] if body.tables[tbl_idx]
77
+ tbl_idx += 1
78
+ end
79
+ else
80
+ body.tables&.each { |tbl| yield tbl }
81
+ end
82
+ end
83
+
84
+ def walk_all_paragraphs(&block)
85
+ walk_body_paragraphs(package.document.body, &block)
86
+
87
+ (package.document&.header_footer_parts || []).each do |part|
88
+ (part[:content]&.paragraphs || []).each(&block)
89
+ end
90
+ end
91
+
92
+ def walk_table_paragraphs(table)
93
+ return unless table
94
+ table.rows&.each do |row|
95
+ row.cells&.each do |cell|
96
+ cell.paragraphs.each { |p| yield p }
97
+ end
98
+ end
99
+ end
100
+
101
+ # -- Element order --
102
+
103
+ def ensure_element_in_order(model, tag_name, after: nil, before: nil)
104
+ order = model.element_order
105
+ return unless order
106
+
107
+ return if order.any? { |e| e.name == tag_name }
108
+
109
+ entry = Lutaml::Xml::Element.new("Element", tag_name)
110
+
111
+ if after
112
+ idx = order.index { |e| e.name == after }
113
+ thaw_and_insert(model, order, idx ? idx + 1 : order.size, entry)
114
+ elsif before
115
+ idx = order.index { |e| e.name == before }
116
+ thaw_and_insert(model, order, idx || 0, entry)
117
+ else
118
+ thaw_and_append(model, order, entry)
119
+ end
120
+ end
121
+
122
+ def insert_element_order(obj, name, position)
123
+ order = obj.element_order
124
+ return unless order
125
+
126
+ return if order.any? { |e| e.name == name }
127
+
128
+ entry = Lutaml::Xml::Element.new("Element", name, node_type: :element)
129
+ thaw_and_insert(obj, order, position, entry)
130
+ end
131
+
132
+ # lutaml-model freezes element_order after XML parsing.
133
+ # Replace the frozen array with a mutable copy when modification is needed.
134
+ def thaw_and_insert(model, order, position, entry)
135
+ model.element_order = order.dup.insert(position, entry)
136
+ end
137
+
138
+ def thaw_and_append(model, order, entry)
139
+ model.element_order = order.dup << entry
140
+ end
141
+
142
+ # -- Run utilities (delegate to RunUtils) --
143
+
144
+ def empty_run?(run)
145
+ Builder::RunUtils.empty_run?(run)
146
+ end
147
+
148
+ def strip_empty_runs(paragraph)
149
+ before = paragraph.runs.size
150
+ paragraph.runs.reject! { |r| empty_run?(r) }
151
+ removed = before - paragraph.runs.size
152
+ return unless removed.positive?
153
+
154
+ record_fix(FixCodes::EMPTY_RUNS_STRIPPED,
155
+ "Stripped #{removed} empty run(s) from " \
156
+ "#{paragraph.class.name.split('::').last}")
157
+ end
158
+
159
+ def strip_empty_runs_from_notes(entries)
160
+ entries.each do |entry|
161
+ entry.paragraphs.each { |p| strip_empty_runs(p) }
162
+ end
163
+ end
164
+
165
+ # Shared paragraph backfill: assign rsid, paraId, textId defaults.
166
+ def backfill_paragraphs(paragraphs, rsid, id_seed)
167
+ paragraphs.each_with_index do |para, idx|
168
+ strip_empty_runs(para)
169
+ para.rsid_r ||= rsid
170
+ para.rsid_r_default ||= "00000000"
171
+ para.para_id ||= generate_hex_id("#{id_seed}:#{idx}")
172
+ para.text_id ||= "77777777"
173
+ end
174
+ end
175
+
176
+ # Merge adjacent runs with identical formatting and consolidate
177
+ # standalone tab/br runs into following text runs (F4 + F5).
178
+ # Respects element_order: only merges runs that are truly adjacent
179
+ # in document order (not separated by hyperlinks, bookmarks, etc.).
180
+ def consolidate_runs(paragraph)
181
+ runs = paragraph.runs
182
+ return if runs.nil? || runs.size < 2
183
+
184
+ run_indices = adjacent_run_indices(paragraph)
185
+ merged = [runs.first]
186
+ runs[1..].each_with_index do |run, idx|
187
+ prev = merged.last
188
+ if run_indices.include?(idx) &&
189
+ run_properties_match?(prev, run) && can_merge?(prev, run)
190
+ merge_run_into(prev, run)
191
+ else
192
+ merged << run
193
+ end
194
+ end
195
+
196
+ paragraph.runs = merged
197
+ end
198
+
199
+ # Returns the set of run indices (0-based into runs[1..]) where
200
+ # the run at that index is immediately preceded by another run
201
+ # in element_order (no non-run elements between them).
202
+ def adjacent_run_indices(paragraph)
203
+ eo = paragraph.element_order
204
+ return (0...paragraph.runs.size - 1).to_a unless eo && !eo.empty?
205
+
206
+ indices = Set.new
207
+ run_count = 0
208
+ prev_was_run = false
209
+ eo.each do |entry|
210
+ if entry.name == "r"
211
+ if prev_was_run
212
+ indices << (run_count - 1)
213
+ end
214
+ run_count += 1
215
+ prev_was_run = true
216
+ else
217
+ prev_was_run = false
218
+ end
219
+ end
220
+ indices
221
+ end
222
+
223
+ def consolidate_runs_in_body(body)
224
+ walk_body_paragraphs(body) { |p| consolidate_runs(p) }
225
+ end
226
+
227
+ # -- YAML config --
228
+
229
+ CONFIG_DIR = File.join(__dir__, "../../../../config")
230
+
231
+ def load_font_metadata
232
+ path = File.join(CONFIG_DIR, "font_metadata.yml")
233
+ YAML.load_file(path)["fonts"]
234
+ rescue StandardError => e
235
+ Uniword.logger&.warn { "Font metadata load failed: #{e.message}" }
236
+ nil
237
+ end
238
+
239
+ def load_latent_styles_config
240
+ path = File.join(CONFIG_DIR, "latent_styles.yml")
241
+ YAML.load_file(path)
242
+ rescue StandardError => e
243
+ Uniword.logger&.warn do
244
+ "Latent styles config load failed: #{e.message}"
245
+ end
246
+ nil
247
+ end
248
+
249
+ # -- mc:Ignorable helpers --
250
+
251
+ def set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES)
252
+ model.mc_ignorable = Ooxml::Types::McIgnorable.new(prefixes)
253
+ end
254
+
255
+ # -- Relationship builders --
256
+
257
+ def build_rel(id, type, target, target_mode: nil)
258
+ attrs = { id: id, type: type, target: target }
259
+ attrs[:target_mode] = target_mode if target_mode
260
+ Ooxml::Relationships::Relationship.new(**attrs)
261
+ end
262
+
263
+ def run_properties_match?(a, b)
264
+ Builder::RunUtils.properties_match?(a, b)
265
+ end
266
+
267
+ def can_merge?(prev, current)
268
+ Builder::RunUtils.text_only_run?(prev) &&
269
+ Builder::RunUtils.text_only_run?(current)
270
+ end
271
+
272
+ def text_only_run?(run)
273
+ Builder::RunUtils.text_only_run?(run)
274
+ end
275
+
276
+ def merge_run_into(target, source)
277
+ Builder::RunUtils.merge_text(target, source)
278
+
279
+ target.tab ||= source.tab
280
+ target.break ||= source.break
281
+ target.position_tab ||= source.position_tab
282
+ target.no_break_hyphen ||= source.no_break_hyphen
283
+ target.del_text ||= source.del_text
284
+ end
285
+
286
+ # -- Note-type dispatch --
287
+ #
288
+ # Single source of truth for :footnote/:endnote discrimination.
289
+ # Adding a third note type means editing only these helpers.
290
+
291
+ def notes_collection_for(type)
292
+ case type
293
+ when :footnote then package.footnotes
294
+ when :endnote then package.endnotes
295
+ end
296
+ end
297
+
298
+ def note_entries_for(notes, type)
299
+ return [] unless notes
300
+
301
+ case type
302
+ when :footnote then notes.footnote_entries
303
+ when :endnote then notes.endnote_entries
304
+ end
305
+ end
306
+
307
+ def note_reference_from_run(run, type)
308
+ case type
309
+ when :footnote then run.footnote_reference
310
+ when :endnote then run.endnote_reference
311
+ end
312
+ end
313
+
314
+ def build_notes_collection(type, entries:)
315
+ case type
316
+ when :footnote then Wordprocessingml::Footnotes.new(footnote_entries: entries)
317
+ when :endnote then Wordprocessingml::Endnotes.new(endnote_entries: entries)
318
+ end
319
+ end
320
+
321
+ def assign_note_pr(settings, type)
322
+ case type
323
+ when :footnote then settings.footnote_pr = Wordprocessingml::FootnotePr.new
324
+ when :endnote then settings.endnote_pr = Wordprocessingml::EndnotePr.new
325
+ end
326
+ end
327
+
328
+ def set_notes_collection(notes, type)
329
+ case type
330
+ when :footnote then package.footnotes = notes
331
+ when :endnote then package.endnotes = notes
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end