uniword 1.5.1 → 1.5.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/lib/uniword/batch/operation/file_result.rb +39 -0
  4. data/lib/uniword/batch/operation/repair_task.rb +34 -0
  5. data/lib/uniword/batch/operation/report.rb +66 -0
  6. data/lib/uniword/batch/operation/runner.rb +30 -0
  7. data/lib/uniword/batch/operation/task.rb +40 -0
  8. data/lib/uniword/batch/operation/verify_task.rb +27 -0
  9. data/lib/uniword/batch/operation.rb +22 -0
  10. data/lib/uniword/batch.rb +1 -0
  11. data/lib/uniword/caption/caption_builder.rb +120 -0
  12. data/lib/uniword/caption/counter.rb +61 -0
  13. data/lib/uniword/caption/cross_reference.rb +44 -0
  14. data/lib/uniword/caption.rb +24 -0
  15. data/lib/uniword/cli/main.rb +134 -0
  16. data/lib/uniword/cli/review_cli.rb +34 -0
  17. data/lib/uniword/configuration.rb +17 -0
  18. data/lib/uniword/diff/semantic/change.rb +66 -0
  19. data/lib/uniword/diff/semantic/engine.rb +31 -0
  20. data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
  21. data/lib/uniword/diff/semantic/result.rb +53 -0
  22. data/lib/uniword/diff/semantic.rb +26 -0
  23. data/lib/uniword/diff.rb +1 -0
  24. data/lib/uniword/docx/deterministic_output.rb +47 -0
  25. data/lib/uniword/docx.rb +1 -0
  26. data/lib/uniword/find_replace/body_scope.rb +23 -0
  27. data/lib/uniword/find_replace/comment_scope.rb +23 -0
  28. data/lib/uniword/find_replace/endnote_scope.rb +23 -0
  29. data/lib/uniword/find_replace/engine.rb +84 -0
  30. data/lib/uniword/find_replace/footer_scope.rb +24 -0
  31. data/lib/uniword/find_replace/footnote_scope.rb +23 -0
  32. data/lib/uniword/find_replace/header_scope.rb +24 -0
  33. data/lib/uniword/find_replace/matcher.rb +23 -0
  34. data/lib/uniword/find_replace/paragraph_walker.rb +61 -0
  35. data/lib/uniword/find_replace/regex_matcher.rb +38 -0
  36. data/lib/uniword/find_replace/result.rb +54 -0
  37. data/lib/uniword/find_replace/scope.rb +98 -0
  38. data/lib/uniword/find_replace/string_matcher.rb +72 -0
  39. data/lib/uniword/find_replace/styles_scope.rb +38 -0
  40. data/lib/uniword/find_replace.rb +29 -0
  41. data/lib/uniword/infrastructure/zip_packager.rb +28 -1
  42. data/lib/uniword/lint/builtin_rules/banned_words.rb +35 -0
  43. data/lib/uniword/lint/builtin_rules/max_paragraph_length.rb +32 -0
  44. data/lib/uniword/lint/builtin_rules/require_body.rb +23 -0
  45. data/lib/uniword/lint/builtin_rules/required_style.rb +31 -0
  46. data/lib/uniword/lint/builtin_rules.rb +23 -0
  47. data/lib/uniword/lint/engine.rb +32 -0
  48. data/lib/uniword/lint/result.rb +59 -0
  49. data/lib/uniword/lint/rule.rb +77 -0
  50. data/lib/uniword/lint/ruleset.rb +65 -0
  51. data/lib/uniword/lint.rb +17 -0
  52. data/lib/uniword/plugin/cli_command.rb +30 -0
  53. data/lib/uniword/plugin/loader.rb +43 -0
  54. data/lib/uniword/plugin/registry.rb +83 -0
  55. data/lib/uniword/plugin/transformer.rb +50 -0
  56. data/lib/uniword/plugin/validator.rb +34 -0
  57. data/lib/uniword/plugin.rb +25 -0
  58. data/lib/uniword/redact/engine.rb +52 -0
  59. data/lib/uniword/redact/pattern.rb +23 -0
  60. data/lib/uniword/redact/pattern_library.rb +60 -0
  61. data/lib/uniword/redact/result.rb +42 -0
  62. data/lib/uniword/redact.rb +15 -0
  63. data/lib/uniword/version.rb +1 -1
  64. data/lib/uniword/wordprocessingml/document_styling.rb +131 -0
  65. data/lib/uniword/wordprocessingml/settings.rb +2 -0
  66. data/lib/uniword/wordprocessingml/track_changes.rb +19 -0
  67. data/lib/uniword/wordprocessingml.rb +1 -0
  68. data/lib/uniword.rb +16 -0
  69. metadata +56 -2
@@ -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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # One classified change. Severity defaults to :info; classifiers
7
+ # can promote to :warning or :error.
8
+ class Change
9
+ KINDS = %i[added removed modified moved].freeze
10
+ MODIFIERS = %i[text format structure].freeze
11
+
12
+ # @return [Symbol] one of KINDS
13
+ attr_reader :kind
14
+
15
+ # @return [Symbol, nil] for :modified, one of MODIFIERS
16
+ attr_reader :modifier
17
+
18
+ # @return [Integer, nil] paragraph index in old (nil when
19
+ # the paragraph is new)
20
+ attr_reader :old_index
21
+
22
+ # @return [Integer, nil] paragraph index in new (nil when
23
+ # the paragraph was removed)
24
+ attr_reader :new_index
25
+
26
+ # @return [String, nil] human-readable summary
27
+ attr_reader :description
28
+
29
+ # @param kind [Symbol]
30
+ # @param modifier [Symbol, nil]
31
+ # @param old_index [Integer, nil]
32
+ # @param new_index [Integer, nil]
33
+ # @param description [String, nil]
34
+ def initialize(kind:, modifier: nil, old_index: nil,
35
+ new_index: nil, description: nil)
36
+ unless KINDS.include?(kind)
37
+ raise ArgumentError,
38
+ "unknown kind #{kind.inspect}"
39
+ end
40
+ if modifier && !MODIFIERS.include?(modifier)
41
+ raise ArgumentError, "unknown modifier #{modifier.inspect}"
42
+ end
43
+
44
+ @kind = kind
45
+ @modifier = modifier
46
+ @old_index = old_index
47
+ @new_index = new_index
48
+ @description = description
49
+ end
50
+
51
+ # @return [Hash]
52
+ def to_h
53
+ { kind: kind, modifier: modifier,
54
+ old_index: old_index, new_index: new_index,
55
+ description: description }
56
+ end
57
+
58
+ # @param other [Object]
59
+ # @return [Boolean]
60
+ def ==(other)
61
+ other.is_a?(Change) && to_h == other.to_h
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # Orchestrates element-level diff between two documents.
7
+ # Currently compares body paragraphs; future comparators
8
+ # (tables, images, styles) plug in via `COMPARATORS`.
9
+ class Engine
10
+ # @param old_doc [Wordprocessingml::DocumentRoot]
11
+ # @param new_doc [Wordprocessingml::DocumentRoot]
12
+ def initialize(old_doc, new_doc)
13
+ @old_doc = old_doc
14
+ @new_doc = new_doc
15
+ end
16
+
17
+ # Run every registered comparator and aggregate the changes.
18
+ #
19
+ # @return [Result]
20
+ def diff
21
+ result = Result.new
22
+ ParagraphComparator.each_change(@old_doc.paragraphs,
23
+ @new_doc.paragraphs) do |change|
24
+ result.add(change)
25
+ end
26
+ result
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # LCS-based paragraph comparator. Aligns old and new paragraph
7
+ # lists with a proper dynamic-programming LCS, classifies each
8
+ # aligned pair, and emits changes for any pair that isn't
9
+ # :unchanged.
10
+ #
11
+ # Reuses the paragraph text via `Paragraph#text`.
12
+ module ParagraphComparator
13
+ module_function
14
+
15
+ # @param old_paras [Array<Wordprocessingml::Paragraph>]
16
+ # @param new_paras [Array<Wordprocessingml::Paragraph>]
17
+ # @yieldparam change [Change]
18
+ # @return [void]
19
+ def each_change(old_paras, new_paras, &block)
20
+ old_paras ||= []
21
+ new_paras ||= []
22
+ old_keys = old_paras.map { |p| fingerprint(p) }
23
+ new_keys = new_paras.map { |p| fingerprint(p) }
24
+ alignment = align(old_keys, new_keys)
25
+
26
+ emit_changes(alignment, old_paras, new_paras, &block)
27
+ end
28
+
29
+ # Stable text fingerprint for one paragraph. Identical text
30
+ # produces identical fingerprints.
31
+ #
32
+ # @param paragraph [Wordprocessingml::Paragraph]
33
+ # @return [String]
34
+ def fingerprint(paragraph)
35
+ (paragraph&.text || "").to_s
36
+ end
37
+
38
+ # DP LCS alignment of two arrays of fingerprints. Returns a
39
+ # list of `[old_idx, new_idx]` pairs:
40
+ # - matching pair `[i, j]` when keys are equal
41
+ # - `[i, nil]` when old[i] is removed
42
+ # - `[nil, j]` when new[j] is added
43
+ #
44
+ # @param old_keys [Array<String>]
45
+ # @param new_keys [Array<String>]
46
+ # @return [Array<Array(Integer, Integer)>]
47
+ def align(old_keys, new_keys)
48
+ lcs_pairs = lcs_match_pairs(old_keys, new_keys)
49
+ pairs = []
50
+ i = 0
51
+ j = 0
52
+ lcs_pairs.each do |mi, mj|
53
+ while i < mi
54
+ pairs << [i, nil]
55
+ i += 1
56
+ end
57
+ while j < mj
58
+ pairs << [nil, j]
59
+ j += 1
60
+ end
61
+ pairs << [mi, mj]
62
+ i = mi + 1
63
+ j = mj + 1
64
+ end
65
+ while i < old_keys.length
66
+ pairs << [i, nil]
67
+ i += 1
68
+ end
69
+ while j < new_keys.length
70
+ pairs << [nil, j]
71
+ j += 1
72
+ end
73
+ pairs
74
+ end
75
+
76
+ # DP LCS: returns the list of matching (i, j) pairs in order.
77
+ #
78
+ # @param a [Array<String>]
79
+ # @param b [Array<String>]
80
+ # @return [Array<Array(Integer, Integer)>]
81
+ def lcs_match_pairs(a, b)
82
+ n = a.length
83
+ m = b.length
84
+ dp = Array.new(n + 1) { Array.new(m + 1, 0) }
85
+ (1..n).each do |i|
86
+ (1..m).each do |j|
87
+ dp[i][j] = if a[i - 1] == b[j - 1]
88
+ dp[i - 1][j - 1] + 1
89
+ else
90
+ [dp[i - 1][j], dp[i][j - 1]].max
91
+ end
92
+ end
93
+ end
94
+ backtrack(dp, a, b, n, m)
95
+ end
96
+
97
+ # Backtrack through the DP table to recover matching pairs.
98
+ def backtrack(dp, a, b, i, j)
99
+ return [] if i.zero? || j.zero?
100
+
101
+ if a[i - 1] == b[j - 1]
102
+ backtrack(dp, a, b, i - 1, j - 1) << [i - 1, j - 1]
103
+ elsif dp[i - 1][j] >= dp[i][j - 1]
104
+ backtrack(dp, a, b, i - 1, j)
105
+ else
106
+ backtrack(dp, a, b, i, j - 1)
107
+ end
108
+ end
109
+
110
+ def emit_changes(alignment, old_paras, new_paras)
111
+ collapsed = collapse_remove_add_pairs(alignment)
112
+ collapsed.each do |old_idx, new_idx|
113
+ case [old_idx.nil?, new_idx.nil?]
114
+ when [true, false]
115
+ yield Change.new(kind: :added, new_index: new_idx,
116
+ description: "Paragraph #{new_idx + 1} added")
117
+ when [false, true]
118
+ yield Change.new(kind: :removed, old_index: old_idx,
119
+ description: "Paragraph #{old_idx + 1} removed")
120
+ when [false, false]
121
+ next if identical?(old_paras[old_idx], new_paras[new_idx])
122
+
123
+ yield classify_modified(old_paras[old_idx],
124
+ new_paras[new_idx],
125
+ old_idx, new_idx)
126
+ end
127
+ end
128
+ end
129
+
130
+ # Collapse adjacent `[i, nil], [nil, j]` pairs into `[i, j]`
131
+ # modified pairs. Without this, text edits show as one remove
132
+ # + one add instead of one modification.
133
+ #
134
+ # @param alignment [Array<Array(Integer, Integer)>]
135
+ # @return [Array<Array(Integer, Integer)>]
136
+ def collapse_remove_add_pairs(alignment)
137
+ result = []
138
+ i = 0
139
+ while i < alignment.length
140
+ curr = alignment[i]
141
+ nxt = alignment[i + 1]
142
+ if curr[0] && curr[1].nil? && nxt && nxt[0].nil? && nxt[1]
143
+ result << [curr[0], nxt[1]]
144
+ i += 2
145
+ else
146
+ result << curr
147
+ i += 1
148
+ end
149
+ end
150
+ result
151
+ end
152
+
153
+ # Two paragraphs are identical when their full XML is byte-
154
+ # equal — same text AND same formatting AND same structure.
155
+ def identical?(old_para, new_para)
156
+ old_para.to_xml == new_para.to_xml
157
+ end
158
+
159
+ # Classify a modified pair by what changed.
160
+ def classify_modified(old_para, new_para, old_idx, new_idx)
161
+ modifier = modifier_for(old_para, new_para)
162
+ Change.new(
163
+ kind: :modified,
164
+ modifier: modifier,
165
+ old_index: old_idx,
166
+ new_index: new_idx,
167
+ description: "Paragraph #{old_idx + 1} -> #{new_idx + 1} " \
168
+ "(#{modifier})",
169
+ )
170
+ end
171
+
172
+ # Decide what changed between two paragraphs with the same
173
+ # text but different XML.
174
+ def modifier_for(old_para, new_para)
175
+ return :text if old_para.text != new_para.text
176
+
177
+ old_xml = old_para.to_xml
178
+ new_xml = new_para.to_xml
179
+ return :format if format_only_change?(old_xml, new_xml)
180
+
181
+ :structure
182
+ end
183
+
184
+ # Heuristic: a format-only change alters rPr but not run
185
+ # structure. True when removing all `<w:rPr>...</w:rPr>`
186
+ # blocks equalizes the XML.
187
+ def format_only_change?(old_xml, new_xml)
188
+ stripped_old = old_xml.gsub(%r{<w:rPr>.*?</w:rPr>}m, "")
189
+ stripped_new = new_xml.gsub(%r{<w:rPr>.*?</w:rPr>}m, "")
190
+ stripped_old == stripped_new
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # Aggregated semantic diff result. Counts by kind and modifier;
7
+ # full change list.
8
+ class Result
9
+ attr_reader :changes
10
+
11
+ def initialize
12
+ @changes = []
13
+ end
14
+
15
+ # @param change [Change]
16
+ # @return [void]
17
+ def add(change)
18
+ @changes << change
19
+ end
20
+
21
+ # Count by kind (`:added`, `:removed`, `:modified`, `:moved`).
22
+ #
23
+ # @return [Hash{Symbol => Integer}]
24
+ def by_kind
25
+ @changes.group_by(&:kind).transform_values(&:count)
26
+ end
27
+
28
+ # For modified changes, count by modifier (`:text`,
29
+ # `:format`, `:structure`).
30
+ #
31
+ # @return [Hash{Symbol => Integer}]
32
+ def by_modifier
33
+ modified = @changes.select { |c| c.kind == :modified }
34
+ modified.group_by(&:modifier).transform_values(&:count)
35
+ end
36
+
37
+ # Total change count.
38
+ #
39
+ # @return [Integer]
40
+ def count
41
+ @changes.length
42
+ end
43
+
44
+ # True when no changes.
45
+ #
46
+ # @return [Boolean]
47
+ def empty?
48
+ @changes.empty?
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ # Element-level semantic diff. Builds on the existing
6
+ # DocumentDiffer (paragraph LCS) to produce a structured change
7
+ # report where each change has a classification:
8
+ #
9
+ # - `:added` — element only in new
10
+ # - `:removed` — element only in old
11
+ # - `:modified` — element in both but different; sub-classified
12
+ # by what changed (`:text`, `:format`, `:structure`)
13
+ # - `:moved` — element in both, same content, different position
14
+ #
15
+ # Open/closed: a new element kind to compare (images, tables,
16
+ # styles) = a new `Comparator` subclass + registration in
17
+ # `Engine::COMPARATORS`.
18
+ module Semantic
19
+ autoload :Engine, "#{__dir__}/semantic/engine"
20
+ autoload :Change, "#{__dir__}/semantic/change"
21
+ autoload :Result, "#{__dir__}/semantic/result"
22
+ autoload :ParagraphComparator,
23
+ "#{__dir__}/semantic/paragraph_comparator"
24
+ end
25
+ end
26
+ end
data/lib/uniword/diff.rb CHANGED
@@ -19,5 +19,6 @@ module Uniword
19
19
  autoload :OpcIssue, "uniword/diff/package_diff_result"
20
20
  autoload :PartChange, "uniword/diff/package_diff_result"
21
21
  autoload :PackageDiffResult, "uniword/diff/package_diff_result"
22
+ autoload :Semantic, "uniword/diff/semantic"
22
23
  end
23
24
  end
@@ -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