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
@@ -20,16 +20,22 @@ module Uniword
20
20
  long_desc <<~DESC
21
21
  Convert a document from one format to another.
22
22
 
23
- Supported formats: DOCX, MHTML, HTML
23
+ Supported formats: DOCX, DOCM, DOTX, DOTM, MHTML/MHT, HTML
24
+
25
+ Use "-" as OUTPUT to write to stdout (binary formats print raw
26
+ bytes; pipe or redirect them).
24
27
 
25
28
  Examples:
26
29
  $ uniword convert input.docx output.mhtml
27
30
  $ uniword convert input.mhtml output.docx --verbose
28
31
  $ uniword convert input.html output.docx
32
+ $ uniword convert input.docx output.html
33
+ $ uniword convert input.docx - > converted.docx
29
34
  DESC
30
35
  option :from, aliases: "-f", desc: "Input format (docx/mhtml/html)",
31
36
  type: :string
32
- option :to, aliases: "-t", desc: "Output format (docx/mhtml)", type: :string
37
+ option :to, aliases: "-t", desc: "Output format (docx/mhtml/html)",
38
+ type: :string
33
39
  option :verbose, aliases: "-v", desc: "Verbose output", type: :boolean,
34
40
  default: false
35
41
  def convert(input_path, output_path)
@@ -68,15 +74,30 @@ module Uniword
68
74
  DESC
69
75
  option :verbose, aliases: "-v", desc: "Show detailed information", type: :boolean,
70
76
  default: false
77
+ option :json, type: :boolean, default: false,
78
+ desc: "Output statistics as JSON (machine-readable)"
71
79
  def info(path)
72
- say "Analyzing #{path}...", :green
73
-
74
80
  detector = ::Uniword::FormatDetector.new
75
81
  format = detector.detect(path)
76
- say "\nFormat: #{format.to_s.upcase}", :cyan
77
-
78
82
  doc = load_document(path)
79
83
 
84
+ if options[:json]
85
+ require "json"
86
+ stats = {
87
+ path: path,
88
+ format: format.to_s.upcase,
89
+ paragraphs: doc.paragraphs.count,
90
+ tables: doc.tables.count,
91
+ text_length: doc.text.length,
92
+ styles: doc.styles_configuration&.styles&.count || 0,
93
+ }
94
+ puts JSON.pretty_generate(stats)
95
+ return
96
+ end
97
+
98
+ say "Analyzing #{path}...", :green
99
+ say "\nFormat: #{format.to_s.upcase}", :cyan
100
+
80
101
  say "\nDocument Statistics:", :cyan
81
102
  say " Paragraphs: #{doc.paragraphs.count}"
82
103
  say " Tables: #{doc.tables.count}"
@@ -130,15 +151,32 @@ module Uniword
130
151
  DESC
131
152
  option :verbose, aliases: "-v", desc: "Show detailed validation results", type: :boolean,
132
153
  default: false
154
+ option :json, type: :boolean, default: false,
155
+ desc: "Output the validation report as JSON (machine-readable)"
133
156
  def validate(path)
134
- say "Validating #{path}...", :green
135
-
136
157
  doc = load_document(path)
137
- say "File format is valid", :green
138
158
 
139
159
  issues = Uniword::Validation::Engine.run(
140
160
  Uniword::Validation::Rules::ModelContext.new(doc),
141
161
  )
162
+
163
+ if options[:json]
164
+ require "json"
165
+ puts JSON.pretty_generate(
166
+ path: path,
167
+ valid: issues.none?(&:error?),
168
+ issues: issues.map { |i|
169
+ { code: i.code, severity: i.severity,
170
+ part: i.part, message: i.message }
171
+ },
172
+ )
173
+ exit 1 if issues.any?(&:error?)
174
+ return
175
+ end
176
+
177
+ say "Validating #{path}...", :green
178
+ say "File format is valid", :green
179
+
142
180
  report_validation_issues(issues)
143
181
  report_content_presence(doc)
144
182
 
@@ -191,6 +229,8 @@ module Uniword
191
229
  long_desc <<~DESC
192
230
  Run document quality and accessibility checks.
193
231
 
232
+ Exits 1 when any report has error-level findings.
233
+
194
234
  Examples:
195
235
  $ uniword check document.docx
196
236
  $ uniword check document.docx --type accessibility
@@ -202,8 +242,14 @@ module Uniword
202
242
  default: false
203
243
  option :json, desc: "Output JSON report", type: :boolean, default: false
204
244
  def check(path)
205
- doc = load_document(path)
206
245
  check_type = options[:type]
246
+ unless CHECK_TYPES.include?(check_type)
247
+ say("Unknown check type: #{check_type} " \
248
+ "(valid: #{CHECK_TYPES.join(', ')})", :red)
249
+ exit 1
250
+ end
251
+
252
+ doc = load_document(path)
207
253
 
208
254
  reports = {}
209
255
 
@@ -219,14 +265,15 @@ module Uniword
219
265
 
220
266
  if options[:json]
221
267
  require "json"
222
- output = reports.transform_values do |r|
223
- { valid: r.valid?,
224
- issues: r.is_a?(Uniword::Quality::CheckReport) ? r.issues.count : 0 }
268
+ output = reports.transform_values do |report|
269
+ { valid: report.valid?, issues: report.violations.count }
225
270
  end
226
271
  puts JSON.pretty_generate(output)
227
272
  else
228
273
  display_check_reports(reports)
229
274
  end
275
+
276
+ exit 1 if reports.values.any? { |report| !report.valid? }
230
277
  rescue Uniword::Error => e
231
278
  handle_error(e)
232
279
  rescue StandardError => e
@@ -383,6 +430,22 @@ module Uniword
383
430
  say "Uniword version #{Uniword::VERSION}", :green
384
431
  end
385
432
 
433
+ desc "completions [SHELL]", "Print shell completion script (bash|zsh|fish)"
434
+ long_desc <<~DESC
435
+ Print a self-contained shell completion script.
436
+
437
+ Install:
438
+ $ uniword completions bash >> ~/.bashrc
439
+ $ uniword completions zsh >> ~/.zshrc
440
+ $ uniword completions fish > ~/.config/fish/completions/uniword.fish
441
+ DESC
442
+ def completions(shell = "bash")
443
+ require "uniword/cli/completions"
444
+ puts Cli::Completions.script(shell, root: self.class)
445
+ rescue ArgumentError => e
446
+ handle_error(e)
447
+ end
448
+
386
449
  desc "redact INPUT OUTPUT", "Redact PII patterns from a document"
387
450
  long_desc <<~DESC
388
451
  Replace PII patterns (SSN, email, phone, credit card, IPv4)
@@ -406,6 +469,8 @@ module Uniword
406
469
  option :verbose, aliases: "-v", type: :boolean, default: false,
407
470
  desc: "Show per-pattern counts"
408
471
  def redact(input_path, output_path)
472
+ validate_scopes!(options[:scope])
473
+ validate_redact_patterns!(options[:pattern])
409
474
  doc = load_document(input_path)
410
475
  patterns = resolve_redact_patterns(options[:pattern])
411
476
  result = doc.redact(patterns: patterns,
@@ -451,6 +516,8 @@ module Uniword
451
516
  option :verbose, aliases: "-v", type: :boolean, default: false,
452
517
  desc: "Show per-scope counts"
453
518
  def find_replace(input_path, output_path, pattern, replacement)
519
+ validate_scopes!(options[:scope])
520
+ validate_regex!(pattern) if options[:regex]
454
521
  scopes = expand_scopes(options[:scope])
455
522
  doc = load_document(input_path)
456
523
  result = run_find_replace(doc, pattern, replacement, scopes)
@@ -541,6 +608,36 @@ module Uniword
541
608
  symbols
542
609
  end
543
610
 
611
+ # Valid values for `check --type`.
612
+ CHECK_TYPES = %w[all quality accessibility].freeze
613
+
614
+ def validate_scopes!(scopes)
615
+ valid = ["all"] + Uniword::FindReplace::Engine::ALL_SCOPES.map(&:to_s)
616
+ unknown = Array(scopes).map(&:to_s) - valid
617
+ return if unknown.empty?
618
+
619
+ say("Unknown scope: #{unknown.join(', ')} " \
620
+ "(valid: #{valid.join(', ')})", :red)
621
+ exit 1
622
+ end
623
+
624
+ def validate_regex!(pattern)
625
+ Regexp.new(pattern)
626
+ rescue RegexpError => e
627
+ say("Invalid regular expression: #{e.message}", :red)
628
+ exit 1
629
+ end
630
+
631
+ def validate_redact_patterns!(names)
632
+ valid = [:pii] + Uniword::Redact::PatternLibrary.all.map(&:name)
633
+ unknown = Array(names).map(&:to_sym) - valid
634
+ return if unknown.empty?
635
+
636
+ say("Unknown pattern: #{unknown.join(', ')} " \
637
+ "(valid: #{valid.join(', ')})", :red)
638
+ exit 1
639
+ end
640
+
544
641
  # Build the right matcher from --regex flag and run the engine.
545
642
  def run_find_replace(doc, pattern, replacement, scopes)
546
643
  matcher = build_find_replace_matcher(pattern, replacement)
@@ -679,14 +776,14 @@ module Uniword
679
776
  label = type.to_s.capitalize
680
777
  if report.valid?
681
778
  say("#{label}: No issues found", :green)
682
- else
683
- issue_count = report.is_a?(Uniword::Quality::CheckReport) ? report.issues.count : "?"
684
- say("#{label}: #{issue_count} issue(s) found", :yellow)
685
- if options[:verbose] && report.is_a?(Uniword::Quality::CheckReport)
686
- report.issues.each do |issue|
687
- say(" - #{issue}", :yellow)
688
- end
689
- end
779
+ next
780
+ end
781
+
782
+ say("#{label}: #{report.violations.count} issue(s) found", :yellow)
783
+ next unless options[:verbose]
784
+
785
+ report.violations.each do |violation|
786
+ say(" - #{violation.message}", :yellow)
690
787
  end
691
788
  end
692
789
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Cli
5
+ # POSIX NO_COLOR convention (https://no-color.org/): when the
6
+ # environment variable is set (to any non-empty value), colored
7
+ # CLI output must be suppressed. Prepended onto Thor's shell only
8
+ # when the variable is present, so the zero-cost path is the norm.
9
+ module NoColor
10
+ def color?
11
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
12
+
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ require "thor/shell/basic"
20
+
21
+ Thor::Shell::Basic.prepend(Uniword::Cli::NoColor)
@@ -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)
@@ -80,7 +80,7 @@ module Uniword
80
80
  rescue Zip::Error => e
81
81
  raise CorruptedFileError.new(path.to_s,
82
82
  "Invalid ZIP structure: #{e.message}")
83
- rescue Nokogiri::XML::SyntaxError => e
83
+ rescue Moxml::ParseError => e
84
84
  raise CorruptedFileError.new(path.to_s, "Invalid XML: #{e.message}")
85
85
  rescue StandardError => e
86
86
  # Re-raise our custom errors
@@ -143,7 +143,7 @@ module Uniword
143
143
  rescue Zip::Error => e
144
144
  raise CorruptedFileError.new(path.to_s,
145
145
  "Invalid ZIP structure: #{e.message}")
146
- rescue Nokogiri::XML::SyntaxError => e
146
+ rescue Moxml::ParseError => e
147
147
  raise CorruptedFileError.new(path.to_s, "Invalid XML: #{e.message}")
148
148
  rescue StandardError => e
149
149
  # Re-raise our custom errors
@@ -49,36 +49,57 @@ module Uniword
49
49
  #
50
50
  # @example Save with explicit format
51
51
  # writer.save("output.mht", format: :mhtml)
52
+ # rubocop:disable Metrics/AbcSize
53
+ # rubocop:disable Metrics/CyclomaticComplexity
52
54
  def save(path, format: :auto, profile: nil, validate: nil)
53
55
  validate_path(path)
54
56
 
57
+ if path == "-"
58
+ # stdout: "-" cannot carry an extension; default to DOCX when
59
+ # the caller does not pin the format (use --to to override).
60
+ format = :docx if format == :auto
61
+ write_to_stream($stdout, format: format, validate: validate)
62
+ return
63
+ end
64
+
55
65
  format = infer_format(path) if format == :auto
56
66
 
57
67
  case format
58
68
  when :docx, :docm
59
69
  Docx::Package.to_file(document, path, profile: profile,
60
- validate: validate)
70
+ validate: validate)
61
71
  when :dotx, :dotm
62
72
  Ooxml::DotxPackage.to_file(document, path, profile: profile,
63
- validate: validate)
73
+ validate: validate)
64
74
  when :mhtml
65
75
  Mhtml::MhtmlPackage.to_file(document, path)
76
+ when :html
77
+ File.write(path, html_document)
66
78
  else
67
79
  raise ArgumentError,
68
80
  "No handler registered for format: #{format.inspect}"
69
81
  end
70
82
  end
83
+ # rubocop:enable Metrics/AbcSize
84
+ # rubocop:enable Metrics/CyclomaticComplexity
85
+
86
+ # Render the document as a standalone HTML string.
87
+ #
88
+ # @return [String] HTML document
89
+ def html_document
90
+ Transformation::OoxmlToHtmlConverter.document_to_html(document)
91
+ end
71
92
 
72
93
  # Infer the format from file extension.
73
94
  #
74
95
  # @param path [String] The file path
75
- # @return [Symbol] The inferred format (:docx, :mhtml)
96
+ # @return [Symbol] The inferred format (:docx, :mhtml, :html)
76
97
  # @raise [ArgumentError] if format cannot be inferred
77
98
  #
78
99
  # @example Infer format
79
100
  # format = writer.infer_format("output.docx")
80
101
  # # => :docx
81
- def infer_format(path)
102
+ def infer_format(path) # rubocop:disable Metrics/CyclomaticComplexity
82
103
  extension = File.extname(path).downcase
83
104
 
84
105
  case extension
@@ -90,16 +111,17 @@ module Uniword
90
111
  :dotx
91
112
  when ".dotm"
92
113
  :dotm
93
- when ".mhtml", ".mht"
94
- :mhtml
95
- when ".doc"
96
- # .doc can be MHTML saved with Word (not binary old Word format)
97
- # We don't support binary .doc output, but MHTML .doc is valid
114
+ when ".html"
115
+ :html
116
+ # .doc can be MHTML saved with Word (not binary old Word format)
117
+ # We don't support binary .doc output, but MHTML .doc is valid
118
+ when ".mhtml", ".mht", ".doc"
98
119
  :mhtml
99
120
  else
100
121
  raise ArgumentError,
101
122
  "Cannot infer format from extension: #{extension}. " \
102
- "Supported extensions: .docx, .docm, .dotx, .dotm, .mhtml, .mht"
123
+ "Supported extensions: .docx, .docm, .dotx, .dotm, " \
124
+ ".html, .mhtml, .mht, .doc"
103
125
  end
104
126
  end
105
127
 
@@ -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