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
@@ -29,7 +29,7 @@ module Uniword
29
29
  @path = path
30
30
  @zip = nil
31
31
  @parsed_parts = {}
32
- @moxml = Moxml.new(:nokogiri)
32
+ @moxml = Moxml.new
33
33
  end
34
34
 
35
35
  # Context type used by the Engine to select rules.
@@ -139,7 +139,7 @@ module Uniword
139
139
  raw = part_raw(rels_path)
140
140
  return [] unless raw
141
141
 
142
- doc = Nokogiri::XML(raw)
142
+ doc = Moxml.parse(raw)
143
143
  doc.xpath("//xmlns:Relationship", "xmlns" => RELS_NS).map do |rel|
144
144
  {
145
145
  id: rel["Id"],
@@ -157,7 +157,7 @@ module Uniword
157
157
  raw = part_raw("[Content_Types].xml")
158
158
  return {} unless raw
159
159
 
160
- doc = Nokogiri::XML(raw)
160
+ doc = Moxml.parse(raw)
161
161
  types = {}
162
162
 
163
163
  doc.xpath("//xmlns:Default", "xmlns" => CT_NS).each do |node|
@@ -89,7 +89,7 @@ module Uniword
89
89
  raw = context.part_raw("word/document.xml")
90
90
  return unless raw
91
91
 
92
- doc = Nokogiri::XML(raw)
92
+ doc = Moxml.parse(raw)
93
93
  doc.xpath("//a:blip", "a" => A_NS).each do |blip|
94
94
  embed = blip["r:embed"] ||
95
95
  blip.attributes.find { |a| a.name == "embed" }&.value
@@ -59,7 +59,7 @@ module Uniword
59
59
  raw = context.part_raw("word/theme/theme1.xml")
60
60
  return unless raw
61
61
 
62
- doc = Nokogiri::XML(raw, &:strict)
62
+ doc = Moxml.parse(raw)
63
63
  root = doc.root
64
64
  return if root&.name == "theme"
65
65
 
@@ -71,7 +71,7 @@ module Uniword
71
71
  suggestion: "The theme part should have <a:theme> as its " \
72
72
  "root element per DrawingML specification.",
73
73
  )
74
- rescue Nokogiri::XML::SyntaxError => e
74
+ rescue Moxml::ParseError => e
75
75
  issues << issue(
76
76
  "Theme part is malformed: #{e.message}",
77
77
  code: "DOC-081",
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "moxml"
4
- require "nokogiri"
4
+ require "leptris/xml/xsd"
5
5
 
6
6
  module Uniword
7
7
  module Validation
8
8
  # Maps XML namespace URIs to XSD schema files and loads schemas for validation.
9
9
  #
10
10
  # Uses Moxml for namespace detection (parsing XML to find declared namespaces)
11
- # and Nokogiri::XML::Schema for actual XSD validation.
11
+ # and Moxml::XSD (Leptris engine) for actual XSD validation.
12
12
  #
13
13
  # The registry knows which OOXML namespace URIs correspond to which XSD files
14
14
  # bundled in data/schemas/.
@@ -17,7 +17,7 @@ module Uniword
17
17
  # registry = SchemaRegistry.new
18
18
  # ns_uris = registry.detect_namespaces(xml_string)
19
19
  # schema = registry.schema_for_namespaces(ns_uris)
20
- # errors = schema.validate(Nokogiri::XML(xml_string))
20
+ # errors = schema.validate(xml_string)
21
21
  class SchemaRegistry
22
22
  # Map of namespace URI => XSD file (relative to data/schemas/)
23
23
  NAMESPACE_XSD_MAP = {
@@ -85,7 +85,7 @@ module Uniword
85
85
 
86
86
  def initialize(schemas_dir: nil)
87
87
  @schemas_dir = schemas_dir || default_schemas_dir
88
- @moxml = Moxml.new(:nokogiri)
88
+ @moxml = Moxml.new
89
89
  @schema_cache = {}
90
90
  end
91
91
 
@@ -145,7 +145,7 @@ module Uniword
145
145
  # Load and cache an XSD schema for validation.
146
146
  #
147
147
  # @param xsd_relative_path [String] Path relative to schemas_dir
148
- # @return [Nokogiri::XML::Schema] Compiled schema
148
+ # @return [Moxml::XSD::Schema] Compiled schema
149
149
  def load_schema(xsd_relative_path)
150
150
  return @schema_cache[xsd_relative_path] if @schema_cache.key?(xsd_relative_path)
151
151
 
@@ -155,18 +155,11 @@ module Uniword
155
155
  "XSD schema not found: #{xsd_path}"
156
156
  end
157
157
 
158
- # Nokogiri::XML::Schema resolves relative imports from CWD.
159
- # We chdir to the schema file's own directory so imports like
160
- # "word12.xsd" (from microsoft/wml-2010.xsd) resolve correctly.
161
- schema = nil
162
- original_dir = Dir.pwd
163
- schema_dir = File.dirname(xsd_path)
164
- begin
165
- Dir.chdir(schema_dir)
166
- schema = Nokogiri::XML::Schema(File.read(File.basename(xsd_path)))
167
- ensure
168
- Dir.chdir(original_dir)
169
- end
158
+ # Path-based compilation resolves xsd:import chains relative
159
+ # to the schema file's own directory; string compilation does
160
+ # not resolve them.
161
+ handle = Leptris::XML::XSD.compile_file(xsd_path)
162
+ schema = Moxml::XSD::Schema.new(handle, @moxml)
170
163
 
171
164
  @schema_cache[xsd_relative_path] = schema
172
165
  schema
@@ -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.0"
4
+ VERSION = "1.7.0"
5
5
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Wordprocessingml
7
+ # Tracked deletion
8
+ #
9
+ # Element: <w:del> (CT_RunTrackChange) — wraps the runs Word
10
+ # deleted while tracking changes. The wrapped runs carry their
11
+ # text in <w:delText> elements (deleted content, excluded from
12
+ # paragraph text until the deletion is rejected).
13
+ #
14
+ # @example
15
+ # del = Deletion.new(id: "8", author: "Alice")
16
+ class Deletion < Lutaml::Model::Serializable
17
+ attribute :id, Ooxml::Types::WmlVal
18
+ attribute :author, Ooxml::Types::WmlVal
19
+ attribute :date, Ooxml::Types::WmlVal
20
+ attribute :runs, Run, collection: true, initialize_empty: true
21
+
22
+ xml do
23
+ element "del"
24
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
25
+ mixed_content
26
+
27
+ map_attribute "id", to: :id, render_nil: false
28
+ map_attribute "author", to: :author, render_nil: false
29
+ map_attribute "date", to: :date, render_nil: false
30
+ map_element "r", to: :runs, render_nil: false
31
+ end
32
+
33
+ # Text of the deleted content (from <w:delText> elements).
34
+ #
35
+ # @return [String]
36
+ def text
37
+ runs.map { |run| run.del_text&.content.to_s }.join
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Wordprocessingml
7
+ # Tracked insertion
8
+ #
9
+ # Element: <w:ins> (CT_RunTrackChange) — wraps the runs Word
10
+ # inserted while tracking changes. Attributes identify the
11
+ # revision; the wrapped runs are live content.
12
+ #
13
+ # @example
14
+ # ins = Insertion.new(id: "7", author: "Alice",
15
+ # runs: [Run.new(text: "inserted")])
16
+ class Insertion < Lutaml::Model::Serializable
17
+ attribute :id, Ooxml::Types::WmlVal
18
+ attribute :author, Ooxml::Types::WmlVal
19
+ attribute :date, Ooxml::Types::WmlVal
20
+ attribute :runs, Run, collection: true, initialize_empty: true
21
+
22
+ xml do
23
+ element "ins"
24
+ namespace Uniword::Ooxml::Namespaces::WordProcessingML
25
+ mixed_content
26
+
27
+ map_attribute "id", to: :id, render_nil: false
28
+ map_attribute "author", to: :author, render_nil: false
29
+ map_attribute "date", to: :date, render_nil: false
30
+ map_element "r", to: :runs, render_nil: false
31
+ end
32
+
33
+ # Text of the wrapped runs (live content).
34
+ #
35
+ # @return [String]
36
+ def text
37
+ runs.map(&:text_string).join
38
+ end
39
+ end
40
+ end
41
+ end
@@ -9,13 +9,13 @@ module Uniword
9
9
  # Generated from OOXML schema: wordprocessingml.yml
10
10
  # Element: <w:pgMar>
11
11
  class PageMargins < Lutaml::Model::Serializable
12
- attribute :top, :integer
13
- attribute :bottom, :integer
14
- attribute :left, :integer
15
- attribute :right, :integer
16
- attribute :header, :integer
17
- attribute :footer, :integer
18
- attribute :gutter, :integer
12
+ attribute :top, Ooxml::Types::WmlInt
13
+ attribute :bottom, Ooxml::Types::WmlInt
14
+ attribute :left, Ooxml::Types::WmlInt
15
+ attribute :right, Ooxml::Types::WmlInt
16
+ attribute :header, Ooxml::Types::WmlInt
17
+ attribute :footer, Ooxml::Types::WmlInt
18
+ attribute :gutter, Ooxml::Types::WmlInt
19
19
 
20
20
  xml do
21
21
  element "pgMar"
@@ -12,6 +12,8 @@ module Uniword
12
12
  class Paragraph < Lutaml::Model::Serializable
13
13
  attribute :properties, ParagraphProperties
14
14
  attribute :runs, Run, collection: true, initialize_empty: true
15
+ attribute :insertions, Insertion, collection: true, initialize_empty: true
16
+ attribute :deletions, Deletion, collection: true, initialize_empty: true
15
17
  attribute :hyperlinks, Hyperlink, collection: true, initialize_empty: true
16
18
  attribute :bookmark_starts, BookmarkStart, collection: true, initialize_empty: true
17
19
  attribute :bookmark_ends, BookmarkEnd, collection: true, initialize_empty: true
@@ -61,6 +63,8 @@ module Uniword
61
63
 
62
64
  map_element "pPr", to: :properties, render_nil: false
63
65
  map_element "r", to: :runs, render_nil: false
66
+ map_element "ins", to: :insertions, render_nil: false
67
+ map_element "del", to: :deletions, render_nil: false
64
68
  map_element "hyperlink", to: :hyperlinks, render_nil: false
65
69
  map_element "bookmarkStart", to: :bookmark_starts, render_nil: false
66
70
  map_element "bookmarkEnd", to: :bookmark_ends, render_nil: false
@@ -83,11 +87,33 @@ module Uniword
83
87
 
84
88
  # Get paragraph text
85
89
  #
86
- # @return [String] Combined text from all runs
90
+ # Insertion runs are live content and included, in document
91
+ # order; deletion runs are deleted content and excluded.
92
+ #
93
+ # @return [String] Combined text from runs and insertions
87
94
  def text
88
- return "" unless runs
95
+ text_sources.map do |source|
96
+ source.is_a?(Insertion) ? source.text : run_text(source)
97
+ end.join
98
+ end
89
99
 
90
- runs.map { |r| run_text(r) }.join
100
+ # Runs and insertions in document order when element_order is
101
+ # available; typed collections in declaration order otherwise.
102
+ #
103
+ # @return [Array<Run, Insertion>]
104
+ def text_sources
105
+ order = element_order
106
+ return runs + insertions if order.nil? || order.empty?
107
+
108
+ runs_left = runs.dup
109
+ insertions_left = insertions.dup
110
+ walked = order.filter_map do |entry|
111
+ case entry.name
112
+ when "r" then runs_left.shift
113
+ when "ins" then insertions_left.shift
114
+ end
115
+ end.compact
116
+ walked + runs_left + insertions_left
91
117
  end
92
118
 
93
119
  # Extract text from a run or SDT element
@@ -107,7 +133,7 @@ module Uniword
107
133
  end
108
134
 
109
135
  def empty?
110
- !runs || runs.empty? || runs.all? { |r| run_text(r).empty? }
136
+ text.empty?
111
137
  end
112
138
 
113
139
  def style
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "nokogiri"
5
4
 
6
5
  module Uniword
7
6
  module Wordprocessingml
@@ -135,11 +134,11 @@ module Uniword
135
134
  def self.from_xml(xml_content)
136
135
  settings = super
137
136
 
138
- doc = Nokogiri::XML(xml_content)
137
+ doc = Moxml.parse(xml_content)
139
138
  doc_ids = doc.xpath('//*[local-name()="docId"]')
140
139
  doc_ids.each do |elem|
141
- ns_uri = elem.namespace&.href
142
- val = elem.attributes["val"]&.value
140
+ ns_uri = elem.namespace&.uri
141
+ val = elem.attribute("val")&.value
143
142
  next unless val
144
143
 
145
144
  if (ns_uri == "http://schemas.microsoft.com/office/word/2012/wordml") && !settings.w15_doc_id&.val
@@ -141,21 +141,12 @@ module Uniword
141
141
  end
142
142
 
143
143
  # Paragraphs in body (incl. table cells), headers/footers,
144
- # notes, comments.
144
+ # notes, comments. Reuses the typed-dispatch walker (Body maps
145
+ # tables and SDTs; TableCell/Header/Footer map tables;
146
+ # Footnote/Endnote/Comment map paragraphs only).
145
147
  def walk_all_paragraphs(&block)
146
- each_container { |container| walk_paragraphs(container, &block) }
147
- end
148
-
149
- def walk_paragraphs(container, &block)
150
- return unless container
151
-
152
- (container.paragraphs || []).each(&block)
153
- walk_tables(container) { |table| walk_table_rows(table, &block) }
154
- end
155
-
156
- def walk_table_rows(table, &block)
157
- (table.rows || []).each do |row|
158
- (row.cells || []).each { |cell| walk_paragraphs(cell, &block) }
148
+ each_container do |container|
149
+ FindReplace::ParagraphWalker.each_paragraph([container], &block)
159
150
  end
160
151
  end
161
152
 
@@ -22,8 +22,8 @@ module Uniword
22
22
  mixed_content
23
23
 
24
24
  map_element "top", to: :top, render_nil: false
25
- map_element "bottom", to: :bottom, render_nil: false
26
25
  map_element "left", to: :left, render_nil: false
26
+ map_element "bottom", to: :bottom, render_nil: false
27
27
  map_element "right", to: :right, render_nil: false
28
28
  map_element "insideH", to: :inside_h, render_nil: false
29
29
  map_element "insideV", to: :inside_v, render_nil: false
@@ -20,8 +20,8 @@ module Uniword
20
20
  mixed_content
21
21
 
22
22
  map_element "top", to: :top, render_nil: false
23
- map_element "bottom", to: :bottom, render_nil: false
24
23
  map_element "left", to: :left, render_nil: false
24
+ map_element "bottom", to: :bottom, render_nil: false
25
25
  map_element "right", to: :right, render_nil: false
26
26
  end
27
27
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "nokogiri"
5
4
 
6
5
  module Uniword
7
6
  module Wordprocessingml
@@ -31,6 +31,11 @@ module Uniword
31
31
  autoload :Pict, "uniword/wordprocessingml/pict"
32
32
  autoload :Symbol, "uniword/wordprocessingml/symbol"
33
33
  autoload :DeletedText, "uniword/wordprocessingml/deleted_text"
34
+
35
+ # Tracked changes (CT_RunTrackChange wrappers)
36
+ autoload :Insertion, "uniword/wordprocessingml/insertion"
37
+ autoload :Deletion, "uniword/wordprocessingml/deletion"
38
+
34
39
  autoload :NoBreakHyphen, "uniword/wordprocessingml/no_break_hyphen"
35
40
  autoload :LastRenderedPageBreak,
36
41
  "uniword/wordprocessingml/last_rendered_page_break"
@@ -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
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "nokogiri"
5
4
  require "zip"
6
5
 
7
- # Configure lutaml-model to use Nokogiri adapter for XML
8
- Lutaml::Model::Config.xml_adapter_type = :nokogiri
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.
9
10
 
10
11
  # Uniword is a comprehensive Ruby library for reading and writing Microsoft Word
11
12
  # documents in DOCX format using a schema-driven architecture.
@@ -147,6 +148,11 @@ module Uniword
147
148
  autoload :Plugin, "uniword/plugin"
148
149
 
149
150
  # CLI
151
+ module Cli
152
+ autoload :Completions, "uniword/cli/completions"
153
+ autoload :HelpSwitch, "uniword/cli/help_switch"
154
+ autoload :NoColor, "uniword/cli/no_color"
155
+ end
150
156
  autoload :CLI, "uniword/cli/main"
151
157
  autoload :GenerateCLI, "uniword/cli/generate_cli"
152
158
  autoload :FontsCLI, "uniword/cli/fonts_cli"
@@ -372,10 +378,6 @@ module Uniword
372
378
  doc
373
379
  end
374
380
 
375
- # Convert HTML to a document with paragraphs.
376
- # Alias for {#from_html} for backward compatibility.
377
- alias html_to_doc from_html
378
-
379
381
  # Convert HTML directly to a DOCX file
380
382
  #
381
383
  # @param html [String] HTML content