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.
- checksums.yaml +4 -4
- data/exe/uniword +9 -0
- data/lib/uniword/accessibility/accessibility_report.rb +8 -0
- data/lib/uniword/builder/chart_builder.rb +129 -134
- data/lib/uniword/cli/completions.rb +99 -0
- data/lib/uniword/cli/generate_cli.rb +5 -0
- data/lib/uniword/cli/help_switch.rb +45 -0
- data/lib/uniword/cli/helpers.rb +1 -0
- data/lib/uniword/cli/main.rb +118 -21
- data/lib/uniword/cli/no_color.rb +21 -0
- data/lib/uniword/diff/package_differ.rb +16 -15
- data/lib/uniword/document_factory.rb +2 -2
- data/lib/uniword/document_writer.rb +32 -10
- data/lib/uniword/docx/package_integrity_checker.rb +10 -11
- data/lib/uniword/docx/reconciler/body.rb +1 -0
- data/lib/uniword/docx/reconciler/helpers.rb +27 -12
- data/lib/uniword/docx/reconciler/parts.rb +5 -0
- data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
- data/lib/uniword/find_replace/scope.rb +7 -4
- data/lib/uniword/infrastructure/mime_packager.rb +3 -10
- data/lib/uniword/infrastructure/mime_parser.rb +23 -1
- data/lib/uniword/math/plurimath_adapter.rb +28 -24
- data/lib/uniword/math_equation.rb +1 -1
- data/lib/uniword/mhtml/html_part.rb +19 -13
- data/lib/uniword/mhtml/math_converter.rb +18 -12
- data/lib/uniword/mhtml/mime_part.rb +5 -16
- data/lib/uniword/mhtml/xml_part.rb +3 -3
- data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
- data/lib/uniword/ooxml/element_order.rb +21 -0
- data/lib/uniword/ooxml/schema/element_serializer.rb +27 -35
- data/lib/uniword/ooxml/schema_library.rb +2 -2
- data/lib/uniword/ooxml/types/characteristics_val.rb +16 -0
- data/lib/uniword/ooxml/types/schema_library_val.rb +17 -0
- data/lib/uniword/ooxml/types/variant_types.rb +10 -2
- data/lib/uniword/ooxml/types/vt_attr.rb +19 -0
- data/lib/uniword/ooxml/types/wml_int.rb +17 -0
- data/lib/uniword/ooxml/types/wml_val.rb +19 -0
- data/lib/uniword/ooxml/types.rb +12 -0
- data/lib/uniword/properties/alignment.rb +4 -0
- data/lib/uniword/properties/boolean_element_factory.rb +1 -1
- data/lib/uniword/properties/font_size.rb +3 -0
- data/lib/uniword/review/review_manager.rb +34 -5
- data/lib/uniword/review/revision_resolver.rb +190 -0
- data/lib/uniword/review.rb +1 -0
- data/lib/uniword/revision.rb +3 -3
- data/lib/uniword/serialization/ooxml_serializer.rb +3 -3
- data/lib/uniword/streaming_parser.rb +22 -20
- data/lib/uniword/theme/theme_xml_parser.rb +4 -6
- data/lib/uniword/transformation/html_element_builder.rb +62 -48
- data/lib/uniword/transformation/html_formatting_mapper.rb +3 -3
- data/lib/uniword/transformation/html_to_ooxml_converter.rb +11 -11
- data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -2
- data/lib/uniword/validation/opc_validator.rb +6 -7
- data/lib/uniword/validation/rules/document_context.rb +3 -3
- data/lib/uniword/validation/rules/images_rule.rb +1 -1
- data/lib/uniword/validation/rules/theme_rule.rb +2 -2
- data/lib/uniword/validation/schema_registry.rb +10 -17
- data/lib/uniword/validation/validators/xml_schema_validator.rb +75 -26
- data/lib/uniword/validation/verify_orchestrator.rb +1 -1
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/deletion.rb +41 -0
- data/lib/uniword/wordprocessingml/insertion.rb +41 -0
- data/lib/uniword/wordprocessingml/page_margins.rb +7 -7
- data/lib/uniword/wordprocessingml/paragraph.rb +30 -4
- data/lib/uniword/wordprocessingml/settings.rb +3 -4
- data/lib/uniword/wordprocessingml/style_cleanup.rb +5 -14
- data/lib/uniword/wordprocessingml/table_borders.rb +1 -1
- data/lib/uniword/wordprocessingml/table_cell_borders.rb +1 -1
- data/lib/uniword/wordprocessingml/text.rb +0 -1
- data/lib/uniword/wordprocessingml.rb +5 -0
- data/lib/uniword/wp_drawing/position_h.rb +1 -1
- data/lib/uniword/wp_drawing/position_v.rb +1 -1
- data/lib/uniword/wp_drawing/size_rel_h.rb +1 -1
- data/lib/uniword/wp_drawing/size_rel_v.rb +1 -1
- data/lib/uniword.rb +9 -7
- metadata +19 -15
- data/lib/uniword/infrastructure/xml_namespace_normalizer.rb +0 -107
data/lib/uniword/cli/main.rb
CHANGED
|
@@ -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)",
|
|
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 |
|
|
223
|
-
{ 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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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 =
|
|
204
|
-
new_doc =
|
|
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
|
|
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
|
|
247
|
-
new_text = new_e.internal_file_attributes
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
# ---
|
|
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 [
|
|
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.
|
|
541
|
+
element.namespaces.each do |ns|
|
|
542
542
|
prefix = ns.prefix || ""
|
|
543
|
-
result[prefix] = ns.
|
|
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 [
|
|
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 |
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
+
validate: validate)
|
|
61
71
|
when :dotx, :dotm
|
|
62
72
|
Ooxml::DotxPackage.to_file(document, path, profile: profile,
|
|
63
|
-
|
|
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 ".
|
|
94
|
-
:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,
|
|
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 [
|
|
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
|
-
|
|
94
|
-
rescue
|
|
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 [
|
|
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.
|
|
191
|
-
next unless attr.namespace&.
|
|
192
|
-
next unless R_REF_ATTRIBUTES.include?(attr.
|
|
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.
|
|
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 [
|
|
247
|
-
# @return [
|
|
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
|
|
@@ -15,7 +15,7 @@ module Uniword
|
|
|
15
15
|
# -- ID generation --
|
|
16
16
|
|
|
17
17
|
def generate_rsid
|
|
18
|
-
"00#{hex_derive(
|
|
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])
|
|
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
|
|
64
|
-
body.tables&.each
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|