uniword 1.6.0 → 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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/exe/uniword +9 -0
  3. data/lib/uniword/accessibility/accessibility_report.rb +8 -0
  4. data/lib/uniword/builder/chart_builder.rb +129 -134
  5. data/lib/uniword/cli/completions.rb +99 -0
  6. data/lib/uniword/cli/generate_cli.rb +5 -0
  7. data/lib/uniword/cli/help_switch.rb +45 -0
  8. data/lib/uniword/cli/helpers.rb +1 -0
  9. data/lib/uniword/cli/main.rb +118 -21
  10. data/lib/uniword/cli/no_color.rb +21 -0
  11. data/lib/uniword/diff/package_differ.rb +16 -15
  12. data/lib/uniword/document_factory.rb +2 -2
  13. data/lib/uniword/document_writer.rb +32 -10
  14. data/lib/uniword/docx/package_integrity_checker.rb +10 -11
  15. data/lib/uniword/docx/reconciler/body.rb +1 -0
  16. data/lib/uniword/docx/reconciler/helpers.rb +27 -12
  17. data/lib/uniword/docx/reconciler/parts.rb +5 -0
  18. data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
  19. data/lib/uniword/find_replace/scope.rb +7 -4
  20. data/lib/uniword/infrastructure/mime_packager.rb +3 -10
  21. data/lib/uniword/infrastructure/mime_parser.rb +23 -1
  22. data/lib/uniword/math/plurimath_adapter.rb +28 -24
  23. data/lib/uniword/math_equation.rb +1 -1
  24. data/lib/uniword/mhtml/html_part.rb +19 -13
  25. data/lib/uniword/mhtml/math_converter.rb +18 -12
  26. data/lib/uniword/mhtml/mime_part.rb +5 -16
  27. data/lib/uniword/mhtml/xml_part.rb +3 -3
  28. data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
  29. data/lib/uniword/ooxml/element_order.rb +21 -0
  30. data/lib/uniword/ooxml/schema/element_serializer.rb +27 -35
  31. data/lib/uniword/ooxml/schema_library.rb +2 -2
  32. data/lib/uniword/ooxml/types/characteristics_val.rb +16 -0
  33. data/lib/uniword/ooxml/types/schema_library_val.rb +17 -0
  34. data/lib/uniword/ooxml/types/variant_types.rb +10 -2
  35. data/lib/uniword/ooxml/types/vt_attr.rb +19 -0
  36. data/lib/uniword/ooxml/types/wml_int.rb +17 -0
  37. data/lib/uniword/ooxml/types/wml_val.rb +19 -0
  38. data/lib/uniword/ooxml/types.rb +12 -0
  39. data/lib/uniword/properties/alignment.rb +4 -0
  40. data/lib/uniword/properties/boolean_element_factory.rb +1 -1
  41. data/lib/uniword/properties/font_size.rb +3 -0
  42. data/lib/uniword/review/review_manager.rb +34 -5
  43. data/lib/uniword/review/revision_resolver.rb +190 -0
  44. data/lib/uniword/review.rb +1 -0
  45. data/lib/uniword/revision.rb +3 -3
  46. data/lib/uniword/serialization/ooxml_serializer.rb +3 -3
  47. data/lib/uniword/streaming_parser.rb +22 -20
  48. data/lib/uniword/theme/theme_xml_parser.rb +4 -6
  49. data/lib/uniword/transformation/html_element_builder.rb +62 -48
  50. data/lib/uniword/transformation/html_formatting_mapper.rb +3 -3
  51. data/lib/uniword/transformation/html_to_ooxml_converter.rb +11 -11
  52. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -2
  53. data/lib/uniword/validation/opc_validator.rb +6 -7
  54. data/lib/uniword/validation/rules/document_context.rb +3 -3
  55. data/lib/uniword/validation/rules/images_rule.rb +1 -1
  56. data/lib/uniword/validation/rules/theme_rule.rb +2 -2
  57. data/lib/uniword/validation/schema_registry.rb +10 -17
  58. data/lib/uniword/validation/validators/xml_schema_validator.rb +75 -26
  59. data/lib/uniword/validation/verify_orchestrator.rb +1 -1
  60. data/lib/uniword/version.rb +1 -1
  61. data/lib/uniword/wordprocessingml/deletion.rb +41 -0
  62. data/lib/uniword/wordprocessingml/insertion.rb +41 -0
  63. data/lib/uniword/wordprocessingml/page_margins.rb +7 -7
  64. data/lib/uniword/wordprocessingml/paragraph.rb +30 -4
  65. data/lib/uniword/wordprocessingml/settings.rb +3 -4
  66. data/lib/uniword/wordprocessingml/style_cleanup.rb +5 -14
  67. data/lib/uniword/wordprocessingml/table_borders.rb +1 -1
  68. data/lib/uniword/wordprocessingml/table_cell_borders.rb +1 -1
  69. data/lib/uniword/wordprocessingml/text.rb +0 -1
  70. data/lib/uniword/wordprocessingml.rb +5 -0
  71. data/lib/uniword/wp_drawing/position_h.rb +1 -1
  72. data/lib/uniword/wp_drawing/position_v.rb +1 -1
  73. data/lib/uniword/wp_drawing/size_rel_h.rb +1 -1
  74. data/lib/uniword/wp_drawing/size_rel_v.rb +1 -1
  75. data/lib/uniword.rb +9 -7
  76. metadata +19 -15
  77. data/lib/uniword/infrastructure/xml_namespace_normalizer.rb +0 -107
@@ -67,7 +67,7 @@ module Uniword
67
67
  schema_def = @schema.definition_for(element.class)
68
68
 
69
69
  # Build XML document
70
- doc = Nokogiri::XML::Document.new
70
+ doc = Moxml.new.create_document
71
71
 
72
72
  # Create root element with proper namespace
73
73
  root = serialize_element_to_node(doc, element, schema_def, options)
@@ -105,21 +105,21 @@ module Uniword
105
105
  OoxmlSchema.load(schema_path)
106
106
  end
107
107
 
108
- # Serialize element to Nokogiri node
108
+ # Serialize element to Moxml node
109
109
  #
110
- # @param doc [Nokogiri::XML::Document] XML document
110
+ # @param doc [Moxml::Document] XML document
111
111
  # @param element [Element] Element to serialize
112
112
  # @param schema_def [ElementDefinition] Schema definition
113
113
  # @param options [Hash] Serialization options
114
- # @return [Nokogiri::XML::Node] Created node
114
+ # @return [Moxml::Element] Created node
115
115
  def serialize_element_to_node(doc, element, schema_def, options)
116
116
  # Create element node with namespace
117
117
  prefix = schema_def.prefix
118
118
  local_name = schema_def.local_name
119
119
  namespace_uri = @schema.namespace[:uri]
120
120
 
121
- node = Nokogiri::XML::Node.new(local_name, doc)
122
- node.namespace = node.add_namespace_definition(prefix, namespace_uri)
121
+ node = doc.create_element(local_name)
122
+ node.add_namespace(prefix, namespace_uri)
123
123
 
124
124
  # Serialize attributes according to schema
125
125
  serialize_attributes_to_node(node, element, schema_def)
@@ -132,7 +132,7 @@ module Uniword
132
132
 
133
133
  # Serialize element attributes to node
134
134
  #
135
- # @param node [Nokogiri::XML::Node] Node to add attributes to
135
+ # @param node [Moxml::Element] Node to add attributes to
136
136
  # @param element [Element] Element with attributes
137
137
  # @param schema_def [ElementDefinition] Schema definition
138
138
  def serialize_attributes_to_node(node, element, schema_def)
@@ -157,8 +157,8 @@ module Uniword
157
157
 
158
158
  # Serialize element children to node
159
159
  #
160
- # @param doc [Nokogiri::XML::Document] XML document
161
- # @param node [Nokogiri::XML::Node] Parent node
160
+ # @param doc [Moxml::Document] XML document
161
+ # @param node [Moxml::Element] Parent node
162
162
  # @param element [Element] Element with children
163
163
  # @param schema_def [ElementDefinition] Schema definition
164
164
  # @param options [Hash] Serialization options
@@ -190,11 +190,11 @@ module Uniword
190
190
 
191
191
  # Serialize a single child element to node
192
192
  #
193
- # @param doc [Nokogiri::XML::Document] XML document
193
+ # @param doc [Moxml::Document] XML document
194
194
  # @param child [Element, Object] Child element or value
195
195
  # @param child_def [ChildDefinition] Child schema definition
196
196
  # @param options [Hash] Serialization options
197
- # @return [Nokogiri::XML::Node, nil] Created node
197
+ # @return [Moxml::Element, nil] Created node
198
198
  def serialize_child_to_node(doc, child, child_def, options)
199
199
  # Get namespace prefix from tag (e.g., 'w:p' -> 'w')
200
200
  tag_parts = child_def.tag.split(":")
@@ -204,24 +204,21 @@ module Uniword
204
204
 
205
205
  if child_def.presence_only?
206
206
  # Presence-only element (e.g., <w:b/> for bold)
207
- node = Nokogiri::XML::Node.new(local_name, doc)
208
- node.namespace = node.add_namespace_definition(prefix,
209
- namespace_uri)
207
+ node = doc.create_element(local_name)
208
+ node.add_namespace(prefix, namespace_uri)
210
209
  node
211
210
  elsif child.is_a?(String)
212
211
  # Simple string value (e.g., text content)
213
- node = Nokogiri::XML::Node.new(local_name, doc)
214
- node.namespace = node.add_namespace_definition(prefix,
215
- namespace_uri)
212
+ node = doc.create_element(local_name)
213
+ node.add_namespace(prefix, namespace_uri)
216
214
  node.content = child
217
215
  node
218
216
  elsif child.is_a?(Uniword::Element)
219
217
  # Check if this is a TextElement that needs special handling
220
218
  if child.instance_of?(::Uniword::TextElement) && child.is_a?(Uniword::TextElement)
221
219
  # TextElement: serialize as element with text content
222
- node = Nokogiri::XML::Node.new(local_name, doc)
223
- node.namespace = node.add_namespace_definition(prefix,
224
- namespace_uri)
220
+ node = doc.create_element(local_name)
221
+ node.add_namespace(prefix, namespace_uri)
225
222
  node.content = child.content.to_s if child.content
226
223
  node
227
224
  else
@@ -238,9 +235,8 @@ module Uniword
238
235
  else
239
236
  # For properties without schema, create empty element
240
237
  # (will be filled with children if schema definition exists for element type)
241
- node = Nokogiri::XML::Node.new(local_name, doc)
242
- node.namespace = node.add_namespace_definition(prefix,
243
- namespace_uri)
238
+ node = doc.create_element(local_name)
239
+ node.add_namespace(prefix, namespace_uri)
244
240
 
245
241
  if child_def.has_attributes?
246
242
  child_def.attributes.each do |attr_config|
@@ -256,9 +252,8 @@ module Uniword
256
252
  end
257
253
  elsif child_def.has_attributes?
258
254
  # Element with attributes but no child elements
259
- node = Nokogiri::XML::Node.new(local_name, doc)
260
- node.namespace = node.add_namespace_definition(prefix,
261
- namespace_uri)
255
+ node = doc.create_element(local_name)
256
+ node.add_namespace(prefix, namespace_uri)
262
257
 
263
258
  child_def.attributes.each do |attr_config|
264
259
  attr_def = AttributeDefinition.new(attr_config)
@@ -271,23 +266,20 @@ module Uniword
271
266
  node
272
267
  elsif child.is_a?(String)
273
268
  # Simple string value
274
- node = Nokogiri::XML::Node.new(local_name, doc)
275
- node.namespace = node.add_namespace_definition(prefix,
276
- namespace_uri)
269
+ node = doc.create_element(local_name)
270
+ node.add_namespace(prefix, namespace_uri)
277
271
  node.content = child
278
272
  node
279
273
  elsif child.is_a?(Uniword::TextElement)
280
274
  # TextElement or similar - get the content
281
- node = Nokogiri::XML::Node.new(local_name, doc)
282
- node.namespace = node.add_namespace_definition(prefix,
283
- namespace_uri)
275
+ node = doc.create_element(local_name)
276
+ node.add_namespace(prefix, namespace_uri)
284
277
  node.content = child.content.to_s if child.content
285
278
  node
286
279
  elsif child
287
280
  # Other value element - convert to string
288
- node = Nokogiri::XML::Node.new(local_name, doc)
289
- node.namespace = node.add_namespace_definition(prefix,
290
- namespace_uri)
281
+ node = doc.create_element(local_name)
282
+ node.add_namespace(prefix, namespace_uri)
291
283
  node.content = child.to_s
292
284
  node
293
285
  end
@@ -12,8 +12,8 @@ module Uniword
12
12
  # Namespace: http://schemas.openxmlformats.org/schemaLibrary/2006/main
13
13
  # Prefix: sl
14
14
  class SchemaLibEntry < Lutaml::Model::Serializable
15
- attribute :uri, :string, default: -> { "" }
16
- attribute :manifest_location, :string
15
+ attribute :uri, Types::SchemaLibraryVal, default: -> { "" }
16
+ attribute :manifest_location, Types::SchemaLibraryVal
17
17
 
18
18
  xml do
19
19
  element "schema"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ # String type in the Additional Characteristics namespace.
9
+ class CharacteristicsVal < Lutaml::Model::Type::String
10
+ xml do
11
+ namespace Uniword::Ooxml::Namespaces::Characteristics
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ # String type in the schemaLibrary namespace (sl:uri,
9
+ # sl:manifestLocation qualified attributes).
10
+ class SchemaLibraryVal < Lutaml::Model::Type::String
11
+ xml do
12
+ namespace Uniword::Ooxml::Namespaces::SchemaLibrary
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -13,6 +13,14 @@ module Uniword
13
13
  module VariantTypes
14
14
  VT_NS = Uniword::Ooxml::Namespaces::VariantTypes
15
15
 
16
+ # String type for qualified vt: attributes (e.g. baseType on
17
+ # vector elements).
18
+ class VtAttr < Lutaml::Model::Type::String
19
+ xml do
20
+ namespace VT_NS
21
+ end
22
+ end
23
+
16
24
  # Base class for simple variant type values (text content only)
17
25
  class VTValue < Lutaml::Model::Serializable
18
26
  attribute :value, :string
@@ -259,7 +267,7 @@ module Uniword
259
267
 
260
268
  # vt:vector - Typed array of values
261
269
  class VtVector < Lutaml::Model::Serializable
262
- attribute :base_type, :string
270
+ attribute :base_type, VtAttr
263
271
  attribute :size, :string
264
272
  attribute :lpwstr_values, VtLpwstr, collection: true
265
273
  attribute :lpstr_values, VtLpstr, collection: true
@@ -287,7 +295,7 @@ module Uniword
287
295
 
288
296
  # vt:array - Typed array with bounds
289
297
  class VtArray < Lutaml::Model::Serializable
290
- attribute :base_type, :string
298
+ attribute :base_type, VtAttr
291
299
  attribute :size, :string
292
300
  attribute :l_bound, :string
293
301
  attribute :u_bound, :string
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ module VariantTypes
9
+ # String type for qualified vt: attributes (e.g. baseType on
10
+ # vector elements).
11
+ class VtAttr < Lutaml::Model::Type::String
12
+ xml do
13
+ namespace VT_NS
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ # Integer type in the WordProcessingML namespace (w:top, w:gutter,
9
+ # w:sz et al. qualified attributes).
10
+ class WmlInt < Lutaml::Model::Type::Integer
11
+ xml do
12
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ # String type in the WordProcessingML namespace.
9
+ # Binds mapped attribute rules to their qualified (URI, local)
10
+ # identity — e.g. w:val on boolean/formatting elements — so
11
+ # strict adapters (Leptris) match exactly what DOCX emits.
12
+ class WmlVal < Lutaml::Model::Type::String
13
+ xml do
14
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -53,6 +53,18 @@ module Uniword
53
53
  # Relationships namespace type for r:embed/r:link cross-namespace attrs
54
54
  autoload :RelationshipId, "#{__dir__}/types/relationship_id"
55
55
 
56
+ # WordProcessingML namespace type for qualified w:val attributes
57
+ autoload :WmlVal, "#{__dir__}/types/wml_val"
58
+
59
+ # WordProcessingML namespace type for qualified integer attributes
60
+ autoload :WmlInt, "#{__dir__}/types/wml_int"
61
+
62
+ # schemaLibrary namespace type for sl: qualified attributes
63
+ autoload :SchemaLibraryVal, "#{__dir__}/types/schema_library_val"
64
+
65
+ # Additional Characteristics namespace type
66
+ autoload :CharacteristicsVal, "#{__dir__}/types/characteristics_val"
67
+
56
68
  # Variant Types (vt: namespace) for OLE property values
57
69
  autoload :VariantTypes, "#{__dir__}/types/variant_types"
58
70
 
@@ -6,6 +6,10 @@ module Uniword
6
6
  module Properties
7
7
  # Namespaced custom type for alignment value
8
8
  class AlignmentValue < Lutaml::Model::Type::String
9
+ xml do
10
+ namespace Ooxml::Namespaces::WordProcessingML
11
+ end
12
+
9
13
  # Full ST_Jc enumeration from ECMA-376 (wml.xsd)
10
14
  VALUES = %w[
11
15
  start center end both mediumKashida distribute numTab
@@ -47,7 +47,7 @@ module Uniword
47
47
  klass = Class.new(Lutaml::Model::Serializable) do
48
48
  include BooleanElement
49
49
 
50
- attribute :val, :string, default: nil
50
+ attribute :val, Ooxml::Types::WmlVal, default: nil
51
51
  include BooleanValSetter
52
52
 
53
53
  xml do
@@ -6,6 +6,9 @@ module Uniword
6
6
  module Properties
7
7
  # Namespaced custom type for font size value
8
8
  class FontSizeValue < Lutaml::Model::Type::Integer
9
+ xml do
10
+ namespace Ooxml::Namespaces::WordProcessingML
11
+ end
9
12
  end
10
13
 
11
14
  # Font size element
@@ -27,6 +27,8 @@ module Uniword
27
27
  def initialize(document)
28
28
  @document = document
29
29
  @accept_reject = AcceptReject.new
30
+ @resolver = RevisionResolver.new(document)
31
+ @resolver_hydrated = false
30
32
  end
31
33
 
32
34
  # --- Comments ---
@@ -141,26 +143,41 @@ module Uniword
141
143
 
142
144
  # Accept a single revision by ID.
143
145
  #
146
+ # Applies the decision to the document models (tracked-change
147
+ # nodes are spliced or removed) and keeps the facade list in
148
+ # sync.
149
+ #
144
150
  # @param revision_id [String] The revision ID to accept
145
151
  # @return [Boolean] true if accepted, false if not found
146
- def accept(revision_id)
152
+ def accept(revision_id) # rubocop:disable Naming/PredicateMethod
147
153
  revision = tracked_changes.find_revision(revision_id)
148
154
  return false unless revision
149
155
 
150
- @accept_reject.accept(revision)
156
+ if @resolver.ids.include?(revision_id.to_s)
157
+ @resolver.accept(revision_id)
158
+ else
159
+ @accept_reject.accept(revision)
160
+ end
151
161
  tracked_changes.remove_revision(revision_id)
152
162
  true
153
163
  end
154
164
 
155
165
  # Reject a single revision by ID.
156
166
  #
167
+ # Applies the decision to the document models and keeps the
168
+ # facade list in sync.
169
+ #
157
170
  # @param revision_id [String] The revision ID to reject
158
171
  # @return [Boolean] true if rejected, false if not found
159
- def reject(revision_id)
172
+ def reject(revision_id) # rubocop:disable Naming/PredicateMethod
160
173
  revision = tracked_changes.find_revision(revision_id)
161
174
  return false unless revision
162
175
 
163
- @accept_reject.reject(revision)
176
+ if @resolver.ids.include?(revision_id.to_s)
177
+ @resolver.reject(revision_id)
178
+ else
179
+ @accept_reject.reject(revision)
180
+ end
164
181
  tracked_changes.remove_revision(revision_id)
165
182
  true
166
183
  end
@@ -169,6 +186,8 @@ module Uniword
169
186
  #
170
187
  # @return [Integer] Number of changes accepted
171
188
  def accept_all
189
+ tracked_changes # hydrates the resolver's node registry
190
+ @resolver.ids.each { |id| @resolver.accept(id) }
172
191
  tracked_changes.accept_all
173
192
  end
174
193
 
@@ -176,6 +195,8 @@ module Uniword
176
195
  #
177
196
  # @return [Integer] Number of changes rejected
178
197
  def reject_all
198
+ tracked_changes # hydrates the resolver's node registry
199
+ @resolver.ids.each { |id| @resolver.reject(id) }
179
200
  tracked_changes.reject_all
180
201
  end
181
202
 
@@ -242,7 +263,10 @@ module Uniword
242
263
  end
243
264
  end
244
265
 
245
- # Get or initialize TrackedChanges for the document.
266
+ # Get or initialize TrackedChanges for the document. On first
267
+ # access, parsed <w:ins>/<w:del> nodes are registered on the
268
+ # facade (via RevisionResolver) so listing and accept/reject
269
+ # reflect the document's real tracked changes.
246
270
  #
247
271
  # @return [Uniword::TrackedChanges] The tracked changes collection
248
272
  def tracked_changes
@@ -256,6 +280,11 @@ module Uniword
256
280
  tc
257
281
  end
258
282
  end
283
+ unless @resolver_hydrated
284
+ @resolver.hydrate(@tracked_changes)
285
+ @resolver_hydrated = true
286
+ end
287
+ @tracked_changes
259
288
  end
260
289
  end
261
290
  end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Review
5
+ # Bridges parsed tracked-change nodes (<w:ins>/<w:del> on
6
+ # paragraphs) to the TrackedChanges facade and applies
7
+ # accept/reject decisions to the document models.
8
+ #
9
+ # Hydration walks the body (tables and SDT content included, via
10
+ # ParagraphWalker) and registers one facade Revision per node,
11
+ # keyed by the OOXML revision id (w:id). Decisions then mutate
12
+ # the models:
13
+ #
14
+ # - accept insert → splice wrapped runs into the paragraph
15
+ # - reject insert → remove the node
16
+ # - accept delete → remove the node (deleted content goes away)
17
+ # - reject delete → splice runs back, delText converted to t
18
+ #
19
+ # element_order arrays are updated alongside the collections so
20
+ # serialization keeps the runs at the revision's position.
21
+ class RevisionResolver
22
+ # @param document [Wordprocessingml::DocumentRoot]
23
+ def initialize(document)
24
+ @document = document
25
+ @nodes = {}
26
+ end
27
+
28
+ # Register every parsed ins/del node on the facade.
29
+ #
30
+ # @param tracked_changes [Uniword::TrackedChanges]
31
+ # @return [Uniword::TrackedChanges]
32
+ def hydrate(tracked_changes)
33
+ each_tracked_node do |paragraph, node|
34
+ id = node.id.to_s
35
+ next if id.empty? || @nodes.key?(id)
36
+
37
+ @nodes[id] = [paragraph, node]
38
+ tracked_changes.add_revision(facade_revision_for(node))
39
+ end
40
+ tracked_changes
41
+ end
42
+
43
+ # Registered revision ids, in document order.
44
+ #
45
+ # @return [Array<String>]
46
+ def ids
47
+ @nodes.keys
48
+ end
49
+
50
+ # Accept one revision: keep inserted content / drop deleted
51
+ # content.
52
+ #
53
+ # @param revision_id [String]
54
+ # @return [Boolean] true when the revision was found and applied
55
+ def accept(revision_id) # rubocop:disable Naming/PredicateMethod
56
+ paragraph, node = @nodes[revision_id.to_s]
57
+ return false unless paragraph
58
+
59
+ if insertion?(node)
60
+ splice_into_paragraph(paragraph, node, node.runs)
61
+ else
62
+ remove_node(paragraph, node)
63
+ end
64
+ @nodes.delete(revision_id.to_s)
65
+ true
66
+ end
67
+
68
+ # Reject one revision: drop inserted content / restore deleted
69
+ # content.
70
+ #
71
+ # @param revision_id [String]
72
+ # @return [Boolean] true when the revision was found and applied
73
+ def reject(revision_id) # rubocop:disable Naming/PredicateMethod
74
+ paragraph, node = @nodes[revision_id.to_s]
75
+ return false unless paragraph
76
+
77
+ if insertion?(node)
78
+ remove_node(paragraph, node)
79
+ else
80
+ restored = node.runs.map { |run| restore_run_text(run) }
81
+ splice_into_paragraph(paragraph, node, restored)
82
+ end
83
+ @nodes.delete(revision_id.to_s)
84
+ true
85
+ end
86
+
87
+ private
88
+
89
+ def insertion?(node)
90
+ node.is_a?(Wordprocessingml::Insertion)
91
+ end
92
+
93
+ def facade_revision_for(node)
94
+ Uniword::Revision.new(
95
+ type: insertion?(node) ? :insert : :delete,
96
+ revision_id: node.id.to_s,
97
+ author: node.author,
98
+ date: node.date,
99
+ text: node.text,
100
+ )
101
+ end
102
+
103
+ def each_tracked_node(&block)
104
+ containers = [@document.body].compact
105
+ FindReplace::ParagraphWalker.each_paragraph(containers) do |p|
106
+ p.insertions.each { |node| yield(p, node) }
107
+ p.deletions.each { |node| yield(p, node) }
108
+ end
109
+ end
110
+
111
+ def splice_into_paragraph(paragraph, node, runs)
112
+ tag = node_tag(node)
113
+ collection = tracked_collection(paragraph, node)
114
+ index = collection.index(node)
115
+
116
+ paragraph.runs.concat(runs)
117
+ collection.delete(node)
118
+ replace_order_entry(paragraph, tag, index, runs.size)
119
+ end
120
+
121
+ def remove_node(paragraph, node)
122
+ tag = node_tag(node)
123
+ collection = tracked_collection(paragraph, node)
124
+ index = collection.index(node)
125
+
126
+ collection.delete(node)
127
+ replace_order_entry(paragraph, tag, index, 0)
128
+ end
129
+
130
+ # Convert a deleted run's delText back into live text so a
131
+ # rejected deletion re-enters the document as content.
132
+ def restore_run_text(run)
133
+ deleted = run.del_text
134
+ if deleted
135
+ run.text = [] if run.text.nil?
136
+ run.text << Wordprocessingml::Text.new(content: deleted.content)
137
+ run.del_text = nil
138
+ retag_order_entry(run, "delText", "t")
139
+ end
140
+ run
141
+ end
142
+
143
+ def node_tag(node)
144
+ insertion?(node) ? "ins" : "del"
145
+ end
146
+
147
+ def tracked_collection(paragraph, node)
148
+ insertion?(node) ? paragraph.insertions : paragraph.deletions
149
+ end
150
+
151
+ # Replace the index-th `tag` entry in element_order with `count`
152
+ # run entries (count 0 removes it). Parsed models carry
153
+ # element_order; built models serialize by collection order.
154
+ def replace_order_entry(model, tag, index, count)
155
+ order = Ooxml::ElementOrder.mutable_order(model)
156
+ return unless order
157
+ return unless index
158
+
159
+ position = nth_entry_index(order, tag, index)
160
+ return unless position
161
+
162
+ entries = Array.new(count) { xml_entry("r") }
163
+ order[position, 1] = entries
164
+ end
165
+
166
+ def retag_order_entry(model, old_tag, new_tag)
167
+ order = Ooxml::ElementOrder.mutable_order(model)
168
+ return unless order
169
+
170
+ position = nth_entry_index(order, old_tag, 0)
171
+ order[position] = xml_entry(new_tag) if position
172
+ end
173
+
174
+ def nth_entry_index(order, tag, nth)
175
+ seen = -1
176
+ order.each_with_index do |entry, i|
177
+ next unless entry.name == tag
178
+
179
+ seen += 1
180
+ return i if seen == nth
181
+ end
182
+ nil
183
+ end
184
+
185
+ def xml_entry(name)
186
+ Lutaml::Xml::Element.new("Element", name)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -14,6 +14,7 @@ module Uniword
14
14
  module Review
15
15
  autoload :AcceptReject, "uniword/review/accept_reject"
16
16
  autoload :ReviewManager, "uniword/review/review_manager"
17
+ autoload :RevisionResolver, "uniword/review/revision_resolver"
17
18
  autoload :InteractiveReview, "uniword/review/interactive_review"
18
19
  end
19
20
  end
@@ -35,13 +35,13 @@ module Uniword
35
35
  # @see TrackedChanges For revision collection management
36
36
  class Revision < Lutaml::Model::Serializable
37
37
  # Unique revision identifier
38
- attribute :revision_id, :string
38
+ attribute :revision_id, Ooxml::Types::WmlVal
39
39
 
40
40
  # Author name
41
- attribute :author, :string
41
+ attribute :author, Ooxml::Types::WmlVal
42
42
 
43
43
  # Revision date/time
44
- attribute :date, :string
44
+ attribute :date, Ooxml::Types::WmlVal
45
45
 
46
46
  # OOXML namespace configuration
47
47
  xml do
@@ -18,12 +18,12 @@ module Uniword
18
18
  document.to_xml(encoding: "UTF-8", prefix: true)
19
19
  end
20
20
 
21
- # Serialize a document to XML and return as a Nokogiri document
21
+ # Serialize a document to XML and return as a Moxml document
22
22
  #
23
23
  # @param document [DocumentRoot] The document to serialize
24
- # @return [Nokogiri::XML::Document] The serialized document
24
+ # @return [Moxml::Document] The serialized document
25
25
  def serialize_to_doc(document)
26
- Nokogiri::XML(serialize(document))
26
+ Moxml.parse(serialize(document))
27
27
  end
28
28
  end
29
29
  end