uniword 1.0.10 → 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 (204) 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 -24
  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 +16 -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 -1069
  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/mhtml_style_builder.rb +9 -2
  83. data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
  84. data/lib/uniword/transformation/ooxml_to_mhtml_converter.rb +7 -15
  85. data/lib/uniword/transformation/yaml_css_generator.rb +815 -0
  86. data/lib/uniword/transformation.rb +2 -0
  87. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  88. data/lib/uniword/version.rb +1 -1
  89. data/lib/uniword/visitor/text_extractor.rb +2 -1
  90. data/lib/uniword/watermark/manager.rb +2 -2
  91. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  92. data/lib/uniword/wordprocessingml/body.rb +53 -18
  93. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  94. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  95. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  96. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  97. data/lib/uniword/wordprocessingml/document_root.rb +6 -3
  98. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  99. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  100. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  101. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  102. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  103. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  104. data/lib/uniword/wordprocessingml/header.rb +4 -0
  105. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  106. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  107. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  108. data/lib/uniword/wordprocessingml/level.rb +2 -0
  109. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  110. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  111. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  112. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  113. data/lib/uniword/wordprocessingml/num.rb +4 -0
  114. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  115. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  116. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  117. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  118. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  119. data/lib/uniword/wordprocessingml/run.rb +32 -36
  120. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  121. data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
  122. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  123. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  124. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  125. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  126. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  127. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  128. data/lib/uniword/wordprocessingml/text.rb +8 -2
  129. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  130. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  131. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  132. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  133. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  134. data/lib/uniword/wordprocessingml.rb +23 -0
  135. data/lib/uniword.rb +27 -0
  136. metadata +47 -70
  137. data/lib/uniword/math/accent.rb +0 -25
  138. data/lib/uniword/math/accent_properties.rb +0 -25
  139. data/lib/uniword/math/argument_properties.rb +0 -22
  140. data/lib/uniword/math/bar.rb +0 -25
  141. data/lib/uniword/math/bar_properties.rb +0 -25
  142. data/lib/uniword/math/begin_char.rb +0 -22
  143. data/lib/uniword/math/border_box.rb +0 -25
  144. data/lib/uniword/math/border_box_properties.rb +0 -39
  145. data/lib/uniword/math/box.rb +0 -25
  146. data/lib/uniword/math/box_properties.rb +0 -33
  147. data/lib/uniword/math/char.rb +0 -22
  148. data/lib/uniword/math/control_properties.rb +0 -27
  149. data/lib/uniword/math/degree.rb +0 -26
  150. data/lib/uniword/math/delimiter.rb +0 -25
  151. data/lib/uniword/math/delimiter_properties.rb +0 -33
  152. data/lib/uniword/math/denominator.rb +0 -26
  153. data/lib/uniword/math/element.rb +0 -61
  154. data/lib/uniword/math/end_char.rb +0 -22
  155. data/lib/uniword/math/equation_array.rb +0 -25
  156. data/lib/uniword/math/equation_array_properties.rb +0 -33
  157. data/lib/uniword/math/fraction.rb +0 -27
  158. data/lib/uniword/math/fraction_properties.rb +0 -25
  159. data/lib/uniword/math/function.rb +0 -27
  160. data/lib/uniword/math/function_name.rb +0 -26
  161. data/lib/uniword/math/function_properties.rb +0 -23
  162. data/lib/uniword/math/group_char.rb +0 -25
  163. data/lib/uniword/math/group_char_properties.rb +0 -29
  164. data/lib/uniword/math/lim.rb +0 -26
  165. data/lib/uniword/math/lower_limit.rb +0 -27
  166. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  167. data/lib/uniword/math/math_break.rb +0 -22
  168. data/lib/uniword/math/math_font.rb +0 -22
  169. data/lib/uniword/math/math_properties.rb +0 -53
  170. data/lib/uniword/math/math_run.rb +0 -42
  171. data/lib/uniword/math/math_run_properties.rb +0 -37
  172. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  173. data/lib/uniword/math/math_simple_val.rb +0 -24
  174. data/lib/uniword/math/math_style.rb +0 -22
  175. data/lib/uniword/math/math_text.rb +0 -23
  176. data/lib/uniword/math/matrix.rb +0 -25
  177. data/lib/uniword/math/matrix_column.rb +0 -23
  178. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  179. data/lib/uniword/math/matrix_columns.rb +0 -23
  180. data/lib/uniword/math/matrix_properties.rb +0 -37
  181. data/lib/uniword/math/matrix_row.rb +0 -23
  182. data/lib/uniword/math/nary.rb +0 -29
  183. data/lib/uniword/math/nary_properties.rb +0 -33
  184. data/lib/uniword/math/numerator.rb +0 -26
  185. data/lib/uniword/math/o_math.rb +0 -65
  186. data/lib/uniword/math/o_math_para.rb +0 -25
  187. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  188. data/lib/uniword/math/phantom.rb +0 -25
  189. data/lib/uniword/math/phantom_properties.rb +0 -33
  190. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  191. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  192. data/lib/uniword/math/radical.rb +0 -27
  193. data/lib/uniword/math/radical_properties.rb +0 -25
  194. data/lib/uniword/math/separator_char.rb +0 -22
  195. data/lib/uniword/math/sub.rb +0 -26
  196. data/lib/uniword/math/sub_superscript.rb +0 -29
  197. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  198. data/lib/uniword/math/subscript.rb +0 -27
  199. data/lib/uniword/math/subscript_properties.rb +0 -23
  200. data/lib/uniword/math/sup.rb +0 -26
  201. data/lib/uniword/math/superscript.rb +0 -27
  202. data/lib/uniword/math/superscript_properties.rb +0 -23
  203. data/lib/uniword/math/upper_limit.rb +0 -27
  204. data/lib/uniword/math/upper_limit_properties.rb +0 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7feae85851d80946524005c3cc6419419d492c717e559628093494ee7532cd9
4
- data.tar.gz: eba8d3d3d433aa158b5a4214659e6afd0e12d74e3fada13997da066021a78f9d
3
+ metadata.gz: 1517a752442fd3914efbcc8f94130cf6f4983c110807b0790f03bebf9424d875
4
+ data.tar.gz: a88ecadccf094ffd2c1031d32e13ded47f361b98ca0d594c373bd48cc74f6898
5
5
  SHA512:
6
- metadata.gz: 940e11bb305505cd3e6acdc6e9cca98e002d151a0f75e7c8872d613353b4c5d85b424062a661572b14474c342b62db3072d2cc081cb9e924f95bb8fdf80e6c39
7
- data.tar.gz: 3ca3c9bea8133f7a18e64897b687ef8dd10843c85aa2e4422dabceed5152ae5a5f9cce56fdff8999f03e734f8510b919732f9f5b5b0d5ac0e9470c8dd0db11fb
6
+ metadata.gz: d728a6dc5a0df3560cc607281fb6ca8dd384c56d75247d7ca4b57a1791a15312b8fa8a2e5a8ae18c4ec60ff0574f76fafde2e68994c4b13b326b329cb5fd134e
7
+ data.tar.gz: cd7339df777bb848a7ad69c09b3d44fc0478fd9ece6a9f4d9c79ebb3114fa205824487f9bab85753338f24ac8aa22345319a254fc6a841d9f54117d2c0f07945
@@ -90,6 +90,62 @@ fonts:
90
90
  # --- East Asian fonts (per locale) ---
91
91
 
92
92
  # Chinese Simplified (zh-CN)
93
+ SimSun:
94
+ alt_name: "宋体"
95
+ panose1: "02010600030101010101"
96
+ charset: "86"
97
+ family: roman
98
+ pitch: variable
99
+ sig:
100
+ usb0: "00000003"
101
+ usb1: "080E0000"
102
+ usb2: "00000010"
103
+ usb3: "00000000"
104
+ csb0: "00040000"
105
+ csb1: "00000000"
106
+
107
+ SimHei:
108
+ alt_name: "黑体"
109
+ panose1: "02010609060101010101"
110
+ charset: "86"
111
+ family: modern
112
+ pitch: fixed
113
+ sig:
114
+ usb0: "800002BF"
115
+ usb1: "38CF7CFA"
116
+ usb2: "00000016"
117
+ usb3: "00000000"
118
+ csb0: "0004000F"
119
+ csb1: "00000000"
120
+
121
+ MS Mincho:
122
+ alt_name: "MS 明朝"
123
+ panose1: "02020609040205080304"
124
+ charset: "80"
125
+ family: roman
126
+ pitch: fixed
127
+ sig:
128
+ usb0: "E00002FF"
129
+ usb1: "6FFF07FF"
130
+ usb2: "00000020"
131
+ usb3: "00000000"
132
+ csb0: "0000019F"
133
+ csb1: "00000000"
134
+
135
+ MS Gothic:
136
+ alt_name: "MS ゴシック"
137
+ panose1: "020B0609020205080204"
138
+ charset: "80"
139
+ family: modern
140
+ pitch: fixed
141
+ sig:
142
+ usb0: "E00002FF"
143
+ usb1: "6FFF07FF"
144
+ usb2: "00000020"
145
+ usb3: "00000000"
146
+ csb0: "0000019F"
147
+ csb1: "00000000"
148
+
93
149
  DengXian:
94
150
  alt_name: "等线"
95
151
  panose1: "02010600030101010101"
@@ -88,7 +88,7 @@ module Uniword
88
88
  if paragraph.is_a?(Lutaml::Model::Serializable) && paragraph.class.attributes.key?(:text)
89
89
  paragraph.text
90
90
  elsif paragraph.is_a?(Lutaml::Model::Serializable) && paragraph.class.attributes.key?(:runs)
91
- paragraph.runs.map(&:text).join
91
+ paragraph.runs.map(&:text_string).join
92
92
  end
93
93
  end
94
94
  end
@@ -166,11 +166,11 @@ module Uniword
166
166
  # @param paragraph [Wordprocessingml::Paragraph] Paragraph to check
167
167
  # @return [Integer, nil] Heading level or nil
168
168
  def heading_level(paragraph)
169
- style_name = paragraph.properties&.style
170
- return nil unless style_name
169
+ style = paragraph.style
170
+ return nil unless style
171
171
 
172
172
  # Check for heading styles (Heading 1, Heading 2, etc.)
173
- match = style_name.match(/^Heading\s*(\d+)$/i)
173
+ match = style.to_s.match(/^Heading\s*(\d+)$/i)
174
174
  return match[1].to_i if match
175
175
 
176
176
  # Check for outline level in properties
@@ -187,17 +187,7 @@ module Uniword
187
187
  # @param paragraph [Wordprocessingml::Paragraph] Source paragraph
188
188
  # @return [String] Extracted text
189
189
  def extract_text(paragraph)
190
- text_parts = []
191
-
192
- paragraph.runs.each do |run|
193
- run_text = run.text
194
- next unless run_text
195
-
196
- # Handle Text objects by converting to string
197
- text_parts << run_text.to_s
198
- end
199
-
200
- text_parts.join
190
+ paragraph.runs.map(&:text_string).join
201
191
  end
202
192
 
203
193
  # Create title paragraph.
@@ -186,10 +186,9 @@ module Uniword
186
186
  # @param run [Run] Run to process
187
187
  # @return [void]
188
188
  def substitute_run(run)
189
- return unless run.text
190
-
191
- content = run.text.to_s
192
- run.text = substitute(content)
189
+ run.substitute_with_block(VARIABLE_PATTERN) do |match|
190
+ resolve_variable(match[1])
191
+ end
193
192
  end
194
193
 
195
194
  # Collect all keys from nested hash.
@@ -76,8 +76,10 @@ module Uniword
76
76
 
77
77
  # Detect style based on properties
78
78
  detected_style = detect_paragraph_style(paragraph)
79
+ target = detected_style && @target_styles[detected_style]
80
+ next unless target
79
81
 
80
- paragraph.properties.style = @target_styles[detected_style] if detected_style && @target_styles[detected_style]
82
+ paragraph.properties.style = target
81
83
  end
82
84
  end
83
85
 
@@ -102,7 +104,7 @@ module Uniword
102
104
 
103
105
  # Check style name
104
106
  if paragraph.style
105
- style_name = paragraph.style.downcase
107
+ style_name = paragraph.style.to_s.downcase
106
108
  return :heading1 if style_name.include?("heading 1")
107
109
  return :heading2 if style_name.include?("heading 2")
108
110
  return :heading3 if style_name.include?("heading 3")
@@ -39,6 +39,31 @@ module Uniword
39
39
  raise NotImplementedError,
40
40
  "#{name} must implement .default_model_class"
41
41
  end
42
+
43
+ private
44
+
45
+ # Tag name for the properties element (override in subclasses).
46
+ # e.g., "pPr" for ParagraphBuilder, "tcPr" for TableCellBuilder.
47
+ def properties_tag
48
+ raise NotImplementedError,
49
+ "#{self.class.name} must implement #properties_tag"
50
+ end
51
+
52
+ def track_element_order(tag)
53
+ @model.element_order ||= []
54
+ ensure_properties_in_order
55
+ @model.element_order << Lutaml::Xml::Element.new("Element", tag)
56
+ end
57
+
58
+ def ensure_properties_in_order
59
+ return unless @model.element_order
60
+ return if @model.element_order.any? { |e| e.element? && e.name == properties_tag }
61
+ return unless @model.properties
62
+
63
+ @model.element_order.unshift(
64
+ Lutaml::Xml::Element.new("Element", properties_tag)
65
+ )
66
+ end
42
67
  end
43
68
  end
44
69
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nokogiri"
4
- require "securerandom"
4
+ require "digest"
5
5
 
6
6
  module Uniword
7
7
  module Builder
@@ -134,6 +134,8 @@ module Uniword
134
134
 
135
135
  private
136
136
 
137
+ include DeterministicId
138
+
137
139
  # Build the inner chart XML
138
140
  def build_chart_xml(xml)
139
141
  xml["c"].chart do
@@ -265,19 +267,36 @@ module Uniword
265
267
 
266
268
  # Build a chart axis
267
269
  def build_axis(xml, tag, id, position, cross_id)
268
- xml["c"].public_send(tag) do
269
- xml["c"].axId("val" => id)
270
- xml["c"].scaling do
271
- xml["c"].orientation("val" => "minMax")
270
+ case tag
271
+ when "catAx"
272
+ xml["c"].catAx do
273
+ write_axis_content(xml, id, position, cross_id)
274
+ end
275
+ when "valAx"
276
+ xml["c"].valAx do
277
+ write_axis_content(xml, id, position, cross_id)
272
278
  end
273
- xml["c"].delete("val" => "0")
274
- xml["c"].axPos("val" => position)
275
- xml["c"].majorGridlines
276
- xml["c"].numFmt("formatCode" => "General",
277
- "sourceLinked" => "1")
278
- xml["c"].tickLblPos("val" => "nextTo")
279
- xml["c"].crossAx("val" => cross_id)
279
+ when "serAx"
280
+ xml["c"].serAx do
281
+ write_axis_content(xml, id, position, cross_id)
282
+ end
283
+ else
284
+ raise ArgumentError, "Unknown axis type: #{tag}"
285
+ end
286
+ end
287
+
288
+ def write_axis_content(xml, id, position, cross_id)
289
+ xml["c"].axId("val" => id)
290
+ xml["c"].scaling do
291
+ xml["c"].orientation("val" => "minMax")
280
292
  end
293
+ xml["c"].delete("val" => "0")
294
+ xml["c"].axPos("val" => position)
295
+ xml["c"].majorGridlines
296
+ xml["c"].numFmt("formatCode" => "General",
297
+ "sourceLinked" => "1")
298
+ xml["c"].tickLblPos("val" => "nextTo")
299
+ xml["c"].crossAx("val" => cross_id)
281
300
  end
282
301
 
283
302
  # Create a Drawing element with chart reference
@@ -288,8 +307,8 @@ module Uniword
288
307
  inline.extent = WpDrawing::Extent.new(cx: @width, cy: @height)
289
308
  inline.effect_extent = WpDrawing::EffectExtent.new
290
309
  inline.doc_properties = WpDrawing::DocProperties.new(
291
- id: SecureRandom.random_number(1_000_000_000),
292
- name: "Chart #{SecureRandom.random_number(1_000_000)}",
310
+ id: deterministic_id("chart", @chart_type, r_id),
311
+ name: "Chart #{@chart_type}",
293
312
  )
294
313
 
295
314
  graphic = Drawingml::Graphic.new
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Uniword
6
+ module Builder
7
+ # Shared deterministic ID generation for builders.
8
+ #
9
+ # Produces stable integer IDs from seed values using SHA256,
10
+ # ensuring reproducible output across runs.
11
+ module DeterministicId
12
+ def deterministic_id(*seeds)
13
+ Digest::SHA256.hexdigest(seeds.join(":")).to_i(16) % 1_000_000_000
14
+ end
15
+ end
16
+ end
17
+ end
@@ -29,15 +29,17 @@ module Uniword
29
29
  # doc.footer { |f| f << Builder.page_number_field }
30
30
  # doc.save('report.docx')
31
31
  class DocumentBuilder < BaseBuilder
32
+ attr_reader :allocator
33
+
32
34
  def self.default_model_class
33
35
  Wordprocessingml::DocumentRoot
34
36
  end
35
37
 
36
- def initialize(model = nil)
37
- super
38
- @bookmark_counter = 0
39
- @footnote_builder = FootnoteBuilder.new(self)
40
- @comment_counter = 0
38
+ def initialize(model = nil, allocator: nil)
39
+ super(model)
40
+ @allocator = allocator
41
+ @model.allocator = allocator if allocator
42
+ @footnote_builder = FootnoteBuilder.new(self, allocator: @allocator)
41
43
  end
42
44
 
43
45
  # Load a document from file for manipulation
@@ -48,6 +50,90 @@ module Uniword
48
50
  new(Uniword.load(path))
49
51
  end
50
52
 
53
+ # Load a DOCX as a template, reset its body to a clean state, and
54
+ # seed the IdAllocator from the template's existing relationships.
55
+ #
56
+ # Use this when you want the template's OOXML scaffolding (settings,
57
+ # fonts, styles, theme, content_types, namespace declarations) but
58
+ # will replace the document body with new content. This is the
59
+ # recommended path to producing Word-valid output: starting from a
60
+ # known-good DOCX preserves mc:Ignorable prefixes, rsid values, and
61
+ # other structural guarantees that Word expects.
62
+ #
63
+ # Resets performed:
64
+ # - Body content cleared (paragraphs, tables, SDTs, bookmarks,
65
+ # element_order, section_properties)
66
+ # - User footnotes/endnotes cleared (separator/continuation kept)
67
+ # - custom_properties and custom_xml_items cleared
68
+ # - Stale image and customXml relationships removed
69
+ # - IdAllocator seeded from document_rels and package_rels
70
+ #
71
+ # @param path [String] Path to .docx template file
72
+ # @return [DocumentBuilder]
73
+ # @raise [ArgumentError] if path does not exist
74
+ def self.from_template(path)
75
+ raise ArgumentError, "Template not found: #{path}" unless File.exist?(path)
76
+
77
+ root = Uniword.load(path)
78
+ reset_template_body(root)
79
+ clear_user_notes(root)
80
+ root.custom_properties = nil
81
+ root.custom_xml_items = nil
82
+ remove_stale_relationships(root)
83
+ seed_allocator(root)
84
+ new(root, allocator: root.allocator)
85
+ end
86
+
87
+ def self.reset_template_body(root)
88
+ return unless root.body
89
+
90
+ root.body.paragraphs.clear
91
+ root.body.tables.clear
92
+ root.body.structured_document_tags.clear
93
+ root.body.bookmark_starts.clear
94
+ root.body.bookmark_ends.clear
95
+ root.body.element_order = [] if root.body.element_order
96
+ root.body.section_properties = nil
97
+ end
98
+ private_class_method :reset_template_body
99
+
100
+ def self.clear_user_notes(root)
101
+ clear_user_note_entries(root.footnotes, :footnote) if root.footnotes
102
+ clear_user_note_entries(root.endnotes, :endnote) if root.endnotes
103
+ end
104
+ private_class_method :clear_user_notes
105
+
106
+ def self.clear_user_note_entries(notes, kind)
107
+ entries = kind == :footnote ? notes.footnote_entries : notes.endnote_entries
108
+ entries.reject! { |e| e.type != "separator" && e.type != "continuationSeparator" }
109
+ notes.element_order = [] if notes.element_order
110
+ end
111
+ private_class_method :clear_user_note_entries
112
+
113
+ def self.remove_stale_relationships(root)
114
+ remove_rels_by_type_fragment(root.document_rels, "/image")
115
+ remove_rels_by_type_fragment(root.document_rels, "/customXml")
116
+ remove_rels_by_type_fragment(root.package_rels, "custom-properties")
117
+ root.content_types&.overrides&.reject! do |o|
118
+ part = o.part_name.to_s
119
+ part == "/docProps/custom.xml" || part.include?("customXml/")
120
+ end
121
+ root.image_parts = nil
122
+ end
123
+ private_class_method :remove_stale_relationships
124
+
125
+ def self.remove_rels_by_type_fragment(rels, fragment)
126
+ return unless rels&.relationships
127
+
128
+ rels.relationships.reject! { |r| r.type.to_s.include?(fragment) }
129
+ end
130
+ private_class_method :remove_rels_by_type_fragment
131
+
132
+ def self.seed_allocator(root)
133
+ root.allocator = Docx::IdAllocator.populate_from_package(root)
134
+ end
135
+ private_class_method :seed_allocator
136
+
51
137
  # Append a top-level element (paragraph or table)
52
138
  #
53
139
  # @param element [Paragraph, Table, ParagraphBuilder, TableBuilder]
@@ -56,12 +142,16 @@ module Uniword
56
142
  case element
57
143
  when Wordprocessingml::Paragraph
58
144
  @model.body.paragraphs << element
145
+ @model.body.append_to_element_order("p")
59
146
  when Wordprocessingml::Table
60
147
  @model.body.tables << element
148
+ @model.body.append_to_element_order("tbl")
61
149
  when ParagraphBuilder
62
150
  @model.body.paragraphs << element.build
151
+ @model.body.append_to_element_order("p")
63
152
  when TableBuilder
64
153
  @model.body.tables << element.build
154
+ @model.body.append_to_element_order("tbl")
65
155
  else
66
156
  raise ArgumentError, "Cannot add #{element.class} to document"
67
157
  end
@@ -74,10 +164,10 @@ module Uniword
74
164
  # @yield [ParagraphBuilder] Builder for configuration
75
165
  # @return [ParagraphBuilder] The paragraph builder
76
166
  def paragraph(text = nil, &block)
77
- para = ParagraphBuilder.new
167
+ para = ParagraphBuilder.new(allocator: @allocator)
78
168
  para << text if text
79
169
  block.call(para) if block_given?
80
- @model.body.paragraphs << para.build
170
+ self << para
81
171
  para
82
172
  end
83
173
 
@@ -88,10 +178,10 @@ module Uniword
88
178
  # @yield [ParagraphBuilder] Builder for additional configuration
89
179
  # @return [ParagraphBuilder] The paragraph builder
90
180
  def heading(text, level: 1)
91
- para = ParagraphBuilder.new
181
+ para = ParagraphBuilder.new(allocator: @allocator)
92
182
  para.style = "Heading#{level}"
93
183
  para << text
94
- @model.body.paragraphs << para.build
184
+ self << para
95
185
  para
96
186
  end
97
187
 
@@ -99,7 +189,7 @@ module Uniword
99
189
  #
100
190
  # @return [self]
101
191
  def page_break
102
- @model.body.paragraphs << Wordprocessingml::Paragraph.new(
192
+ self << Wordprocessingml::Paragraph.new(
103
193
  runs: [Builder.page_break]
104
194
  )
105
195
  self
@@ -112,7 +202,7 @@ module Uniword
112
202
  def table(&block)
113
203
  tbl = TableBuilder.new
114
204
  block.call(tbl) if block_given?
115
- @model.body.tables << tbl.build
205
+ self << tbl
116
206
  tbl
117
207
  end
118
208
 
@@ -135,7 +225,7 @@ module Uniword
135
225
  # @yield [HeaderFooterBuilder] Builder for header content
136
226
  # @return [HeaderFooterBuilder] The header/footer builder
137
227
  def header(type: "default", &block)
138
- hf = HeaderFooterBuilder.new(:header, type: type)
228
+ hf = HeaderFooterBuilder.new(:header, type: type, allocator: @allocator)
139
229
  block.call(hf) if block_given?
140
230
  (@model.headers ||= {})[type] = hf.build
141
231
  hf
@@ -147,7 +237,7 @@ module Uniword
147
237
  # @yield [HeaderFooterBuilder] Builder for footer content
148
238
  # @return [HeaderFooterBuilder] The header/footer builder
149
239
  def footer(type: "default", &block)
150
- hf = HeaderFooterBuilder.new(:footer, type: type)
240
+ hf = HeaderFooterBuilder.new(:footer, type: type, allocator: @allocator)
151
241
  block.call(hf) if block_given?
152
242
  (@model.footers ||= {})[type] = hf.build
153
243
  hf
@@ -160,7 +250,7 @@ module Uniword
160
250
  # @return [self]
161
251
  def toc(title: "Table of Contents", styles: nil)
162
252
  TocBuilder.build(title: title, styles: styles).each do |para|
163
- @model.body.paragraphs << para
253
+ self << para
164
254
  end
165
255
  self
166
256
  end
@@ -198,16 +288,17 @@ module Uniword
198
288
  # @yield [ParagraphBuilder] Builder for bookmark content
199
289
  # @return [ParagraphBuilder] The paragraph builder
200
290
  def bookmark(name, &block)
201
- @bookmark_counter += 1
202
- id = @bookmark_counter.to_s
291
+ id = @allocator ? @allocator.alloc_bookmark_id : begin
292
+ @bookmark_counter ||= 0
293
+ @bookmark_counter += 1
294
+ @bookmark_counter.to_s
295
+ end
203
296
 
204
- para = ParagraphBuilder.new
205
- para.model.bookmark_starts <<
206
- Wordprocessingml::BookmarkStart.new(id: id, name: name)
297
+ para = ParagraphBuilder.new(allocator: @allocator)
298
+ para << Wordprocessingml::BookmarkStart.new(id: id, name: name)
207
299
  block.call(para) if block_given?
208
- para.model.bookmark_ends <<
209
- Wordprocessingml::BookmarkEnd.new(id: id)
210
- @model.body.paragraphs << para.build
300
+ para << Wordprocessingml::BookmarkEnd.new(id: id)
301
+ self << para
211
302
  para
212
303
  end
213
304
 
@@ -306,12 +397,12 @@ module Uniword
306
397
  # @param size [Integer] Border size in eighths of a point (default 6)
307
398
  # @return [self]
308
399
  def horizontal_rule(style: "single", color: "auto", size: 6)
309
- para = ParagraphBuilder.new
400
+ para = ParagraphBuilder.new(allocator: @allocator)
310
401
  para.borders(
311
402
  bottom: { style: style, color: color, size: size }
312
403
  )
313
404
  para.spacing(after: 0)
314
- @model.body.paragraphs << para.build
405
+ self << para
315
406
  self
316
407
  end
317
408
 
@@ -327,7 +418,7 @@ module Uniword
327
418
  para.runs << ImageBuilder.create_run(
328
419
  self, path, width: width, height: height, alt_text: alt_text
329
420
  )
330
- @model.body.paragraphs << para
421
+ self << para
331
422
  self
332
423
  end
333
424
 
@@ -350,7 +441,7 @@ module Uniword
350
441
  self, path, width: width, height: height, alt_text: alt_text,
351
442
  align: align, wrap: wrap, behind_text: behind_text
352
443
  )
353
- @model.body.paragraphs << para
444
+ self << para
354
445
  self
355
446
  end
356
447
 
@@ -389,10 +480,14 @@ module Uniword
389
480
  # @yield [CommentBuilder] Builder for rich comment content
390
481
  # @return [Comment] The created Comment model
391
482
  def comment(author:, text: nil, initials: nil, &block)
392
- @comment_counter += 1
483
+ comment_id = @allocator ? @allocator.alloc_comment_id : begin
484
+ @comment_counter ||= 0
485
+ @comment_counter += 1
486
+ @comment_counter.to_s
487
+ end
393
488
  cb = CommentBuilder.new(
394
489
  author: author,
395
- comment_id: @comment_counter.to_s,
490
+ comment_id: comment_id,
396
491
  initials: initials
397
492
  )
398
493
  cb << text if text
@@ -437,7 +532,7 @@ module Uniword
437
532
  sdt = SdtBuilder.bibliography.build
438
533
  para = Wordprocessingml::Paragraph.new
439
534
  para.sdts << sdt
440
- @model.body.paragraphs << para
535
+ self << para
441
536
  self
442
537
  end
443
538
 
@@ -458,7 +553,7 @@ module Uniword
458
553
  run.drawings << drawing
459
554
  para = Wordprocessingml::Paragraph.new
460
555
  para.runs << run
461
- @model.body.paragraphs << para
556
+ self << para
462
557
  cb
463
558
  end
464
559
 
@@ -466,7 +561,7 @@ module Uniword
466
561
  #
467
562
  # @return [self]
468
563
  def page_number
469
- @model.body.paragraphs << Builder.page_number_field
564
+ self << Builder.page_number_field
470
565
  self
471
566
  end
472
567
 
@@ -474,7 +569,7 @@ module Uniword
474
569
  #
475
570
  # @return [self]
476
571
  def total_pages
477
- @model.body.paragraphs << Builder.total_pages_field
572
+ self << Builder.total_pages_field
478
573
  self
479
574
  end
480
575
 
@@ -483,7 +578,7 @@ module Uniword
483
578
  # @param format [String] Date format (default 'M/d/yyyy')
484
579
  # @return [self]
485
580
  def date_field(format: "M/d/yyyy")
486
- @model.body.paragraphs << Builder.date_field(format: format)
581
+ self << Builder.date_field(format: format)
487
582
  self
488
583
  end
489
584
 
@@ -492,7 +587,7 @@ module Uniword
492
587
  # @param format [String] Time format (default 'h:mm:ss am/pm')
493
588
  # @return [self]
494
589
  def time_field(format: "h:mm:ss am/pm")
495
- @model.body.paragraphs << Builder.time_field(format: format)
590
+ self << Builder.time_field(format: format)
496
591
  self
497
592
  end
498
593