uniword 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/uniword/builder/chart_builder.rb +129 -134
  3. data/lib/uniword/diff/package_differ.rb +16 -15
  4. data/lib/uniword/docx/package_integrity_checker.rb +10 -11
  5. data/lib/uniword/docx/reconciler/body.rb +1 -0
  6. data/lib/uniword/docx/reconciler/helpers.rb +27 -12
  7. data/lib/uniword/docx/reconciler/parts.rb +5 -0
  8. data/lib/uniword/infrastructure/mime_packager.rb +3 -10
  9. data/lib/uniword/infrastructure/mime_parser.rb +23 -1
  10. data/lib/uniword/math/plurimath_adapter.rb +28 -24
  11. data/lib/uniword/math_equation.rb +1 -1
  12. data/lib/uniword/mhtml/html_part.rb +19 -13
  13. data/lib/uniword/mhtml/math_converter.rb +18 -12
  14. data/lib/uniword/mhtml/mime_part.rb +5 -16
  15. data/lib/uniword/ooxml/element_order.rb +21 -0
  16. data/lib/uniword/ooxml/schema/element_serializer.rb +27 -35
  17. data/lib/uniword/streaming_parser.rb +22 -20
  18. data/lib/uniword/theme/theme_xml_parser.rb +3 -3
  19. data/lib/uniword/transformation/html_element_builder.rb +62 -48
  20. data/lib/uniword/transformation/html_formatting_mapper.rb +3 -3
  21. data/lib/uniword/transformation/html_to_ooxml_converter.rb +11 -11
  22. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -2
  23. data/lib/uniword/validation/schema_registry.rb +9 -16
  24. data/lib/uniword/validation/validators/xml_schema_validator.rb +75 -26
  25. data/lib/uniword/validation/verify_orchestrator.rb +1 -1
  26. data/lib/uniword/version.rb +1 -1
  27. data/lib/uniword/wp_drawing/position_h.rb +1 -1
  28. data/lib/uniword/wp_drawing/position_v.rb +1 -1
  29. data/lib/uniword/wp_drawing/size_rel_h.rb +1 -1
  30. data/lib/uniword/wp_drawing/size_rel_v.rb +1 -1
  31. data/lib/uniword.rb +4 -8
  32. metadata +8 -9
  33. data/lib/uniword/infrastructure/xml_namespace_normalizer.rb +0 -107
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3edc29b5332204a6bf88fdd016239deab4b9694b8f02a823482fc0d5c3e857b
4
- data.tar.gz: b02521733dd3189181864a89824e30c23eb04cd2112abaded49e6bf8643a43b9
3
+ metadata.gz: 34ddbf784b5b85ba8aedc6b4df9b255763e2b8014bcdea0124563e261555b32b
4
+ data.tar.gz: e4ef3fce0529a6db2642cbf547c28628512834339ce636f15d90c30dccba7eb2
5
5
  SHA512:
6
- metadata.gz: ee58e9718495648ea4516db97b921cb973e79787b5745cb852593614f1ab136716f7fceecf7de9a8b093f1dd361c43282cb3841dd9acd6b32c51f3c0d6afd2ff
7
- data.tar.gz: d55e30fb35e6b199429a8f1d5379319945a281bc2a81f132b2f2efe558ee212cc33c4af90a0fd2884cd211414a34faad9269b0fa44d75005d3cb61bc10cfd00f
6
+ metadata.gz: ae06c93c3b13ffffc70b0bddc7137945e4a035f2a4f75e315406547166c4423cdfe8ff8835b47ae5df50be9c6a021f500b89bd17512ae646cb3dd4c890bf8d8b
7
+ data.tar.gz: 7a37bcf5e4cc1ece3a92ad962757a48613be7f960890bd52017dcf8343bb1989172c7b9bd931a384dfe4bcee58c24de27b80b76e9dd4d3eca09280b078f8e3f1
@@ -28,13 +28,16 @@ module Uniword
28
28
  # end
29
29
  class ChartBuilder
30
30
  CHART_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
31
+ DRAWINGML_MAIN_NS =
32
+ "http://schemas.openxmlformats.org/drawingml/2006/main"
33
+ OFFICE_DOCUMENT_RELS_NS =
34
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
31
35
  CHART_REL_TYPE = Ooxml::PartRegistry.find_by_key(:chart).rel_type
32
36
  CHART_CONTENT_TYPE =
33
37
  Ooxml::PartRegistry.find_by_key(:chart).content_type
34
38
 
35
- attr_reader :chart_type, :title_text, :series_list
36
-
37
- attr_reader :show_legend, :width, :height, :series_list
39
+ attr_reader :chart_type, :title_text, :series_list, :show_legend, :width,
40
+ :height
38
41
 
39
42
  def initialize(chart_type: :bar)
40
43
  @chart_type = chart_type
@@ -101,16 +104,15 @@ module Uniword
101
104
  #
102
105
  # @return [String] Chart XML
103
106
  def build_xml
104
- builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
105
- xml["c"].chartSpace("xmlns:c" => CHART_NS,
106
- "xmlns:a" =>
107
- "http://schemas.openxmlformats.org/drawingml/2006/main",
108
- "xmlns:r" =>
109
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships") do
110
- build_chart_xml(xml)
111
- end
112
- end
113
- builder.to_xml
107
+ @document = Moxml.new.create_document
108
+ @document.add_child(@document.create_declaration("1.0", "UTF-8"))
109
+ root = @document.create_element("c:chartSpace")
110
+ root.add_namespace("c", CHART_NS)
111
+ root.add_namespace("a", DRAWINGML_MAIN_NS)
112
+ root.add_namespace("r", OFFICE_DOCUMENT_RELS_NS)
113
+ @document.root = root
114
+ build_chart(root)
115
+ @document.to_xml
114
116
  end
115
117
 
116
118
  # Register chart on a document and create the Drawing element
@@ -147,166 +149,159 @@ module Uniword
147
149
  include DeterministicId
148
150
 
149
151
  # Build the inner chart XML
150
- def build_chart_xml(xml)
151
- xml["c"].chart do
152
- if @title_text
153
- xml["c"].title do
154
- xml["c"].tx do
155
- xml["c"].rich do
156
- xml["a"].bodyPr
157
- xml["a"].lstStyle
158
- xml["a"].p do
159
- xml["a"].r do
160
- xml["a"].t(@title_text)
161
- end
162
- end
163
- end
164
- end
165
- xml["c"].overlay("val" => "0")
166
- end
167
- end
168
-
169
- xml["c"].autoTitleDeleted("val" => "0")
170
-
171
- xml["c"].plotArea do
172
- build_chart_type(xml)
173
-
174
- unless @chart_type == :pie
175
- build_axis(xml, "catAx", "10", "b", "20")
176
- build_axis(xml, "valAx", "20", "l", "10")
177
- end
178
- end
179
-
180
- if @show_legend
181
- xml["c"].legend do
182
- xml["c"].legendPos("val" => @legend_position)
183
- xml["c"].overlay("val" => "0")
184
- end
185
- end
186
-
187
- xml["c"].plotVisOnly("val" => "1")
152
+ def build_chart(parent)
153
+ chart = add_element(parent, "c:chart")
154
+ if @title_text
155
+ title = add_element(chart, "c:title")
156
+ tx = add_element(title, "c:tx")
157
+ rich = add_element(tx, "c:rich")
158
+ add_element(rich, "a:bodyPr")
159
+ add_element(rich, "a:lstStyle")
160
+ p_el = add_element(rich, "a:p")
161
+ r_el = add_element(p_el, "a:r")
162
+ add_text(add_element(r_el, "a:t"), @title_text)
163
+ add_element(title, "c:overlay", "val" => "0")
164
+ end
165
+
166
+ add_element(chart, "c:autoTitleDeleted", "val" => "0")
167
+
168
+ plot_area = add_element(chart, "c:plotArea")
169
+ build_chart_type(plot_area)
170
+
171
+ unless @chart_type == :pie
172
+ build_axis(plot_area, "catAx", "10", "b", "20")
173
+ build_axis(plot_area, "valAx", "20", "l", "10")
174
+ end
175
+
176
+ if @show_legend
177
+ legend = add_element(chart, "c:legend")
178
+ add_element(legend, "c:legendPos", "val" => @legend_position)
179
+ add_element(legend, "c:overlay", "val" => "0")
188
180
  end
181
+
182
+ add_element(chart, "c:plotVisOnly", "val" => "1")
183
+ chart
189
184
  end
190
185
 
191
186
  # Build the specific chart type element
192
- def build_chart_type(xml)
187
+ def build_chart_type(parent)
193
188
  case @chart_type
194
189
  when :bar
195
- xml["c"].barChart do
196
- xml["c"].barDir("val" => "col")
197
- xml["c"].grouping("val" => "clustered")
198
- xml["c"].varyColors("val" => "0")
199
- build_all_series(xml)
200
- xml["c"].axId("val" => "10")
201
- xml["c"].axId("val" => "20")
202
- end
190
+ chart = add_element(parent, "c:barChart")
191
+ add_element(chart, "c:barDir", "val" => "col")
192
+ add_element(chart, "c:grouping", "val" => "clustered")
193
+ add_element(chart, "c:varyColors", "val" => "0")
194
+ build_all_series(chart)
195
+ add_element(chart, "c:axId", "val" => "10")
196
+ add_element(chart, "c:axId", "val" => "20")
203
197
  when :line
204
- xml["c"].lineChart do
205
- xml["c"].grouping("val" => "standard")
206
- xml["c"].varyColors("val" => "0")
207
- build_all_series(xml)
208
- xml["c"].axId("val" => "10")
209
- xml["c"].axId("val" => "20")
210
- end
198
+ chart = add_element(parent, "c:lineChart")
199
+ add_element(chart, "c:grouping", "val" => "standard")
200
+ add_element(chart, "c:varyColors", "val" => "0")
201
+ build_all_series(chart)
202
+ add_element(chart, "c:axId", "val" => "10")
203
+ add_element(chart, "c:axId", "val" => "20")
211
204
  when :pie
212
- xml["c"].pieChart do
213
- xml["c"].varyColors("val" => "1")
214
- build_all_series(xml)
215
- end
205
+ chart = add_element(parent, "c:pieChart")
206
+ add_element(chart, "c:varyColors", "val" => "1")
207
+ build_all_series(chart)
216
208
  end
217
209
  end
218
210
 
219
211
  # Build all series XML blocks
220
- def build_all_series(xml)
212
+ def build_all_series(parent)
221
213
  @series_list.each_with_index do |s, i|
222
- build_series(xml, s, i)
214
+ build_series(parent, s, i)
223
215
  end
224
216
  end
225
217
 
226
218
  # Build a single series XML block
227
- def build_series(xml, series, index)
228
- xml["c"].ser do
229
- xml["c"].idx("val" => index)
230
- xml["c"].order("val" => index)
231
- xml["c"].tx do
232
- xml["c"].strRef do
233
- xml["c"].f("Sheet1!$B$#{index + 1}")
234
- xml["c"].strCache do
235
- xml["c"].ptCount("val" => 1)
236
- xml["c"].pt("idx" => 0) { xml["c"].v(series[:name]) }
237
- end
238
- end
239
- end
240
- build_categories(xml)
241
- build_values(xml, series[:data])
242
- end
219
+ def build_series(parent, series, index)
220
+ ser = add_element(parent, "c:ser")
221
+ add_element(ser, "c:idx", "val" => index)
222
+ add_element(ser, "c:order", "val" => index)
223
+ tx = add_element(ser, "c:tx")
224
+ str_ref = add_element(tx, "c:strRef")
225
+ add_text(add_element(str_ref, "c:f"), "Sheet1!$B$#{index + 1}")
226
+ str_cache = add_element(str_ref, "c:strCache")
227
+ add_element(str_cache, "c:ptCount", "val" => 1)
228
+ pt = add_element(str_cache, "c:pt", "idx" => 0)
229
+ add_text(add_element(pt, "c:v"), series[:name])
230
+ build_categories(ser)
231
+ build_values(ser, series[:data])
232
+ ser
243
233
  end
244
234
 
245
235
  # Build category data XML
246
- def build_categories(xml)
236
+ def build_categories(parent)
247
237
  return if @categories.empty?
248
238
 
249
- xml["c"].cat do
250
- xml["c"].strRef do
251
- xml["c"].f("Sheet1!$A$2:$A$100")
252
- xml["c"].strCache do
253
- xml["c"].ptCount("val" => @categories.size)
254
- @categories.each_with_index do |cat, idx|
255
- xml["c"].pt("idx" => idx) { xml["c"].v(cat) }
256
- end
257
- end
258
- end
239
+ cat = add_element(parent, "c:cat")
240
+ str_ref = add_element(cat, "c:strRef")
241
+ add_text(add_element(str_ref, "c:f"), "Sheet1!$A$2:$A$100")
242
+ str_cache = add_element(str_ref, "c:strCache")
243
+ add_element(str_cache, "c:ptCount", "val" => @categories.size)
244
+ @categories.each_with_index do |category, idx|
245
+ pt = add_element(str_cache, "c:pt", "idx" => idx)
246
+ add_text(add_element(pt, "c:v"), category)
259
247
  end
260
248
  end
261
249
 
262
250
  # Build values data XML
263
- def build_values(xml, data)
264
- xml["c"].val do
265
- xml["c"].numRef do
266
- xml["c"].f("Sheet1!$C$2:$C$100")
267
- xml["c"].numCache do
268
- xml["c"].formatCode("General")
269
- xml["c"].ptCount("val" => data.size)
270
- data.each_with_index do |val, idx|
271
- xml["c"].pt("idx" => idx) { xml["c"].v(val.to_s) }
272
- end
273
- end
274
- end
251
+ def build_values(parent, data)
252
+ val = add_element(parent, "c:val")
253
+ num_ref = add_element(val, "c:numRef")
254
+ add_text(add_element(num_ref, "c:f"), "Sheet1!$C$2:$C$100")
255
+ num_cache = add_element(num_ref, "c:numCache")
256
+ add_text(add_element(num_cache, "c:formatCode"), "General")
257
+ add_element(num_cache, "c:ptCount", "val" => data.size)
258
+ data.each_with_index do |value, idx|
259
+ pt = add_element(num_cache, "c:pt", "idx" => idx)
260
+ add_text(add_element(pt, "c:v"), value.to_s)
275
261
  end
276
262
  end
277
263
 
278
264
  # Build a chart axis
279
- def build_axis(xml, tag, id, position, cross_id)
265
+ def build_axis(parent, tag, id, position, cross_id)
280
266
  case tag
281
267
  when "catAx"
282
- xml["c"].catAx do
283
- write_axis_content(xml, id, position, cross_id)
284
- end
268
+ write_axis_content(add_element(parent, "c:catAx"), id, position,
269
+ cross_id)
285
270
  when "valAx"
286
- xml["c"].valAx do
287
- write_axis_content(xml, id, position, cross_id)
288
- end
271
+ write_axis_content(add_element(parent, "c:valAx"), id, position,
272
+ cross_id)
289
273
  when "serAx"
290
- xml["c"].serAx do
291
- write_axis_content(xml, id, position, cross_id)
292
- end
274
+ write_axis_content(add_element(parent, "c:serAx"), id, position,
275
+ cross_id)
293
276
  else
294
277
  raise ArgumentError, "Unknown axis type: #{tag}"
295
278
  end
296
279
  end
297
280
 
298
- def write_axis_content(xml, id, position, cross_id)
299
- xml["c"].axId("val" => id)
300
- xml["c"].scaling do
301
- xml["c"].orientation("val" => "minMax")
302
- end
303
- xml["c"].delete("val" => "0")
304
- xml["c"].axPos("val" => position)
305
- xml["c"].majorGridlines
306
- xml["c"].numFmt("formatCode" => "General",
307
- "sourceLinked" => "1")
308
- xml["c"].tickLblPos("val" => "nextTo")
309
- xml["c"].crossAx("val" => cross_id)
281
+ def write_axis_content(axis, id, position, cross_id)
282
+ add_element(axis, "c:axId", "val" => id)
283
+ scaling = add_element(axis, "c:scaling")
284
+ add_element(scaling, "c:orientation", "val" => "minMax")
285
+ add_element(axis, "c:delete", "val" => "0")
286
+ add_element(axis, "c:axPos", "val" => position)
287
+ add_element(axis, "c:majorGridlines")
288
+ add_element(axis, "c:numFmt", "formatCode" => "General",
289
+ "sourceLinked" => "1")
290
+ add_element(axis, "c:tickLblPos", "val" => "nextTo")
291
+ add_element(axis, "c:crossAx", "val" => cross_id)
292
+ end
293
+
294
+ # Append an element with attributes to a parent node.
295
+ def add_element(parent, name, attributes = {})
296
+ element = @document.create_element(name)
297
+ attributes.each { |key, value| element[key] = value }
298
+ parent.add_child(element)
299
+ element
300
+ end
301
+
302
+ # Append a text node to a parent node.
303
+ def add_text(parent, content)
304
+ parent.add_child(@document.create_text(content))
310
305
  end
311
306
 
312
307
  # Create a Drawing element with chart reference
@@ -200,8 +200,8 @@ canon_profile: :spec_friendly)
200
200
  def compare_xml(part_name, old_xml, new_xml)
201
201
  changes = []
202
202
 
203
- old_doc = Nokogiri::XML(old_xml)
204
- new_doc = Nokogiri::XML(new_xml)
203
+ old_doc = Moxml.parse(old_xml)
204
+ new_doc = Moxml.parse(new_xml)
205
205
 
206
206
  # Compare namespace declarations
207
207
  changes.concat(
@@ -219,7 +219,7 @@ canon_profile: :spec_friendly)
219
219
  )
220
220
 
221
221
  changes
222
- rescue Nokogiri::XML::SyntaxError
222
+ rescue Moxml::ParseError
223
223
  changes
224
224
  end
225
225
 
@@ -243,8 +243,8 @@ canon_profile: :spec_friendly)
243
243
  compression_name(new_e.compression_method)]
244
244
  end
245
245
 
246
- old_text = old_e.internal_file_attributes & 1 != 0
247
- new_text = new_e.internal_file_attributes & 1 != 0
246
+ old_text = old_e.internal_file_attributes.anybits?(1)
247
+ new_text = new_e.internal_file_attributes.anybits?(1)
248
248
  if old_text != new_text
249
249
  diffs[:internal_attr] = [old_text ? "text" : "binary",
250
250
  new_text ? "text" : "binary"]
@@ -361,7 +361,7 @@ canon_profile: :spec_friendly)
361
361
  return issues
362
362
  end
363
363
 
364
- doc = Nokogiri::XML(rels_xml)
364
+ doc = Moxml.parse(rels_xml)
365
365
  doc.xpath("//xmlns:Relationship").each do |rel|
366
366
  target = rel["Target"]&.sub(%r{^/}, "")
367
367
  next unless target
@@ -376,7 +376,7 @@ canon_profile: :spec_friendly)
376
376
  end
377
377
 
378
378
  issues
379
- rescue Nokogiri::XML::SyntaxError
379
+ rescue Moxml::ParseError
380
380
  issues
381
381
  end
382
382
 
@@ -386,7 +386,7 @@ canon_profile: :spec_friendly)
386
386
  # @return [Hash{String => String}, nil]
387
387
  def parse_content_types(zip)
388
388
  ct_xml = zip.read("[Content_Types].xml")
389
- doc = Nokogiri::XML(ct_xml)
389
+ doc = Moxml.parse(ct_xml)
390
390
  overrides = {}
391
391
  doc.xpath("//xmlns:Override").each do |node|
392
392
  overrides[node["PartName"]] = node["ContentType"]
@@ -397,7 +397,7 @@ canon_profile: :spec_friendly)
397
397
  nil
398
398
  end
399
399
 
400
- # --- Legacy structural comparison methods (kept for backward compat) ---
400
+ # --- Structural comparison methods ---
401
401
 
402
402
  # Compare namespace declarations between two XML documents.
403
403
  #
@@ -532,28 +532,29 @@ canon_profile: :spec_friendly)
532
532
 
533
533
  # Extract namespace prefix-to-URI mapping from an element.
534
534
  #
535
- # @param element [Nokogiri::XML::Element]
535
+ # @param element [Moxml::Element]
536
536
  # @return [Hash{String => String}]
537
537
  def namespace_hash(element)
538
538
  return {} unless element
539
539
 
540
540
  result = {}
541
- element.namespace_definitions.each do |ns|
541
+ element.namespaces.each do |ns|
542
542
  prefix = ns.prefix || ""
543
- result[prefix] = ns.href
543
+ result[prefix] = ns.uri
544
544
  end
545
545
  result
546
546
  end
547
547
 
548
548
  # Extract attribute name-to-value mapping from an element.
549
549
  #
550
- # @param element [Nokogiri::XML::Element]
550
+ # @param element [Moxml::Element]
551
551
  # @return [Hash{String => String}]
552
552
  def attribute_hash(element)
553
553
  return {} unless element
554
554
 
555
555
  result = {}
556
- element.attributes.each do |name, attr|
556
+ element.attributes.each do |attr|
557
+ name = attr.name
557
558
  key = attr.namespace ? "#{attr.namespace.prefix}:#{name}" : name
558
559
  result[key] = attr.value
559
560
  end
@@ -562,7 +563,7 @@ canon_profile: :spec_friendly)
562
563
 
563
564
  # Count elements by qualified name.
564
565
  #
565
- # @param doc [Nokogiri::XML::Document]
566
+ # @param doc [Moxml::Document]
566
567
  # @return [Hash{String => Integer}]
567
568
  def element_counts(doc)
568
569
  counts = Hash.new(0)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri"
4
3
  require "set"
5
4
 
6
5
  module Uniword
@@ -83,15 +82,15 @@ module Uniword
83
82
  # Memoized strict parse of one entry.
84
83
  #
85
84
  # @param name [String] Entry path
86
- # @return [Nokogiri::XML::Document, nil] Parsed document, or nil when
85
+ # @return [Moxml::Document, nil] Parsed document, or nil when
87
86
  # the entry is malformed (OPC-008 already recorded)
88
87
  def parsed_part(name)
89
88
  return @parsed_parts[name] if @parsed_parts.key?(name)
90
89
 
91
90
  @parsed_parts[name] =
92
91
  begin
93
- Nokogiri::XML(@content[name], &:strict)
94
- rescue Nokogiri::XML::SyntaxError => e
92
+ Moxml.parse(@content[name])
93
+ rescue Moxml::ParseError => e
95
94
  add_issue("OPC-008", name,
96
95
  "Malformed XML in #{name}: #{e.message}")
97
96
  nil
@@ -180,19 +179,19 @@ module Uniword
180
179
 
181
180
  # Collect (attribute local name, value) pairs for r:id/r:embed/r:link.
182
181
  #
183
- # @param doc [Nokogiri::XML::Document] Parsed XML part
182
+ # @param doc [Moxml::Document] Parsed XML part
184
183
  # @return [Array<Array(String, String)>] Reference pairs
185
184
  def relationship_references(doc)
186
185
  refs = []
187
186
  doc.traverse do |node|
188
187
  next unless node.element?
189
188
 
190
- node.attribute_nodes.each do |attr|
191
- next unless attr.namespace&.href == OFFICE_RELS_NS
192
- next unless R_REF_ATTRIBUTES.include?(attr.node_name)
189
+ node.attributes.each do |attr|
190
+ next unless attr.namespace&.uri == OFFICE_RELS_NS
191
+ next unless R_REF_ATTRIBUTES.include?(attr.name)
193
192
 
194
193
  value = attr.value
195
- refs << [attr.node_name, value] unless value.empty?
194
+ refs << [attr.name, value] unless value.empty?
196
195
  end
197
196
  end
198
197
  refs
@@ -243,8 +242,8 @@ module Uniword
243
242
 
244
243
  # Relationship elements of a parsed .rels document.
245
244
  #
246
- # @param doc [Nokogiri::XML::Document] Parsed .rels part
247
- # @return [Nokogiri::XML::NodeSet] Relationship elements
245
+ # @param doc [Moxml::Document] Parsed .rels part
246
+ # @return [Moxml::NodeSet] Relationship elements
248
247
  def relationship_nodes(doc)
249
248
  doc.xpath("//xmlns:Relationship", "xmlns" => RELS_NS)
250
249
  end
@@ -87,6 +87,7 @@ module Uniword
87
87
  backfill_paragraphs(part.content.paragraphs, rsid,
88
88
  "#{abbrev}:#{idx}")
89
89
  end
90
+ ensure_w14_ignorable(part.content)
90
91
  end
91
92
  end
92
93
 
@@ -15,7 +15,7 @@ module Uniword
15
15
  # -- ID generation --
16
16
 
17
17
  def generate_rsid
18
- "00#{hex_derive("rsid", 3)}"
18
+ "00#{hex_derive('rsid', 3)}"
19
19
  end
20
20
 
21
21
  def generate_hex_id(seed = 0)
@@ -24,7 +24,7 @@ module Uniword
24
24
 
25
25
  def hex_derive(seed, byte_count)
26
26
  Digest::SHA256.hexdigest(
27
- "#{document_fingerprint}:#{seed}"
27
+ "#{document_fingerprint}:#{seed}",
28
28
  )[0...(byte_count * 2)].upcase
29
29
  end
30
30
 
@@ -43,7 +43,7 @@ module Uniword
43
43
 
44
44
  # -- Traversal --
45
45
 
46
- def walk_body_paragraphs(body)
46
+ def walk_body_paragraphs(body, &block)
47
47
  if body.element_order && !body.element_order.empty?
48
48
  p_idx = 0
49
49
  tbl_idx = 0
@@ -54,18 +54,20 @@ module Uniword
54
54
  p_idx += 1
55
55
  when "tbl"
56
56
  if body.tables[tbl_idx]
57
- walk_table_paragraphs(body.tables[tbl_idx]) { |p| yield p }
57
+ walk_table_paragraphs(body.tables[tbl_idx], &block)
58
58
  end
59
59
  tbl_idx += 1
60
60
  end
61
61
  end
62
62
  else
63
- body.paragraphs.each { |p| yield p }
64
- body.tables&.each { |tbl| walk_table_paragraphs(tbl) { |p| yield p } }
63
+ body.paragraphs.each(&block)
64
+ body.tables&.each do |tbl|
65
+ walk_table_paragraphs(tbl, &block)
66
+ end
65
67
  end
66
68
  end
67
69
 
68
- def walk_body_tables(body)
70
+ def walk_body_tables(body, &block)
69
71
  return unless body
70
72
 
71
73
  if body.element_order && !body.element_order.empty?
@@ -77,7 +79,7 @@ module Uniword
77
79
  tbl_idx += 1
78
80
  end
79
81
  else
80
- body.tables&.each { |tbl| yield tbl }
82
+ body.tables&.each(&block)
81
83
  end
82
84
  end
83
85
 
@@ -89,11 +91,12 @@ module Uniword
89
91
  end
90
92
  end
91
93
 
92
- def walk_table_paragraphs(table)
94
+ def walk_table_paragraphs(table, &block)
93
95
  return unless table
96
+
94
97
  table.rows&.each do |row|
95
98
  row.cells&.each do |cell|
96
- cell.paragraphs.each { |p| yield p }
99
+ cell.paragraphs.each(&block)
97
100
  end
98
101
  end
99
102
  end
@@ -152,6 +155,18 @@ module Uniword
152
155
  end
153
156
  end
154
157
 
158
+ # The paragraph backfill above emits w14:paraId/textId; part
159
+ # roots that never declared w14 ignorable must list it or
160
+ # strict MCE consumers reject those attributes.
161
+ def ensure_w14_ignorable(root)
162
+ prefixes = root.mc_ignorable.to_s.split(/\s+/)
163
+ return if prefixes.include?("w14")
164
+
165
+ root.mc_ignorable = Ooxml::Types::McIgnorable.new(
166
+ (prefixes + ["w14"]).join(" "),
167
+ )
168
+ end
169
+
155
170
  # Merge adjacent runs with identical formatting and consolidate
156
171
  # standalone tab/br runs into following text runs (F4 + F5).
157
172
  # Respects element_order: only merges runs that are truly adjacent
@@ -165,7 +180,7 @@ module Uniword
165
180
  runs[1..].each_with_index do |run, idx|
166
181
  prev = merged.last
167
182
  if run_indices.include?(idx) &&
168
- run_properties_match?(prev, run) && can_merge?(prev, run)
183
+ run_properties_match?(prev, run) && can_merge?(prev, run)
169
184
  merge_run_into(prev, run)
170
185
  else
171
186
  merged << run
@@ -180,7 +195,7 @@ module Uniword
180
195
  # in element_order (no non-run elements between them).
181
196
  def adjacent_run_indices(paragraph)
182
197
  eo = paragraph.element_order
183
- return (0...paragraph.runs.size - 1).to_a unless eo && !eo.empty?
198
+ return (0...(paragraph.runs.size - 1)).to_a unless eo && !eo.empty?
184
199
 
185
200
  indices = Set.new
186
201
  run_count = 0
@@ -50,6 +50,11 @@ module Uniword
50
50
  val: profile.list_separator,
51
51
  )
52
52
 
53
+ # CT_Settings places w:rsids between w:compat and m:mathPr;
54
+ # fixtures carrying rsids last serialize it after the
55
+ # appended siblings and fail strict sequence validation.
56
+ Ooxml::ElementOrder.move_once(settings, "rsids", after: "compat")
57
+
53
58
  # w:updateFields set by the user/API: ensure it serializes —
54
59
  # parsed settings carry a stored element_order that lacks it.
55
60
  if settings.update_fields