uniword 1.5.2 → 1.5.4

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -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/builder/image_builder.rb +1 -1
  12. data/lib/uniword/caption/caption_builder.rb +120 -0
  13. data/lib/uniword/caption/counter.rb +61 -0
  14. data/lib/uniword/caption/cross_reference.rb +44 -0
  15. data/lib/uniword/caption.rb +24 -0
  16. data/lib/uniword/diff/semantic/change.rb +66 -0
  17. data/lib/uniword/diff/semantic/engine.rb +31 -0
  18. data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
  19. data/lib/uniword/diff/semantic/result.rb +53 -0
  20. data/lib/uniword/diff/semantic.rb +26 -0
  21. data/lib/uniword/diff.rb +1 -0
  22. data/lib/uniword/docx/package_defaults.rb +1 -1
  23. data/lib/uniword/docx/package_serialization.rb +1 -1
  24. data/lib/uniword/docx/reconciler/helpers.rb +1 -1
  25. data/lib/uniword/docx/reconciler/referential_integrity.rb +1 -1
  26. data/lib/uniword/find_replace/scope.rb +9 -11
  27. data/lib/uniword/ooxml/package_file.rb +1 -1
  28. data/lib/uniword/picture/fill_rect.rb +2 -2
  29. data/lib/uniword/picture/picture_source_rect.rb +3 -2
  30. data/lib/uniword/picture/picture_stretch.rb +3 -2
  31. data/lib/uniword/picture/tile.rb +3 -2
  32. data/lib/uniword/plugin/cli_command.rb +30 -0
  33. data/lib/uniword/plugin/loader.rb +43 -0
  34. data/lib/uniword/plugin/registry.rb +83 -0
  35. data/lib/uniword/plugin/transformer.rb +50 -0
  36. data/lib/uniword/plugin/validator.rb +34 -0
  37. data/lib/uniword/plugin.rb +25 -0
  38. data/lib/uniword/properties/word2010_id_value.rb +2 -2
  39. data/lib/uniword/version.rb +1 -1
  40. data/lib/uniword/wordprocessingml/body.rb +54 -6
  41. data/lib/uniword/wordprocessingml/document_styling.rb +40 -0
  42. data/lib/uniword/wordprocessingml/styles_configuration.rb +1 -1
  43. data/lib/uniword.rb +6 -0
  44. metadata +26 -4
@@ -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
@@ -112,7 +112,7 @@ module Uniword
112
112
  rels = Ooxml::Relationships::PackageRelationships.new
113
113
  rels.relationships = part_keys.each_with_index.map do |key, idx|
114
114
  defn = Ooxml::PartRegistry.find_by_key(key)
115
- Ooxml::Relationships::Relationship.new(
115
+ Ooxml::Relationships::PackageRelationship.new(
116
116
  id: "rId#{idx + 1}",
117
117
  type: defn.rel_type,
118
118
  target: defn.target,
@@ -260,7 +260,7 @@ document_rels)
260
260
 
261
261
  # The reconciler runs before injection; register the rId with
262
262
  # the allocator (single authority) and append the rel here.
263
- package_rels.relationships << Ooxml::Relationships::Relationship.new(
263
+ package_rels.relationships << Ooxml::Relationships::PackageRelationship.new(
264
264
  id: allocator.alloc_rid(target: definition.target,
265
265
  type: definition.rel_type,
266
266
  scope: :package),
@@ -236,7 +236,7 @@ module Uniword
236
236
  def build_rel(id, type, target, target_mode: nil)
237
237
  attrs = { id: id, type: type, target: target }
238
238
  attrs[:target_mode] = target_mode if target_mode
239
- Ooxml::Relationships::Relationship.new(**attrs)
239
+ Ooxml::Relationships::PackageRelationship.new(**attrs)
240
240
  end
241
241
 
242
242
  def run_properties_match?(a, b)
@@ -317,7 +317,7 @@ module Uniword
317
317
  new_rid = allocator.alloc_rid(target: url, type: rel_type,
318
318
  target_mode: "External")
319
319
  unless valid_rids.include?(new_rid)
320
- rels.relationships << Ooxml::Relationships::Relationship.new(
320
+ rels.relationships << Ooxml::Relationships::PackageRelationship.new(
321
321
  id: new_rid,
322
322
  type: rel_type,
323
323
  target: url,
@@ -38,9 +38,8 @@ module Uniword
38
38
  protected
39
39
 
40
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).
41
+ # `<w:t>` Text elements on the `text` accessor (declared as a
42
+ # collection; lutaml-model 0.8.32+ returns an Array).
44
43
  #
45
44
  # @param run [Wordprocessingml::Run, nil]
46
45
  # @yieldparam text_element [Wordprocessingml::Text]
@@ -49,14 +48,13 @@ module Uniword
49
48
  def each_text_in_run(run)
50
49
  return unless run
51
50
 
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
51
+ run.text&.each do |text_element|
52
+ accessor = TextAccessor.new(
53
+ -> { text_element.content },
54
+ ->(value) { text_element.content = value },
55
+ )
56
+ yield text_element, accessor
57
+ end
60
58
  end
61
59
 
62
60
  # Walk every paragraph in `containers` and yield each run's
@@ -86,7 +86,7 @@ module Uniword
86
86
  # Ensure output directory exists
87
87
  FileUtils.mkdir_p(File.dirname(output_path))
88
88
 
89
- Zip::File.open(output_path, Zip::File::CREATE) do |zipfile|
89
+ Zip::File.open(output_path, create: true) do |zipfile|
90
90
  Dir.glob(File.join(@extracted_dir, "**", "*")).each do |file_path|
91
91
  next if File.directory?(file_path)
92
92
 
@@ -7,7 +7,7 @@ module Uniword
7
7
  # Fill rectangle insets
8
8
  #
9
9
  # Generated from OOXML schema: picture.yml
10
- # Element: <pic:fillRect>
10
+ # Element: <a:fillRect> (child of a:stretch in CT_BlipFillProperties)
11
11
  class FillRect < Lutaml::Model::Serializable
12
12
  attribute :l, :integer
13
13
  attribute :t, :integer
@@ -16,7 +16,7 @@ module Uniword
16
16
 
17
17
  xml do
18
18
  element "fillRect"
19
- namespace Uniword::Ooxml::Namespaces::Picture
19
+ namespace Uniword::Ooxml::Namespaces::DrawingML
20
20
 
21
21
  map_attribute "l", to: :l
22
22
  map_attribute "t", to: :t
@@ -7,7 +7,8 @@ module Uniword
7
7
  # Source rectangle for picture cropping
8
8
  #
9
9
  # Generated from OOXML schema: picture.yml
10
- # Element: <pic:srcRect>
10
+ # Element: <a:srcRect> (CT_BlipFillProperties reuses DrawingML
11
+ # children inside pic:blipFill)
11
12
  class PictureSourceRect < Lutaml::Model::Serializable
12
13
  attribute :l, :integer
13
14
  attribute :t, :integer
@@ -16,7 +17,7 @@ module Uniword
16
17
 
17
18
  xml do
18
19
  element "srcRect"
19
- namespace Uniword::Ooxml::Namespaces::Picture
20
+ namespace Uniword::Ooxml::Namespaces::DrawingML
20
21
 
21
22
  map_attribute "l", to: :l
22
23
  map_attribute "t", to: :t
@@ -7,13 +7,14 @@ module Uniword
7
7
  # Stretch fill properties
8
8
  #
9
9
  # Generated from OOXML schema: picture.yml
10
- # Element: <pic:stretch>
10
+ # Element: <a:stretch> (CT_BlipFillProperties reuses DrawingML
11
+ # children inside pic:blipFill)
11
12
  class PictureStretch < Lutaml::Model::Serializable
12
13
  attribute :fill_rect, FillRect
13
14
 
14
15
  xml do
15
16
  element "stretch"
16
- namespace Uniword::Ooxml::Namespaces::Picture
17
+ namespace Uniword::Ooxml::Namespaces::DrawingML
17
18
  mixed_content
18
19
 
19
20
  map_element "fillRect", to: :fill_rect, render_nil: false
@@ -7,7 +7,8 @@ module Uniword
7
7
  # Tile properties for picture fill
8
8
  #
9
9
  # Generated from OOXML schema: picture.yml
10
- # Element: <pic:tile>
10
+ # Element: <a:tile> (CT_BlipFillProperties reuses DrawingML
11
+ # children inside pic:blipFill)
11
12
  class Tile < Lutaml::Model::Serializable
12
13
  attribute :tx, :integer
13
14
  attribute :ty, :integer
@@ -17,7 +18,7 @@ module Uniword
17
18
 
18
19
  xml do
19
20
  element "tile"
20
- namespace Uniword::Ooxml::Namespaces::Picture
21
+ namespace Uniword::Ooxml::Namespaces::DrawingML
21
22
 
22
23
  map_attribute "tx", to: :tx
23
24
  map_attribute "ty", to: :ty
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Base class for plugin-provided CLI commands. A plugin CLI
6
+ # command is a Thor subclass that the main CLI registers as a
7
+ # subcommand.
8
+ #
9
+ # Subclasses declare `subcommand_name` (the name CLI users type)
10
+ # and `description` (shown in `uniword help`).
11
+ class CliCommand
12
+ class << self
13
+ # @return [Symbol] subcommand name (e.g. :myplugin)
14
+ def subcommand_name
15
+ raise NotImplementedError
16
+ end
17
+
18
+ # @return [String] one-line description
19
+ def description
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # @return [Class<Thor>] the Thor subclass to register
24
+ def thor_class
25
+ raise NotImplementedError
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Discovers plugins from installed gems. Each plugin ships a
6
+ # ruby file under `uniword/plugin/<name>.rb` that registers
7
+ # itself with `Plugin::Registry` on load.
8
+ class Loader
9
+ GLOB = "uniword/plugin/*.rb"
10
+
11
+ class << self
12
+ # Load every plugin file found in installed gems.
13
+ #
14
+ # @return [Array<String>] paths loaded
15
+ def load_all
16
+ Gem.find_files(GLOB).each { |path| require path }
17
+ end
18
+
19
+ # Run every registered transformer whose `stages` include
20
+ # `stage`, in registration order.
21
+ #
22
+ # @param document [Wordprocessingml::DocumentRoot]
23
+ # @param stage [Symbol] one of `Transformer::STAGES`
24
+ # @return [void]
25
+ def run_transformers(document:, stage:)
26
+ Registry.transformers.each_value do |transformer|
27
+ next unless transformer.applies_to?(stage)
28
+
29
+ transformer.transform(document)
30
+ end
31
+ end
32
+
33
+ # Yield every registered validator in registration order.
34
+ #
35
+ # @yieldparam validator [Plugin::Validator]
36
+ # @return [void]
37
+ def each_validator(&block)
38
+ Registry.validators.each_value(&block)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Central registry for installed plugins. Each plugin registers
6
+ # its extension surface (validator, transformer, cli command) by
7
+ # name; lookup is by name or by class.
8
+ #
9
+ # Open/closed: new extension surfaces = new methods on this
10
+ # class. Existing entries untouched.
11
+ class Registry
12
+ @validators = {}
13
+ @transformers = {}
14
+ @cli_commands = {}
15
+
16
+ class << self
17
+ # @return [Hash{Symbol => Plugin::Validator}]
18
+ attr_reader :validators
19
+
20
+ # @return [Hash{Symbol => Plugin::Transformer}]
21
+ attr_reader :transformers
22
+
23
+ # @return [Hash{Symbol => Plugin::CliCommand}]
24
+ attr_reader :cli_commands
25
+
26
+ # Register a validator. Append-only; duplicate name raises.
27
+ #
28
+ # @param name [Symbol]
29
+ # @param validator [Plugin::Validator]
30
+ # @return [void]
31
+ def register_validator(name, validator)
32
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
33
+ unless validator.is_a?(Validator)
34
+ raise ArgumentError,
35
+ "validator must be a Validator"
36
+ end
37
+
38
+ @validators[name] = validator
39
+ end
40
+
41
+ # Register a transformer.
42
+ #
43
+ # @param name [Symbol]
44
+ # @param transformer [Plugin::Transformer]
45
+ # @return [void]
46
+ def register_transformer(name, transformer)
47
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
48
+ unless transformer.is_a?(Transformer)
49
+ raise ArgumentError,
50
+ "transformer must be a Transformer"
51
+ end
52
+
53
+ @transformers[name] = transformer
54
+ end
55
+
56
+ # Register a CLI command (a Thor subclass).
57
+ #
58
+ # @param name [Symbol]
59
+ # @param command_class [Class]
60
+ # @return [void]
61
+ def register_cli_command(name, command_class)
62
+ raise ArgumentError, "name must be a Symbol" unless name.is_a?(Symbol)
63
+ unless command_class.is_a?(Class)
64
+ raise ArgumentError,
65
+ "command_class must be a Class"
66
+ end
67
+
68
+ @cli_commands[name] = command_class
69
+ end
70
+
71
+ # Clear every registry. Used between tests and by
72
+ # `Configuration#reset!`.
73
+ #
74
+ # @return [void]
75
+ def clear
76
+ @validators.clear
77
+ @transformers.clear
78
+ @cli_commands.clear
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Plugin
5
+ # Base class for plugin-provided document transformers.
6
+ # Subclasses implement `#transform(document)` which mutates the
7
+ # document in place.
8
+ #
9
+ # Pipelines call transformers at defined stages; see
10
+ # `Plugin.transform_for_stage`.
11
+ class Transformer
12
+ STAGES = %i[after_load before_save after_reconcile].freeze
13
+
14
+ # @return [Symbol] transformer name
15
+ attr_reader :name
16
+
17
+ # @return [Array<Symbol>] stages when this transformer runs
18
+ attr_reader :stages
19
+
20
+ # @param name [Symbol]
21
+ # @param stages [Array<Symbol>, Symbol] one or more of
22
+ # STAGES; defaults to `:before_save`
23
+ def initialize(name:, stages: :before_save)
24
+ @name = name
25
+ @stages = Array(stages)
26
+ unknown = @stages - STAGES
27
+ return if unknown.empty?
28
+
29
+ raise ArgumentError,
30
+ "unknown stages: #{unknown.inspect}; valid: #{STAGES.inspect}"
31
+ end
32
+
33
+ # True when this transformer runs at the given stage.
34
+ #
35
+ # @param stage [Symbol]
36
+ # @return [Boolean]
37
+ def applies_to?(stage)
38
+ @stages.include?(stage)
39
+ end
40
+
41
+ # Mutate the document. Subclasses override.
42
+ #
43
+ # @param document [Wordprocessingml::DocumentRoot]
44
+ # @return [void]
45
+ def transform(document)
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
50
+ end