uniword 1.5.4 → 1.6.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/exe/uniword +17 -0
  3. data/lib/uniword/accessibility/accessibility_report.rb +8 -0
  4. data/lib/uniword/cli/completions.rb +99 -0
  5. data/lib/uniword/cli/generate_cli.rb +5 -0
  6. data/lib/uniword/cli/help_switch.rb +45 -0
  7. data/lib/uniword/cli/helpers.rb +1 -0
  8. data/lib/uniword/cli/main.rb +118 -21
  9. data/lib/uniword/cli/no_color.rb +21 -0
  10. data/lib/uniword/document_factory.rb +2 -2
  11. data/lib/uniword/document_writer.rb +32 -10
  12. data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
  13. data/lib/uniword/find_replace/scope.rb +7 -4
  14. data/lib/uniword/mhtml/xml_part.rb +3 -3
  15. data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
  16. data/lib/uniword/ooxml/schema_library.rb +2 -2
  17. data/lib/uniword/ooxml/types/characteristics_val.rb +16 -0
  18. data/lib/uniword/ooxml/types/schema_library_val.rb +17 -0
  19. data/lib/uniword/ooxml/types/variant_types.rb +10 -2
  20. data/lib/uniword/ooxml/types/vt_attr.rb +19 -0
  21. data/lib/uniword/ooxml/types/wml_int.rb +17 -0
  22. data/lib/uniword/ooxml/types/wml_val.rb +19 -0
  23. data/lib/uniword/ooxml/types.rb +12 -0
  24. data/lib/uniword/properties/alignment.rb +4 -0
  25. data/lib/uniword/properties/boolean_element_factory.rb +1 -1
  26. data/lib/uniword/properties/font_size.rb +3 -0
  27. data/lib/uniword/review/review_manager.rb +34 -5
  28. data/lib/uniword/review/revision_resolver.rb +190 -0
  29. data/lib/uniword/review.rb +1 -0
  30. data/lib/uniword/revision.rb +3 -3
  31. data/lib/uniword/serialization/ooxml_serializer.rb +3 -3
  32. data/lib/uniword/theme/theme_xml_parser.rb +1 -3
  33. data/lib/uniword/validation/opc_validator.rb +6 -7
  34. data/lib/uniword/validation/rules/document_context.rb +3 -3
  35. data/lib/uniword/validation/rules/images_rule.rb +1 -1
  36. data/lib/uniword/validation/rules/theme_rule.rb +2 -2
  37. data/lib/uniword/validation/schema_registry.rb +1 -1
  38. data/lib/uniword/version.rb +1 -1
  39. data/lib/uniword/wordprocessingml/deletion.rb +41 -0
  40. data/lib/uniword/wordprocessingml/insertion.rb +41 -0
  41. data/lib/uniword/wordprocessingml/page_margins.rb +7 -7
  42. data/lib/uniword/wordprocessingml/paragraph.rb +30 -4
  43. data/lib/uniword/wordprocessingml/settings.rb +3 -4
  44. data/lib/uniword/wordprocessingml/style_cleanup.rb +5 -14
  45. data/lib/uniword/wordprocessingml/table_borders.rb +1 -1
  46. data/lib/uniword/wordprocessingml/table_cell_borders.rb +1 -1
  47. data/lib/uniword/wordprocessingml/text.rb +0 -1
  48. data/lib/uniword/wordprocessingml.rb +5 -0
  49. data/lib/uniword.rb +9 -3
  50. metadata +15 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2355e85fc7e7ec5d55ada1d7f832ef323334131cc5d4573df06ff5db1f08b1d
4
- data.tar.gz: 3ce0b3e201390c160c09513e85baebf3c1603f70fec278b6b90e8990710212d1
3
+ metadata.gz: f3edc29b5332204a6bf88fdd016239deab4b9694b8f02a823482fc0d5c3e857b
4
+ data.tar.gz: b02521733dd3189181864a89824e30c23eb04cd2112abaded49e6bf8643a43b9
5
5
  SHA512:
6
- metadata.gz: 0c168e086f836c6c1f0120e7d654082c7cc159509db621b584de0e04eb042d0b48f976c63485566bdee5d0c10e367ca568d80cd5f864bca5b0b88940711400a7
7
- data.tar.gz: 00dedb5f4183d03688a6765d95a550ec117e26325f0e2dfe0262ff77a4ff3b7473d6c816548e18ba46d70ebacd93274feb84d00c81ae280992fc40a061796e5c
6
+ metadata.gz: ee58e9718495648ea4516db97b921cb973e79787b5745cb852593614f1ab136716f7fceecf7de9a8b093f1dd361c43282cb3841dd9acd6b32c51f3c0d6afd2ff
7
+ data.tar.gz: d55e30fb35e6b199429a8f1d5379319945a281bc2a81f132b2f2efe558ee212cc33c4af90a0fd2884cd211414a34faad9269b0fa44d75005d3cb61bc10cfd00f
data/exe/uniword CHANGED
@@ -2,5 +2,22 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundler/setup"
5
+
6
+ # Hydration-heavy workloads run ~1.75x under YJIT (measured on the
7
+ # 608KB corpus). Opt out with UNIWORD_DISABLE_YJIT=1.
8
+ if defined?(RubyVM::YJIT) && !RubyVM::YJIT.enabled? &&
9
+ ENV["UNIWORD_DISABLE_YJIT"] != "1"
10
+ RubyVM::YJIT.enable
11
+ end
12
+
13
+ # POSIX NO_COLOR convention: suppress colored CLI output.
14
+ if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
15
+ require "uniword/cli/no_color"
16
+ end
17
+
18
+ # Never let a task's return value become the process exit status:
19
+ # success is 0, and failures raise SystemExit inside `start`
20
+ # (exit_on_failure? / handle_error).
5
21
  require "uniword"
6
22
  Uniword::CLI.start(ARGV)
23
+ exit 0
@@ -46,6 +46,14 @@ module Uniword
46
46
  errors.empty?
47
47
  end
48
48
 
49
+ # Report-contract predicate shared with Quality::QualityReport:
50
+ # true when the document has no error-level violations.
51
+ #
52
+ # @return [Boolean]
53
+ def valid?
54
+ compliant?
55
+ end
56
+
49
57
  # Get all error-level violations
50
58
  #
51
59
  # @return [Array<AccessibilityViolation>] Error violations
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Cli
5
+ # Builds shell completion scripts from live CLI metadata so new
6
+ # commands appear in completions without editing the scripts.
7
+ module Completions
8
+ BUILTINS = %w[help completions].freeze
9
+
10
+ module_function
11
+
12
+ # @param shell [String] "bash", "zsh", or "fish"
13
+ # @param root [Class] the root Thor class (Uniword::CLI)
14
+ # @return [String] completion script
15
+ def script(shell, root:)
16
+ commands = root.all_commands.keys - BUILTINS
17
+ subcommands = subcommand_map(root)
18
+
19
+ case shell
20
+ when "bash" then bash(commands, subcommands)
21
+ when "zsh" then zsh(commands, subcommands)
22
+ when "fish" then fish(commands, subcommands)
23
+ else
24
+ raise ArgumentError,
25
+ "unsupported shell: #{shell.inspect} (bash|zsh|fish)"
26
+ end
27
+ end
28
+
29
+ # Map command name → registered subcommand Thor class, derived
30
+ # from class names (ThemeCLI → "theme"), so `subcommand` call
31
+ # sites stay the single source of truth.
32
+ # Space-separated subcommand word list for one class.
33
+ def words_for(klass)
34
+ klass.all_commands.keys.reject { |c| c == "help" }.join(" ")
35
+ end
36
+
37
+ def subcommand_map(root)
38
+ root.subcommand_classes.filter_map do |name, klass|
39
+ [name, klass]
40
+ end.to_h
41
+ end
42
+
43
+ def bash(commands, subcommands)
44
+ cmds = commands.join(" ")
45
+ script = <<~BASH
46
+ # uniword bash completion — generated by `uniword completions bash`
47
+ _uniword() {
48
+ local cur="${COMP_WORDS[COMP_CWORD]}"
49
+ local prev="${COMP_WORDS[COMP_CWORD-1]}"
50
+ local cmds="#{cmds}"
51
+ BASH
52
+ subcommands.each do |name, klass|
53
+ words = words_for(klass)
54
+ script << " if [ \"$prev\" = \"#{name}\" ]; then\n " \
55
+ "COMPREPLY=( $(compgen -W \"#{words}\" " \
56
+ "-- \"$cur\") )\n return\n fi\n"
57
+ end
58
+ script << " COMPREPLY=( $(compgen -W \"$cmds\" -- \"$cur\") )\n" \
59
+ "}\n" \
60
+ "complete -F _uniword uniword\n"
61
+ script
62
+ end
63
+
64
+ def zsh(commands, subcommands)
65
+ script = <<~ZSH
66
+ #compdef uniword
67
+ # uniword zsh completion — generated by `uniword completions zsh`
68
+ _uniword() {
69
+ local -a cmds
70
+ cmds=(#{commands.map { |c| "'#{c}'" }.join(' ')})
71
+ _describe 'command' cmds
72
+ ZSH
73
+ subcommands.each do |name, klass|
74
+ words = words_for(klass).split.map { |c| "'#{c}'" }.join(" ")
75
+ script << " if (( CURRENT == 2 )) && [[ #{name} == * ]]; " \
76
+ "then\n local -a sub\n sub=(#{words})\n " \
77
+ "_describe '#{name} command' sub\n fi\n"
78
+ end
79
+ script << "}\n"
80
+ script
81
+ end
82
+
83
+ def fish(commands, subcommands)
84
+ script = +<<~FISH
85
+ # uniword fish completion — generated by `uniword completions fish`
86
+ FISH
87
+ commands.each do |c|
88
+ script << "complete -c uniword -n '__fish_use_subcommand' -a '#{c}'\n"
89
+ end
90
+ subcommands.each do |name, klass|
91
+ words = words_for(klass)
92
+ script << "complete -c uniword -n '__fish_seen_subcommand_from " \
93
+ "#{name}' -a '#{words}'\n"
94
+ end
95
+ script
96
+ end
97
+ end
98
+ end
99
+ end
@@ -10,6 +10,11 @@ module Uniword
10
10
  class GenerateCLI < Thor
11
11
  include CLIHelpers
12
12
 
13
+ # `uniword generate INPUT OUTPUT ...` dispatches straight to the
14
+ # generate task; the explicit `generate generate ...` form stays
15
+ # valid for backwards compatibility.
16
+ default_task :generate
17
+
13
18
  desc "generate INPUT OUTPUT",
14
19
  "Generate DOCX from structured text"
15
20
  long_desc <<~DESC
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Cli
5
+ # Rewrites help/version flag invocations before Thor dispatch so
6
+ # help works on tasks with required arguments:
7
+ #
8
+ # uniword convert --help → uniword help convert
9
+ # uniword images add --help → uniword help images add
10
+ # uniword --help / -h → root help
11
+ # uniword --version / -V → uniword version
12
+ #
13
+ # The rewrite only fires when the help/version flag is the first
14
+ # flag-like token and everything before it looks like a task path
15
+ # (no options), so real option values (e.g. `--from docx`) are
16
+ # never mistaken for the chain.
17
+ module HelpSwitch
18
+ HELP_FLAGS = %w[--help -h].freeze
19
+ VERSION_FLAGS = %w[--version -V].freeze
20
+
21
+ def start(given_args = ARGV, config = {})
22
+ args = given_args.map(&:to_s)
23
+
24
+ if (help_idx = args.index { |a| HELP_FLAGS.include?(a) })
25
+ chain = args[0...help_idx]
26
+ if option_free_task_path?(chain)
27
+ return super(chain.unshift("help"), config)
28
+ end
29
+ end
30
+
31
+ if args.size == 1 && VERSION_FLAGS.include?(args[0])
32
+ return super(["version"], config)
33
+ end
34
+
35
+ super
36
+ end
37
+
38
+ private
39
+
40
+ def option_free_task_path?(chain)
41
+ chain.all? { |a| !a.start_with?("-") }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -10,6 +10,7 @@ module Uniword
10
10
  module CLIHelpers
11
11
  def self.included(base)
12
12
  base.extend(ClassMethods)
13
+ base.extend(Cli::HelpSwitch)
13
14
  end
14
15
 
15
16
  module ClassMethods
@@ -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)
@@ -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
 
@@ -2,15 +2,21 @@
2
2
 
3
3
  module Uniword
4
4
  module FindReplace
5
- # Walks an enumerable of containers (Body, TableCell, SDT, ...)
6
- # and yields every Paragraph reachable. Used by all scopes that
7
- # traverse paragraph-bearing parts (body, headers, footers,
8
- # footnotes, endnotes, comments).
5
+ # Walks an enumerable of containers (Body, TableCell, Header,
6
+ # Footer, Footnote, Endnote, Comment) and yields every Paragraph
7
+ # reachable. Used by all scopes that traverse paragraph-bearing
8
+ # parts (body, headers, footers, footnotes, endnotes, comments).
9
+ #
10
+ # Container classes map different subsets of OOXML block-level
11
+ # content, so dispatch is explicit per class (mirrors
12
+ # Docx::DocumentStatistics):
13
+ # - Body maps paragraphs, tables, and structured document tags
14
+ # - TableCell, Header, Footer map paragraphs and tables
15
+ # - Footnote, Endnote, Comment map paragraphs only
9
16
  module ParagraphWalker
10
17
  module_function
11
18
 
12
- # @param containers [Enumerable<#paragraphs, #tables,
13
- # #structured_document_tags>] containers to walk
19
+ # @param containers [Enumerable<#paragraphs>] containers to walk
14
20
  # @yieldparam paragraph [Wordprocessingml::Paragraph]
15
21
  # @return [void]
16
22
  def each_paragraph(containers, &block)
@@ -23,13 +29,37 @@ module Uniword
23
29
  # @yieldparam paragraph [Wordprocessingml::Paragraph]
24
30
  # @return [void]
25
31
  def walk_container(container, &block)
26
- container.paragraphs&.each(&block)
27
- walk_tables(container.tables, &block) if container.tables
28
- if container.structured_document_tags
29
- walk_sdts(container.structured_document_tags, &block)
32
+ case container
33
+ when Wordprocessingml::Body
34
+ walk_body(container, &block)
35
+ else
36
+ walk_non_body(container, &block)
30
37
  end
31
38
  end
32
39
 
40
+ # Body is the only container class that maps structured
41
+ # document tags.
42
+ def walk_body(body, &block)
43
+ body.paragraphs&.each(&block)
44
+ walk_tables(body.tables, &block)
45
+ walk_sdts(body.structured_document_tags, &block)
46
+ end
47
+
48
+ # TableCell, Header, and Footer map tables; Footnote, Endnote,
49
+ # and Comment map paragraphs only.
50
+ def walk_non_body(container, &block)
51
+ container.paragraphs&.each(&block)
52
+ return unless table_bearing?(container)
53
+
54
+ walk_tables(container.tables, &block)
55
+ end
56
+
57
+ def table_bearing?(container)
58
+ container.is_a?(Wordprocessingml::TableCell) ||
59
+ container.is_a?(Wordprocessingml::Header) ||
60
+ container.is_a?(Wordprocessingml::Footer)
61
+ end
62
+
33
63
  def walk_tables(tables, &block)
34
64
  tables.each do |table|
35
65
  walk_table_rows(table, &block) if table.rows
@@ -48,13 +78,20 @@ module Uniword
48
78
  end
49
79
  end
50
80
 
81
+ # SDT content (sdtContent) is itself a block-level container
82
+ # with paragraphs, tables, and nested SDTs.
51
83
  def walk_sdts(sdts, &block)
52
84
  sdts.each do |sdt|
53
- sdt.paragraphs&.each(&block)
85
+ next unless sdt.content
86
+
87
+ sdt.content.paragraphs&.each(&block)
88
+ walk_tables(sdt.content.tables, &block)
89
+ walk_sdts(sdt.content.sdts, &block)
54
90
  end
55
91
  end
56
92
 
57
- private_class_method :walk_container, :walk_tables, :walk_table_rows,
93
+ private_class_method :walk_container, :walk_body, :walk_non_body,
94
+ :table_bearing?, :walk_tables, :walk_table_rows,
58
95
  :walk_table_cells, :walk_sdts
59
96
  end
60
97
  end