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



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/uniword/docx/reconciler/helpers.rb', line 181

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



308
309
310
311
312
313
# File 'lib/uniword/docx/reconciler/helpers.rb', line 308

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.



145
146
147
148
149
150
151
152
153
# File 'lib/uniword/docx/reconciler/helpers.rb', line 145

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



301
302
303
304
305
306
# File 'lib/uniword/docx/reconciler/helpers.rb', line 301

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



236
237
238
239
240
# File 'lib/uniword/docx/reconciler/helpers.rb', line 236

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)


246
247
248
249
# File 'lib/uniword/docx/reconciler/helpers.rb', line 246

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



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/uniword/docx/reconciler/helpers.rb', line 159

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



202
203
204
# File 'lib/uniword/docx/reconciler/helpers.rb', line 202

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)


123
124
125
# File 'lib/uniword/docx/reconciler/helpers.rb', line 123

def empty_run?(run)
  Builder::RunUtils.empty_run?(run)
end

#ensure_element_in_order(model, tag_name, after: nil, before: nil) ⇒ Object

Idempotent singleton insert in schema position.



104
105
106
107
# File 'lib/uniword/docx/reconciler/helpers.rb', line 104

def ensure_element_in_order(model, tag_name, after: nil, before: nil)
  Ooxml::ElementOrder.insert_once(model, tag_name,
                                  after: after, before: before)
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

Positional insert for repeatable elements.



110
111
112
113
114
115
116
117
118
119
# File 'lib/uniword/docx/reconciler/helpers.rb', line 110

def insert_element_order(obj, name, position)
  order = obj.element_order
  return unless order
  return if order.any? { |e| e.name == name }

  Ooxml::ElementOrder.insert_at(
    obj, position, Lutaml::Xml::Element.new("Element", name,
                                            node_type: :element)
  )
end

#load_font_metadataObject



210
211
212
213
214
215
216
# File 'lib/uniword/docx/reconciler/helpers.rb', line 210

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



218
219
220
221
222
223
224
225
226
# File 'lib/uniword/docx/reconciler/helpers.rb', line 218

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



255
256
257
258
259
260
261
262
263
# File 'lib/uniword/docx/reconciler/helpers.rb', line 255

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



285
286
287
288
289
290
291
292
# File 'lib/uniword/docx/reconciler/helpers.rb', line 285

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



294
295
296
297
298
299
# File 'lib/uniword/docx/reconciler/helpers.rb', line 294

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.



270
271
272
273
274
275
# File 'lib/uniword/docx/reconciler/helpers.rb', line 270

def notes_collection_for(type)
  case type
  when :footnote then package.footnotes
  when :endnote  then package.endnotes
  end
end

#notes_part_for(type) ⇒ Object

Package part path of the notes collection for the note type.



278
279
280
281
282
283
# File 'lib/uniword/docx/reconciler/helpers.rb', line 278

def notes_part_for(type)
  case type
  when :footnote then "word/footnotes.xml"
  when :endnote  then "word/endnotes.xml"
  end
end

#run_properties_match?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


242
243
244
# File 'lib/uniword/docx/reconciler/helpers.rb', line 242

def run_properties_match?(a, b)
  Builder::RunUtils.properties_match?(a, b)
end

#set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES) ⇒ Object

-- mc:Ignorable helpers --



230
231
232
# File 'lib/uniword/docx/reconciler/helpers.rb', line 230

def set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES)
  model.mc_ignorable = Ooxml::Types::McIgnorable.new(prefixes)
end

#set_notes_collection(notes, type) ⇒ Object



315
316
317
318
319
320
# File 'lib/uniword/docx/reconciler/helpers.rb', line 315

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



127
128
129
130
131
132
133
134
135
136
# File 'lib/uniword/docx/reconciler/helpers.rb', line 127

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



138
139
140
141
142
# File 'lib/uniword/docx/reconciler/helpers.rb', line 138

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)


251
252
253
# File 'lib/uniword/docx/reconciler/helpers.rb', line 251

def text_only_run?(run)
  Builder::RunUtils.text_only_run?(run)
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