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.
- 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/builder/image_builder.rb +1 -1
- 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/docx/package_defaults.rb +1 -1
- data/lib/uniword/docx/package_serialization.rb +1 -1
- data/lib/uniword/docx/reconciler/helpers.rb +1 -1
- data/lib/uniword/docx/reconciler/referential_integrity.rb +1 -1
- data/lib/uniword/find_replace/scope.rb +9 -11
- data/lib/uniword/ooxml/package_file.rb +1 -1
- data/lib/uniword/picture/fill_rect.rb +2 -2
- data/lib/uniword/picture/picture_source_rect.rb +3 -2
- data/lib/uniword/picture/picture_stretch.rb +3 -2
- data/lib/uniword/picture/tile.rb +3 -2
- 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/properties/word2010_id_value.rb +2 -2
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/body.rb +54 -6
- data/lib/uniword/wordprocessingml/document_styling.rb +40 -0
- data/lib/uniword/wordprocessingml/styles_configuration.rb +1 -1
- data/lib/uniword.rb +6 -0
- 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
|
@@ -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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
|
42
|
-
#
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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: <
|
|
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::
|
|
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: <
|
|
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::
|
|
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: <
|
|
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::
|
|
17
|
+
namespace Uniword::Ooxml::Namespaces::DrawingML
|
|
17
18
|
mixed_content
|
|
18
19
|
|
|
19
20
|
map_element "fillRect", to: :fill_rect, render_nil: false
|
data/lib/uniword/picture/tile.rb
CHANGED
|
@@ -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: <
|
|
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::
|
|
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
|