uniword 1.0.11 → 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 (200) 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 -25
  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 +15 -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 -1097
  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/ooxml_to_html_converter.rb +3 -5
  83. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  84. data/lib/uniword/version.rb +1 -1
  85. data/lib/uniword/visitor/text_extractor.rb +2 -1
  86. data/lib/uniword/watermark/manager.rb +2 -2
  87. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  88. data/lib/uniword/wordprocessingml/body.rb +51 -34
  89. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  90. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  91. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  92. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  93. data/lib/uniword/wordprocessingml/document_root.rb +4 -3
  94. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  95. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  96. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  97. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  98. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  99. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  100. data/lib/uniword/wordprocessingml/header.rb +4 -0
  101. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  102. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  103. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  104. data/lib/uniword/wordprocessingml/level.rb +2 -0
  105. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  106. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  107. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  108. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  109. data/lib/uniword/wordprocessingml/num.rb +4 -0
  110. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  111. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  112. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  113. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  114. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  115. data/lib/uniword/wordprocessingml/run.rb +32 -36
  116. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  117. data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
  118. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  119. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  120. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  121. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  122. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  123. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  124. data/lib/uniword/wordprocessingml/text.rb +8 -2
  125. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  126. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  127. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  128. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  129. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  130. data/lib/uniword/wordprocessingml.rb +23 -0
  131. data/lib/uniword.rb +27 -0
  132. metadata +46 -70
  133. data/lib/uniword/math/accent.rb +0 -25
  134. data/lib/uniword/math/accent_properties.rb +0 -25
  135. data/lib/uniword/math/argument_properties.rb +0 -22
  136. data/lib/uniword/math/bar.rb +0 -25
  137. data/lib/uniword/math/bar_properties.rb +0 -25
  138. data/lib/uniword/math/begin_char.rb +0 -22
  139. data/lib/uniword/math/border_box.rb +0 -25
  140. data/lib/uniword/math/border_box_properties.rb +0 -39
  141. data/lib/uniword/math/box.rb +0 -25
  142. data/lib/uniword/math/box_properties.rb +0 -33
  143. data/lib/uniword/math/char.rb +0 -22
  144. data/lib/uniword/math/control_properties.rb +0 -27
  145. data/lib/uniword/math/degree.rb +0 -26
  146. data/lib/uniword/math/delimiter.rb +0 -25
  147. data/lib/uniword/math/delimiter_properties.rb +0 -33
  148. data/lib/uniword/math/denominator.rb +0 -26
  149. data/lib/uniword/math/element.rb +0 -61
  150. data/lib/uniword/math/end_char.rb +0 -22
  151. data/lib/uniword/math/equation_array.rb +0 -25
  152. data/lib/uniword/math/equation_array_properties.rb +0 -33
  153. data/lib/uniword/math/fraction.rb +0 -27
  154. data/lib/uniword/math/fraction_properties.rb +0 -25
  155. data/lib/uniword/math/function.rb +0 -27
  156. data/lib/uniword/math/function_name.rb +0 -26
  157. data/lib/uniword/math/function_properties.rb +0 -23
  158. data/lib/uniword/math/group_char.rb +0 -25
  159. data/lib/uniword/math/group_char_properties.rb +0 -29
  160. data/lib/uniword/math/lim.rb +0 -26
  161. data/lib/uniword/math/lower_limit.rb +0 -27
  162. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  163. data/lib/uniword/math/math_break.rb +0 -22
  164. data/lib/uniword/math/math_font.rb +0 -22
  165. data/lib/uniword/math/math_properties.rb +0 -53
  166. data/lib/uniword/math/math_run.rb +0 -42
  167. data/lib/uniword/math/math_run_properties.rb +0 -37
  168. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  169. data/lib/uniword/math/math_simple_val.rb +0 -24
  170. data/lib/uniword/math/math_style.rb +0 -22
  171. data/lib/uniword/math/math_text.rb +0 -23
  172. data/lib/uniword/math/matrix.rb +0 -25
  173. data/lib/uniword/math/matrix_column.rb +0 -23
  174. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  175. data/lib/uniword/math/matrix_columns.rb +0 -23
  176. data/lib/uniword/math/matrix_properties.rb +0 -37
  177. data/lib/uniword/math/matrix_row.rb +0 -23
  178. data/lib/uniword/math/nary.rb +0 -29
  179. data/lib/uniword/math/nary_properties.rb +0 -33
  180. data/lib/uniword/math/numerator.rb +0 -26
  181. data/lib/uniword/math/o_math.rb +0 -65
  182. data/lib/uniword/math/o_math_para.rb +0 -25
  183. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  184. data/lib/uniword/math/phantom.rb +0 -25
  185. data/lib/uniword/math/phantom_properties.rb +0 -33
  186. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  187. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  188. data/lib/uniword/math/radical.rb +0 -27
  189. data/lib/uniword/math/radical_properties.rb +0 -25
  190. data/lib/uniword/math/separator_char.rb +0 -22
  191. data/lib/uniword/math/sub.rb +0 -26
  192. data/lib/uniword/math/sub_superscript.rb +0 -29
  193. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  194. data/lib/uniword/math/subscript.rb +0 -27
  195. data/lib/uniword/math/subscript_properties.rb +0 -23
  196. data/lib/uniword/math/sup.rb +0 -26
  197. data/lib/uniword/math/superscript.rb +0 -27
  198. data/lib/uniword/math/superscript_properties.rb +0 -23
  199. data/lib/uniword/math/upper_limit.rb +0 -27
  200. data/lib/uniword/math/upper_limit_properties.rb +0 -23
@@ -24,6 +24,13 @@ module Uniword
24
24
  # Generate package-level .rels file
25
25
  #
26
26
  # @return [PackageRelationships] Relationships object for _rels/.rels
27
+ def self.next_available_rid(relationships)
28
+ max = relationships.relationships.filter_map do |r|
29
+ r.id[/\ArId(\d+)\z/, 1]&.to_i
30
+ end.max || 0
31
+ "rId#{max + 1}"
32
+ end
33
+
27
34
  def self.generate_package_rels
28
35
  new(
29
36
  relationships: [
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require_relative "../properties/relationship_id"
5
4
 
6
5
  module Uniword
7
6
  module Presentationml
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require_relative "../properties/relationship_id"
5
4
 
6
5
  module Uniword
7
6
  module Presentationml
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "boolean_element_factory"
4
-
5
3
  module Uniword
6
4
  module Properties
7
5
  BooleanElementFactory.define("b", "Bold")
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "boolean_element_factory"
4
-
5
3
  module Uniword
6
4
  module Properties
7
5
  # Run-level boolean formatting elements
@@ -28,8 +28,8 @@ module Uniword
28
28
  namespace Ooxml::Namespaces::WordProcessingML
29
29
 
30
30
  map_element "top", to: :top, render_nil: false
31
- map_element "bottom", to: :bottom, render_nil: false
32
31
  map_element "left", to: :left, render_nil: false
32
+ map_element "bottom", to: :bottom, render_nil: false
33
33
  map_element "right", to: :right, render_nil: false
34
34
  map_element "between", to: :between, render_nil: false
35
35
  map_element "bar", to: :bar, render_nil: false
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "boolean_element_factory"
4
-
5
3
  module Uniword
6
4
  module Properties
7
5
  BooleanElementFactory.define("i", "Italic")
@@ -17,8 +17,8 @@ module Uniword
17
17
  element "numPr"
18
18
  namespace Ooxml::Namespaces::WordProcessingML
19
19
 
20
- map_element "numId", to: :num_id, render_nil: false
21
20
  map_element "ilvl", to: :ilvl, render_nil: false
21
+ map_element "numId", to: :num_id, render_nil: false
22
22
  end
23
23
  end
24
24
  end
@@ -17,6 +17,19 @@ module Uniword
17
17
  map_attribute "val", to: :value
18
18
  end
19
19
 
20
+ # Cast raw style-id strings into references (mirrors Text.cast), so
21
+ # `properties.style = "Heading1"` stores a proper StyleReference
22
+ def self.cast(value)
23
+ case value
24
+ when StyleReference, nil
25
+ value
26
+ when String
27
+ new(value: value)
28
+ else
29
+ super
30
+ end
31
+ end
32
+
20
33
  # Compare with another StyleReference or a string
21
34
  def ==(other)
22
35
  if other.is_a?(StyleReference)
@@ -72,7 +72,7 @@ module Uniword
72
72
  # @param paragraph [Paragraph] The paragraph to check
73
73
  # @return [Integer, nil] Heading level (1-9) or nil if not a heading
74
74
  def extract_heading_level(paragraph)
75
- style_name = paragraph.properties&.style
75
+ style_name = paragraph.style
76
76
  return nil unless style_name
77
77
 
78
78
  match = style_name.to_s.match(HEADING_PATTERN)
@@ -76,7 +76,7 @@ module Uniword
76
76
  # @param paragraph [Paragraph] The paragraph to check
77
77
  # @return [Boolean] true if has style
78
78
  def has_style?(paragraph)
79
- style = paragraph.properties&.style
79
+ style = paragraph.style
80
80
  style&.value && !style.value.empty?
81
81
  end
82
82
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "accept_reject"
4
-
5
3
  module Uniword
6
4
  module Review
7
5
  # Orchestrator for comments and tracked changes in a document.
@@ -105,12 +105,12 @@ module Uniword
105
105
  # recursively process all text nodes
106
106
  return unless element.is_a?(Uniword::Wordprocessingml::Run)
107
107
 
108
- text = element.text
108
+ text = element.text_string
109
109
  # Simple variable replacement ({{var}})
110
110
  text.scan(/\{\{([^@].+?)\}\}/).each do |match|
111
111
  var_name = match[0]
112
112
  value = resolver.resolve(var_name)
113
- element.text = text.gsub("{{#{var_name}}}", value.to_s)
113
+ element.substitute("{{#{var_name}}}", value.to_s)
114
114
  end
115
115
  end
116
116
  end
@@ -175,7 +175,20 @@ module Uniword
175
175
 
176
176
  return false if left_num.nil? || right_num.nil?
177
177
 
178
- left_num.public_send(operator, right_num)
178
+ case operator
179
+ when :== then left_num == right_num
180
+ when :!= then left_num != right_num
181
+ when :> then left_num > right_num
182
+ when :>= then left_num >= right_num
183
+ when :< then left_num < right_num
184
+ when :<= then left_num <= right_num
185
+ when :+ then left_num + right_num
186
+ when :- then left_num - right_num
187
+ when :* then left_num * right_num
188
+ when :/ then left_num / right_num
189
+ else
190
+ raise ArgumentError, "Unsupported operator: #{operator.inspect}"
191
+ end
179
192
  end
180
193
 
181
194
  # Convert value to number
@@ -79,7 +79,7 @@ module Uniword
79
79
 
80
80
  # Set the color using the appropriate attribute
81
81
  attr_name = color_name == "folHlink" ? :fol_hlink : color_name.to_sym
82
- scheme.public_send(:"#{attr_name}=", color_obj)
82
+ scheme.assign_color_by_name(attr_name, color_obj)
83
83
  end
84
84
 
85
85
  # Sync hash interface
@@ -142,14 +142,7 @@ module Uniword
142
142
  # @param paragraph [Wordprocessingml::Paragraph] Paragraph to inspect
143
143
  # @return [String, nil] Style name or nil
144
144
  def resolve_style_name(paragraph)
145
- style_ref = paragraph.properties&.style
146
- return nil unless style_ref
147
-
148
- if style_ref.is_a?(Uniword::Properties::StyleReference)
149
- style_ref.value
150
- else
151
- style_ref.to_s
152
- end
145
+ paragraph.style&.value
153
146
  end
154
147
 
155
148
  # Extract text content from a paragraph.
@@ -197,9 +190,7 @@ module Uniword
197
190
  def build_title_paragraph
198
191
  para = Wordprocessingml::Paragraph.new
199
192
  para.properties = Wordprocessingml::ParagraphProperties.new
200
- para.properties.style = Uniword::Properties::StyleReference.new(
201
- value: "TOCHeading",
202
- )
193
+ para.properties.style = "TOCHeading"
203
194
 
204
195
  run = Wordprocessingml::Run.new
205
196
  run.text = "Table of Contents"
@@ -251,9 +242,7 @@ module Uniword
251
242
  def build_entry_paragraph(entry)
252
243
  para = Wordprocessingml::Paragraph.new
253
244
  para.properties = Wordprocessingml::ParagraphProperties.new
254
- para.properties.style = Uniword::Properties::StyleReference.new(
255
- value: "TOC#{entry.level}",
256
- )
245
+ para.properties.style = "TOC#{entry.level}"
257
246
 
258
247
  run = Wordprocessingml::Run.new
259
248
  run.text = entry.text
@@ -69,7 +69,8 @@ module Uniword
69
69
  # Convert OOXML Run to HTML
70
70
  def run_to_html(run)
71
71
  # Handle breaks and drawings first (these never get style wrapping)
72
- return break_to_html(run.break) if run.break
72
+ # (parsed runs hold [] for absent collections, so check emptiness)
73
+ return break_to_html(run.break) if Array(run.break).any?
73
74
  if run.drawings && !run.drawings.empty?
74
75
  return drawing_to_html(run.drawings.first)
75
76
  end
@@ -105,7 +106,8 @@ module Uniword
105
106
  # Skip wrapping for internal OOXML styles that shouldn't produce
106
107
  # visible HTML class wrappers
107
108
  is_internal_style = %w[stem hyperlink].include?(style_id)
108
- is_stem_spacer = (style_id == "stem") && run.text.to_s.strip.empty?
109
+ is_stem_spacer = (style_id == "stem") &&
110
+ run.text_string.strip.empty?
109
111
  unless is_internal_style || is_stem_spacer
110
112
  style_val = run_style_to_class(props.style.value)
111
113
  content = %(<span class="#{style_val}">#{content}</span>)
@@ -125,7 +127,7 @@ module Uniword
125
127
  elsif run.tab
126
128
  tab_to_html(run)
127
129
  else
128
- text = run.text.to_s
130
+ text = run.text_string
129
131
  return "" if text.empty?
130
132
 
131
133
  escape_html(text)
@@ -142,13 +144,15 @@ module Uniword
142
144
  %(<span class="MsoEndnoteReference"><span style="mso-special-character:endnote"></span></span>)
143
145
  end
144
146
 
145
- # Convert OOXML Break to HTML
146
- def break_to_html(brk)
147
- if brk.type == "page"
148
- %(<br clear="all" style="mso-special-character:line-break;page-break-before:always" />)
149
- else
150
- %(<br />)
151
- end
147
+ # Convert OOXML Breaks to HTML, one <br> per Break element
148
+ def break_to_html(breaks)
149
+ Array(breaks).map do |brk|
150
+ if brk.type == "page"
151
+ %(<br clear="all" style="mso-special-character:line-break;page-break-before:always" />)
152
+ else
153
+ %(<br />)
154
+ end
155
+ end.join
152
156
  end
153
157
 
154
158
  # Convert OOXML FieldChar to HTML span
@@ -272,7 +276,9 @@ module Uniword
272
276
  end
273
277
  end
274
278
 
275
- # Convert OOXML OMath to HTML (wrapped in stem span)
279
+ # Convert an OOXML OMath model to HTML (wrapped in stem span).
280
+ # Accepts Omml::Models::CTOMath/OMath/OMathPara (the typed attribute
281
+ # values on Paragraph) — anything responding to `to_xml` works.
276
282
  def omath_to_html(o_math)
277
283
  xml = o_math.to_xml
278
284
  # Strip XML declaration and namespace prefixes for clean inline HTML
@@ -491,23 +497,87 @@ module Uniword
491
497
 
492
498
  private
493
499
 
494
- # Get paragraph content including runs, hyperlinks, SDTs, and oMath
495
- # Interleaves all element types in correct order using position markers.
500
+ # Render paragraph content (runs, hyperlinks, SDTs, oMath) in the
501
+ # document order captured by `paragraph.element_order`.
502
+ #
503
+ # Walking element_order (rather than `build_position_map` keyed on
504
+ # run_position) keeps MHTML positioning in lockstep with the DOCX
505
+ # serializer, which is the single source of truth for interleaving.
506
+ # It also handles inline math, which is now an Omml::Models::CTOMath
507
+ # instance without a run_position attribute.
496
508
  def paragraph_content_to_html(paragraph)
509
+ order = paragraph.element_order
510
+ return paragraph_content_to_html_legacy(paragraph) if order.nil? || order.empty?
511
+
512
+ run_idx = 0
513
+ hl_idx = 0
514
+ omath_idx = 0
515
+ omp_idx = 0
516
+ sdt_idx = 0
517
+
518
+ parts = []
519
+ order.each do |entry|
520
+ next unless entry.element?
521
+
522
+ case entry.name
523
+ when "r"
524
+ collect_run_at(paragraph, run_idx, parts)
525
+ run_idx += 1
526
+ when "hyperlink"
527
+ hl = paragraph.hyperlinks[hl_idx]
528
+ parts << hyperlink_to_html(hl) if hl
529
+ hl_idx += 1
530
+ when "oMath"
531
+ om = paragraph.o_maths[omath_idx]
532
+ parts << omath_to_html(om) if om
533
+ omath_idx += 1
534
+ when "oMathPara"
535
+ omp = paragraph.o_math_paras[omp_idx]
536
+ parts << omath_to_html(omp) if omp
537
+ omp_idx += 1
538
+ when "sdt"
539
+ sdt = paragraph.sdts[sdt_idx]
540
+ parts << sdt_to_inline_html(sdt) if sdt
541
+ sdt_idx += 1
542
+ end
543
+ end
544
+
545
+ # Defensive: emit any math/SDTs not represented in element_order.
546
+ paragraph.o_math_paras.each_with_index do |omp, i|
547
+ next if i < omp_idx
548
+
549
+ parts << omath_to_html(omp)
550
+ end
551
+ paragraph.sdts.each_with_index do |sdt, i|
552
+ next if i < sdt_idx
553
+
554
+ parts << sdt_to_inline_html(sdt)
555
+ end
556
+
557
+ parts.join
558
+ end
559
+
560
+ def collect_run_at(paragraph, idx, parts)
561
+ run = paragraph.runs[idx]
562
+ return unless run
563
+
564
+ parts << if run.is_a?(Uniword::Wordprocessingml::StructuredDocumentTag)
565
+ sdt_to_inline_html(run)
566
+ else
567
+ run_to_html(run)
568
+ end
569
+ end
570
+
571
+ # Fallback for Paragraphs constructed without element_order tracking
572
+ # (older code paths). Preserves the previous behavior of appending
573
+ # inline math/hyperlinks at the end.
574
+ def paragraph_content_to_html_legacy(paragraph)
497
575
  run_count = paragraph.runs.size
498
576
 
499
- # Build position-indexed maps of hyperlinks and oMaths
500
577
  hl_by_pos = build_position_map(paragraph.hyperlinks, run_count)
501
- omath_by_pos = build_position_map(paragraph.o_maths, run_count)
502
578
 
503
579
  parts = []
504
580
  paragraph.runs.each_with_index do |run, idx|
505
- # Insert any oMaths that should appear at this position
506
- if omath_by_pos.key?(idx)
507
- omath_by_pos[idx].each { |om| parts << omath_to_html(om) }
508
- end
509
-
510
- # Insert any hyperlinks that should appear at this position
511
581
  if hl_by_pos.key?(idx)
512
582
  hl_by_pos[idx].each { |hl| parts << hyperlink_to_html(hl) }
513
583
  end
@@ -519,26 +589,13 @@ module Uniword
519
589
  end
520
590
  end
521
591
 
522
- # Append any remaining oMaths at the end
523
- if omath_by_pos.key?(run_count)
524
- omath_by_pos[run_count].each { |om| parts << omath_to_html(om) }
525
- end
526
-
527
- # Append any remaining hyperlinks at the end
528
592
  if hl_by_pos.key?(run_count)
529
593
  hl_by_pos[run_count].each { |hl| parts << hyperlink_to_html(hl) }
530
594
  end
531
595
 
532
- paragraph.o_math_paras.each do |omp|
533
- # Block math: render each oMath inside the oMathPara
534
- omp.o_maths.each do |om|
535
- parts << omath_to_html(om)
536
- end
537
- end
538
-
539
- paragraph.sdts.each do |sdt|
540
- parts << sdt_to_inline_html(sdt)
541
- end
596
+ paragraph.o_maths.each { |om| parts << omath_to_html(om) }
597
+ paragraph.o_math_paras.each { |omp| parts << omath_to_html(omp) }
598
+ paragraph.sdts.each { |sdt| parts << sdt_to_inline_html(sdt) }
542
599
 
543
600
  parts.join
544
601
  end
@@ -184,7 +184,7 @@ module Uniword
184
184
  para_chars = 0
185
185
 
186
186
  element.runs.each do |run|
187
- text = run.text.to_s
187
+ text = run.text_string
188
188
  characters += text.length
189
189
  para_chars += text.length
190
190
  words += text.split.length
@@ -54,7 +54,7 @@ module Uniword
54
54
  # @param run [Uniword::Wordprocessingml::Run]
55
55
  # @return [String] HTML text content with inline formatting
56
56
  def self.run_to_html(run)
57
- text = escape_html(run.text || "")
57
+ text = escape_html(run.text_string)
58
58
  return text if text.empty?
59
59
 
60
60
  props = run.properties
@@ -135,12 +135,10 @@ module Uniword
135
135
  # @param paragraph [Uniword::Wordprocessingml::Paragraph]
136
136
  # @return [String] HTML class/style attribute or empty string
137
137
  def self.paragraph_style(paragraph)
138
- return "" unless paragraph.properties
139
-
140
- style = paragraph.properties.style
138
+ style = paragraph.style
141
139
  return "" unless style
142
140
 
143
- " class=\"#{escape_html(style)}\""
141
+ " class=\"#{escape_html(style.to_s)}\""
144
142
  end
145
143
 
146
144
  # Convert OOXML font size (half-points) to HTML font size
@@ -133,10 +133,9 @@ module Uniword
133
133
  return [] unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
134
134
 
135
135
  document.paragraphs.select do |p|
136
- p.is_a?(Lutaml::Model::Serializable) && p.class.attributes.key?(:style) && p.style&.match?(/^Heading/)
136
+ p.style&.to_s&.match?(/^Heading/)
137
137
  end.filter_map do |p|
138
- # Generate bookmark from heading text
139
- p.is_a?(Lutaml::Model::Serializable) && p.class.attributes.key?(:text) ? heading_to_bookmark(p.text) : nil
138
+ heading_to_bookmark(p.text)
140
139
  end
141
140
  end
142
141
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uniword
4
- VERSION = "1.0.11"
4
+ VERSION = "1.2.4"
5
5
  end
@@ -103,7 +103,8 @@ module Uniword
103
103
  # @param run [Run] The run to visit
104
104
  # @return [void]
105
105
  def visit_run(run)
106
- @text_parts << run.text if run.text
106
+ text = run.text_string
107
+ @text_parts << text unless text.empty?
107
108
  end
108
109
 
109
110
  # Visit an image element.
@@ -108,11 +108,11 @@ module Uniword
108
108
  return false unless paragraph.properties
109
109
  return false unless paragraph.properties.is_a?(Uniword::Wordprocessingml::ParagraphProperties)
110
110
 
111
- paragraph.properties.style.to_s == "UniwordWatermark"
111
+ paragraph.style == "UniwordWatermark"
112
112
  end
113
113
 
114
114
  def extract_watermark_text(paragraph)
115
- paragraph.runs.map { |r| r.text.to_s }.join
115
+ paragraph.runs.map(&:text_string).join
116
116
  end
117
117
  end
118
118
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Wordprocessingml
7
+ class AllowPng < Lutaml::Model::Serializable
8
+ xml do
9
+ element "allowPNG"
10
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
11
+ end
12
+ end
13
+ end
14
+ end
@@ -66,53 +66,70 @@ module Uniword
66
66
  sync_element_order
67
67
  end
68
68
 
69
+ # Record a programmatically-added element in element_order.
70
+ # Called by DocumentBuilder to preserve insertion order when
71
+ # interleaving paragraphs, tables, and SDTs.
72
+ def append_to_element_order(element_name)
73
+ entry = Lutaml::Xml::Element.new("Element", element_name)
74
+ if element_order.nil? || element_order.empty?
75
+ self.element_order = [entry]
76
+ else
77
+ self.element_order = element_order + [entry]
78
+ end
79
+ end
80
+
69
81
  private
70
82
 
71
83
  def sync_element_order
72
84
  return if element_order.nil? || element_order.empty?
73
85
 
74
- counts = element_order.each_with_object(Hash.new(0)) do |e, h|
75
- h[e.name] += 1
76
- end
77
-
78
- needed = false
79
- needed = true if paragraphs.size > counts["p"]
80
- needed = true if tables.size > counts["tbl"]
81
- needed = true if structured_document_tags.size > counts["sdt"]
82
- needed = true if bookmark_starts.size > counts["bookmarkStart"]
83
- needed = true if bookmark_ends.size > counts["bookmarkEnd"]
84
- needed = true if section_properties && counts["sectPr"].zero?
86
+ # lutaml-model freezes element_order after XML parsing.
87
+ # Build a mutable copy to append missing elements.
88
+ new_entries = []
85
89
 
86
- return unless needed
87
-
88
- dup_element_order_if_frozen
90
+ # Count how many p/tbl/sdt entries exist in element_order
91
+ ordered_p_count = element_order.count { |e| e.name == "p" }
92
+ ordered_tbl_count = element_order.count { |e| e.name == "tbl" }
93
+ ordered_sdt_count = element_order.count { |e| e.name == "sdt" }
94
+ ordered_bookmark_start_count = element_order.count do |e|
95
+ e.name == "bookmarkStart"
96
+ end
97
+ ordered_bookmark_end_count = element_order.count do |e|
98
+ e.name == "bookmarkEnd"
99
+ end
89
100
 
90
- append_missing_elements("p", paragraphs.size - counts["p"])
91
- append_missing_elements("tbl", tables.size - counts["tbl"])
92
- append_missing_elements("sdt",
93
- structured_document_tags.size - counts["sdt"])
94
- append_missing_elements("bookmarkStart",
95
- bookmark_starts.size - counts["bookmarkStart"])
96
- append_missing_elements("bookmarkEnd",
97
- bookmark_ends.size - counts["bookmarkEnd"])
101
+ # Collect missing paragraphs
102
+ (paragraphs.size - ordered_p_count).times do
103
+ new_entries << Lutaml::Xml::Element.new("Element", "p")
104
+ end
98
105
 
99
- return unless section_properties && counts["sectPr"].zero?
106
+ # Collect missing tables
107
+ (tables.size - ordered_tbl_count).times do
108
+ new_entries << Lutaml::Xml::Element.new("Element", "tbl")
109
+ end
100
110
 
101
- element_order << build_order_element("sectPr")
102
- end
111
+ # Collect missing structured document tags
112
+ (structured_document_tags.size - ordered_sdt_count).times do
113
+ new_entries << Lutaml::Xml::Element.new("Element", "sdt")
114
+ end
103
115
 
104
- def dup_element_order_if_frozen
105
- return unless element_order.frozen?
116
+ # Collect missing bookmark starts
117
+ (bookmark_starts.size - ordered_bookmark_start_count).times do
118
+ new_entries << Lutaml::Xml::Element.new("Element", "bookmarkStart")
119
+ end
106
120
 
107
- self.element_order = element_order.dup
108
- end
121
+ # Collect missing bookmark ends
122
+ (bookmark_ends.size - ordered_bookmark_end_count).times do
123
+ new_entries << Lutaml::Xml::Element.new("Element", "bookmarkEnd")
124
+ end
109
125
 
110
- def append_missing_elements(name, count)
111
- count.times { element_order << build_order_element(name) }
112
- end
126
+ # Ensure section_properties is in element_order if present
127
+ if section_properties && element_order.none? { |e| e.name == "sectPr" }
128
+ new_entries << Lutaml::Xml::Element.new("Element", "sectPr")
129
+ end
113
130
 
114
- def build_order_element(name)
115
- Lutaml::Xml::Element.new("Element", name)
131
+ # Replace frozen array with mutable copy containing new entries
132
+ self.element_order = element_order + new_entries unless new_entries.empty?
116
133
  end
117
134
  end
118
135
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Wordprocessingml
7
+ class BodyDiv < Lutaml::Model::Serializable
8
+ attribute :val, :string
9
+
10
+ xml do
11
+ element "bodyDiv"
12
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
13
+ map_attribute "val", to: :val
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Wordprocessingml
7
+ class DivBorder < Lutaml::Model::Serializable
8
+ attribute :val, :string
9
+ attribute :sz, :string
10
+ attribute :space, :string
11
+ attribute :color, :string
12
+
13
+ xml do
14
+ element "border"
15
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
16
+ map_attribute "val", to: :val
17
+ map_attribute "sz", to: :sz
18
+ map_attribute "space", to: :space
19
+ map_attribute "color", to: :color
20
+ end
21
+ end
22
+ end
23
+ end