uniword 1.5.1 → 1.5.2
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/CHANGELOG.md +27 -0
- data/lib/uniword/cli/main.rb +134 -0
- data/lib/uniword/cli/review_cli.rb +34 -0
- data/lib/uniword/configuration.rb +17 -0
- data/lib/uniword/docx/deterministic_output.rb +47 -0
- data/lib/uniword/docx.rb +1 -0
- data/lib/uniword/find_replace/body_scope.rb +23 -0
- data/lib/uniword/find_replace/comment_scope.rb +23 -0
- data/lib/uniword/find_replace/endnote_scope.rb +23 -0
- data/lib/uniword/find_replace/engine.rb +84 -0
- data/lib/uniword/find_replace/footer_scope.rb +24 -0
- data/lib/uniword/find_replace/footnote_scope.rb +23 -0
- data/lib/uniword/find_replace/header_scope.rb +24 -0
- data/lib/uniword/find_replace/matcher.rb +23 -0
- data/lib/uniword/find_replace/paragraph_walker.rb +61 -0
- data/lib/uniword/find_replace/regex_matcher.rb +38 -0
- data/lib/uniword/find_replace/result.rb +54 -0
- data/lib/uniword/find_replace/scope.rb +98 -0
- data/lib/uniword/find_replace/string_matcher.rb +72 -0
- data/lib/uniword/find_replace/styles_scope.rb +38 -0
- data/lib/uniword/find_replace.rb +29 -0
- data/lib/uniword/infrastructure/zip_packager.rb +28 -1
- data/lib/uniword/lint/builtin_rules/banned_words.rb +35 -0
- data/lib/uniword/lint/builtin_rules/max_paragraph_length.rb +32 -0
- data/lib/uniword/lint/builtin_rules/require_body.rb +23 -0
- data/lib/uniword/lint/builtin_rules/required_style.rb +31 -0
- data/lib/uniword/lint/builtin_rules.rb +23 -0
- data/lib/uniword/lint/engine.rb +32 -0
- data/lib/uniword/lint/result.rb +59 -0
- data/lib/uniword/lint/rule.rb +77 -0
- data/lib/uniword/lint/ruleset.rb +65 -0
- data/lib/uniword/lint.rb +17 -0
- data/lib/uniword/redact/engine.rb +52 -0
- data/lib/uniword/redact/pattern.rb +23 -0
- data/lib/uniword/redact/pattern_library.rb +60 -0
- data/lib/uniword/redact/result.rb +42 -0
- data/lib/uniword/redact.rb +15 -0
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/document_styling.rb +91 -0
- data/lib/uniword/wordprocessingml/settings.rb +2 -0
- data/lib/uniword/wordprocessingml/track_changes.rb +19 -0
- data/lib/uniword/wordprocessingml.rb +1 -0
- data/lib/uniword.rb +10 -0
- metadata +34 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9574cce7758124ff340199b7b07e95bc332e86ed1428e663eff2ff5059bc044
|
|
4
|
+
data.tar.gz: 2d80ed18e0289d232422c9c882201eeed2cded201df55c3ae887945b1ec9d0bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05f384f87289e6325f6761a90afec1f08a9129bfe09b4b1bc381b0dd23bb6acd81f438f423c7463f3b609dba2c81d12ef3a6eef900b766816c69bcecb2a24c80
|
|
7
|
+
data.tar.gz: 185871d7a5926e6b1e4708cbf24169642cc4e4096edd416f184399c887ef4a6c3db40bb3dc88f3b7fb025c7826bf2a23aa86b13b9e8660ce95f35272d0282f56
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `DocumentRoot#find_replace(pattern, replacement, scope:, ignore_case:)`
|
|
14
|
+
— Word's Home → Replace dialog as an API. Replaces every
|
|
15
|
+
non-overlapping match across one or more scopes (`body`, `headers`,
|
|
16
|
+
`footers`, `footnotes`, `endnotes`, `comments`, `styles`, or `:all`
|
|
17
|
+
default). Supports plain-string and regex patterns with capture
|
|
18
|
+
references (`\1`, `\2`, ...).
|
|
19
|
+
- `uniword find-replace INPUT OUTPUT PATTERN REPLACEMENT` CLI command.
|
|
20
|
+
Same surface as the Ruby API; supports `--scope` (repeatable),
|
|
21
|
+
`--regex`, `--ignore-case`, `--verbose`.
|
|
22
|
+
- `Uniword::FindReplace` module — orchestrator (`Engine`), per-part
|
|
23
|
+
strategies (`Scope` subclasses), and matcher hierarchy
|
|
24
|
+
(`StringMatcher`, `RegexMatcher`). Open/closed: new scope = new
|
|
25
|
+
subclass + registration.
|
|
26
|
+
- `Wordprocessingml::TrackChanges` element + `Settings#track_changes`
|
|
27
|
+
attribute — Word's `<w:trackChanges/>` toggle.
|
|
28
|
+
- `DocumentRoot#track_changes_on!`, `#track_changes_off!`,
|
|
29
|
+
`#track_changes_enabled?` — Review → Track Changes as an API.
|
|
30
|
+
- `uniword review track-changes on/off/status FILE` CLI subcommand.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `Wordprocessingml::Settings` now carries an optional `track_changes`
|
|
35
|
+
element. Documents that already had `<w:trackChanges/>` round-trip
|
|
36
|
+
it correctly (previously dropped on load).
|
|
37
|
+
|
|
11
38
|
## [1.5.0] - 2026-07-22
|
|
12
39
|
|
|
13
40
|
### Added
|
data/lib/uniword/cli/main.rb
CHANGED
|
@@ -383,6 +383,92 @@ module Uniword
|
|
|
383
383
|
say "Uniword version #{Uniword::VERSION}", :green
|
|
384
384
|
end
|
|
385
385
|
|
|
386
|
+
desc "redact INPUT OUTPUT", "Redact PII patterns from a document"
|
|
387
|
+
long_desc <<~DESC
|
|
388
|
+
Replace PII patterns (SSN, email, phone, credit card, IPv4)
|
|
389
|
+
with [REDACTED] across the document. Compliance use case Word
|
|
390
|
+
cannot serve.
|
|
391
|
+
|
|
392
|
+
Scopes: body, headers, footers, footnotes, endnotes, comments,
|
|
393
|
+
styles, all (default). Pass --scope multiple times to combine.
|
|
394
|
+
|
|
395
|
+
Examples:
|
|
396
|
+
$ uniword redact report.docx redacted.docx
|
|
397
|
+
$ uniword redact report.docx redacted.docx --pattern ssn --pattern email
|
|
398
|
+
$ uniword redact report.docx redacted.docx --scope body --verbose
|
|
399
|
+
DESC
|
|
400
|
+
option :pattern, type: :array, default: [:pii],
|
|
401
|
+
desc: "Pattern(s): pii (default), ssn, email, " \
|
|
402
|
+
"phone, credit_card, ipv4"
|
|
403
|
+
option :scope, type: :array, default: [:all],
|
|
404
|
+
desc: "Scope(s): body, headers, footers, footnotes, " \
|
|
405
|
+
"endnotes, comments, styles, all"
|
|
406
|
+
option :verbose, aliases: "-v", type: :boolean, default: false,
|
|
407
|
+
desc: "Show per-pattern counts"
|
|
408
|
+
def redact(input_path, output_path)
|
|
409
|
+
doc = load_document(input_path)
|
|
410
|
+
patterns = resolve_redact_patterns(options[:pattern])
|
|
411
|
+
result = doc.redact(patterns: patterns,
|
|
412
|
+
scope: expand_scopes(options[:scope]))
|
|
413
|
+
|
|
414
|
+
if options[:verbose]
|
|
415
|
+
say "Redactions by pattern:", :cyan
|
|
416
|
+
result.by_pattern.each do |name, count|
|
|
417
|
+
say " #{name}: #{count}" if count.positive?
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
say "Redacted #{result.count} match(es) in #{output_path}", :green
|
|
421
|
+
doc.save(output_path)
|
|
422
|
+
rescue Uniword::Error => e
|
|
423
|
+
handle_error(e)
|
|
424
|
+
rescue StandardError => e
|
|
425
|
+
handle_error(e, verbose: options[:verbose])
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
desc "find-replace INPUT OUTPUT PATTERN REPLACEMENT",
|
|
429
|
+
"Find and replace text across document parts"
|
|
430
|
+
long_desc <<~DESC
|
|
431
|
+
Replace every non-overlapping match of PATTERN with REPLACEMENT
|
|
432
|
+
across the configured document parts -- Word's Home → Replace
|
|
433
|
+
dialog as a one-shot CLI command.
|
|
434
|
+
|
|
435
|
+
Scopes: body, headers, footers, footnotes, endnotes, comments,
|
|
436
|
+
styles, all (default). Pass --scope multiple times to combine.
|
|
437
|
+
|
|
438
|
+
Examples:
|
|
439
|
+
$ uniword find-replace report.docx out.docx "Acme" "MegaCorp"
|
|
440
|
+
$ uniword find-replace report.docx out.docx 'Chapter (\\d+)' 'Ch. \\1' --regex
|
|
441
|
+
$ uniword find-replace report.docx out.docx "DRAFT" "FINAL" --scope headers --scope footers
|
|
442
|
+
$ uniword find-replace report.docx out.docx "foo" "bar" --ignore-case --verbose
|
|
443
|
+
DESC
|
|
444
|
+
option :scope, type: :array, default: [:all],
|
|
445
|
+
desc: "Scope(s): body, headers, footers, footnotes, " \
|
|
446
|
+
"endnotes, comments, styles, all"
|
|
447
|
+
option :regex, type: :boolean, default: false,
|
|
448
|
+
desc: "Treat PATTERN as a regular expression"
|
|
449
|
+
option :ignore_case, type: :boolean, default: false,
|
|
450
|
+
desc: "Case-insensitive match (plain-string mode)"
|
|
451
|
+
option :verbose, aliases: "-v", type: :boolean, default: false,
|
|
452
|
+
desc: "Show per-scope counts"
|
|
453
|
+
def find_replace(input_path, output_path, pattern, replacement)
|
|
454
|
+
scopes = expand_scopes(options[:scope])
|
|
455
|
+
doc = load_document(input_path)
|
|
456
|
+
result = run_find_replace(doc, pattern, replacement, scopes)
|
|
457
|
+
|
|
458
|
+
if options[:verbose]
|
|
459
|
+
say "Replacements by scope:", :cyan
|
|
460
|
+
result.by_scope.each do |scope_name, count|
|
|
461
|
+
say " #{scope_name}: #{count}" if count.positive?
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
say "Replaced #{result.count} match(es) in #{output_path}", :green
|
|
465
|
+
doc.save(output_path)
|
|
466
|
+
rescue Uniword::Error => e
|
|
467
|
+
handle_error(e)
|
|
468
|
+
rescue StandardError => e
|
|
469
|
+
handle_error(e, verbose: options[:verbose])
|
|
470
|
+
end
|
|
471
|
+
|
|
386
472
|
# Register subcommands
|
|
387
473
|
desc "theme SUBCOMMAND", "Manage document themes"
|
|
388
474
|
subcommand "theme", ThemeCLI
|
|
@@ -439,6 +525,54 @@ module Uniword
|
|
|
439
525
|
|
|
440
526
|
private
|
|
441
527
|
|
|
528
|
+
# -- find-replace helpers -------------------------------------------
|
|
529
|
+
|
|
530
|
+
# Normalize --scope arguments. `[:all]` collapses to `:all`; any
|
|
531
|
+
# combination of named scopes stays as an array.
|
|
532
|
+
#
|
|
533
|
+
# @param scopes [Array<Symbol>]
|
|
534
|
+
# @return [Symbol, Array<Symbol>]
|
|
535
|
+
def expand_scopes(scopes)
|
|
536
|
+
return :all if scopes.nil? || scopes.empty?
|
|
537
|
+
|
|
538
|
+
symbols = scopes.map(&:to_sym)
|
|
539
|
+
return :all if symbols.include?(:all)
|
|
540
|
+
|
|
541
|
+
symbols
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Build the right matcher from --regex flag and run the engine.
|
|
545
|
+
def run_find_replace(doc, pattern, replacement, scopes)
|
|
546
|
+
matcher = build_find_replace_matcher(pattern, replacement)
|
|
547
|
+
Uniword::FindReplace::Engine.new(document: doc, matcher: matcher,
|
|
548
|
+
scopes: scopes).run
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Translate --pattern args (`pii`, `ssn`, ...) into a list of
|
|
552
|
+
# Redact::Pattern objects via PatternLibrary.
|
|
553
|
+
#
|
|
554
|
+
# @param names [Array<Symbol>]
|
|
555
|
+
# @return [Array<Uniword::Redact::Pattern>]
|
|
556
|
+
def resolve_redact_patterns(names)
|
|
557
|
+
symbols = Array(names).map(&:to_sym)
|
|
558
|
+
return :pii if symbols == [:pii]
|
|
559
|
+
|
|
560
|
+
Uniword::Redact::PatternLibrary.select(symbols)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def build_find_replace_matcher(pattern, replacement)
|
|
564
|
+
if options[:regex]
|
|
565
|
+
Uniword::FindReplace::RegexMatcher.new(pattern: pattern,
|
|
566
|
+
replacement: replacement)
|
|
567
|
+
else
|
|
568
|
+
Uniword::FindReplace::StringMatcher.new(
|
|
569
|
+
pattern: pattern,
|
|
570
|
+
replacement: replacement,
|
|
571
|
+
ignore_case: options[:ignore_case],
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
442
576
|
# Report substantive repairs; routine normalization is summarized
|
|
443
577
|
# separately so a healthy document does not look "repaired".
|
|
444
578
|
def report_repairs(fixes)
|
|
@@ -212,6 +212,40 @@ module Uniword
|
|
|
212
212
|
handle_error(e)
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
+
desc "track-changes SUBCOMMAND", "Toggle or query change tracking"
|
|
216
|
+
long_desc <<~DESC
|
|
217
|
+
Turn Word's Review → Track Changes toggle on or off, or query
|
|
218
|
+
its current state.
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
$ uniword review track-changes on input.docx output.docx
|
|
222
|
+
$ uniword review track-changes off input.docx output.docx
|
|
223
|
+
$ uniword review track-changes status input.docx
|
|
224
|
+
DESC
|
|
225
|
+
def track_changes(command, input_path, output_path = nil)
|
|
226
|
+
doc = load_document(input_path)
|
|
227
|
+
|
|
228
|
+
case command.to_sym
|
|
229
|
+
when :on
|
|
230
|
+
doc.track_changes_on!
|
|
231
|
+
doc.save(output_path || input_path)
|
|
232
|
+
say "Change tracking ON in #{output_path || input_path}", :green
|
|
233
|
+
when :off
|
|
234
|
+
doc.track_changes_off!
|
|
235
|
+
doc.save(output_path || input_path)
|
|
236
|
+
say "Change tracking OFF in #{output_path || input_path}", :green
|
|
237
|
+
when :status
|
|
238
|
+
say doc.track_changes_enabled? ? "ON" : "OFF"
|
|
239
|
+
else
|
|
240
|
+
say "Unknown subcommand: #{command}. Use on, off, or status.", :red
|
|
241
|
+
exit 1
|
|
242
|
+
end
|
|
243
|
+
rescue Uniword::Error => e
|
|
244
|
+
handle_error(e)
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
handle_error(e)
|
|
247
|
+
end
|
|
248
|
+
|
|
215
249
|
desc "interactive FILE", "Interactively review all changes"
|
|
216
250
|
long_desc <<~DESC
|
|
217
251
|
Step through comments and tracked changes one-by-one.
|
|
@@ -52,6 +52,14 @@ module Uniword
|
|
|
52
52
|
# @return [Symbol] `:strip` (default) or `:raise`
|
|
53
53
|
attr_reader :on_noncompliant_content
|
|
54
54
|
|
|
55
|
+
# Whether save produces deterministic output (fixed ZIP
|
|
56
|
+
# timestamps, sorted entry order). Default false (Word-compatible
|
|
57
|
+
# timestamps and order). Enable for git-tracked documents where
|
|
58
|
+
# byte-stable diffs matter.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
attr_reader :deterministic_output
|
|
62
|
+
|
|
55
63
|
# Create a configuration with default policy values.
|
|
56
64
|
#
|
|
57
65
|
# Defaults: validate_on_save: true, xsd_validation: false,
|
|
@@ -68,6 +76,7 @@ module Uniword
|
|
|
68
76
|
@xsd_validation = false
|
|
69
77
|
@log_save_fixes = true
|
|
70
78
|
@on_noncompliant_content = :strip
|
|
79
|
+
@deterministic_output = false
|
|
71
80
|
self
|
|
72
81
|
end
|
|
73
82
|
|
|
@@ -109,6 +118,14 @@ module Uniword
|
|
|
109
118
|
@on_noncompliant_content = typed_mode(value, :on_noncompliant_content)
|
|
110
119
|
end
|
|
111
120
|
|
|
121
|
+
# Set the deterministic-output policy.
|
|
122
|
+
#
|
|
123
|
+
# @param value [Boolean]
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def deterministic_output=(value)
|
|
126
|
+
@deterministic_output = typed_boolean(value, :deterministic_output)
|
|
127
|
+
end
|
|
128
|
+
|
|
112
129
|
private
|
|
113
130
|
|
|
114
131
|
# Validate that a value is strictly boolean.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Normalizes package output for byte-stable diffs.
|
|
6
|
+
#
|
|
7
|
+
# When `Uniword.configuration.deterministic_output` is true:
|
|
8
|
+
# - ZIP entry timestamps are fixed at 1980-01-01 (DOS epoch).
|
|
9
|
+
# - ZIP entry order is sorted alphabetically (except
|
|
10
|
+
# `[Content_Types].xml` and `_rels/.rels`, which must come
|
|
11
|
+
# first per OPC).
|
|
12
|
+
# - Compression level is fixed.
|
|
13
|
+
#
|
|
14
|
+
# XML output is already deterministic via IdAllocator's stable
|
|
15
|
+
# rIds and the serializer's stable attribute order, so no XML
|
|
16
|
+
# normalization is needed here.
|
|
17
|
+
module DeterministicOutput
|
|
18
|
+
# Fixed DOS epoch timestamp (1980-01-01 00:00:00 UTC) for ZIP
|
|
19
|
+
# entries. ZIP format doesn't support earlier dates.
|
|
20
|
+
FIXED_TIMESTAMP = Time.utc(1980, 1, 1, 0, 0, 0).freeze
|
|
21
|
+
|
|
22
|
+
# Reorder entries: [Content_Types].xml and _rels/.rels first
|
|
23
|
+
# (required by OPC), then alphabetical for the rest.
|
|
24
|
+
#
|
|
25
|
+
# @param entries [Array<String>] ZIP entry paths
|
|
26
|
+
# @return [Array<String>] reordered paths
|
|
27
|
+
def self.reorder_entries(entries)
|
|
28
|
+
priority = PRIORITY_ORDER.filter_map { |p| entries.find { |e| e == p } }
|
|
29
|
+
rest = (entries - PRIORITY_ORDER).sort
|
|
30
|
+
priority + rest
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Apply fixed timestamps to a ZIP output stream's entries.
|
|
34
|
+
#
|
|
35
|
+
# @param zos [Zip::OutputStream]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def self.stamp_entries(zos)
|
|
38
|
+
zos.each_with_index do |_entry, _idx|
|
|
39
|
+
# Entry timestamps are set when the entry is created;
|
|
40
|
+
# callers must use #write_entry to apply this.
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
PRIORITY_ORDER = %w[[Content_Types].xml _rels/.rels].freeze
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/uniword/docx.rb
CHANGED
|
@@ -29,5 +29,6 @@ module Uniword
|
|
|
29
29
|
autoload :Profile, "#{__dir__}/docx/profile"
|
|
30
30
|
autoload :DocumentStatistics, "#{__dir__}/docx/document_statistics"
|
|
31
31
|
autoload :Reconciler, "#{__dir__}/docx/reconciler"
|
|
32
|
+
autoload :DeterministicOutput, "#{__dir__}/docx/deterministic_output"
|
|
32
33
|
end
|
|
33
34
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Body scope: text in `word/document.xml`. Walks every paragraph
|
|
6
|
+
# reachable from the body (including paragraphs nested in table
|
|
7
|
+
# cells and structured document tags).
|
|
8
|
+
class BodyScope < Scope
|
|
9
|
+
# @return [Symbol]
|
|
10
|
+
def name
|
|
11
|
+
:body
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
15
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
16
|
+
def each_text_node
|
|
17
|
+
return unless @document.body
|
|
18
|
+
|
|
19
|
+
each_text_in_containers([@document.body]) { |*a| yield(*a) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Comments scope: text in `word/comments.xml`. Walks every
|
|
6
|
+
# comment's paragraphs.
|
|
7
|
+
class CommentScope < Scope
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
def name
|
|
10
|
+
:comments
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
14
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
15
|
+
def each_text_node
|
|
16
|
+
comments = @document.comments&.comments
|
|
17
|
+
return unless comments
|
|
18
|
+
|
|
19
|
+
each_text_in_containers(comments) { |*a| yield(*a) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Endnotes scope: text in `word/endnotes.xml`. Walks every
|
|
6
|
+
# endnote entry's paragraphs.
|
|
7
|
+
class EndnoteScope < Scope
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
def name
|
|
10
|
+
:endnotes
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
14
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
15
|
+
def each_text_node
|
|
16
|
+
entries = @document.endnotes&.endnote_entries
|
|
17
|
+
return unless entries
|
|
18
|
+
|
|
19
|
+
each_text_in_containers(entries) { |*a| yield(*a) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Orchestrates a find-replace pass over a package.
|
|
6
|
+
#
|
|
7
|
+
# Owns the scope registry (which parts to scan) and matcher
|
|
8
|
+
# dispatch (literal vs regex). Returns a `Result` with total
|
|
9
|
+
# count and per-scope breakdown.
|
|
10
|
+
#
|
|
11
|
+
# Open/closed: scopes register in `SCOPE_REGISTRY`; adding one is
|
|
12
|
+
# data, not behavior. The matcher class is chosen by the
|
|
13
|
+
# `regex:` flag, but callers can pass a custom `Matcher` instance
|
|
14
|
+
# to bypass the flag entirely.
|
|
15
|
+
class Engine
|
|
16
|
+
# Symbol => Scope subclass. Adding a scope = adding an entry.
|
|
17
|
+
SCOPE_REGISTRY = {
|
|
18
|
+
body: BodyScope,
|
|
19
|
+
headers: HeaderScope,
|
|
20
|
+
footers: FooterScope,
|
|
21
|
+
footnotes: FootnoteScope,
|
|
22
|
+
endnotes: EndnoteScope,
|
|
23
|
+
comments: CommentScope,
|
|
24
|
+
styles: StylesScope,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# All registered scope names. Used by `:all` to expand.
|
|
28
|
+
ALL_SCOPES = SCOPE_REGISTRY.keys.freeze
|
|
29
|
+
|
|
30
|
+
# @param document [Wordprocessingml::DocumentRoot]
|
|
31
|
+
# @param matcher [Matcher]
|
|
32
|
+
# @param scopes [Array<Symbol>, :all] scopes to scan; `:all`
|
|
33
|
+
# expands to every registered scope
|
|
34
|
+
def initialize(document:, matcher:, scopes: :all)
|
|
35
|
+
@document = document
|
|
36
|
+
@matcher = matcher
|
|
37
|
+
@scopes = resolve_scopes(scopes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run the find-replace pass.
|
|
41
|
+
#
|
|
42
|
+
# @return [Result]
|
|
43
|
+
def run
|
|
44
|
+
result = Result.new
|
|
45
|
+
return result if @matcher.nil? || @scopes.empty?
|
|
46
|
+
|
|
47
|
+
@scopes.each do |scope_name|
|
|
48
|
+
scope_class = SCOPE_REGISTRY.fetch(scope_name)
|
|
49
|
+
scope = scope_class.new(@document)
|
|
50
|
+
substitutions_in_scope = apply_scope(scope)
|
|
51
|
+
result.add(scope_name, substitutions_in_scope)
|
|
52
|
+
end
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def resolve_scopes(scopes)
|
|
59
|
+
return ALL_SCOPES if scopes == :all
|
|
60
|
+
|
|
61
|
+
Array(scopes).select { |s| SCOPE_REGISTRY.key?(s) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Apply the matcher to every text accessor in one scope.
|
|
65
|
+
#
|
|
66
|
+
# @param scope [Scope]
|
|
67
|
+
# @return [Integer] substitutions applied in this scope
|
|
68
|
+
def apply_scope(scope)
|
|
69
|
+
substitutions = 0
|
|
70
|
+
scope.each_text_node do |_holder, accessor|
|
|
71
|
+
original = accessor.value
|
|
72
|
+
next unless original
|
|
73
|
+
|
|
74
|
+
new_text, count = @matcher.apply(original)
|
|
75
|
+
next if count.zero?
|
|
76
|
+
|
|
77
|
+
accessor.value = new_text
|
|
78
|
+
substitutions += count
|
|
79
|
+
end
|
|
80
|
+
substitutions
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Footers scope: text in every `word/footer*.xml`. Walks all
|
|
6
|
+
# footer parts in the document's header-footer store.
|
|
7
|
+
class FooterScope < Scope
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
def name
|
|
10
|
+
:footers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
14
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
15
|
+
def each_text_node
|
|
16
|
+
parts = @document.header_footer_parts
|
|
17
|
+
return unless parts
|
|
18
|
+
|
|
19
|
+
containers = parts.of_kind(:footer).filter_map(&:content)
|
|
20
|
+
each_text_in_containers(containers) { |*a| yield(*a) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Footnotes scope: text in `word/footnotes.xml`. Walks every
|
|
6
|
+
# footnote entry's paragraphs.
|
|
7
|
+
class FootnoteScope < Scope
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
def name
|
|
10
|
+
:footnotes
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
14
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
15
|
+
def each_text_node
|
|
16
|
+
entries = @document.footnotes&.footnote_entries
|
|
17
|
+
return unless entries
|
|
18
|
+
|
|
19
|
+
each_text_in_containers(entries) { |*a| yield(*a) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Headers scope: text in every `word/header*.xml`. Walks all
|
|
6
|
+
# header parts in the document's header-footer store.
|
|
7
|
+
class HeaderScope < Scope
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
def name
|
|
10
|
+
:headers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
14
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
15
|
+
def each_text_node
|
|
16
|
+
parts = @document.header_footer_parts
|
|
17
|
+
return unless parts
|
|
18
|
+
|
|
19
|
+
containers = parts.of_kind(:header).filter_map(&:content)
|
|
20
|
+
each_text_in_containers(containers) { |*a| yield(*a) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Abstract matcher: decides whether a string matches and what to
|
|
6
|
+
# replace it with.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses implement `matches?` (returns MatchData or nil) and
|
|
9
|
+
# `substitute` (applies the replacement given MatchData). The
|
|
10
|
+
# engine drives both via `apply`, which performs one substitution
|
|
11
|
+
# pass over a single string and returns `[new_string, count]`.
|
|
12
|
+
class Matcher
|
|
13
|
+
# Apply the matcher to one string, replacing every non-overlapping
|
|
14
|
+
# match. Returns the new string and the count of substitutions.
|
|
15
|
+
#
|
|
16
|
+
# @param text [String] the text to scan
|
|
17
|
+
# @return [Array(String, Integer)] new text and substitution count
|
|
18
|
+
def apply(text)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
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).
|
|
9
|
+
module ParagraphWalker
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param containers [Enumerable<#paragraphs, #tables,
|
|
13
|
+
# #structured_document_tags>] containers to walk
|
|
14
|
+
# @yieldparam paragraph [Wordprocessingml::Paragraph]
|
|
15
|
+
# @return [void]
|
|
16
|
+
def each_paragraph(containers, &block)
|
|
17
|
+
containers.each do |container|
|
|
18
|
+
walk_container(container, &block)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param container [Object]
|
|
23
|
+
# @yieldparam paragraph [Wordprocessingml::Paragraph]
|
|
24
|
+
# @return [void]
|
|
25
|
+
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)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def walk_tables(tables, &block)
|
|
34
|
+
tables.each do |table|
|
|
35
|
+
walk_table_rows(table, &block) if table.rows
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def walk_table_rows(table, &block)
|
|
40
|
+
table.rows.each do |row|
|
|
41
|
+
walk_table_cells(row, &block) if row.cells
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def walk_table_cells(row, &block)
|
|
46
|
+
row.cells.each do |cell|
|
|
47
|
+
walk_container(cell, &block) if cell
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def walk_sdts(sdts, &block)
|
|
52
|
+
sdts.each do |sdt|
|
|
53
|
+
sdt.paragraphs&.each(&block)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method :walk_container, :walk_tables, :walk_table_rows,
|
|
58
|
+
:walk_table_cells, :walk_sdts
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Regex matcher. Replaces every match of `pattern` with
|
|
6
|
+
# `replacement`, supporting capture-group references (`\1`,
|
|
7
|
+
# `\2`, ...) and `ignore_case`.
|
|
8
|
+
class RegexMatcher < Matcher
|
|
9
|
+
# @param pattern [Regexp, String] pattern to match. String is
|
|
10
|
+
# compiled to a Regexp.
|
|
11
|
+
# @param replacement [String] replacement, may reference captures
|
|
12
|
+
# @param ignore_case [Boolean] force case-insensitive (only
|
|
13
|
+
# applies when pattern is a String; Regexp keeps its own flags)
|
|
14
|
+
def initialize(pattern:, replacement:, ignore_case: false)
|
|
15
|
+
@pattern = compile_pattern(pattern, ignore_case)
|
|
16
|
+
@replacement = replacement
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @return [Array(String, Integer)]
|
|
21
|
+
def apply(text)
|
|
22
|
+
matches = text.scan(@pattern).size
|
|
23
|
+
return [text, 0] if matches.zero?
|
|
24
|
+
|
|
25
|
+
[text.gsub(@pattern, @replacement), matches]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def compile_pattern(pattern, ignore_case)
|
|
31
|
+
return pattern if pattern.is_a?(Regexp)
|
|
32
|
+
return Regexp.new(pattern, Regexp::IGNORECASE) if ignore_case
|
|
33
|
+
|
|
34
|
+
Regexp.new(pattern)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|