coradoc-docx 0.1.0

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 (29) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +164 -0
  3. data/lib/coradoc/docx/transform/context.rb +72 -0
  4. data/lib/coradoc/docx/transform/from_core_model.rb +577 -0
  5. data/lib/coradoc/docx/transform/numbering_resolver.rb +127 -0
  6. data/lib/coradoc/docx/transform/ordered_content.rb +95 -0
  7. data/lib/coradoc/docx/transform/rule.rb +57 -0
  8. data/lib/coradoc/docx/transform/rule_registry.rb +60 -0
  9. data/lib/coradoc/docx/transform/rules/bookmark_rule.rb +34 -0
  10. data/lib/coradoc/docx/transform/rules/break_rule.rb +30 -0
  11. data/lib/coradoc/docx/transform/rules/footnote_rule.rb +27 -0
  12. data/lib/coradoc/docx/transform/rules/heading_rule.rb +53 -0
  13. data/lib/coradoc/docx/transform/rules/hyperlink_rule.rb +58 -0
  14. data/lib/coradoc/docx/transform/rules/image_rule.rb +125 -0
  15. data/lib/coradoc/docx/transform/rules/list_item_rule.rb +47 -0
  16. data/lib/coradoc/docx/transform/rules/math_rule.rb +82 -0
  17. data/lib/coradoc/docx/transform/rules/paragraph_rule.rb +65 -0
  18. data/lib/coradoc/docx/transform/rules/proof_error_rule.rb +25 -0
  19. data/lib/coradoc/docx/transform/rules/run_rule.rb +189 -0
  20. data/lib/coradoc/docx/transform/rules/simple_field_rule.rb +87 -0
  21. data/lib/coradoc/docx/transform/rules/structured_document_tag_rule.rb +36 -0
  22. data/lib/coradoc/docx/transform/rules/table_rule.rb +85 -0
  23. data/lib/coradoc/docx/transform/rules/text_rule.rb +25 -0
  24. data/lib/coradoc/docx/transform/style_resolver.rb +249 -0
  25. data/lib/coradoc/docx/transform/to_core_model.rb +340 -0
  26. data/lib/coradoc/docx/transform.rb +38 -0
  27. data/lib/coradoc/docx/version.rb +7 -0
  28. data/lib/coradoc/docx.rb +99 -0
  29. metadata +155 -0
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uniword'
4
+
5
+ module Coradoc
6
+ module Docx
7
+ module Transform
8
+ # Transforms CoreModel to OOXML document via Uniword Builder.
9
+ #
10
+ # Follows the hub-and-spoke architecture: CoreModel elements are
11
+ # dispatched to handler methods that produce Uniword OOXML objects.
12
+ # The resulting DocumentRoot is serialized to .docx format.
13
+ #
14
+ # @example Convert CoreModel to DOCX file
15
+ # Coradoc::Docx::Transform::FromCoreModel.transform_to_file(core, "output.docx")
16
+ #
17
+ # @example Get Uniword DocumentRoot
18
+ # doc = Coradoc::Docx::Transform::FromCoreModel.transform(core)
19
+ # doc.save("output.docx")
20
+ class FromCoreModel
21
+ class << self
22
+ # Transform a CoreModel document to a Uniword DocumentRoot
23
+ #
24
+ # @param core [Coradoc::CoreModel::Base] CoreModel document
25
+ # @return [Uniword::Wordprocessingml::DocumentRoot] OOXML document
26
+ def transform(core)
27
+ new.transform(core)
28
+ end
29
+
30
+ # Transform a CoreModel document and save to .docx file
31
+ #
32
+ # @param core [Coradoc::CoreModel::Base] CoreModel document
33
+ # @param path [String] output file path
34
+ # @return [void]
35
+ def transform_to_file(core, path)
36
+ doc = transform(core)
37
+ doc.save(path)
38
+ end
39
+ end
40
+
41
+ def transform(core)
42
+ case core
43
+ when Coradoc::CoreModel::StructuralElement
44
+ transform_structural_element(core)
45
+ when Coradoc::CoreModel::AnnotationBlock
46
+ transform_annotation_block(core)
47
+ when Coradoc::CoreModel::Block
48
+ transform_block(core)
49
+ when Coradoc::CoreModel::ListBlock
50
+ transform_list(core)
51
+ when Coradoc::CoreModel::Table
52
+ transform_table(core)
53
+ when Coradoc::CoreModel::Image
54
+ transform_image(core)
55
+ when Coradoc::CoreModel::InlineElement
56
+ transform_inline(core)
57
+ when Coradoc::CoreModel::FootnoteReference
58
+ build_ooxml_footnote_reference(core)
59
+ when Coradoc::CoreModel::Footnote
60
+ build_ooxml_footnote(core)
61
+ when Coradoc::CoreModel::DefinitionList
62
+ build_ooxml_definition_list(core)
63
+ when Coradoc::CoreModel::Toc
64
+ build_ooxml_toc(core)
65
+ when Coradoc::CoreModel::Term
66
+ build_ooxml_term(core)
67
+ when Coradoc::CoreModel::Abbreviation
68
+ build_ooxml_abbreviation(core)
69
+ when Coradoc::CoreModel::Bibliography
70
+ build_ooxml_bibliography(core)
71
+ when Coradoc::CoreModel::BibliographyEntry
72
+ build_ooxml_bibliography_entry(core)
73
+ when Coradoc::CoreModel::TocEntry
74
+ build_ooxml_toc_entry(core)
75
+ when Array
76
+ transform_array(core)
77
+ else
78
+ core
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def transform_structural_element(element)
85
+ case element.element_type
86
+ when 'document'
87
+ transform_document(element)
88
+ when 'section'
89
+ transform_section(element)
90
+ else
91
+ transform_section(element)
92
+ end
93
+ end
94
+
95
+ def transform_document(element)
96
+ doc = Uniword::Builder::DocumentBuilder.new
97
+ doc.title(element.title) if element.title
98
+
99
+ transform_children(element.children, doc)
100
+
101
+ doc.build
102
+ end
103
+
104
+ def transform_section(element)
105
+ paragraphs = []
106
+
107
+ paragraphs << build_heading(element.title, level: element.heading_level) if element.title
108
+
109
+ element.children&.each do |child|
110
+ case child
111
+ when Coradoc::CoreModel::StructuralElement
112
+ paragraphs << transform_section(child)
113
+ when Coradoc::CoreModel::AnnotationBlock
114
+ paragraphs << transform_annotation_block(child)
115
+ when Coradoc::CoreModel::Block
116
+ paragraphs << build_ooxml_paragraph(child)
117
+ when Coradoc::CoreModel::ListBlock
118
+ paragraphs.concat(build_ooxml_list(child))
119
+ when Coradoc::CoreModel::Table
120
+ paragraphs << build_ooxml_table(child)
121
+ when Coradoc::CoreModel::Image
122
+ paragraphs << build_ooxml_image(child)
123
+ end
124
+ end
125
+
126
+ paragraphs
127
+ end
128
+
129
+ def transform_block(block)
130
+ case block.element_type
131
+ when 'page_break'
132
+ build_page_break
133
+ when 'paragraph', nil
134
+ build_ooxml_paragraph(block)
135
+ when 'comment'
136
+ nil
137
+ else
138
+ build_ooxml_paragraph(block)
139
+ end
140
+ end
141
+
142
+ def transform_annotation_block(annotation)
143
+ para = Uniword::Wordprocessingml::Paragraph.new
144
+ type_run = Uniword::Wordprocessingml::Run.new
145
+ type_run.text = Uniword::Wordprocessingml::Text.new(
146
+ content: "#{annotation.annotation_type}: "
147
+ )
148
+ type_run.properties = Uniword::Wordprocessingml::RunProperties.new
149
+ type_run.properties.bold = Uniword::Properties::Bold.new
150
+
151
+ text_run = Uniword::Wordprocessingml::Run.new
152
+ text_run.text = Uniword::Wordprocessingml::Text.new(content: annotation.flat_text)
153
+
154
+ para.runs << type_run
155
+ para.runs << text_run
156
+ para
157
+ end
158
+
159
+ def transform_list(list_block)
160
+ build_ooxml_list(list_block)
161
+ end
162
+
163
+ def transform_table(table)
164
+ build_ooxml_table(table)
165
+ end
166
+
167
+ def transform_image(image)
168
+ build_ooxml_image(image)
169
+ end
170
+
171
+ def transform_inline(inline)
172
+ build_ooxml_run(inline)
173
+ end
174
+
175
+ def transform_array(elements)
176
+ elements.map { |e| transform(e) }
177
+ end
178
+
179
+ # Helper to transform children into builder calls
180
+ def transform_children(children, builder)
181
+ return unless children
182
+
183
+ children.each do |child|
184
+ case child
185
+ when Coradoc::CoreModel::StructuralElement
186
+ add_section_to_builder(child, builder)
187
+ when Coradoc::CoreModel::AnnotationBlock
188
+ add_annotation_to_builder(child, builder)
189
+ when Coradoc::CoreModel::Block
190
+ add_block_to_builder(child, builder)
191
+ when Coradoc::CoreModel::ListBlock
192
+ add_list_to_builder(child, builder)
193
+ when Coradoc::CoreModel::Table
194
+ add_table_to_builder(child, builder)
195
+ when Coradoc::CoreModel::Image
196
+ add_image_to_builder(child, builder)
197
+ end
198
+ end
199
+ end
200
+
201
+ def add_section_to_builder(element, builder)
202
+ level = element.heading_level
203
+ builder.heading(element.title, level: level) if element.title
204
+
205
+ transform_children(element.children, builder)
206
+ end
207
+
208
+ def add_block_to_builder(block, builder)
209
+ case block.element_type
210
+ when 'page_break'
211
+ builder.page_break
212
+ else
213
+ add_paragraph_to_builder(block, builder)
214
+ end
215
+ end
216
+
217
+ def add_annotation_to_builder(annotation, builder)
218
+ text = "#{annotation.annotation_type}: #{annotation.flat_text}"
219
+ builder.paragraph do |p|
220
+ p << Uniword::Builder.text(text, bold: true)
221
+ end
222
+ end
223
+
224
+ def add_paragraph_to_builder(block, builder)
225
+ content = block.renderable_content
226
+
227
+ if content.is_a?(Array) && content.any? { |c| !c.is_a?(String) }
228
+ builder.paragraph do |p|
229
+ content.each do |child|
230
+ case child
231
+ when String
232
+ p << child
233
+ when Coradoc::CoreModel::InlineElement
234
+ p << build_inline_text(child)
235
+ end
236
+ end
237
+ end
238
+ else
239
+ text = content.is_a?(Array) ? content.join : content.to_s
240
+ builder.paragraph(text)
241
+ end
242
+ end
243
+
244
+ def add_list_to_builder(list_block, builder)
245
+ list_method = list_block.marker_type == 'numbered' ? :numbered_list : :bullet_list
246
+
247
+ if list_method == :numbered_list
248
+ builder.numbered_list do |list|
249
+ list_block.items.each do |item|
250
+ list.item(item.flat_text)
251
+ end
252
+ end
253
+ else
254
+ builder.bullet_list do |list|
255
+ list_block.items.each do |item|
256
+ list.item(item.flat_text)
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ def add_table_to_builder(table, builder)
263
+ builder.table do |t|
264
+ table.rows.each do |row|
265
+ t.row do |r|
266
+ row.cells.each do |cell|
267
+ r.cell(text: cell.flat_text)
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+
274
+ def add_image_to_builder(image, builder)
275
+ if image.src && File.exist?(image.src)
276
+ builder.image(image.src, alt_text: image.alt || '')
277
+ else
278
+ builder.paragraph("[Image: #{image.alt || image.src}]")
279
+ end
280
+ end
281
+
282
+ # Build OOXML objects directly (low-level)
283
+
284
+ def build_heading(text, level:)
285
+ para = Uniword::Wordprocessingml::Paragraph.new
286
+ para.properties = Uniword::Wordprocessingml::ParagraphProperties.new
287
+ para.properties.style = Uniword::Properties::StyleReference.new(
288
+ value: "Heading#{level}"
289
+ )
290
+ run = Uniword::Wordprocessingml::Run.new
291
+ run.text = Uniword::Wordprocessingml::Text.new(content: text)
292
+ para.runs << run
293
+ para
294
+ end
295
+
296
+ def build_ooxml_paragraph(block)
297
+ para = Uniword::Wordprocessingml::Paragraph.new
298
+
299
+ content = block.renderable_content
300
+ if content.is_a?(Array)
301
+ content.each do |child|
302
+ case child
303
+ when String
304
+ run = Uniword::Wordprocessingml::Run.new
305
+ run.text = Uniword::Wordprocessingml::Text.new(content: child)
306
+ para.runs << run
307
+ when Coradoc::CoreModel::InlineElement
308
+ para.runs << build_ooxml_run(child)
309
+ end
310
+ end
311
+ else
312
+ run = Uniword::Wordprocessingml::Run.new
313
+ run.text = Uniword::Wordprocessingml::Text.new(content: content.to_s)
314
+ para.runs << run
315
+ end
316
+
317
+ para.id = block.id if block.id
318
+ para
319
+ end
320
+
321
+ def build_ooxml_run(inline)
322
+ run = Uniword::Wordprocessingml::Run.new
323
+ run.text = Uniword::Wordprocessingml::Text.new(content: inline.content.to_s)
324
+
325
+ props = Uniword::Wordprocessingml::RunProperties.new
326
+
327
+ case inline.format_type
328
+ when 'bold'
329
+ props.bold = Uniword::Properties::Bold.new
330
+ when 'italic'
331
+ props.italic = Uniword::Properties::Italic.new
332
+ when 'underline'
333
+ props.underline = Uniword::Properties::Underline.new
334
+ when 'strikethrough'
335
+ props.strike = Uniword::Properties::Strike.new
336
+ when 'subscript'
337
+ va = Uniword::Properties::VerticalAlign.new
338
+ va.value = 'subscript'
339
+ props.vertical_align = va
340
+ when 'superscript'
341
+ va = Uniword::Properties::VerticalAlign.new
342
+ va.value = 'superscript'
343
+ props.vertical_align = va
344
+ when 'monospace'
345
+ props.fonts = Uniword::Wordprocessingml::RFonts.new(ascii: 'Courier New')
346
+ when 'link'
347
+ # Links need to be at the paragraph level (w:hyperlink)
348
+ # Return the run; caller should wrap in Hyperlink
349
+ return run
350
+ when 'highlight'
351
+ hl = Uniword::Properties::Highlight.new
352
+ hl.value = 'yellow'
353
+ props.highlight = hl
354
+ when 'xref'
355
+ return run
356
+ when 'stem'
357
+ return run
358
+ else
359
+ return run
360
+ end
361
+
362
+ run.properties = props unless plain_properties?(props)
363
+ run
364
+ end
365
+
366
+ def build_ooxml_list(list_block)
367
+ items = list_block.items || []
368
+ items.map do |item|
369
+ para = Uniword::Wordprocessingml::Paragraph.new
370
+ para.properties = Uniword::Wordprocessingml::ParagraphProperties.new
371
+ para.properties.num_id = 1
372
+ para.properties.ilvl = (list_block.marker_level || 1) - 1
373
+
374
+ content = item.flat_text
375
+ run = Uniword::Wordprocessingml::Run.new
376
+ run.text = Uniword::Wordprocessingml::Text.new(content: content)
377
+ para.runs << run
378
+ para
379
+ end
380
+ end
381
+
382
+ def build_ooxml_table(table)
383
+ tbl = Uniword::Wordprocessingml::Table.new
384
+
385
+ table.rows.each do |row|
386
+ tr = Uniword::Wordprocessingml::TableRow.new
387
+ row.cells.each do |cell|
388
+ tc = Uniword::Wordprocessingml::TableCell.new
389
+
390
+ para = Uniword::Wordprocessingml::Paragraph.new
391
+ run = Uniword::Wordprocessingml::Run.new
392
+ run.text = Uniword::Wordprocessingml::Text.new(content: cell.flat_text)
393
+ para.runs << run
394
+ tc.paragraphs << para
395
+
396
+ tc.column_span = cell.colspan if cell.colspan && cell.colspan > 1
397
+ tc.row_span = cell.rowspan if cell.rowspan && cell.rowspan > 1
398
+
399
+ tr.cells << tc
400
+ end
401
+ tbl.rows << tr
402
+ end
403
+
404
+ tbl
405
+ end
406
+
407
+ def build_ooxml_image(image)
408
+ if image.src && File.exist?(image.src)
409
+ run = Uniword::Builder::ImageBuilder.create_run(
410
+ nil, image.src, alt_text: image.alt
411
+ )
412
+ para = Uniword::Wordprocessingml::Paragraph.new
413
+ else
414
+ para = Uniword::Wordprocessingml::Paragraph.new
415
+ run = Uniword::Wordprocessingml::Run.new
416
+ run.text = Uniword::Wordprocessingml::Text.new(
417
+ content: "[Image: #{image.alt || image.src}]"
418
+ )
419
+ end
420
+ para.runs << run
421
+ para
422
+ end
423
+
424
+ def build_page_break
425
+ para = Uniword::Wordprocessingml::Paragraph.new
426
+ run = Uniword::Wordprocessingml::Run.new
427
+ run.break = Uniword::Wordprocessingml::Break.new
428
+ run.break.type = 'page'
429
+ para.runs << run
430
+ para
431
+ end
432
+
433
+ def build_inline_text(inline)
434
+ text = inline.content.to_s
435
+
436
+ case inline.format_type
437
+ when 'bold' then Uniword::Builder.text(text, bold: true)
438
+ when 'italic' then Uniword::Builder.text(text, italic: true)
439
+ when 'underline' then Uniword::Builder.text(text, underline: true)
440
+ when 'strikethrough' then Uniword::Builder.text(text, strike: true)
441
+ when 'highlight' then Uniword::Builder.text(text, highlight: 'yellow')
442
+ when 'monospace' then Uniword::Builder.text(text, font: 'Courier New')
443
+ when 'subscript', 'superscript'
444
+ run = Uniword::Wordprocessingml::Run.new(text: text)
445
+ props = Uniword::Wordprocessingml::RunProperties.new
446
+ va = Uniword::Properties::VerticalAlign.new
447
+ va.value = inline.format_type
448
+ props.vertical_align = va
449
+ run.properties = props
450
+ run
451
+ when 'link'
452
+ Uniword::Builder.hyperlink(inline.target.to_s, text)
453
+ else
454
+ text
455
+ end
456
+ end
457
+
458
+ def plain_properties?(props)
459
+ props.bold.nil? &&
460
+ props.italic.nil? &&
461
+ props.underline.nil? &&
462
+ props.strike.nil? &&
463
+ props.vertical_align.nil? &&
464
+ props.highlight.nil?
465
+ end
466
+
467
+ # Build OOXML footnote reference (w:footnoteReference)
468
+ def build_ooxml_footnote_reference(footnote_ref)
469
+ ref = Uniword::Wordprocessingml::FootnoteReference.new
470
+ ref.id = footnote_ref.id.to_s
471
+
472
+ run = Uniword::Wordprocessingml::Run.new
473
+ run.footnote_reference = ref
474
+ run
475
+ end
476
+
477
+ # Build OOXML footnote content as a paragraph placeholder
478
+ def build_ooxml_footnote(footnote)
479
+ para = Uniword::Wordprocessingml::Paragraph.new
480
+ run = Uniword::Wordprocessingml::Run.new
481
+ run.text = Uniword::Wordprocessingml::Text.new(content: footnote.content.to_s)
482
+ para.runs << run
483
+ para
484
+ end
485
+
486
+ # Build OOXML definition list as a two-column table
487
+ def build_ooxml_definition_list(dl)
488
+ tbl = Uniword::Wordprocessingml::Table.new
489
+
490
+ Array(dl.items).each do |item|
491
+ row = Uniword::Wordprocessingml::TableRow.new
492
+
493
+ term_cell = Uniword::Wordprocessingml::TableCell.new
494
+ term_para = Uniword::Wordprocessingml::Paragraph.new
495
+ term_run = Uniword::Wordprocessingml::Run.new
496
+ term_run.text = Uniword::Wordprocessingml::Text.new(content: item.term.to_s)
497
+ term_props = Uniword::Wordprocessingml::RunProperties.new
498
+ term_props.bold = Uniword::Properties::Bold.new
499
+ term_run.properties = term_props
500
+ term_para.runs << term_run
501
+ term_cell.paragraphs << term_para
502
+
503
+ def_cell = Uniword::Wordprocessingml::TableCell.new
504
+ def_text = Array(item.definitions).map(&:to_s).join('; ')
505
+ def_para = Uniword::Wordprocessingml::Paragraph.new
506
+ def_run = Uniword::Wordprocessingml::Run.new
507
+ def_run.text = Uniword::Wordprocessingml::Text.new(content: def_text)
508
+ def_para.runs << def_run
509
+ def_cell.paragraphs << def_para
510
+
511
+ row.cells << term_cell
512
+ row.cells << def_cell
513
+ tbl.rows << row
514
+ end
515
+
516
+ tbl
517
+ end
518
+
519
+ # Build OOXML TOC as a text placeholder
520
+ def build_ooxml_toc(_toc)
521
+ para = Uniword::Wordprocessingml::Paragraph.new
522
+ run = Uniword::Wordprocessingml::Run.new
523
+ run.text = Uniword::Wordprocessingml::Text.new(content: '[Table of Contents]')
524
+ para.runs << run
525
+ para
526
+ end
527
+
528
+ # Build OOXML term as a bold paragraph
529
+ def build_ooxml_term(term)
530
+ para = Uniword::Wordprocessingml::Paragraph.new
531
+ run = Uniword::Wordprocessingml::Run.new
532
+ run.text = Uniword::Wordprocessingml::Text.new(content: term.text.to_s)
533
+ run_props = Uniword::Wordprocessingml::RunProperties.new
534
+ run_props.bold = Uniword::Properties::Bold.new
535
+ run.properties = run_props
536
+ para.runs << run
537
+ para
538
+ end
539
+
540
+ def build_ooxml_abbreviation(abbr)
541
+ para = Uniword::Wordprocessingml::Paragraph.new
542
+ run = Uniword::Wordprocessingml::Run.new
543
+ text = abbr.term.to_s
544
+ text += " (#{abbr.definition})" if abbr.definition
545
+ run.text = Uniword::Wordprocessingml::Text.new(content: text)
546
+ para.runs << run
547
+ para
548
+ end
549
+
550
+ def build_ooxml_bibliography(bib)
551
+ entries = Array(bib.entries).map { |e| build_ooxml_bibliography_entry(e) }
552
+
553
+ result = []
554
+ result << build_heading(bib.title, level: bib.level || 2) if bib.title
555
+ result.concat(entries)
556
+ result
557
+ end
558
+
559
+ def build_ooxml_bibliography_entry(entry)
560
+ para = Uniword::Wordprocessingml::Paragraph.new
561
+ run = Uniword::Wordprocessingml::Run.new
562
+ run.text = Uniword::Wordprocessingml::Text.new(content: entry.display_text)
563
+ para.runs << run
564
+ para
565
+ end
566
+
567
+ def build_ooxml_toc_entry(entry)
568
+ para = Uniword::Wordprocessingml::Paragraph.new
569
+ run = Uniword::Wordprocessingml::Run.new
570
+ run.text = Uniword::Wordprocessingml::Text.new(content: entry.title.to_s)
571
+ para.runs << run
572
+ para
573
+ end
574
+ end
575
+ end
576
+ end
577
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Docx
5
+ module Transform
6
+ # Resolves OOXML numbering definitions to list style information.
7
+ #
8
+ # OOXML stores list formatting in numbering definitions (abstractNum).
9
+ # Each numPr reference (numId + ilvl) points to a specific numbering
10
+ # format (bullet, decimal, lowerLetter, etc.).
11
+ #
12
+ # The resolver walks Uniword's NumberingConfiguration to build a map
13
+ # of numId → { ordered, marker_type, format }.
14
+ class NumberingResolver
15
+ ORDERED_FORMATS = %w[decimal lowerLetter upperLetter lowerRoman upperRoman
16
+ russianLower russianUpper hebrew1 hebrew2 thaiLetters
17
+ japaneseDigitalTenji japaneseKorean chineseCounting].freeze
18
+
19
+ # @param numbering_configuration [Object, nil] Uniword numbering config
20
+ def initialize(numbering_configuration)
21
+ @config = numbering_configuration
22
+ @num_map = build_num_map(numbering_configuration)
23
+ end
24
+
25
+ # Determine if a numId represents an ordered list
26
+ #
27
+ # @param num_id [Integer, String, nil] numbering definition ID
28
+ # @return [Boolean] true if ordered, false if unordered
29
+ def ordered?(num_id)
30
+ return false unless num_id
31
+
32
+ info = @num_map[num_id.to_i]
33
+ return false unless info
34
+
35
+ info[:ordered]
36
+ end
37
+
38
+ # Get the list style for a numId
39
+ #
40
+ # @param num_id [Integer, String, nil]
41
+ # @return [Symbol] :ordered, :unordered
42
+ def list_style(num_id)
43
+ ordered?(num_id) ? :ordered : :unordered
44
+ end
45
+
46
+ # Get marker type string for a numId
47
+ #
48
+ # @param num_id [Integer, String, nil]
49
+ # @return [String] "numbered", "asterisk", "lower_alpha", etc.
50
+ def marker_type(num_id)
51
+ return 'asterisk' unless num_id
52
+
53
+ info = @num_map[num_id.to_i]
54
+ return 'asterisk' unless info
55
+
56
+ info[:marker_type]
57
+ end
58
+
59
+ private
60
+
61
+ def build_num_map(config)
62
+ return {} unless config
63
+
64
+ instances = config.instances
65
+ return {} if instances.nil? || instances.empty?
66
+
67
+ map = {}
68
+ instances.each do |inst|
69
+ num_id = inst.num_id
70
+ abstract_num_id = extract_abstract_num_id(inst)
71
+ next unless num_id && abstract_num_id
72
+
73
+ definition = find_definition(config, abstract_num_id)
74
+ next unless definition
75
+
76
+ levels = definition.levels
77
+ level = levels&.first
78
+ next unless level
79
+
80
+ fmt = extract_num_fmt(level)
81
+ ordered = ORDERED_FORMATS.include?(fmt)
82
+ map[num_id] = {
83
+ ordered: ordered,
84
+ marker_type: marker_type_for(fmt),
85
+ format: fmt
86
+ }
87
+ end
88
+
89
+ map
90
+ end
91
+
92
+ def extract_abstract_num_id(instance)
93
+ aid = instance.abstract_num_id
94
+ return nil unless aid
95
+
96
+ aid.is_a?(Uniword::Wordprocessingml::AbstractNumId) ? aid.val : aid
97
+ end
98
+
99
+ def find_definition(config, abstract_num_id)
100
+ defs = config.definitions
101
+ return [] unless defs
102
+
103
+ defs.find { |d| d.abstract_num_id == abstract_num_id }
104
+ end
105
+
106
+ def extract_num_fmt(level)
107
+ nf = level.numFmt
108
+ return 'bullet' unless nf
109
+
110
+ nf.is_a?(Uniword::Wordprocessingml::NumFmt) ? nf.val.to_s : nf.to_s
111
+ end
112
+
113
+ def marker_type_for(fmt)
114
+ case fmt
115
+ when 'decimal' then 'numbered'
116
+ when 'lowerLetter' then 'lower_alpha'
117
+ when 'upperLetter' then 'upper_alpha'
118
+ when 'lowerRoman' then 'lower_roman'
119
+ when 'upperRoman' then 'upper_roman'
120
+ when 'bullet' then 'asterisk'
121
+ else 'numbered'
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end