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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/lib/uniword/batch/operation/file_result.rb +39 -0
- data/lib/uniword/batch/operation/repair_task.rb +34 -0
- data/lib/uniword/batch/operation/report.rb +66 -0
- data/lib/uniword/batch/operation/runner.rb +30 -0
- data/lib/uniword/batch/operation/task.rb +40 -0
- data/lib/uniword/batch/operation/verify_task.rb +27 -0
- data/lib/uniword/batch/operation.rb +22 -0
- data/lib/uniword/batch.rb +1 -0
- data/lib/uniword/caption/caption_builder.rb +120 -0
- data/lib/uniword/caption/counter.rb +61 -0
- data/lib/uniword/caption/cross_reference.rb +44 -0
- data/lib/uniword/caption.rb +24 -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/diff/semantic/change.rb +66 -0
- data/lib/uniword/diff/semantic/engine.rb +31 -0
- data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
- data/lib/uniword/diff/semantic/result.rb +53 -0
- data/lib/uniword/diff/semantic.rb +26 -0
- data/lib/uniword/diff.rb +1 -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/plugin/cli_command.rb +30 -0
- data/lib/uniword/plugin/loader.rb +43 -0
- data/lib/uniword/plugin/registry.rb +83 -0
- data/lib/uniword/plugin/transformer.rb +50 -0
- data/lib/uniword/plugin/validator.rb +34 -0
- data/lib/uniword/plugin.rb +25 -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 +131 -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 +16 -0
- metadata +56 -2
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,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
|
@@ -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
|