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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zip"
4
- require "nokogiri"
4
+ require "moxml"
5
5
  # LayerValidator autoloaded via lib/uniword/validation.rb
6
6
 
7
7
  module Uniword
@@ -14,7 +14,8 @@ module Uniword
14
14
  # - XSD validation: parts validated against OOXML XSD schemas
15
15
  #
16
16
  # XSD validation uses SchemaRegistry for namespace-aware schema selection.
17
- # Uses Moxml for namespace detection and Nokogiri::XML::Schema for validation.
17
+ # Uses Moxml throughout: parse, namespace detection, and the
18
+ # Moxml::XSD (Leptris engine) validator.
18
19
  #
19
20
  # @example Validate with XSD
20
21
  # validator = XmlSchemaValidator.new("xml_schema" => { "xsd_validation" => true })
@@ -66,8 +67,8 @@ module Uniword
66
67
 
67
68
  def validate_entry(entry, issues)
68
69
  content = entry.get_input_stream.read
69
- Nokogiri::XML(content, &:strict)
70
- rescue Nokogiri::XML::SyntaxError => e
70
+ moxml.parse(content)
71
+ rescue Moxml::ParseError => e
71
72
  issues << Report::ValidationIssue.new(
72
73
  severity: "error",
73
74
  code: "XSD-001",
@@ -87,16 +88,15 @@ module Uniword
87
88
  begin
88
89
  content = entry.get_input_stream.read
89
90
  schema = registry.load_schema(schema_path)
90
- doc = Nokogiri::XML(content)
91
+ doc = moxml.parse(content)
91
92
  preprocess_mce(doc)
92
93
 
93
94
  schema.validate(doc).each do |error|
94
95
  issues << Report::ValidationIssue.new(
95
96
  severity: "error",
96
97
  code: "XSD-002",
97
- message: error.message.gsub("\n", " ").strip,
98
+ message: error.to_s.gsub("\n", " ").strip,
98
99
  part: entry.name,
99
- line: error.line,
100
100
  )
101
101
  end
102
102
 
@@ -120,42 +120,87 @@ module Uniword
120
120
  end
121
121
  end
122
122
 
123
- # Strip MCE-ignorable ATTRIBUTES per the part's own mc:Ignorable
124
- # declaration (w14:paraId/textId and the mc:Ignorable attribute
125
- # itself), so XSD validation is not drowned in false errors for
126
- # content every real document carries.
123
+ # Apply MCE preprocessing per the part's own mc:Ignorable
124
+ # declaration so strict XSD validation is not drowned in errors
125
+ # for extension content:
126
+ #
127
+ # - mc:AlternateContent is unwrapped to the first understood
128
+ # mc:Choice (else the mc:Fallback) per MCE branch selection;
129
+ # - elements in ignorable namespaces are removed (w15:docId,
130
+ # w16se:* settings extensions);
131
+ # - attributes in ignorable namespaces are removed (w14:paraId,
132
+ # w14:textId), plus the mc:Ignorable attribute itself, which
133
+ # is undeclared on most roots in the transitional XSDs.
127
134
  #
128
- # Extension ELEMENTS are deliberately kept: removing them changes
129
- # libxml2's content-model evaluation and can expose pre-existing
130
- # Word-vs-schema quirks (e.g. rsids placed late, tolerated via the
131
- # sequence's trailing xsd:any) as false baseline errors.
132
135
  # Operates in place on the parsed document; never touches the
133
136
  # packaged bytes.
134
137
  #
135
- # @param doc [Nokogiri::XML::Document] parsed part to clean
138
+ # @param doc [Moxml::Document] parsed part to clean
136
139
  # @return [void]
137
140
  def preprocess_mce(doc)
138
141
  root = doc.root
139
142
  return unless root
140
143
 
141
- ignorable = root["Ignorable"] || root["mc:Ignorable"]
142
- return if ignorable.nil? || ignorable.empty?
144
+ uris = ignorable_namespace_uris(root)
145
+ unwrap_alternate_content(doc, uris)
146
+ remove_ignorable_elements(doc, uris) unless uris.empty?
147
+ strip_mce_attributes(root, uris + [MCE_NAMESPACE_URI])
148
+ end
143
149
 
144
- uris = ignorable.split(/\s+/).filter_map do |prefix|
145
- root.namespaces["xmlns:#{prefix}"]
150
+ # Namespace URIs the root declares ignorable via mc:Ignorable.
151
+ def ignorable_namespace_uris(root)
152
+ ignorable = root["mc:Ignorable"]
153
+ return [] if ignorable.nil? || ignorable.empty?
154
+
155
+ prefix_uris = root.namespaces.to_h { |ns| [ns.prefix, ns.uri] }
156
+ ignorable.split(/\s+/).filter_map { |prefix| prefix_uris[prefix] }
157
+ end
158
+
159
+ # Replace each mc:AlternateContent with the content of its first
160
+ # understood mc:Choice, or its mc:Fallback when no Choice applies.
161
+ def unwrap_alternate_content(doc, ignorable_uris)
162
+ doc.xpath("//mc:AlternateContent", "mc" => MCE_NAMESPACE_URI)
163
+ .to_a.reverse.each do |alternate|
164
+ branch = chosen_branch(alternate, ignorable_uris)
165
+ if branch
166
+ anchor = alternate
167
+ branch.children.each do |child|
168
+ anchor.add_next_sibling(child)
169
+ anchor = child
170
+ end
171
+ end
172
+ alternate.remove
173
+ end
174
+ end
175
+
176
+ # The first mc:Choice whose required namespaces are all
177
+ # non-ignorable, else the mc:Fallback, else nil.
178
+ def chosen_branch(alternate, ignorable_uris)
179
+ choices = alternate.xpath("./mc:Choice", "mc" => MCE_NAMESPACE_URI)
180
+ choices.find do |choice|
181
+ requires = choice.attributes
182
+ .find { |attr| attr.name == "Requires" }
183
+ &.value.to_s.split(/\s+/)
184
+ requires.all? { |prefix| !ignorable_uris.include?(prefix) }
185
+ end || alternate.at_xpath("./mc:Fallback", "mc" => MCE_NAMESPACE_URI)
186
+ end
187
+
188
+ # Remove elements whose namespace is declared ignorable.
189
+ def remove_ignorable_elements(doc, uris)
190
+ doc.traverse do |node|
191
+ next unless node.is_a?(Moxml::Element)
192
+
193
+ node.remove if uris.include?(node.namespace&.uri)
146
194
  end
147
- # The mc:Ignorable attribute itself is MCE machinery and is
148
- # undeclared on most root elements in the transitional XSDs.
149
- strip_mce_attributes(root, uris + [MCE_NAMESPACE_URI])
150
195
  end
151
196
 
152
197
  # Remove attributes in ignorable namespaces from every element.
153
198
  def strip_mce_attributes(doc, uris)
154
199
  doc.traverse do |node|
155
- next unless node.element?
200
+ next unless node.is_a?(Moxml::Element)
156
201
 
157
- node.attribute_nodes.each do |attr|
158
- attr.remove if uris.include?(attr.namespace&.href)
202
+ node.attributes.each do |attr|
203
+ attr.remove if uris.include?(attr.namespace&.uri)
159
204
  end
160
205
  end
161
206
  end
@@ -192,6 +237,10 @@ module Uniword
192
237
  layer_config["xsd_validation"] == true
193
238
  end
194
239
 
240
+ def moxml
241
+ @moxml ||= Moxml.new
242
+ end
243
+
195
244
  def layer_config
196
245
  @config["xml_schema"] || {}
197
246
  end
@@ -8,7 +8,7 @@ module Uniword
8
8
  #
9
9
  # Coordinates:
10
10
  # 1. OPC Package validation (ZIP, content types, relationships)
11
- # 2. XSD Schema validation (namespace-aware, using Moxml + Nokogiri)
11
+ # 2. XSD Schema validation (namespace-aware, via Moxml::XSD)
12
12
  # 3. Word Document semantic validation (extensible rule system)
13
13
  #
14
14
  # Returns a VerificationReport that can be serialized to JSON/YAML
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uniword
4
- VERSION = "1.6.1"
4
+ VERSION = "1.7.0"
5
5
  end
@@ -18,7 +18,7 @@ module Uniword
18
18
  namespace Uniword::Ooxml::Namespaces::WordProcessingDrawing
19
19
  mixed_content
20
20
 
21
- map_attribute "relative-from", to: :relative_from
21
+ map_attribute "relativeFrom", to: :relative_from
22
22
  map_element "align", to: :align, render_nil: false
23
23
  map_element "posOffset", to: :pos_offset, render_nil: false
24
24
  end
@@ -18,7 +18,7 @@ module Uniword
18
18
  namespace Uniword::Ooxml::Namespaces::WordProcessingDrawing
19
19
  mixed_content
20
20
 
21
- map_attribute "relative-from", to: :relative_from
21
+ map_attribute "relativeFrom", to: :relative_from
22
22
  map_element "align", to: :align, render_nil: false
23
23
  map_element "posOffset", to: :pos_offset, render_nil: false
24
24
  end
@@ -17,7 +17,7 @@ module Uniword
17
17
  namespace Uniword::Ooxml::Namespaces::WordProcessingDrawing
18
18
  mixed_content
19
19
 
20
- map_attribute "relative-from", to: :relative_from
20
+ map_attribute "relativeFrom", to: :relative_from
21
21
  map_element "pctWidth", to: :pct_width, render_nil: false
22
22
  end
23
23
  end
@@ -17,7 +17,7 @@ module Uniword
17
17
  namespace Uniword::Ooxml::Namespaces::WordProcessingDrawing
18
18
  mixed_content
19
19
 
20
- map_attribute "relative-from", to: :relative_from
20
+ map_attribute "relativeFrom", to: :relative_from
21
21
  map_element "pctHeight", to: :pct_height, render_nil: false
22
22
  end
23
23
  end
data/lib/uniword.rb CHANGED
@@ -3,10 +3,10 @@
3
3
  require "lutaml/model"
4
4
  require "zip"
5
5
 
6
- # XML hydration routes through lutaml-model's Moxml layer with the
7
- # runtime default adapter (Leptris). Nokogiri remains available as a
8
- # Moxml adapter and is used directly for XSD schema validation and
9
- # HTML handling.
6
+ # XML hydration, XSD validation, and HTML parsing all route through
7
+ # lutaml-model's Moxml layer with the runtime default adapter
8
+ # (Leptris). Nokogiri remains available as an alternative Moxml
9
+ # adapter for development and testing.
10
10
 
11
11
  # Uniword is a comprehensive Ruby library for reading and writing Microsoft Word
12
12
  # documents in DOCX format using a schema-driven architecture.
@@ -378,10 +378,6 @@ module Uniword
378
378
  doc
379
379
  end
380
380
 
381
- # Convert HTML to a document with paragraphs.
382
- # Alias for {#from_html} for backward compatibility.
383
- alias html_to_doc from_html
384
-
385
381
  # Convert HTML directly to a DOCX file
386
382
  #
387
383
  # @param html [String] HTML content
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: uniword
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.1
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -25,33 +25,33 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: lutaml-model
28
+ name: leptris
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.8'
33
+ version: 1.9.352
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.8'
40
+ version: 1.9.352
41
41
  - !ruby/object:Gem::Dependency
42
- name: nokogiri
42
+ name: lutaml-model
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '1.19'
47
+ version: '0.8'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '1.19'
54
+ version: '0.8'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: omml
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -1072,7 +1072,6 @@ files:
1072
1072
  - lib/uniword/infrastructure.rb
1073
1073
  - lib/uniword/infrastructure/mime_packager.rb
1074
1074
  - lib/uniword/infrastructure/mime_parser.rb
1075
- - lib/uniword/infrastructure/xml_namespace_normalizer.rb
1076
1075
  - lib/uniword/infrastructure/zip_extractor.rb
1077
1076
  - lib/uniword/infrastructure/zip_packager.rb
1078
1077
  - lib/uniword/lazy_loader.rb
@@ -1,107 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "nokogiri"
4
-
5
- module Uniword
6
- module Infrastructure
7
- # XML normalization utilities for round-trip fidelity
8
- #
9
- # Ensures that serialized XML matches the expected namespace declaration format
10
- # by adding missing prefixed namespace declarations to root elements.
11
- module XmlNamespaceNormalizer
12
- # Prefixed namespaces that should be declared at root level in OOXML documents
13
- PREFIXED_NAMESPACES = {
14
- "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" => "wp",
15
- "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" => "wp14",
16
- }.freeze
17
-
18
- # Normalize XML to ensure prefixed namespace declarations are at root level
19
- #
20
- # @param xml [String] XML content
21
- # @return [String] Normalized XML content
22
- def self.normalize(xml)
23
- doc = Nokogiri::XML(xml)
24
- return xml unless doc.root
25
-
26
- # Check if any prefixed namespaces are used but not declared at root
27
- root = doc.root
28
- root_ns = root.namespace_definitions.to_h { |ns| [ns.prefix, ns.href] }
29
-
30
- PREFIXED_NAMESPACES.each do |uri, expected_prefix|
31
- # Check if this namespace URI is used in the document
32
- elements_with_ns = doc.xpath("//*[namespace-uri()='#{uri}']")
33
- next if elements_with_ns.empty?
34
-
35
- # Check if the expected prefix is already declared at root
36
- if root_ns.key?(expected_prefix) && (root_ns[expected_prefix] == uri)
37
- # Check if the URI matches
38
- next
39
- end
40
-
41
- # Check if default namespace with this URI exists
42
- default_ns = root_ns.find { |k, v| k.nil? && v == uri }
43
-
44
- if default_ns
45
- # Replace default namespace with prefixed namespace
46
- # Find the default xmlns attribute and rename it
47
- root.attributes.each do |name, attr|
48
- next unless name == "xmlns" && attr.value == uri
49
-
50
- # Remove the default namespace
51
- root.remove_attribute("xmlns")
52
- # Add the prefixed namespace
53
- root["xmlns:#{expected_prefix}"] = uri
54
-
55
- # Update all elements that use this namespace to use the prefix
56
- update_elements_namespace(elements_with_ns, expected_prefix)
57
- break
58
- end
59
- else
60
- # Namespace is used but not declared at root - add it
61
- root["xmlns:#{expected_prefix}"] = uri
62
- end
63
- end
64
-
65
- doc.to_xml(indent: 0, save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
66
- end
67
-
68
- # Update elements to use the correct namespace prefix
69
- #
70
- # @param elements [Nokogiri::XML::NodeSet] Elements to update
71
- # @param prefix [String] The prefix to use
72
- def self.update_elements_namespace(elements, prefix)
73
- elements.each do |el|
74
- # Update the element's namespace
75
- ns = el.document.root.namespaces.find do |_k, v|
76
- v == el.namespace.href
77
- end
78
- next unless ns
79
-
80
- # Change namespace key from default to prefixed
81
- el.document.root.namespaces.delete(ns[0])
82
- el.document.root["xmlns:#{prefix}"] = ns[1]
83
- end
84
- end
85
-
86
- # Normalize namespace declarations in a ZIP content hash
87
- #
88
- # @param zip_content [Hash] File path => content mapping
89
- # @param target_files [Array<String>] Files to normalize
90
- # @return [Hash] Updated zip content
91
- def self.normalize_zip_content(zip_content, target_files = nil)
92
- result = zip_content.dup
93
- target_files ||= result.keys.select do |k|
94
- k.end_with?(".xml", ".rels")
95
- end
96
-
97
- target_files.each do |path|
98
- next unless result[path]
99
-
100
- result[path] = normalize(result[path])
101
- end
102
-
103
- result
104
- end
105
- end
106
- end
107
- end