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
@@ -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
- content.each do |entry_path, entry_content|
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.