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
@@ -5,25 +5,16 @@ module Uniword
5
5
  # Builds footnotes and endnotes for documents.
6
6
  #
7
7
  # Manages footnote/endnote creation, ID assignment, and wiring
8
- # references into the document body.
9
- #
10
- # @example Add footnotes to a document
11
- # doc = DocumentBuilder.new
12
- # doc.paragraph { |p| p << 'See the '; p << doc.footnote('A note') }
13
- #
14
- # @example Add endnotes
15
- # doc.paragraph { |p| p << 'See '; p << doc.endnote('An endnote') }
8
+ # references into the document body. Uses IdAllocator for all ID
9
+ # assignment when available.
16
10
  class FootnoteBuilder
17
- def initialize(document)
11
+ def initialize(document, allocator: nil)
18
12
  @document = document
13
+ @allocator = allocator
19
14
  end
20
15
 
21
16
  # Create a footnote and return a Run with a footnoteReference.
22
17
  #
23
- # The footnote body is stored in the document's footnotes collection.
24
- # The returned Run contains a <w:footnoteReference> element that
25
- # Word uses to link to the footnote body.
26
- #
27
18
  # @param text [String] Footnote text content
28
19
  # @yield [ParagraphBuilder] Builder for rich footnote content
29
20
  # @return [Wordprocessingml::Run] Run with footnote reference
@@ -61,24 +52,32 @@ module Uniword
61
52
  end
62
53
 
63
54
  def next_footnote_id
64
- @footnote_counter ||= 1
65
- id = @footnote_counter
66
- @footnote_counter += 1
67
- id
55
+ if @allocator
56
+ @allocator.alloc_footnote_id
57
+ else
58
+ @footnote_counter ||= 1
59
+ id = @footnote_counter
60
+ @footnote_counter += 1
61
+ id
62
+ end
68
63
  end
69
64
 
70
65
  def next_endnote_id
71
- @endnote_counter ||= 1
72
- id = @endnote_counter
73
- @endnote_counter += 1
74
- id
66
+ if @allocator
67
+ @allocator.alloc_endnote_id
68
+ else
69
+ @endnote_counter ||= 1
70
+ id = @endnote_counter
71
+ @endnote_counter += 1
72
+ id
73
+ end
75
74
  end
76
75
 
77
76
  def create_footnote_entry(id, text, &block)
78
77
  # OOXML spec: regular footnotes must NOT have w:type.
79
78
  # Only separator/continuationSeparator use w:type.
80
79
  entry = Wordprocessingml::Footnote.new(id: id.to_s)
81
- para = ParagraphBuilder.new
80
+ para = ParagraphBuilder.new(allocator: @allocator)
82
81
  if block_given?
83
82
  block.call(para)
84
83
  elsif text
@@ -90,7 +89,7 @@ module Uniword
90
89
 
91
90
  def create_endnote_entry(id, text, &block)
92
91
  entry = Wordprocessingml::Endnote.new(id: id.to_s)
93
- para = ParagraphBuilder.new
92
+ para = ParagraphBuilder.new(allocator: @allocator)
94
93
  if block_given?
95
94
  block.call(para)
96
95
  elsif text
@@ -15,10 +15,11 @@ module Uniword
15
15
  # f << Builder.page_number_field
16
16
  # end
17
17
  class HeaderFooterBuilder
18
- attr_reader :model, :kind
18
+ attr_reader :model, :kind, :allocator
19
19
 
20
- def initialize(kind, type: "default")
20
+ def initialize(kind, type: "default", allocator: nil)
21
21
  @kind = kind
22
+ @allocator = allocator
22
23
  @model = if kind == :header
23
24
  Wordprocessingml::Header.new
24
25
  else
@@ -33,15 +34,14 @@ module Uniword
33
34
  def <<(element)
34
35
  case element
35
36
  when String
36
- para = Wordprocessingml::Paragraph.new
37
- para.runs << Wordprocessingml::Run.new(text: element)
38
- @model.paragraphs << para
37
+ para = ParagraphBuilder.new(allocator: @allocator)
38
+ para << element
39
+ @model.paragraphs << para.build
39
40
  when Wordprocessingml::Run
40
- # Auto-wrap Run in a paragraph, appending to the last one if possible
41
41
  if @model.paragraphs.empty?
42
- para = Wordprocessingml::Paragraph.new
43
- para.runs << element
44
- @model.paragraphs << para
42
+ para = ParagraphBuilder.new(allocator: @allocator)
43
+ para << element
44
+ @model.paragraphs << para.build
45
45
  else
46
46
  @model.paragraphs.last.runs << element
47
47
  end
@@ -63,7 +63,7 @@ module Uniword
63
63
  # @yield [ParagraphBuilder] Builder for paragraph configuration
64
64
  # @return [ParagraphBuilder]
65
65
  def paragraph(text = nil, &block)
66
- para = ParagraphBuilder.new
66
+ para = ParagraphBuilder.new(allocator: @allocator)
67
67
  para << text if text
68
68
  yield(para) if block
69
69
  @model.paragraphs << para.build
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "digest"
4
4
  require "stringio"
5
5
 
6
6
  module Uniword
@@ -27,20 +27,26 @@ module Uniword
27
27
  # Picture namespace URI for GraphicData
28
28
  PIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture"
29
29
 
30
+ IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
31
+
30
32
  # Register an image part on the document for DOCX packaging.
31
33
  #
32
34
  # @param document [DocumentBuilder, DocumentRoot] Target document
33
35
  # @param path [String] Path to image file
34
- # @return [String] Relationship ID (e.g., 'rIdImg1')
36
+ # @return [String] Relationship ID (e.g., 'rId5')
35
37
  def self.register_image(document, path)
36
38
  root = document.is_a?(Uniword::Builder::DocumentBuilder) ? document.model : document
37
- if root
38
- root.image_parts ||= {}
39
- r_id = "rIdImg#{root.image_parts.size + 1}"
40
- else
41
- # No document context — generate a placeholder rId
42
- r_id = "rIdImg#{SecureRandom.random_number(1_000_000)}"
43
- end
39
+ alloc = document.is_a?(Uniword::Builder::DocumentBuilder) ? document.allocator : nil
40
+
41
+ target = "media/#{File.basename(path)}"
42
+ r_id = if alloc
43
+ alloc.alloc_rid(target: target, type: IMAGE_REL_TYPE)
44
+ elsif root
45
+ root.image_parts ||= {}
46
+ "rId#{root.image_parts.size + 1}"
47
+ else
48
+ "rId#{deterministic_id('img_rid', path)}"
49
+ end
44
50
 
45
51
  content_type = case File.extname(path).downcase
46
52
  when ".png" then "image/png"
@@ -53,6 +59,7 @@ module Uniword
53
59
  end
54
60
 
55
61
  if root
62
+ root.image_parts ||= {}
56
63
  root.image_parts[r_id] = {
57
64
  path: path,
58
65
  data: File.binread(path),
@@ -133,11 +140,11 @@ alt_text: nil)
133
140
 
134
141
  drawing = Wordprocessingml::Drawing.new
135
142
 
136
- inline = WpDrawing::Inline.new
143
+ inline = WpDrawing::Inline.new(dist_t: 0, dist_b: 0, dist_l: 0, dist_r: 0)
137
144
  inline.extent = WpDrawing::Extent.new(cx: w, cy: h)
138
- inline.effect_extent = WpDrawing::EffectExtent.new
145
+ inline.effect_extent = WpDrawing::EffectExtent.new(l: 0, t: 0, r: 0, b: 0)
139
146
  inline.doc_properties = WpDrawing::DocProperties.new(
140
- id: SecureRandom.random_number(1_000_000_000),
147
+ id: deterministic_id("inline", path),
141
148
  name: File.basename(path, ".*"),
142
149
  )
143
150
  inline.graphic = build_graphic(r_id, w, h)
@@ -217,7 +224,7 @@ alt_text: nil)
217
224
  end
218
225
 
219
226
  anchor.doc_properties = WpDrawing::DocProperties.new(
220
- id: SecureRandom.random_number(1_000_000_000),
227
+ id: deterministic_id("anchor", path),
221
228
  name: File.basename(path, ".*"),
222
229
  )
223
230
  anchor.graphic = build_graphic(r_id, w, h)
@@ -266,7 +273,7 @@ alt_text: nil)
266
273
  end
267
274
 
268
275
  class << self
269
- private
276
+ include DeterministicId
270
277
 
271
278
  # Build the Graphic > GraphicData > Picture chain
272
279
  #
@@ -295,8 +302,8 @@ alt_text: nil)
295
302
  # Non-visual properties
296
303
  pic.nv_pic_pr = Picture::NonVisualPictureProperties.new
297
304
  pic.nv_pic_pr.c_nv_pr = Drawingml::NonVisualDrawingProperties.new(
298
- id: SecureRandom.random_number(1_000_000_000),
299
- name: "Picture #{SecureRandom.random_number(1_000_000)}",
305
+ id: deterministic_id("pic", r_id),
306
+ name: "Picture",
300
307
  )
301
308
  pic.nv_pic_pr.c_nv_pic_pr = Picture::NonVisualPictureDrawingProperties.new
302
309
 
@@ -312,6 +319,7 @@ alt_text: nil)
312
319
  pic.sp_pr.xfrm.off = Drawingml::Offset.new(x: 0, y: 0)
313
320
  pic.sp_pr.xfrm.ext = Drawingml::Extents.new(cx: width, cy: height)
314
321
  pic.sp_pr.prst_geom = Drawingml::PresetGeometry.new(prst: "rect")
322
+ pic.sp_pr.prst_geom.av_lst = Drawingml::AdjustValueList.new
315
323
 
316
324
  pic
317
325
  end
@@ -40,7 +40,8 @@ module Uniword
40
40
  # @yield [ParagraphBuilder] Builder for item content
41
41
  # @return [ParagraphBuilder] The paragraph builder
42
42
  def item(text = nil, level: 0, &block)
43
- para = ParagraphBuilder.new
43
+ alloc = @document.is_a?(DocumentBuilder) ? @document.allocator : nil
44
+ para = ParagraphBuilder.new(allocator: alloc)
44
45
  para.numbering(@num_id, level)
45
46
  para << text if text
46
47
  yield(para) if block
@@ -1,31 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Uniword
4
6
  module Builder
5
- # Builds and configures Paragraph objects.
6
- #
7
- # Uses << operator to append child elements with smart type routing.
8
- #
9
- # @example Create a simple paragraph
10
- # para = ParagraphBuilder.new
11
- # para << 'Hello World'
12
- # para.build
13
- #
14
- # @example Styled paragraph with mixed content
15
- # para = ParagraphBuilder.new
16
- # para.style = 'Heading1'
17
- # para << Builder.text('Title', bold: true, size: 24)
18
- # para << Builder.hyperlink('https://example.com', 'link')
19
- # para << Builder.tab_stop(position: 7200)
20
- # para.build
21
7
  class ParagraphBuilder < BaseBuilder
22
8
  include HasBorders
23
9
  include HasShading
24
10
 
11
+ @sequence = 0
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ attr_accessor :sequence, :mutex
16
+ end
17
+
25
18
  def self.default_model_class
26
19
  Wordprocessingml::Paragraph
27
20
  end
28
21
 
22
+ attr_reader :allocator
23
+
24
+ def initialize(model = nil, allocator: nil)
25
+ @allocator = allocator
26
+ super(model)
27
+ assign_tracking_attributes
28
+ end
29
+
29
30
  # Append a child element. Routes by type:
30
31
  # - String -> creates a Run
31
32
  # - Run -> appends to runs
@@ -39,23 +40,36 @@ module Uniword
39
40
  def <<(element)
40
41
  case element
41
42
  when String
42
- @model.runs << Wordprocessingml::Run.new(text: element)
43
+ append_string(element)
44
+ when Omml::Models::CTOMath, Omml::Models::OMath
45
+ @model.o_maths << element
46
+ track_element_order("oMath")
47
+ when Omml::Models::OMathPara
48
+ @model.o_math_paras << element
49
+ track_element_order("oMathPara")
43
50
  when Wordprocessingml::Run
44
- @model.runs << element
51
+ append_run(element)
45
52
  when Wordprocessingml::Hyperlink
46
53
  @model.hyperlinks << element
54
+ track_element_order("hyperlink")
47
55
  when Properties::TabStop
48
56
  ensure_properties
49
57
  @model.properties.tabs ||= Properties::Tabs.new
50
58
  @model.properties.tabs << element
51
59
  when Wordprocessingml::BookmarkStart
52
60
  @model.bookmark_starts << element
61
+ track_element_order("bookmarkStart")
53
62
  when Wordprocessingml::BookmarkEnd
54
63
  @model.bookmark_ends << element
64
+ track_element_order("bookmarkEnd")
55
65
  when Wordprocessingml::StructuredDocumentTag
56
66
  @model.sdts << element
67
+ track_element_order("sdt")
68
+ when Wordprocessingml::SimpleField
69
+ @model.simple_fields << element
70
+ track_element_order("fldSimple")
57
71
  when RunBuilder
58
- @model.runs << element.build
72
+ append_run(element.build)
59
73
  else
60
74
  raise ArgumentError, "Cannot add #{element.class} to paragraph"
61
75
  end
@@ -63,10 +77,14 @@ module Uniword
63
77
  end
64
78
 
65
79
  def style=(name)
66
- ensure_properties.style = Properties::StyleReference.new(value: name)
80
+ ensure_properties.style = name
67
81
  self
68
82
  end
69
83
 
84
+ def style
85
+ @model.style
86
+ end
87
+
70
88
  def align=(value)
71
89
  ensure_properties.alignment = Properties::Alignment.new(value: value.to_s)
72
90
  self
@@ -146,8 +164,65 @@ module Uniword
146
164
 
147
165
  private
148
166
 
167
+ def assign_tracking_attributes
168
+ if @allocator
169
+ @model.rsid_r = @allocator.alloc_rsid
170
+ @model.para_id = Wordprocessingml::W14ParaId.new(@allocator.alloc_para_id)
171
+ else
172
+ idx = self.class.mutex.synchronize do
173
+ self.class.sequence += 1
174
+ end
175
+ hash = Digest::SHA256.hexdigest("para:#{idx}:#{Process.pid}")
176
+ @model.rsid_r = hash[0, 8]
177
+ @model.para_id = Wordprocessingml::W14ParaId.new(hash[8, 8])
178
+ end
179
+ @model.rsid_r_default = "00000000"
180
+ @model.text_id = Wordprocessingml::W14TextId.new("77777777")
181
+ end
182
+
183
+ def append_run(run)
184
+ return if RunUtils.empty_run?(run)
185
+
186
+ last = @model.runs.last
187
+ if last && RunUtils.mergeable?(last, run)
188
+ RunUtils.merge_text(last, run)
189
+ else
190
+ @model.runs << run
191
+ track_element_order("r")
192
+ end
193
+ end
194
+
195
+ def append_string(str)
196
+ return if str.nil? || str.empty?
197
+
198
+ unless str.include?("\n")
199
+ @model.runs << Wordprocessingml::Run.new(text: str)
200
+ track_element_order("r")
201
+ return
202
+ end
203
+
204
+ segments = str.split("\n", -1)
205
+ segments.each_with_index do |seg, i|
206
+ unless seg.empty?
207
+ @model.runs << Wordprocessingml::Run.new(text: seg)
208
+ track_element_order("r")
209
+ end
210
+ if i < segments.size - 1
211
+ br_run = Wordprocessingml::Run.new
212
+ br_run.break = Wordprocessingml::Break.new
213
+ @model.runs << br_run
214
+ track_element_order("r")
215
+ end
216
+ end
217
+ end
218
+
219
+ def properties_tag
220
+ "pPr"
221
+ end
222
+
149
223
  def ensure_properties
150
224
  @model.properties ||= Wordprocessingml::ParagraphProperties.new
225
+ ensure_properties_in_order
151
226
  @model.properties
152
227
  end
153
228
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Builder
5
+ # Shared run utilities used by both ParagraphBuilder (build-time) and
6
+ # Reconciler::Helpers (reconcile-time).
7
+ #
8
+ # Provides canonical implementations of run classification and merging.
9
+ # Both modules delegate here to ensure identical behavior.
10
+ module RunUtils
11
+ module_function
12
+
13
+ # Whether a run contains no renderable content.
14
+ #
15
+ # A run is empty only if it has no text content and no structural
16
+ # elements (breaks, tabs, drawings, field chars, etc.).
17
+ def empty_run?(run)
18
+ return false if Array(run.break).any?
19
+ return false if run.tab
20
+ return false if run.drawings&.any?
21
+ return false if run.pictures&.any?
22
+ return false if run.alternate_content
23
+ return false if run.footnote_reference
24
+ return false if run.endnote_reference
25
+ return false if run.field_char
26
+ return false if run.instr_text
27
+ return false if run.position_tab
28
+ return false if run.del_text
29
+ return false if run.no_break_hyphen
30
+ return false if run.sym
31
+ return false if run.last_rendered_page_break
32
+ return false if run.separator_char
33
+ return false if run.continuation_separator_char
34
+
35
+ run.text_string.empty?
36
+ end
37
+
38
+ # Whether a run contains only text (no structural elements).
39
+ #
40
+ # Runs with drawings, breaks, tabs, field chars, etc. are NOT
41
+ # text-only and should never be merged with other runs.
42
+ def text_only_run?(run)
43
+ return false if run.drawings&.any?
44
+ return false if run.pictures&.any?
45
+ return false if run.alternate_content
46
+ return false if run.footnote_reference
47
+ return false if run.endnote_reference
48
+ return false if run.field_char
49
+ return false if run.instr_text
50
+ return false if run.sym
51
+ return false if Array(run.break).any?
52
+ return false if run.tab
53
+ return false if run.position_tab
54
+ return false if run.no_break_hyphen
55
+ return false if run.del_text
56
+
57
+ true
58
+ end
59
+
60
+ # Whether two runs have identical formatting (RunProperties).
61
+ def properties_match?(a, b)
62
+ rpr_a = a.properties
63
+ rpr_b = b.properties
64
+ return true if rpr_a.nil? && rpr_b.nil?
65
+ return false if rpr_a.nil? || rpr_b.nil?
66
+
67
+ rpr_a.to_xml == rpr_b.to_xml
68
+ end
69
+
70
+ # Whether two text-only runs can be merged.
71
+ #
72
+ # Runs can be merged if both are text-only with identical formatting.
73
+ def mergeable?(existing, incoming)
74
+ return false unless text_only_run?(existing) && text_only_run?(incoming)
75
+ return false if incoming.text_string.empty?
76
+
77
+ properties_match?(existing, incoming)
78
+ end
79
+
80
+ # Merge source run's text into target run.
81
+ #
82
+ # Handles xml:space="preserve" for whitespace-sensitive content.
83
+ def merge_text(target, source)
84
+ if Array(source.text).any?
85
+ existing = target.text_string
86
+ appended = source.text_string
87
+ combined = existing + appended
88
+ new_text = Wordprocessingml::Text.new(content: combined)
89
+ if Wordprocessingml::Text.preserve_whitespace?(combined)
90
+ new_text.xml_space = "preserve"
91
+ end
92
+ target.text = new_text
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -21,18 +21,19 @@ module Uniword
21
21
  Wordprocessingml::SectionProperties
22
22
  end
23
23
 
24
+ def initialize(model = nil)
25
+ super
26
+ ensure_section_defaults
27
+ end
28
+
24
29
  def type=(value)
25
30
  @model.type = value
26
31
  self
27
32
  end
28
33
 
29
- # Set page size
30
- #
31
- # @param width [Integer] Page width in twips (default US Letter: 12240)
32
- # @param height [Integer] Page height in twips (default US Letter: 15840)
33
- # @param orientation [String] 'portrait' or 'landscape'
34
- # @return [self]
35
- def page_size(width: 12_240, height: 15_840, orientation: "portrait")
34
+ def page_size(width: Wordprocessingml::PageDefaults::LETTER_WIDTH,
35
+ height: Wordprocessingml::PageDefaults::LETTER_HEIGHT,
36
+ orientation: "portrait")
36
37
  @model.page_size ||= Wordprocessingml::PageSize.new
37
38
  @model.page_size.width = width
38
39
  @model.page_size.height = height
@@ -40,16 +41,6 @@ module Uniword
40
41
  self
41
42
  end
42
43
 
43
- # Set page margins
44
- #
45
- # @param top [Integer] Top margin in twips (default 1440 = 1 inch)
46
- # @param bottom [Integer] Bottom margin in twips
47
- # @param left [Integer] Left margin in twips
48
- # @param right [Integer] Right margin in twips
49
- # @param header [Integer] Header distance from edge in twips
50
- # @param footer [Integer] Footer distance from edge in twips
51
- # @param gutter [Integer] Gutter margin in twips
52
- # @return [self]
53
44
  def margins(top: 1440, bottom: 1440, left: 1440, right: 1440,
54
45
  header: 720, footer: 720, gutter: 0)
55
46
  @model.page_margins ||= Wordprocessingml::PageMargins.new
@@ -63,42 +54,6 @@ module Uniword
63
54
  self
64
55
  end
65
56
 
66
- # Set number of columns
67
- #
68
- # @param count [Integer] Number of columns
69
- # @param spacing [Integer] Space between columns in twips
70
- # @return [self]
71
- def columns(count: 1, spacing: 720)
72
- @model.columns ||= Wordprocessingml::Columns.new
73
- @model.columns.num = count
74
- @model.columns.space = spacing
75
- self
76
- end
77
-
78
- def page_borders(**_options)
79
- @model.page_borders ||= Wordprocessingml::PageBorders.new
80
- self
81
- end
82
-
83
- # Set line numbering
84
- #
85
- # @param count_by [Integer] Line numbering interval
86
- # @param start [Integer] Starting line number
87
- # @param restart [String] Restart setting ('continuous', 'newPage', 'newSection')
88
- # @return [self]
89
- def line_numbering(count_by: 1, start: 1, restart: "continuous")
90
- @model.line_numbering ||= Wordprocessingml::LineNumbering.new
91
- @model.line_numbering.count_by = count_by
92
- @model.line_numbering.start = start
93
- @model.line_numbering.restart = restart
94
- self
95
- end
96
-
97
- # Set page numbering for this section
98
- #
99
- # @param start [Integer, nil] Starting page number
100
- # @param format [String, nil] Number format ('decimal', 'lowerRoman', 'upperRoman')
101
- # @return [self]
102
57
  def page_numbering(start: nil, format: nil)
103
58
  @model.page_numbering ||= Wordprocessingml::PageNumbering.new
104
59
  @model.page_numbering.start = start if start
@@ -106,37 +61,55 @@ module Uniword
106
61
  self
107
62
  end
108
63
 
109
- # Configure a header for this section
110
- #
111
- # @param type [String] Header type ('default', 'first', 'even')
112
- # @yield [HeaderFooterBuilder] Builder for header content
113
- # @return [HeaderFooterBuilder] The header/footer builder
64
+ def columns(count: 1, spacing: 720)
65
+ @model.columns ||= Wordprocessingml::Columns.new
66
+ @model.columns.num = count
67
+ @model.columns.space = spacing
68
+ self
69
+ end
70
+
114
71
  def header(type: "default", &block)
115
72
  hf = HeaderFooterBuilder.new(:header, type: type)
116
- yield(hf) if block
117
-
73
+ block&.call(hf)
118
74
  ref = Wordprocessingml::HeaderReference.new(
119
75
  type: type, r_id: "rIdHdr#{type}",
120
76
  )
121
77
  @model.header_references << ref
78
+ @header_builders ||= {}
79
+ @header_builders[type] = hf
122
80
  hf
123
81
  end
124
82
 
125
- # Configure a footer for this section
126
- #
127
- # @param type [String] Footer type ('default', 'first', 'even')
128
- # @yield [HeaderFooterBuilder] Builder for footer content
129
- # @return [HeaderFooterBuilder] The header/footer builder
130
83
  def footer(type: "default", &block)
131
84
  hf = HeaderFooterBuilder.new(:footer, type: type)
132
- yield(hf) if block
133
-
85
+ block&.call(hf)
134
86
  ref = Wordprocessingml::FooterReference.new(
135
87
  type: type, r_id: "rIdFtr#{type}",
136
88
  )
137
89
  @model.footer_references << ref
90
+ @footer_builders ||= {}
91
+ @footer_builders[type] = hf
138
92
  hf
139
93
  end
94
+
95
+ # Built header content models keyed by type.
96
+ def header_models
97
+ (@header_builders || {}).transform_values(&:build)
98
+ end
99
+
100
+ # Built footer content models keyed by type.
101
+ def footer_models
102
+ (@footer_builders || {}).transform_values(&:build)
103
+ end
104
+
105
+ private
106
+
107
+ def ensure_section_defaults
108
+ page_size
109
+ margins
110
+ columns
111
+ @model.doc_grid ||= Wordprocessingml::PageDefaults.default_doc_grid
112
+ end
140
113
  end
141
114
  end
142
115
  end