Module: Uniword::Docx::Reconciler::Helpers
- Included in:
- Uniword::Docx::Reconciler
- Defined in:
- lib/uniword/docx/reconciler/helpers.rb
Overview
Shared helpers used across all reconciliation modules.
Provides ID generation, document traversal, element_order manipulation, run utilities, and YAML config loading.
Constant Summary collapse
- CONFIG_DIR =
-- YAML config --
File.join(__dir__, "../../../../config")
Instance Method Summary collapse
-
#adjacent_run_indices(paragraph) ⇒ Object
Returns the set of run indices (0-based into runs) where the run at that index is immediately preceded by another run in element_order (no non-run elements between them).
- #assign_note_pr(settings, type) ⇒ Object
-
#backfill_paragraphs(paragraphs, rsid, id_seed) ⇒ Object
Shared paragraph backfill: assign rsid, paraId, textId defaults.
- #build_notes_collection(type, entries:) ⇒ Object
-
#build_rel(id, type, target, target_mode: nil) ⇒ Object
-- Relationship builders --.
- #can_merge?(prev, current) ⇒ Boolean
-
#consolidate_runs(paragraph) ⇒ Object
Merge adjacent runs with identical formatting and consolidate standalone tab/br runs into following text runs (F4 + F5).
- #consolidate_runs_in_body(body) ⇒ Object
- #document_fingerprint ⇒ Object
-
#empty_run?(run) ⇒ Boolean
-- Run utilities (delegate to RunUtils) --.
-
#ensure_element_in_order(model, tag_name, after: nil, before: nil) ⇒ Object
-- Element order --.
- #generate_hex_id(seed = 0) ⇒ Object
-
#generate_rsid ⇒ Object
-- ID generation --.
- #hex_derive(seed, byte_count) ⇒ Object
- #insert_element_order(obj, name, position) ⇒ Object
- #load_font_metadata ⇒ Object
- #load_latent_styles_config ⇒ Object
- #merge_run_into(target, source) ⇒ Object
- #note_entries_for(notes, type) ⇒ Object
- #note_reference_from_run(run, type) ⇒ Object
-
#notes_collection_for(type) ⇒ Object
-- Note-type dispatch --.
- #run_properties_match?(a, b) ⇒ Boolean
-
#set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES) ⇒ Object
-- mc:Ignorable helpers --.
- #set_notes_collection(notes, type) ⇒ Object
- #strip_empty_runs(paragraph) ⇒ Object
- #strip_empty_runs_from_notes(entries) ⇒ Object
- #text_only_run?(run) ⇒ Boolean
- #thaw_and_append(model, order, entry) ⇒ Object
-
#thaw_and_insert(model, order, position, entry) ⇒ Object
lutaml-model freezes element_order after XML parsing.
- #walk_all_paragraphs(&block) ⇒ Object
-
#walk_body_paragraphs(body) ⇒ Object
-- Traversal --.
- #walk_body_tables(body) ⇒ Object
- #walk_table_paragraphs(table) ⇒ Object
Instance Method Details
#adjacent_run_indices(paragraph) ⇒ Object
Returns the set of run indices (0-based into runs) where the run at that index is immediately preceded by another run in element_order (no non-run elements between them).
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 202 def adjacent_run_indices(paragraph) eo = paragraph.element_order return (0...paragraph.runs.size - 1).to_a unless eo && !eo.empty? indices = Set.new run_count = 0 prev_was_run = false eo.each do |entry| if entry.name == "r" if prev_was_run indices << (run_count - 1) end run_count += 1 prev_was_run = true else prev_was_run = false end end indices end |
#assign_note_pr(settings, type) ⇒ Object
321 322 323 324 325 326 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 321 def assign_note_pr(settings, type) case type when :footnote then settings.footnote_pr = Wordprocessingml::FootnotePr.new when :endnote then settings.endnote_pr = Wordprocessingml::EndnotePr.new end end |
#backfill_paragraphs(paragraphs, rsid, id_seed) ⇒ Object
Shared paragraph backfill: assign rsid, paraId, textId defaults.
166 167 168 169 170 171 172 173 174 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 166 def backfill_paragraphs(paragraphs, rsid, id_seed) paragraphs.each_with_index do |para, idx| strip_empty_runs(para) para.rsid_r ||= rsid para.rsid_r_default ||= "00000000" para.para_id ||= generate_hex_id("#{id_seed}:#{idx}") para.text_id ||= "77777777" end end |
#build_notes_collection(type, entries:) ⇒ Object
314 315 316 317 318 319 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 314 def build_notes_collection(type, entries:) case type when :footnote then Wordprocessingml::Footnotes.new(footnote_entries: entries) when :endnote then Wordprocessingml::Endnotes.new(endnote_entries: entries) end end |
#build_rel(id, type, target, target_mode: nil) ⇒ Object
-- Relationship builders --
257 258 259 260 261 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 257 def build_rel(id, type, target, target_mode: nil) attrs = { id: id, type: type, target: target } attrs[:target_mode] = target_mode if target_mode Ooxml::Relationships::Relationship.new(**attrs) end |
#can_merge?(prev, current) ⇒ Boolean
267 268 269 270 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 267 def can_merge?(prev, current) Builder::RunUtils.text_only_run?(prev) && Builder::RunUtils.text_only_run?(current) end |
#consolidate_runs(paragraph) ⇒ Object
Merge adjacent runs with identical formatting and consolidate standalone tab/br runs into following text runs (F4 + F5). Respects element_order: only merges runs that are truly adjacent in document order (not separated by hyperlinks, bookmarks, etc.).
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 180 def consolidate_runs(paragraph) runs = paragraph.runs return if runs.nil? || runs.size < 2 run_indices = adjacent_run_indices(paragraph) merged = [runs.first] runs[1..].each_with_index do |run, idx| prev = merged.last if run_indices.include?(idx) && run_properties_match?(prev, run) && can_merge?(prev, run) merge_run_into(prev, run) else merged << run end end paragraph.runs = merged end |
#consolidate_runs_in_body(body) ⇒ Object
223 224 225 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 223 def consolidate_runs_in_body(body) walk_body_paragraphs(body) { |p| consolidate_runs(p) } end |
#document_fingerprint ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 31 def document_fingerprint @document_fingerprint ||= begin body = package.document&.body return "empty" unless body texts = [] walk_body_paragraphs(body) do |para| texts << (para.runs || []).map(&:text_string).join end Digest::SHA256.hexdigest(texts.join("|")) end end |
#empty_run?(run) ⇒ Boolean
-- Run utilities (delegate to RunUtils) --
144 145 146 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 144 def empty_run?(run) Builder::RunUtils.empty_run?(run) end |
#ensure_element_in_order(model, tag_name, after: nil, before: nil) ⇒ Object
-- Element order --
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 103 def ensure_element_in_order(model, tag_name, after: nil, before: nil) order = model.element_order return unless order return if order.any? { |e| e.name == tag_name } entry = Lutaml::Xml::Element.new("Element", tag_name) if after idx = order.index { |e| e.name == after } thaw_and_insert(model, order, idx ? idx + 1 : order.size, entry) elsif before idx = order.index { |e| e.name == before } thaw_and_insert(model, order, idx || 0, entry) else thaw_and_append(model, order, entry) end end |
#generate_hex_id(seed = 0) ⇒ Object
21 22 23 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 21 def generate_hex_id(seed = 0) hex_derive("paraId:#{seed}", 4) end |
#generate_rsid ⇒ Object
-- ID generation --
17 18 19 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 17 def generate_rsid "00#{hex_derive("rsid", 3)}" end |
#hex_derive(seed, byte_count) ⇒ Object
25 26 27 28 29 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 25 def hex_derive(seed, byte_count) Digest::SHA256.hexdigest( "#{document_fingerprint}:#{seed}" )[0...(byte_count * 2)].upcase end |
#insert_element_order(obj, name, position) ⇒ Object
122 123 124 125 126 127 128 129 130 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 122 def insert_element_order(obj, name, position) order = obj.element_order return unless order return if order.any? { |e| e.name == name } entry = Lutaml::Xml::Element.new("Element", name, node_type: :element) thaw_and_insert(obj, order, position, entry) end |
#load_font_metadata ⇒ Object
231 232 233 234 235 236 237 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 231 def path = File.join(CONFIG_DIR, "font_metadata.yml") YAML.load_file(path)["fonts"] rescue StandardError => e Uniword.logger&.warn { "Font metadata load failed: #{e.}" } nil end |
#load_latent_styles_config ⇒ Object
239 240 241 242 243 244 245 246 247 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 239 def load_latent_styles_config path = File.join(CONFIG_DIR, "latent_styles.yml") YAML.load_file(path) rescue StandardError => e Uniword.logger&.warn do "Latent styles config load failed: #{e.}" end nil end |
#merge_run_into(target, source) ⇒ Object
276 277 278 279 280 281 282 283 284 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 276 def merge_run_into(target, source) Builder::RunUtils.merge_text(target, source) target.tab ||= source.tab target.break ||= source.break target.position_tab ||= source.position_tab target.no_break_hyphen ||= source.no_break_hyphen target.del_text ||= source.del_text end |
#note_entries_for(notes, type) ⇒ Object
298 299 300 301 302 303 304 305 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 298 def note_entries_for(notes, type) return [] unless notes case type when :footnote then notes.footnote_entries when :endnote then notes.endnote_entries end end |
#note_reference_from_run(run, type) ⇒ Object
307 308 309 310 311 312 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 307 def note_reference_from_run(run, type) case type when :footnote then run.footnote_reference when :endnote then run.endnote_reference end end |
#notes_collection_for(type) ⇒ Object
-- Note-type dispatch --
Single source of truth for :footnote/:endnote discrimination. Adding a third note type means editing only these helpers.
291 292 293 294 295 296 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 291 def notes_collection_for(type) case type when :footnote then package.footnotes when :endnote then package.endnotes end end |
#run_properties_match?(a, b) ⇒ Boolean
263 264 265 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 263 def run_properties_match?(a, b) Builder::RunUtils.properties_match?(a, b) end |
#set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES) ⇒ Object
-- mc:Ignorable helpers --
251 252 253 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 251 def set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES) model.mc_ignorable = Ooxml::Types::McIgnorable.new(prefixes) end |
#set_notes_collection(notes, type) ⇒ Object
328 329 330 331 332 333 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 328 def set_notes_collection(notes, type) case type when :footnote then package.footnotes = notes when :endnote then package.endnotes = notes end end |
#strip_empty_runs(paragraph) ⇒ Object
148 149 150 151 152 153 154 155 156 157 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 148 def strip_empty_runs(paragraph) before = paragraph.runs.size paragraph.runs.reject! { |r| empty_run?(r) } removed = before - paragraph.runs.size return unless removed.positive? record_fix(FixCodes::EMPTY_RUNS_STRIPPED, "Stripped #{removed} empty run(s) from " \ "#{paragraph.class.name.split('::').last}") end |
#strip_empty_runs_from_notes(entries) ⇒ Object
159 160 161 162 163 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 159 def strip_empty_runs_from_notes(entries) entries.each do |entry| entry.paragraphs.each { |p| strip_empty_runs(p) } end end |
#text_only_run?(run) ⇒ Boolean
272 273 274 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 272 def text_only_run?(run) Builder::RunUtils.text_only_run?(run) end |
#thaw_and_append(model, order, entry) ⇒ Object
138 139 140 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 138 def thaw_and_append(model, order, entry) model.element_order = order.dup << entry end |
#thaw_and_insert(model, order, position, entry) ⇒ Object
lutaml-model freezes element_order after XML parsing. Replace the frozen array with a mutable copy when modification is needed.
134 135 136 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 134 def thaw_and_insert(model, order, position, entry) model.element_order = order.dup.insert(position, entry) end |
#walk_all_paragraphs(&block) ⇒ Object
84 85 86 87 88 89 90 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 84 def walk_all_paragraphs(&block) walk_body_paragraphs(package.document.body, &block) (package.document&. || []).each do |part| (part[:content]&.paragraphs || []).each(&block) end end |
#walk_body_paragraphs(body) ⇒ Object
-- Traversal --
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 46 def walk_body_paragraphs(body) if body.element_order && !body.element_order.empty? p_idx = 0 tbl_idx = 0 body.element_order.each do |entry| case entry.name when "p" yield body.paragraphs[p_idx] if body.paragraphs[p_idx] p_idx += 1 when "tbl" if body.tables[tbl_idx] walk_table_paragraphs(body.tables[tbl_idx]) { |p| yield p } end tbl_idx += 1 end end else body.paragraphs.each { |p| yield p } body.tables&.each { |tbl| walk_table_paragraphs(tbl) { |p| yield p } } end end |
#walk_body_tables(body) ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 68 def walk_body_tables(body) return unless body if body.element_order && !body.element_order.empty? tbl_idx = 0 body.element_order.each do |entry| next unless entry.name == "tbl" yield body.tables[tbl_idx] if body.tables[tbl_idx] tbl_idx += 1 end else body.tables&.each { |tbl| yield tbl } end end |
#walk_table_paragraphs(table) ⇒ Object
92 93 94 95 96 97 98 99 |
# File 'lib/uniword/docx/reconciler/helpers.rb', line 92 def walk_table_paragraphs(table) return unless table table.rows&.each do |row| row.cells&.each do |cell| cell.paragraphs.each { |p| yield p } end end end |