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

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

Returns:

  • (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_fingerprintObject



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) --

Returns:

  • (Boolean)


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_rsidObject

-- 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_metadataObject



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.message}" }
  nil
end

#load_latent_styles_configObject



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.message}"
  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

Returns:

  • (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

Returns:

  • (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&.header_footer_parts || []).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