uniword 1.5.2 → 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 +27 -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/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/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/version.rb +1 -1
- data/lib/uniword/wordprocessingml/document_styling.rb +40 -0
- data/lib/uniword.rb +6 -0
- metadata +24 -2
|
@@ -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,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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Plugin
|
|
5
|
+
# Base class for plugin-provided validation rules. Subclasses
|
|
6
|
+
# implement `#check(document)` (yields findings).
|
|
7
|
+
#
|
|
8
|
+
# Open/closed: adding a new validator = subclassing + calling
|
|
9
|
+
# `Registry.register_validator`.
|
|
10
|
+
class Validator
|
|
11
|
+
# @return [Symbol] validator name (matches the registry key)
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
# @return [Symbol] default severity (:error, :warning, :info)
|
|
15
|
+
attr_reader :severity
|
|
16
|
+
|
|
17
|
+
# @param name [Symbol]
|
|
18
|
+
# @param severity [Symbol]
|
|
19
|
+
def initialize(name:, severity: :warning)
|
|
20
|
+
@name = name
|
|
21
|
+
@severity = severity
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Walk the document and yield each finding as a hash.
|
|
25
|
+
#
|
|
26
|
+
# @param document [Wordprocessingml::DocumentRoot]
|
|
27
|
+
# @yieldparam finding [Hash] { message:, path: }
|
|
28
|
+
# @return [void]
|
|
29
|
+
def check(document)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
# Plugin system: extend uniword without forking.
|
|
5
|
+
#
|
|
6
|
+
# Three extension surfaces, each with a base class:
|
|
7
|
+
#
|
|
8
|
+
# - `Plugin::Validator` — register custom validation rules
|
|
9
|
+
# - `Plugin::Transformer` — mutate documents at defined pipeline
|
|
10
|
+
# stages (load, pre-save, post-reconcile)
|
|
11
|
+
# - `Plugin::CliCommand` — register new Thor subcommands
|
|
12
|
+
#
|
|
13
|
+
# Discovery: `Plugin::Loader.load_all` walks installed gems via
|
|
14
|
+
# `Gem.find_files("uniword/plugin/*.rb")` and loads each.
|
|
15
|
+
#
|
|
16
|
+
# Configuration: `Uniword.configuration.plugins = [:my_plugin]`
|
|
17
|
+
# (default `:all`).
|
|
18
|
+
module Plugin
|
|
19
|
+
autoload :Registry, "#{__dir__}/plugin/registry"
|
|
20
|
+
autoload :Validator, "#{__dir__}/plugin/validator"
|
|
21
|
+
autoload :Transformer, "#{__dir__}/plugin/transformer"
|
|
22
|
+
autoload :CliCommand, "#{__dir__}/plugin/cli_command"
|
|
23
|
+
autoload :Loader, "#{__dir__}/plugin/loader"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/uniword/version.rb
CHANGED
|
@@ -195,6 +195,46 @@ module Uniword
|
|
|
195
195
|
Lint::Engine.new(document: self, ruleset: ruleset).run
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
+
# Counter for auto-numbered captions. Persisted across the
|
|
199
|
+
# document's lifetime; reset on document load.
|
|
200
|
+
#
|
|
201
|
+
# @return [Caption::Counter]
|
|
202
|
+
def caption_counter
|
|
203
|
+
@caption_counter ||= Caption::Counter.new
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Add an auto-numbered caption paragraph to the body and
|
|
207
|
+
# return the bookmark name (for use in cross-references).
|
|
208
|
+
#
|
|
209
|
+
# The caption is appended as a new paragraph with style
|
|
210
|
+
# "Caption" and a SEQ field. The bookmark wraps the entire
|
|
211
|
+
# paragraph so cross-references resolve to the caption text.
|
|
212
|
+
#
|
|
213
|
+
# @param label [String] "Figure", "Table", "Equation", or any
|
|
214
|
+
# other category — each gets its own counter
|
|
215
|
+
# @param text [String] caption body
|
|
216
|
+
# @param separator [String] between the label/number and the
|
|
217
|
+
# body (default ": ")
|
|
218
|
+
# @return [String] bookmark name (e.g. "_Figure1")
|
|
219
|
+
def add_caption(label:, text:, separator: ": ")
|
|
220
|
+
builder = Caption::CaptionBuilder.new(caption_counter)
|
|
221
|
+
paragraph, bookmark_name = builder.build(label: label,
|
|
222
|
+
text: text,
|
|
223
|
+
separator: separator)
|
|
224
|
+
body.paragraphs << paragraph
|
|
225
|
+
bookmark_name
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Build a cross-reference run targeting a bookmark. Returns a
|
|
229
|
+
# Run containing a SimpleField with a REF instruction. The
|
|
230
|
+
# caller decides where to place it.
|
|
231
|
+
#
|
|
232
|
+
# @param bookmark_name [String] target bookmark
|
|
233
|
+
# @return [Wordprocessingml::SimpleField]
|
|
234
|
+
def cross_reference_to(bookmark_name)
|
|
235
|
+
Caption::CrossReference.new(bookmark_name).build
|
|
236
|
+
end
|
|
237
|
+
|
|
198
238
|
# Apply uniform page setup to every section of the document
|
|
199
239
|
#
|
|
200
240
|
# Mirrors Word's Layout dialog: named paper sizes, orientation
|
data/lib/uniword.rb
CHANGED
|
@@ -140,6 +140,12 @@ module Uniword
|
|
|
140
140
|
# Document lint (style-guide enforcement).
|
|
141
141
|
autoload :Lint, "uniword/lint"
|
|
142
142
|
|
|
143
|
+
# Auto-numbered captions + cross-references.
|
|
144
|
+
autoload :Caption, "uniword/caption"
|
|
145
|
+
|
|
146
|
+
# Plugin system (validators, transformers, CLI commands).
|
|
147
|
+
autoload :Plugin, "uniword/plugin"
|
|
148
|
+
|
|
143
149
|
# CLI
|
|
144
150
|
autoload :CLI, "uniword/cli/main"
|
|
145
151
|
autoload :GenerateCLI, "uniword/cli/generate_cli"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: uniword
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -592,6 +592,13 @@ files:
|
|
|
592
592
|
- lib/uniword/batch.rb
|
|
593
593
|
- lib/uniword/batch/batch_result.rb
|
|
594
594
|
- lib/uniword/batch/document_processor.rb
|
|
595
|
+
- lib/uniword/batch/operation.rb
|
|
596
|
+
- lib/uniword/batch/operation/file_result.rb
|
|
597
|
+
- lib/uniword/batch/operation/repair_task.rb
|
|
598
|
+
- lib/uniword/batch/operation/report.rb
|
|
599
|
+
- lib/uniword/batch/operation/runner.rb
|
|
600
|
+
- lib/uniword/batch/operation/task.rb
|
|
601
|
+
- lib/uniword/batch/operation/verify_task.rb
|
|
595
602
|
- lib/uniword/batch/processing_stage.rb
|
|
596
603
|
- lib/uniword/batch/stages/compress_images_stage.rb
|
|
597
604
|
- lib/uniword/batch/stages/convert_format_stage.rb
|
|
@@ -656,6 +663,10 @@ files:
|
|
|
656
663
|
- lib/uniword/builder/theme_builder.rb
|
|
657
664
|
- lib/uniword/builder/toc_builder.rb
|
|
658
665
|
- lib/uniword/builder/watermark_builder.rb
|
|
666
|
+
- lib/uniword/caption.rb
|
|
667
|
+
- lib/uniword/caption/caption_builder.rb
|
|
668
|
+
- lib/uniword/caption/counter.rb
|
|
669
|
+
- lib/uniword/caption/cross_reference.rb
|
|
659
670
|
- lib/uniword/chart.rb
|
|
660
671
|
- lib/uniword/chart/area3_d_chart.rb
|
|
661
672
|
- lib/uniword/chart/area_chart.rb
|
|
@@ -800,6 +811,11 @@ files:
|
|
|
800
811
|
- lib/uniword/diff/formatter.rb
|
|
801
812
|
- lib/uniword/diff/package_diff_result.rb
|
|
802
813
|
- lib/uniword/diff/package_differ.rb
|
|
814
|
+
- lib/uniword/diff/semantic.rb
|
|
815
|
+
- lib/uniword/diff/semantic/change.rb
|
|
816
|
+
- lib/uniword/diff/semantic/engine.rb
|
|
817
|
+
- lib/uniword/diff/semantic/paragraph_comparator.rb
|
|
818
|
+
- lib/uniword/diff/semantic/result.rb
|
|
803
819
|
- lib/uniword/document_factory.rb
|
|
804
820
|
- lib/uniword/document_input.rb
|
|
805
821
|
- lib/uniword/document_variables.rb
|
|
@@ -1205,6 +1221,12 @@ files:
|
|
|
1205
1221
|
- lib/uniword/picture/picture_source_rect.rb
|
|
1206
1222
|
- lib/uniword/picture/picture_stretch.rb
|
|
1207
1223
|
- lib/uniword/picture/tile.rb
|
|
1224
|
+
- lib/uniword/plugin.rb
|
|
1225
|
+
- lib/uniword/plugin/cli_command.rb
|
|
1226
|
+
- lib/uniword/plugin/loader.rb
|
|
1227
|
+
- lib/uniword/plugin/registry.rb
|
|
1228
|
+
- lib/uniword/plugin/transformer.rb
|
|
1229
|
+
- lib/uniword/plugin/validator.rb
|
|
1208
1230
|
- lib/uniword/presentationml.rb
|
|
1209
1231
|
- lib/uniword/presentationml/audio.rb
|
|
1210
1232
|
- lib/uniword/presentationml/body_properties.rb
|