uniword 1.7.1 → 1.7.2
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 +6 -0
- data/lib/uniword/accessibility/rules/image_alt_text_rule.rb +36 -13
- data/lib/uniword/builder/image_builder.rb +2 -0
- data/lib/uniword/builder/paragraph_builder.rb +5 -6
- data/lib/uniword/builder/sdt_builder.rb +12 -4
- data/lib/uniword/builder/style_builder.rb +5 -5
- data/lib/uniword/builder/table_builder.rb +2 -2
- data/lib/uniword/builder/table_cell_builder.rb +3 -3
- data/lib/uniword/builder/table_row_builder.rb +2 -1
- data/lib/uniword/cli/comments_cli.rb +173 -0
- data/lib/uniword/cli/diff_cli.rb +60 -0
- data/lib/uniword/cli/main.rb +104 -0
- data/lib/uniword/cli/plain_text_command.rb +101 -0
- data/lib/uniword/cli/review_cli.rb +173 -34
- data/lib/uniword/cli/revisions_cli.rb +88 -0
- data/lib/uniword/cli/template_cli.rb +81 -0
- data/lib/uniword/converter/plain_text.rb +426 -0
- data/lib/uniword/converter/plain_text_result.rb +112 -0
- data/lib/uniword/converter.rb +11 -0
- data/lib/uniword/diff/document_differ.rb +2 -88
- data/lib/uniword/diff/lcs.rb +101 -0
- data/lib/uniword/diff/redline_result.rb +94 -0
- data/lib/uniword/diff/redliner.rb +319 -0
- data/lib/uniword/diff.rb +3 -0
- data/lib/uniword/errors.rb +33 -0
- data/lib/uniword/mhtml/word_css.rb +16 -8
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +19 -7
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +4 -8
- data/lib/uniword/properties/boolean_element_factory.rb +48 -3
- data/lib/uniword/properties/boolean_formatting.rb +4 -0
- data/lib/uniword/properties/outline.rb +4 -13
- data/lib/uniword/quality/rules/image_alt_text_rule.rb +22 -43
- data/lib/uniword/review/review_manager.rb +128 -1
- data/lib/uniword/template/fill_result.rb +121 -0
- data/lib/uniword/template/filler.rb +292 -0
- data/lib/uniword/template.rb +2 -0
- data/lib/uniword/transformation/mhtml_element_renderer.rb +9 -3
- data/lib/uniword/transformation/ooxml_to_html_converter.rb +8 -3
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/document_root.rb +40 -6
- data/lib/uniword/wordprocessingml/drawing.rb +51 -0
- data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
- data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
- data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
- data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
- data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
- data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
- data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
- data/lib/uniword/wordprocessingml/style.rb +43 -40
- data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
- data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
- data/lib/uniword/wordprocessingml.rb +17 -4
- data/lib/uniword.rb +9 -0
- metadata +20 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Diff
|
|
5
|
+
# Longest-common-subsequence alignment over sequences.
|
|
6
|
+
#
|
|
7
|
+
# Shared substrate for the differ (paragraph alignment) and the
|
|
8
|
+
# redliner (paragraph and word-token alignment). Sequences are
|
|
9
|
+
# compared by element equality, so both operate on plain strings.
|
|
10
|
+
module Lcs
|
|
11
|
+
# Matched (old_index, new_index) pairs, ascending.
|
|
12
|
+
#
|
|
13
|
+
# @param old_seq [Array] base sequence
|
|
14
|
+
# @param new_seq [Array] revised sequence
|
|
15
|
+
# @return [Array<Array(Integer, Integer)>]
|
|
16
|
+
def self.pairs(old_seq, new_seq)
|
|
17
|
+
m = old_seq.size
|
|
18
|
+
n = new_seq.size
|
|
19
|
+
|
|
20
|
+
dp = Array.new(m + 1) { Array.new(n + 1, 0) }
|
|
21
|
+
(1..m).each do |i|
|
|
22
|
+
(1..n).each do |j|
|
|
23
|
+
dp[i][j] = if old_seq[i - 1] == new_seq[j - 1]
|
|
24
|
+
dp[i - 1][j - 1] + 1
|
|
25
|
+
else
|
|
26
|
+
[dp[i - 1][j], dp[i][j - 1]].max
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
matches = []
|
|
32
|
+
i = m
|
|
33
|
+
j = n
|
|
34
|
+
while i.positive? && j.positive?
|
|
35
|
+
if old_seq[i - 1] == new_seq[j - 1]
|
|
36
|
+
matches << [i - 1, j - 1]
|
|
37
|
+
i -= 1
|
|
38
|
+
j -= 1
|
|
39
|
+
elsif dp[i - 1][j] >= dp[i][j - 1]
|
|
40
|
+
i -= 1
|
|
41
|
+
else
|
|
42
|
+
j -= 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
matches.reverse
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Alignment walk over two sequences.
|
|
50
|
+
#
|
|
51
|
+
# Returns tuples [action, old_index, new_index] in document
|
|
52
|
+
# order, where action is :kept or :modified (both indexes set),
|
|
53
|
+
# :removed (old only), or :added (new only).
|
|
54
|
+
#
|
|
55
|
+
# @param old_seq [Array]
|
|
56
|
+
# @param new_seq [Array]
|
|
57
|
+
# @return [Array<Array(Symbol, Integer, Integer)>]
|
|
58
|
+
def self.alignment(old_seq, new_seq)
|
|
59
|
+
lcs = pairs(old_seq, new_seq)
|
|
60
|
+
|
|
61
|
+
result = []
|
|
62
|
+
old_i = 0
|
|
63
|
+
new_i = 0
|
|
64
|
+
lcs_idx = 0
|
|
65
|
+
|
|
66
|
+
while old_i < old_seq.size || new_i < new_seq.size
|
|
67
|
+
old_in_lcs = lcs_idx < lcs.size &&
|
|
68
|
+
lcs[lcs_idx][0] == old_i
|
|
69
|
+
new_in_lcs = lcs_idx < lcs.size &&
|
|
70
|
+
lcs[lcs_idx][1] == new_i
|
|
71
|
+
|
|
72
|
+
if old_in_lcs && new_in_lcs
|
|
73
|
+
action = old_seq[old_i] == new_seq[new_i] ? :kept : :modified
|
|
74
|
+
result << [action, old_i, new_i]
|
|
75
|
+
old_i += 1
|
|
76
|
+
new_i += 1
|
|
77
|
+
lcs_idx += 1
|
|
78
|
+
elsif old_in_lcs
|
|
79
|
+
result << [:added, nil, new_i]
|
|
80
|
+
new_i += 1
|
|
81
|
+
elsif new_in_lcs
|
|
82
|
+
result << [:removed, old_i, nil]
|
|
83
|
+
old_i += 1
|
|
84
|
+
elsif old_i < old_seq.size && new_i < new_seq.size
|
|
85
|
+
result << [:modified, old_i, new_i]
|
|
86
|
+
old_i += 1
|
|
87
|
+
new_i += 1
|
|
88
|
+
elsif old_i < old_seq.size
|
|
89
|
+
result << [:removed, old_i, nil]
|
|
90
|
+
old_i += 1
|
|
91
|
+
else
|
|
92
|
+
result << [:added, nil, new_i]
|
|
93
|
+
new_i += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
module Diff
|
|
7
|
+
# Value object holding the result of redlining two documents.
|
|
8
|
+
#
|
|
9
|
+
# Carries the redlined base document plus change statistics.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = Redliner.new(base_doc, revised_doc).redline
|
|
13
|
+
# result.document.save("redline.docx")
|
|
14
|
+
# puts result.summary
|
|
15
|
+
class RedlineResult
|
|
16
|
+
attr_reader :document, :stats
|
|
17
|
+
|
|
18
|
+
# @param document [Wordprocessingml::DocumentRoot] redlined base
|
|
19
|
+
# @param stats [Hash{Symbol => Integer}] change counts by kind
|
|
20
|
+
# (:kept, :modified, :added, :removed paragraphs)
|
|
21
|
+
def initialize(document:, stats: {})
|
|
22
|
+
@document = document
|
|
23
|
+
@stats = stats
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Number of tracked insertion wrappers (w:ins) emitted.
|
|
27
|
+
#
|
|
28
|
+
# @return [Integer]
|
|
29
|
+
def insertions
|
|
30
|
+
document.body.paragraphs.sum { |p| p.insertions.size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Number of tracked deletion wrappers (w:del) emitted.
|
|
34
|
+
#
|
|
35
|
+
# @return [Integer]
|
|
36
|
+
def deletions
|
|
37
|
+
document.body.paragraphs.sum { |p| p.deletions.size }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether the redline carries no tracked changes.
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def empty?
|
|
44
|
+
insertions.zero? && deletions.zero? &&
|
|
45
|
+
stats[:added].to_i.zero? && stats[:removed].to_i.zero? &&
|
|
46
|
+
stats[:modified].to_i.zero?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Human-readable summary of the redline.
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def summary
|
|
53
|
+
return "No differences found." if empty?
|
|
54
|
+
|
|
55
|
+
parts = []
|
|
56
|
+
if stats[:added].positive?
|
|
57
|
+
parts << "#{stats[:added]} paragraph(s) inserted"
|
|
58
|
+
end
|
|
59
|
+
if stats[:removed].positive?
|
|
60
|
+
parts << "#{stats[:removed]} paragraph(s) deleted"
|
|
61
|
+
end
|
|
62
|
+
if stats[:modified].positive?
|
|
63
|
+
parts << "#{stats[:modified]} paragraph(s) modified"
|
|
64
|
+
end
|
|
65
|
+
parts << "#{insertions} insertion(s), #{deletions} deletion(s)"
|
|
66
|
+
parts.join(", ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Serialize to a JSON string.
|
|
70
|
+
#
|
|
71
|
+
# @return [String] Pretty-printed JSON
|
|
72
|
+
def to_json(*_args)
|
|
73
|
+
JSON.pretty_generate(to_h)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Convert to a plain Hash suitable for serialization.
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash]
|
|
79
|
+
def to_h
|
|
80
|
+
{
|
|
81
|
+
paragraphs: {
|
|
82
|
+
kept: stats[:kept].to_i,
|
|
83
|
+
modified: stats[:modified].to_i,
|
|
84
|
+
added: stats[:added].to_i,
|
|
85
|
+
removed: stats[:removed].to_i,
|
|
86
|
+
},
|
|
87
|
+
insertions: insertions,
|
|
88
|
+
deletions: deletions,
|
|
89
|
+
summary: summary,
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Diff
|
|
5
|
+
# Produces a tracked-changes (redline) document from two revisions.
|
|
6
|
+
#
|
|
7
|
+
# The base document (the old revision) is rewritten in place: a
|
|
8
|
+
# paragraph removed in the new revision has its runs wrapped in
|
|
9
|
+
# w:del and its paragraph mark marked deleted; an added paragraph
|
|
10
|
+
# is inserted with its runs wrapped in w:ins and its paragraph
|
|
11
|
+
# mark marked inserted; a modified paragraph keeps the base text
|
|
12
|
+
# with the word-level delta emitted as interleaved w:del / w:ins
|
|
13
|
+
# runs. Tables pass through unchanged (issue #77 scope item 3).
|
|
14
|
+
#
|
|
15
|
+
# Revision identity (w:id, author, date) is deterministic per
|
|
16
|
+
# change position, so the same pair of documents always produces
|
|
17
|
+
# the same redline.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# base = DocumentFactory.from_file("old.docx")
|
|
21
|
+
# revised = DocumentFactory.from_file("new.docx")
|
|
22
|
+
# result = Redliner.new(base, revised, author: "Alice").redline
|
|
23
|
+
# result.document.save("redline.docx")
|
|
24
|
+
class Redliner
|
|
25
|
+
include Builder::DeterministicId
|
|
26
|
+
|
|
27
|
+
DEFAULT_AUTHOR = "uniword"
|
|
28
|
+
|
|
29
|
+
# Tokenizer: words and whitespace runs, originals preserved so
|
|
30
|
+
# concatenation reproduces the paragraph text exactly.
|
|
31
|
+
TOKEN_PATTERN = /\S+|\s+/
|
|
32
|
+
|
|
33
|
+
# Wordprocessingml::Paragraph children the redliner does not
|
|
34
|
+
# rebuild; their element_order entries are carried over on
|
|
35
|
+
# rewritten paragraphs.
|
|
36
|
+
REBUILT_CHILDREN = %w[pPr r ins del].freeze
|
|
37
|
+
|
|
38
|
+
# @param old_doc [Wordprocessingml::DocumentRoot] base revision,
|
|
39
|
+
# rewritten in place into the redline
|
|
40
|
+
# @param new_doc [Wordprocessingml::DocumentRoot] revised version
|
|
41
|
+
# @param author [String] revision author for emitted changes
|
|
42
|
+
# @param date [String, nil] revision date (ISO 8601) for emitted
|
|
43
|
+
# changes; omitted from the XML when nil
|
|
44
|
+
def initialize(old_doc, new_doc, author: DEFAULT_AUTHOR, date: nil)
|
|
45
|
+
@old_doc = old_doc
|
|
46
|
+
@new_doc = new_doc
|
|
47
|
+
@author = author
|
|
48
|
+
@date = date
|
|
49
|
+
@inserted = 0
|
|
50
|
+
@old_cursor = 0
|
|
51
|
+
@seq = 0
|
|
52
|
+
@stats = Hash.new(0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Rewrite the base document into a redline.
|
|
56
|
+
#
|
|
57
|
+
# @return [RedlineResult] the redlined document plus change stats
|
|
58
|
+
def redline
|
|
59
|
+
Lcs.alignment(old_texts, new_texts).each { |step| apply(step) }
|
|
60
|
+
RedlineResult.new(document: @old_doc, stats: @stats)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def body
|
|
66
|
+
@old_doc.body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def old_paras
|
|
70
|
+
body.paragraphs
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def new_paras
|
|
74
|
+
@new_doc.body&.paragraphs || []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def old_texts
|
|
78
|
+
old_paras.map { |p| p.text.to_s }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def new_texts
|
|
82
|
+
new_paras.map { |p| p.text.to_s }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply((action, old_i, new_i))
|
|
86
|
+
case action
|
|
87
|
+
when :kept
|
|
88
|
+
@stats[:kept] += 1
|
|
89
|
+
@old_cursor += 1
|
|
90
|
+
when :modified
|
|
91
|
+
if old_paras[old_i].text == new_paras[new_i].text
|
|
92
|
+
@stats[:kept] += 1
|
|
93
|
+
else
|
|
94
|
+
rewrite_modified(old_paras[old_i], new_paras[new_i])
|
|
95
|
+
@stats[:modified] += 1
|
|
96
|
+
end
|
|
97
|
+
@old_cursor += 1
|
|
98
|
+
when :removed
|
|
99
|
+
mark_deleted(old_paras[old_i])
|
|
100
|
+
@stats[:removed] += 1
|
|
101
|
+
@old_cursor += 1
|
|
102
|
+
when :added
|
|
103
|
+
insert_added(new_paras[new_i])
|
|
104
|
+
@stats[:added] += 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Word-level delta of two paragraphs as ordered segments.
|
|
109
|
+
#
|
|
110
|
+
# Contiguous tokens from the same source run merge into one
|
|
111
|
+
# segment, so a single-span change yields exactly one revision
|
|
112
|
+
# element, the way Word writes redlines.
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<Hash>] { kind: :keep/:del/:ins, text:, properties: }
|
|
115
|
+
def diff_segments(old_para, new_para)
|
|
116
|
+
old_tokens = tokenize(old_para)
|
|
117
|
+
new_tokens = tokenize(new_para)
|
|
118
|
+
|
|
119
|
+
segments = []
|
|
120
|
+
current = nil
|
|
121
|
+
Lcs.alignment(old_tokens.map(&:last), new_tokens.map(&:last))
|
|
122
|
+
.each do |action, old_i, new_i|
|
|
123
|
+
case action
|
|
124
|
+
when :kept
|
|
125
|
+
current = merge(segments, current, :keep,
|
|
126
|
+
old_tokens[old_i].last,
|
|
127
|
+
old_tokens[old_i].first.properties)
|
|
128
|
+
when :removed, :modified
|
|
129
|
+
current = merge(segments, current, :del,
|
|
130
|
+
old_tokens[old_i].last,
|
|
131
|
+
old_tokens[old_i].first.properties)
|
|
132
|
+
end
|
|
133
|
+
next unless %i[added modified].include?(action)
|
|
134
|
+
|
|
135
|
+
current = merge(segments, current, :ins,
|
|
136
|
+
new_tokens[new_i].last,
|
|
137
|
+
new_tokens[new_i].first.properties)
|
|
138
|
+
end
|
|
139
|
+
merge(segments, current, nil, nil, nil)
|
|
140
|
+
segments.reject { |segment| segment[:text].empty? }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Append a token to the current segment, or start a new one when
|
|
144
|
+
# the kind or the owning run's properties change.
|
|
145
|
+
def merge(segments, current, kind, text, properties)
|
|
146
|
+
if current && current[:kind] == kind &&
|
|
147
|
+
current[:properties].equal?(properties)
|
|
148
|
+
current[:text] << text
|
|
149
|
+
return current
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
segments << current if current
|
|
153
|
+
segment = { kind: kind, text: +"", properties: properties }
|
|
154
|
+
segment[:text] << text if text
|
|
155
|
+
segment
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Tokens of a paragraph as [run, text] pairs.
|
|
159
|
+
def tokenize(para)
|
|
160
|
+
para.runs.flat_map do |run|
|
|
161
|
+
run.text_string.to_s.scan(TOKEN_PATTERN).map { |t| [run, t] }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def rewrite_modified(old_para, new_para)
|
|
166
|
+
kept_runs = []
|
|
167
|
+
deletions = []
|
|
168
|
+
insertions = []
|
|
169
|
+
names = []
|
|
170
|
+
names << "pPr" if old_para.properties
|
|
171
|
+
|
|
172
|
+
diff_segments(old_para, new_para).each do |segment|
|
|
173
|
+
case segment[:kind]
|
|
174
|
+
when :keep
|
|
175
|
+
kept_runs << Wordprocessingml::Run.new(
|
|
176
|
+
properties: segment[:properties], text: segment[:text],
|
|
177
|
+
)
|
|
178
|
+
names << "r"
|
|
179
|
+
when :del
|
|
180
|
+
deletions << Wordprocessingml::Deletion.new(
|
|
181
|
+
id: next_id, author: @author, date: @date,
|
|
182
|
+
runs: [Wordprocessingml::Run.new(
|
|
183
|
+
properties: segment[:properties],
|
|
184
|
+
del_text: deleted_text(segment[:text]),
|
|
185
|
+
)]
|
|
186
|
+
)
|
|
187
|
+
names << "del"
|
|
188
|
+
when :ins
|
|
189
|
+
insertions << Wordprocessingml::Insertion.new(
|
|
190
|
+
id: next_id, author: @author, date: @date,
|
|
191
|
+
runs: [Wordprocessingml::Run.new(
|
|
192
|
+
properties: segment[:properties], text: segment[:text],
|
|
193
|
+
)]
|
|
194
|
+
)
|
|
195
|
+
names << "ins"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
old_para.runs = kept_runs
|
|
200
|
+
old_para.deletions = deletions
|
|
201
|
+
old_para.insertions = insertions
|
|
202
|
+
set_paragraph_order(old_para, names)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Wrap every run of a removed paragraph in w:del and mark the
|
|
206
|
+
# paragraph mark deleted, so accepting the revision drops the
|
|
207
|
+
# whole paragraph.
|
|
208
|
+
def mark_deleted(para)
|
|
209
|
+
deleted_runs = para.runs.map { |run| deleted_run(run) }
|
|
210
|
+
para.runs = []
|
|
211
|
+
|
|
212
|
+
names = []
|
|
213
|
+
names << "pPr" if para.properties
|
|
214
|
+
unless deleted_runs.empty?
|
|
215
|
+
para.deletions = [Wordprocessingml::Deletion.new(
|
|
216
|
+
id: next_id, author: @author, date: @date, runs: deleted_runs,
|
|
217
|
+
)]
|
|
218
|
+
names << "del"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
props = ensure_mark(para)
|
|
222
|
+
props.run_properties ||= Wordprocessingml::RunProperties.new
|
|
223
|
+
props.run_properties.del =
|
|
224
|
+
Wordprocessingml::DeletedParagraphMark.new(
|
|
225
|
+
id: next_id, author: @author, date: @date,
|
|
226
|
+
)
|
|
227
|
+
set_paragraph_order(para, names)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def deleted_text(text)
|
|
231
|
+
deleted = Wordprocessingml::DeletedText.new(content: text)
|
|
232
|
+
if Wordprocessingml::Text.preserve_whitespace?(text)
|
|
233
|
+
deleted.xml_space = "preserve"
|
|
234
|
+
end
|
|
235
|
+
deleted
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def deleted_run(run)
|
|
239
|
+
text = run.text_string.to_s
|
|
240
|
+
return run if text.empty?
|
|
241
|
+
|
|
242
|
+
Wordprocessingml::Run.new(properties: run.properties,
|
|
243
|
+
del_text: deleted_text(text))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Insert a new-only paragraph with its runs wrapped in w:ins and
|
|
247
|
+
# its paragraph mark marked inserted, right before the base
|
|
248
|
+
# paragraph the alignment cursor sits on.
|
|
249
|
+
def insert_added(new_para)
|
|
250
|
+
para = Wordprocessingml::Paragraph.new
|
|
251
|
+
para.properties =
|
|
252
|
+
new_para.properties || Wordprocessingml::ParagraphProperties.new
|
|
253
|
+
para.properties.run_properties ||= Wordprocessingml::RunProperties.new
|
|
254
|
+
para.properties.run_properties.ins =
|
|
255
|
+
Wordprocessingml::InsertedParagraphMark.new(
|
|
256
|
+
id: next_id, author: @author, date: @date,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
names = ["pPr"]
|
|
260
|
+
if new_para.runs.any?
|
|
261
|
+
para.insertions = [Wordprocessingml::Insertion.new(
|
|
262
|
+
id: next_id, author: @author, date: @date,
|
|
263
|
+
runs: new_para.runs.dup
|
|
264
|
+
)]
|
|
265
|
+
names << "ins"
|
|
266
|
+
end
|
|
267
|
+
set_paragraph_order(para, names)
|
|
268
|
+
|
|
269
|
+
position = @old_cursor + @inserted
|
|
270
|
+
body.paragraphs.insert(position, para)
|
|
271
|
+
insert_order_entry(position)
|
|
272
|
+
@inserted += 1
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Record the paragraph's child sequence in element_order so the
|
|
276
|
+
# interleaved runs / del / ins serialize in emission order and
|
|
277
|
+
# untouched children (hyperlinks, bookmarks, comments) survive.
|
|
278
|
+
def set_paragraph_order(para, names)
|
|
279
|
+
order = para.element_order
|
|
280
|
+
carried = if order.nil? || order.empty?
|
|
281
|
+
[]
|
|
282
|
+
else
|
|
283
|
+
order.map(&:name).reject do |name|
|
|
284
|
+
REBUILT_CHILDREN.include?(name)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
para.element_order = (names + carried).map do |name|
|
|
288
|
+
Lutaml::Xml::Element.new("Element", name)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Insert the element_order entry for a body paragraph added at
|
|
293
|
+
# +paragraph_index+ so it serializes at its document position.
|
|
294
|
+
def insert_order_entry(paragraph_index)
|
|
295
|
+
order = body.element_order
|
|
296
|
+
return if order.nil? || order.empty?
|
|
297
|
+
|
|
298
|
+
order = Ooxml::ElementOrder.mutable_order(body)
|
|
299
|
+
p_positions = order.each_index.select { |i| order[i].name == "p" }
|
|
300
|
+
at = if paragraph_index < p_positions.size
|
|
301
|
+
p_positions[paragraph_index]
|
|
302
|
+
else
|
|
303
|
+
order.index { |e| e.name == "sectPr" } || order.size
|
|
304
|
+
end
|
|
305
|
+
order.insert(at, Lutaml::Xml::Element.new("Element", "p"))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def ensure_mark(para)
|
|
309
|
+
para.properties ||= Wordprocessingml::ParagraphProperties.new
|
|
310
|
+
para.properties
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def next_id
|
|
314
|
+
@seq += 1
|
|
315
|
+
deterministic_id("uniword-redline", @seq).to_s
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
data/lib/uniword/diff.rb
CHANGED
|
@@ -13,7 +13,10 @@ module Uniword
|
|
|
13
13
|
autoload :DiffResult, "uniword/diff/diff_result"
|
|
14
14
|
autoload :DocumentDiffer, "uniword/diff/document_differ"
|
|
15
15
|
autoload :Formatter, "uniword/diff/formatter"
|
|
16
|
+
autoload :Lcs, "uniword/diff/lcs"
|
|
16
17
|
autoload :PackageDiffer, "uniword/diff/package_differ"
|
|
18
|
+
autoload :Redliner, "uniword/diff/redliner"
|
|
19
|
+
autoload :RedlineResult, "uniword/diff/redline_result"
|
|
17
20
|
autoload :XmlChange, "uniword/diff/package_diff_result"
|
|
18
21
|
autoload :ZipMetadataChange, "uniword/diff/package_diff_result"
|
|
19
22
|
autoload :OpcIssue, "uniword/diff/package_diff_result"
|
data/lib/uniword/errors.rb
CHANGED
|
@@ -144,4 +144,37 @@ module Uniword
|
|
|
144
144
|
# @return [String] The reason
|
|
145
145
|
attr_reader :reason
|
|
146
146
|
end
|
|
147
|
+
|
|
148
|
+
# Raised when a template fill (strict mode) finds placeholders whose
|
|
149
|
+
# variables have no data
|
|
150
|
+
class MissingVariablesError < Error
|
|
151
|
+
# @param variables [Array<String>] The variable names without data
|
|
152
|
+
def initialize(variables)
|
|
153
|
+
super("Missing template variables: #{variables.join(', ')}")
|
|
154
|
+
@variables = variables
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Array<String>] The missing variable names
|
|
158
|
+
attr_reader :variables
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Raised when a strict plain-text conversion (Converter::PlainText)
|
|
162
|
+
# encounters features that would be dropped or degraded in the
|
|
163
|
+
# target format
|
|
164
|
+
class FidelityError < Error
|
|
165
|
+
# @param format [Symbol, String] The target format of the conversion
|
|
166
|
+
# @param dropped_features [Array<String>] The features that would be lost
|
|
167
|
+
def initialize(format, dropped_features)
|
|
168
|
+
super("Strict #{format} conversion would drop: " \
|
|
169
|
+
"#{dropped_features.join(', ')}")
|
|
170
|
+
@format = format
|
|
171
|
+
@dropped_features = dropped_features
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Symbol, String] The target format
|
|
175
|
+
attr_reader :format
|
|
176
|
+
|
|
177
|
+
# @return [Array<String>] The features that would be lost
|
|
178
|
+
attr_reader :dropped_features
|
|
179
|
+
end
|
|
147
180
|
end
|
|
@@ -119,29 +119,37 @@ module Uniword
|
|
|
119
119
|
|
|
120
120
|
# Build a CSS rule for a style.
|
|
121
121
|
#
|
|
122
|
-
# @param style [Style] The style
|
|
122
|
+
# @param style [Wordprocessingml::Style] The style
|
|
123
123
|
# @return [String, nil] The CSS rule or nil
|
|
124
124
|
def self.build_style_rule(style)
|
|
125
125
|
return nil unless style
|
|
126
126
|
|
|
127
|
+
# w:styleId is optional in the schema, and without it there is no
|
|
128
|
+
# class selector to hang the rule on.
|
|
129
|
+
style_id = style.id
|
|
130
|
+
return nil if style_id.nil? || style_id.empty?
|
|
131
|
+
|
|
127
132
|
properties = []
|
|
128
133
|
|
|
129
134
|
# Font properties
|
|
130
|
-
|
|
135
|
+
font_family = style.font_family
|
|
136
|
+
properties << "font-family: '#{font_family}'" if font_family
|
|
131
137
|
if style.font_size
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
# w:sz is in half-points, so it needs the font-size formatter.
|
|
139
|
+
formatted_size = CssNumberFormatter
|
|
140
|
+
.format_font_size(style.font_size, precision: 1)
|
|
141
|
+
properties << "font-size: #{formatted_size}"
|
|
134
142
|
end
|
|
135
143
|
properties << "font-weight: bold" if style.bold
|
|
136
144
|
properties << "font-style: italic" if style.italic
|
|
137
145
|
|
|
138
|
-
# Paragraph properties
|
|
139
|
-
|
|
146
|
+
# Paragraph properties, whose alignment is a w:jc wrapper element
|
|
147
|
+
alignment = style.alignment&.value
|
|
148
|
+
properties << "text-align: #{alignment}" if alignment
|
|
140
149
|
|
|
141
150
|
return nil if properties.empty?
|
|
142
151
|
|
|
143
|
-
|
|
144
|
-
"#{selector} {\n #{properties.join(";\n ")};\n}"
|
|
152
|
+
".#{style_id} {\n #{properties.join(";\n ")};\n}"
|
|
145
153
|
end
|
|
146
154
|
|
|
147
155
|
# Build a CSS rule for list numbering.
|
|
@@ -11,8 +11,9 @@ module Uniword
|
|
|
11
11
|
#
|
|
12
12
|
# Parsing: "1"/"true"/"on" -> true, "0"/"false"/"off"/nil -> false
|
|
13
13
|
# Serialization: true -> "1", false -> "0"
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# Serializing anything else raises
|
|
15
|
+
# Lutaml::Model::Type::InvalidValueError, because a value the program
|
|
16
|
+
# set is a bug rather than a document we were handed.
|
|
16
17
|
class OoxmlBoolean < Lutaml::Model::Type::Boolean
|
|
17
18
|
# Accepted ST_OnOff spellings for Boolean true
|
|
18
19
|
TRUE_VALUES = [true, 1, "1", "true", "on"].freeze
|
|
@@ -23,14 +24,25 @@ module Uniword
|
|
|
23
24
|
# All accepted ST_OnOff spellings
|
|
24
25
|
ON_OFF_VALUES = (TRUE_VALUES + FALSE_VALUES).freeze
|
|
25
26
|
|
|
27
|
+
# The one ST_OnOff reading, shared by these attribute types and by
|
|
28
|
+
# Properties::BooleanElement.
|
|
29
|
+
#
|
|
30
|
+
# A reader must not raise on a malformed document. One bad token in
|
|
31
|
+
# styles.xml used to kill the whole parse, and a token outside the
|
|
32
|
+
# vocabulary is not an off token, so it reads as on — the same way an
|
|
33
|
+
# unrecognised w:val does.
|
|
34
|
+
#
|
|
35
|
+
# @param value [Object] Raw ST_OnOff token
|
|
36
|
+
# @return [Boolean] true when the toggle is on
|
|
37
|
+
def self.on?(value)
|
|
38
|
+
!FALSE_VALUES.include?(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
def self.cast(value, _options = {})
|
|
27
42
|
return value if Lutaml::Model::Utils.uninitialized?(value)
|
|
28
|
-
return
|
|
29
|
-
return false if FALSE_VALUES.include?(value) || value.nil?
|
|
43
|
+
return false if value.nil?
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
value, ON_OFF_VALUES
|
|
33
|
-
)
|
|
45
|
+
on?(value)
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
def self.serialize(value)
|
|
@@ -10,9 +10,9 @@ module Uniword
|
|
|
10
10
|
# Key behavior:
|
|
11
11
|
# - cast(nil) -> nil (doesn't convert to false like OoxmlBoolean)
|
|
12
12
|
# - cast("1"/"true"/"on") -> true, cast("0"/"false"/"off") -> false
|
|
13
|
-
# - cast of any other
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# - cast of any other token -> true, the same reading
|
|
14
|
+
# Properties::BooleanElement gives an unrecognised w:val. A reader
|
|
15
|
+
# must not raise on a malformed document.
|
|
16
16
|
# - to_xml(true) -> "1"
|
|
17
17
|
# - to_xml(false) -> "0" (explicit false in original)
|
|
18
18
|
# - to_xml(nil) -> nil (attribute absent, omit from output)
|
|
@@ -26,12 +26,8 @@ module Uniword
|
|
|
26
26
|
def self.cast(value, _options = {})
|
|
27
27
|
return value if Lutaml::Model::Utils.uninitialized?(value)
|
|
28
28
|
return nil if value.nil?
|
|
29
|
-
return true if OoxmlBoolean::TRUE_VALUES.include?(value)
|
|
30
|
-
return false if OoxmlBoolean::FALSE_VALUES.include?(value)
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
value, OoxmlBoolean::ON_OFF_VALUES
|
|
34
|
-
)
|
|
30
|
+
OoxmlBoolean.on?(value)
|
|
35
31
|
end
|
|
36
32
|
|
|
37
33
|
def self.serialize(value)
|