uniword 1.6.0 → 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.
- checksums.yaml +4 -4
- data/exe/uniword +9 -0
- data/lib/uniword/accessibility/accessibility_report.rb +8 -0
- 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/document_factory.rb +2 -2
- data/lib/uniword/document_writer.rb +32 -10
- data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
- data/lib/uniword/find_replace/scope.rb +7 -4
- data/lib/uniword/mhtml/xml_part.rb +3 -3
- data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
- 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/theme/theme_xml_parser.rb +1 -3
- 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 +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.rb +9 -3
- metadata +15 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3edc29b5332204a6bf88fdd016239deab4b9694b8f02a823482fc0d5c3e857b
|
|
4
|
+
data.tar.gz: b02521733dd3189181864a89824e30c23eb04cd2112abaded49e6bf8643a43b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee58e9718495648ea4516db97b921cb973e79787b5745cb852593614f1ab136716f7fceecf7de9a8b093f1dd361c43282cb3841dd9acd6b32c51f3c0d6afd2ff
|
|
7
|
+
data.tar.gz: d55e30fb35e6b199429a8f1d5379319945a281bc2a81f132b2f2efe558ee212cc33c4af90a0fd2884cd211414a34faad9269b0fa44d75005d3cb61bc10cfd00f
|
data/exe/uniword
CHANGED
|
@@ -10,5 +10,14 @@ if defined?(RubyVM::YJIT) && !RubyVM::YJIT.enabled? &&
|
|
|
10
10
|
RubyVM::YJIT.enable
|
|
11
11
|
end
|
|
12
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).
|
|
13
21
|
require "uniword"
|
|
14
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
|
data/lib/uniword/cli/helpers.rb
CHANGED
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)
|
|
@@ -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
|
|
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Uniword
|
|
4
4
|
module FindReplace
|
|
5
|
-
# Walks an enumerable of containers (Body, TableCell,
|
|
6
|
-
# and yields every Paragraph
|
|
7
|
-
# traverse paragraph-bearing
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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, :
|
|
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
|