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
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Aggregated result of a find-replace pass.
|
|
6
|
+
#
|
|
7
|
+
# Counts total substitutions and breaks the count down by scope
|
|
8
|
+
# so callers can see where matches happened. Returned by
|
|
9
|
+
# `Engine#run` and `DocumentRoot#find_replace`.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = engine.run
|
|
13
|
+
# result.count # => 7
|
|
14
|
+
# result.by_scope # => { body: 5, headers: 2 }
|
|
15
|
+
# result.scopes_touched # => [:body, :headers]
|
|
16
|
+
class Result
|
|
17
|
+
attr_reader :by_scope
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@by_scope = Hash.new { |hash, key| hash[key] = 0 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Increment the count for one scope.
|
|
24
|
+
#
|
|
25
|
+
# @param scope [Symbol] scope name (e.g. :body, :headers)
|
|
26
|
+
# @param substitutions [Integer] substitutions added
|
|
27
|
+
# @return [void]
|
|
28
|
+
def add(scope, substitutions)
|
|
29
|
+
@by_scope[scope] += substitutions
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Total substitutions across all scopes.
|
|
33
|
+
#
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
def count
|
|
36
|
+
@by_scope.values.sum
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Scopes that produced at least one substitution.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Symbol>]
|
|
42
|
+
def scopes_touched
|
|
43
|
+
@by_scope.select { |_, c| c.positive? }.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when zero substitutions were made.
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def empty?
|
|
50
|
+
count.zero?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Abstract scope: knows how to enumerate text-bearing nodes in
|
|
6
|
+
# one part of the package and run a matcher over them.
|
|
7
|
+
#
|
|
8
|
+
# Each subclass implements `each_text_node`, yielding
|
|
9
|
+
# `[holder, accessor]` pairs where `holder` is the object whose
|
|
10
|
+
# text we read/write and `accessor` is a `TextAccessor` value
|
|
11
|
+
# object with `value` and `value=` methods.
|
|
12
|
+
#
|
|
13
|
+
# Open/closed: a new scope = a new subclass + registration in
|
|
14
|
+
# `Engine::SCOPE_REGISTRY`. Engine and other scopes are
|
|
15
|
+
# unchanged.
|
|
16
|
+
class Scope
|
|
17
|
+
# @param document [Wordprocessingml::DocumentRoot]
|
|
18
|
+
def initialize(document)
|
|
19
|
+
@document = document
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Symbol] scope name (e.g. :body, :headers)
|
|
23
|
+
def name
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yield `[holder, accessor]` for every text node this scope
|
|
28
|
+
# covers. Engine applies the matcher to each accessor.
|
|
29
|
+
#
|
|
30
|
+
# @yieldparam holder [Wordprocessingml::Text] the Text element
|
|
31
|
+
# being read/written (its `content` attribute holds the string)
|
|
32
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def each_text_node
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
# Yield every Text element inside a run. A run carries its
|
|
41
|
+
# `<w:t>` Text element on the `text` accessor (lutaml-model
|
|
42
|
+
# returns the instance directly even when the attribute is
|
|
43
|
+
# declared as a collection).
|
|
44
|
+
#
|
|
45
|
+
# @param run [Wordprocessingml::Run, nil]
|
|
46
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
47
|
+
# @yieldparam accessor [TextAccessor]
|
|
48
|
+
# @return [void]
|
|
49
|
+
def each_text_in_run(run)
|
|
50
|
+
return unless run
|
|
51
|
+
|
|
52
|
+
text_element = run.text
|
|
53
|
+
return unless text_element
|
|
54
|
+
|
|
55
|
+
accessor = TextAccessor.new(
|
|
56
|
+
-> { text_element.content },
|
|
57
|
+
->(value) { text_element.content = value },
|
|
58
|
+
)
|
|
59
|
+
yield text_element, accessor
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Walk every paragraph in `containers` and yield each run's
|
|
63
|
+
# text elements. Shared by body / headers / footers / footnotes
|
|
64
|
+
# / endnotes / comments scopes.
|
|
65
|
+
#
|
|
66
|
+
# @param containers [Enumerable<#paragraphs, #tables,
|
|
67
|
+
# #structured_document_tags>]
|
|
68
|
+
# @yieldparam text_element [Wordprocessingml::Text]
|
|
69
|
+
# @yieldparam accessor [TextAccessor]
|
|
70
|
+
# @return [void]
|
|
71
|
+
def each_text_in_containers(containers)
|
|
72
|
+
ParagraphWalker.each_paragraph(containers) do |paragraph|
|
|
73
|
+
paragraph.runs&.each do |run|
|
|
74
|
+
each_text_in_run(run) { |*a| yield(*a) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Small value object with a `value` reader and `value=`
|
|
80
|
+
# writer, backed by lambdas. Avoids `instance_variable_set` /
|
|
81
|
+
# `send`; lets callers wire read/write however they like.
|
|
82
|
+
class TextAccessor
|
|
83
|
+
def initialize(reader, writer)
|
|
84
|
+
@reader = reader
|
|
85
|
+
@writer = writer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def value
|
|
89
|
+
@reader.call
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def value=(new_value)
|
|
93
|
+
@writer.call(new_value)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Literal substring matcher. Replaces every non-overlapping
|
|
6
|
+
# occurrence of `pattern` with `replacement`.
|
|
7
|
+
class StringMatcher < Matcher
|
|
8
|
+
# @param pattern [String] literal substring to find
|
|
9
|
+
# @param replacement [String] replacement text
|
|
10
|
+
# @param ignore_case [Boolean] match case-insensitively
|
|
11
|
+
def initialize(pattern:, replacement:, ignore_case: false)
|
|
12
|
+
raise ArgumentError, "pattern cannot be empty" if pattern.empty?
|
|
13
|
+
|
|
14
|
+
@pattern = pattern
|
|
15
|
+
@replacement = replacement
|
|
16
|
+
@ignore_case = ignore_case
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @return [Array(String, Integer)]
|
|
21
|
+
def apply(text)
|
|
22
|
+
unless text.include?(@pattern) || matches_ignore_case?(text)
|
|
23
|
+
return [text,
|
|
24
|
+
0]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
text.size
|
|
28
|
+
result = replace_all(text)
|
|
29
|
+
substitutions = count_substitutions(text, result)
|
|
30
|
+
[result, substitutions]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def matches_ignore_case?(text)
|
|
36
|
+
@ignore_case && text.downcase.include?(@pattern.downcase)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def replace_all(text)
|
|
40
|
+
if @ignore_case
|
|
41
|
+
pattern = /#{Regexp.escape(@pattern)}/i
|
|
42
|
+
text.gsub(pattern, @replacement)
|
|
43
|
+
else
|
|
44
|
+
text.gsub(@pattern, @replacement)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Net change in match count from the substitution. Computed by
|
|
49
|
+
# walking the original text and counting non-overlapping
|
|
50
|
+
# matches; the gsub above cannot be relied on directly when
|
|
51
|
+
# ignore_case is on (the pattern escapes are regex-based).
|
|
52
|
+
def count_substitutions(original, _replacement)
|
|
53
|
+
pattern = @ignore_case ? /#{Regexp.escape(@pattern)}/i : nil
|
|
54
|
+
if @ignore_case
|
|
55
|
+
original.scan(pattern).size
|
|
56
|
+
else
|
|
57
|
+
count_literal(original)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def count_literal(text)
|
|
62
|
+
count = 0
|
|
63
|
+
idx = 0
|
|
64
|
+
while (idx = text.index(@pattern, idx))
|
|
65
|
+
count += 1
|
|
66
|
+
idx += @pattern.length
|
|
67
|
+
end
|
|
68
|
+
count
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module FindReplace
|
|
5
|
+
# Styles scope: text in style display names (`<w:name w:val=...>`
|
|
6
|
+
# inside `word/styles.xml`).
|
|
7
|
+
#
|
|
8
|
+
# Style identifiers (`w:styleId`) are deliberately not touched —
|
|
9
|
+
# renaming a styleId breaks every reference. Use
|
|
10
|
+
# `DocumentRoot#rename_style` for that, which keeps references
|
|
11
|
+
# intact.
|
|
12
|
+
class StylesScope < Scope
|
|
13
|
+
# @return [Symbol]
|
|
14
|
+
def name
|
|
15
|
+
:styles
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @yieldparam name_element [Wordprocessingml::StyleName]
|
|
19
|
+
# @yieldparam accessor [Scope::TextAccessor]
|
|
20
|
+
def each_text_node
|
|
21
|
+
styles = @document.styles_configuration&.styles
|
|
22
|
+
return unless styles
|
|
23
|
+
|
|
24
|
+
styles.each do |style|
|
|
25
|
+
name_element = style.name
|
|
26
|
+
next unless name_element
|
|
27
|
+
next unless name_element.val
|
|
28
|
+
|
|
29
|
+
accessor = TextAccessor.new(
|
|
30
|
+
-> { name_element.val },
|
|
31
|
+
->(value) { name_element.val = value },
|
|
32
|
+
)
|
|
33
|
+
yield name_element, accessor
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
# Find & replace over the text-bearing parts of a DOCX package.
|
|
5
|
+
#
|
|
6
|
+
# Public surface: `Engine` (orchestrator), `Scope` (per-part
|
|
7
|
+
# strategy), `Matcher` (literal or regex), `Result` (counts +
|
|
8
|
+
# per-scope breakdown).
|
|
9
|
+
#
|
|
10
|
+
# Open/closed: adding a new scope = new subclass of `Scope` plus
|
|
11
|
+
# registration. Adding a new matcher type = new subclass of
|
|
12
|
+
# `Matcher`. Engine unchanged in both cases.
|
|
13
|
+
module FindReplace
|
|
14
|
+
autoload :Engine, "#{__dir__}/find_replace/engine"
|
|
15
|
+
autoload :Matcher, "#{__dir__}/find_replace/matcher"
|
|
16
|
+
autoload :StringMatcher, "#{__dir__}/find_replace/string_matcher"
|
|
17
|
+
autoload :RegexMatcher, "#{__dir__}/find_replace/regex_matcher"
|
|
18
|
+
autoload :Scope, "#{__dir__}/find_replace/scope"
|
|
19
|
+
autoload :BodyScope, "#{__dir__}/find_replace/body_scope"
|
|
20
|
+
autoload :HeaderScope, "#{__dir__}/find_replace/header_scope"
|
|
21
|
+
autoload :FooterScope, "#{__dir__}/find_replace/footer_scope"
|
|
22
|
+
autoload :FootnoteScope, "#{__dir__}/find_replace/footnote_scope"
|
|
23
|
+
autoload :EndnoteScope, "#{__dir__}/find_replace/endnote_scope"
|
|
24
|
+
autoload :CommentScope, "#{__dir__}/find_replace/comment_scope"
|
|
25
|
+
autoload :StylesScope, "#{__dir__}/find_replace/styles_scope"
|
|
26
|
+
autoload :ParagraphWalker, "#{__dir__}/find_replace/paragraph_walker"
|
|
27
|
+
autoload :Result, "#{__dir__}/find_replace/result"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -44,15 +44,19 @@ module Uniword
|
|
|
44
44
|
# causing put_next_entry to discard our Entry and create a fresh one.
|
|
45
45
|
temp_path = "#{output_path}.#{Process.pid}.tmp"
|
|
46
46
|
|
|
47
|
+
ordered_content = order_content(content)
|
|
48
|
+
fixed_time = deterministic_timestamp
|
|
49
|
+
|
|
47
50
|
was_zip64 = Zip.write_zip64_support
|
|
48
51
|
Zip.write_zip64_support = false
|
|
49
52
|
begin
|
|
50
53
|
Zip::OutputStream.open(temp_path) do |zos|
|
|
51
|
-
|
|
54
|
+
ordered_content.each do |entry_path, entry_content|
|
|
52
55
|
entry = Zip::Entry.new(temp_path, entry_path.to_s)
|
|
53
56
|
entry.internal_file_attributes = 0
|
|
54
57
|
entry.external_file_attributes = 0
|
|
55
58
|
entry.fstype = Zip::FSTYPE_FAT
|
|
59
|
+
entry.time = fixed_time if fixed_time
|
|
56
60
|
|
|
57
61
|
zos.put_next_entry(entry)
|
|
58
62
|
|
|
@@ -145,6 +149,29 @@ module Uniword
|
|
|
145
149
|
|
|
146
150
|
private
|
|
147
151
|
|
|
152
|
+
# When `Uniword.configuration.deterministic_output` is true,
|
|
153
|
+
# reorder entries (priority for OPC-required first, alphabetical
|
|
154
|
+
# for the rest). Otherwise return content unchanged (insertion
|
|
155
|
+
# order, which matches Word's behavior).
|
|
156
|
+
#
|
|
157
|
+
# @param content [Hash<String, String>]
|
|
158
|
+
# @return [Hash<String, String>] ordered hash
|
|
159
|
+
def order_content(content)
|
|
160
|
+
return content unless Uniword.configuration.deterministic_output
|
|
161
|
+
|
|
162
|
+
ordered_keys = Docx::DeterministicOutput.reorder_entries(content.keys)
|
|
163
|
+
ordered_keys.to_h { |k| [k, content[k]] }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Fixed timestamp for deterministic mode; nil otherwise.
|
|
167
|
+
#
|
|
168
|
+
# @return [Time, nil]
|
|
169
|
+
def deterministic_timestamp
|
|
170
|
+
return nil unless Uniword.configuration.deterministic_output
|
|
171
|
+
|
|
172
|
+
Docx::DeterministicOutput::FIXED_TIMESTAMP
|
|
173
|
+
end
|
|
174
|
+
|
|
148
175
|
# Write content to a ZIP file using a temp file and atomic move.
|
|
149
176
|
# This avoids Windows file locking issues by ensuring we never
|
|
150
177
|
# write directly to the target file while it might be open.
|