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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/uniword/cli/main.rb +134 -0
  4. data/lib/uniword/cli/review_cli.rb +34 -0
  5. data/lib/uniword/configuration.rb +17 -0
  6. data/lib/uniword/docx/deterministic_output.rb +47 -0
  7. data/lib/uniword/docx.rb +1 -0
  8. data/lib/uniword/find_replace/body_scope.rb +23 -0
  9. data/lib/uniword/find_replace/comment_scope.rb +23 -0
  10. data/lib/uniword/find_replace/endnote_scope.rb +23 -0
  11. data/lib/uniword/find_replace/engine.rb +84 -0
  12. data/lib/uniword/find_replace/footer_scope.rb +24 -0
  13. data/lib/uniword/find_replace/footnote_scope.rb +23 -0
  14. data/lib/uniword/find_replace/header_scope.rb +24 -0
  15. data/lib/uniword/find_replace/matcher.rb +23 -0
  16. data/lib/uniword/find_replace/paragraph_walker.rb +61 -0
  17. data/lib/uniword/find_replace/regex_matcher.rb +38 -0
  18. data/lib/uniword/find_replace/result.rb +54 -0
  19. data/lib/uniword/find_replace/scope.rb +98 -0
  20. data/lib/uniword/find_replace/string_matcher.rb +72 -0
  21. data/lib/uniword/find_replace/styles_scope.rb +38 -0
  22. data/lib/uniword/find_replace.rb +29 -0
  23. data/lib/uniword/infrastructure/zip_packager.rb +28 -1
  24. data/lib/uniword/lint/builtin_rules/banned_words.rb +35 -0
  25. data/lib/uniword/lint/builtin_rules/max_paragraph_length.rb +32 -0
  26. data/lib/uniword/lint/builtin_rules/require_body.rb +23 -0
  27. data/lib/uniword/lint/builtin_rules/required_style.rb +31 -0
  28. data/lib/uniword/lint/builtin_rules.rb +23 -0
  29. data/lib/uniword/lint/engine.rb +32 -0
  30. data/lib/uniword/lint/result.rb +59 -0
  31. data/lib/uniword/lint/rule.rb +77 -0
  32. data/lib/uniword/lint/ruleset.rb +65 -0
  33. data/lib/uniword/lint.rb +17 -0
  34. data/lib/uniword/redact/engine.rb +52 -0
  35. data/lib/uniword/redact/pattern.rb +23 -0
  36. data/lib/uniword/redact/pattern_library.rb +60 -0
  37. data/lib/uniword/redact/result.rb +42 -0
  38. data/lib/uniword/redact.rb +15 -0
  39. data/lib/uniword/version.rb +1 -1
  40. data/lib/uniword/wordprocessingml/document_styling.rb +91 -0
  41. data/lib/uniword/wordprocessingml/settings.rb +2 -0
  42. data/lib/uniword/wordprocessingml/track_changes.rb +19 -0
  43. data/lib/uniword/wordprocessingml.rb +1 -0
  44. data/lib/uniword.rb +10 -0
  45. metadata +34 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d06dcd363ee45ec4dff3dfaa8c56b9062aa478915e429188691bdaac4ee5dbc1
4
- data.tar.gz: bd597d9b4c1cf37dff1fe4d5092b14ffa5273fe12ec696ea028c4cacc6024b41
3
+ metadata.gz: b9574cce7758124ff340199b7b07e95bc332e86ed1428e663eff2ff5059bc044
4
+ data.tar.gz: 2d80ed18e0289d232422c9c882201eeed2cded201df55c3ae887945b1ec9d0bf
5
5
  SHA512:
6
- metadata.gz: d2dc4f986025a8d4753bbb1bd82e9bb1d13b3cdf6d90804c1778c46763001a0fe9ace019dfc3f559cceb1f399b4ffca94b1f764be46599470accd43e837a1230
7
- data.tar.gz: e68202ac74cad763250c93261789a2bfa6dad2c7fe906e424fb01948fd51cd6d84d8eb783b6e85fed7637ef2e2d2c1b6230398c36caa1591080bf87f85bbf20a
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
@@ -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