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,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Template
|
|
5
|
+
# Fills document placeholders (mail merge) from a data hash.
|
|
6
|
+
#
|
|
7
|
+
# Three placeholder sources are recognised, in precedence order:
|
|
8
|
+
#
|
|
9
|
+
# 1. Content controls — a w:sdt whose sdtPr alias or tag names a
|
|
10
|
+
# variable has its content replaced with the value (the first
|
|
11
|
+
# run's properties are kept, extra runs are dropped).
|
|
12
|
+
# 2. Mail merge fields — a w:fldSimple whose instr is
|
|
13
|
+
# `MERGEFIELD name` (first token after MERGEFIELD, quoted names
|
|
14
|
+
# allowed) has its run content replaced with the value.
|
|
15
|
+
# 3. `{{var}}` text placeholders inside run text (w:t).
|
|
16
|
+
#
|
|
17
|
+
# Data keys may be strings or symbols; nested hashes are flattened
|
|
18
|
+
# to dotted paths ({"client" => {"name" => "X"}} fills
|
|
19
|
+
# {{client.name}}).
|
|
20
|
+
#
|
|
21
|
+
# V1 limitations:
|
|
22
|
+
# - A `{{var}}` placeholder must be fully contained in a single
|
|
23
|
+
# run's text; cross-run placeholders are not stitched.
|
|
24
|
+
# - Only the main document body is scanned (paragraphs, tables,
|
|
25
|
+
# block/inline content controls, hyperlink runs); headers,
|
|
26
|
+
# footers, footnotes, and complex (fldChar) fields are not.
|
|
27
|
+
# - A filled content control's value is inserted verbatim and is
|
|
28
|
+
# not rescanned for further placeholders.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# result = Filler.new(document).fill(
|
|
32
|
+
# { "client" => { "name" => "X" }, "total" => 42 }
|
|
33
|
+
# )
|
|
34
|
+
# result.total_filled # => 2
|
|
35
|
+
# result.missing # => ["editor"]
|
|
36
|
+
class Filler
|
|
37
|
+
PLACEHOLDER_PATTERN = /\{\{\s*([^{}]+?)\s*\}\}/
|
|
38
|
+
MERGEFIELD_PATTERN = /\A\s*MERGEFIELD\s+(?:"([^"]*)"|(\S+))/i
|
|
39
|
+
|
|
40
|
+
# @param document [Wordprocessingml::DocumentRoot] document to fill
|
|
41
|
+
def initialize(document)
|
|
42
|
+
@document = document
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fill all recognised placeholders in the document body.
|
|
46
|
+
#
|
|
47
|
+
# @param data [Hash] values keyed by variable name (nested hashes
|
|
48
|
+
# become dotted paths); missing keys leave their placeholders
|
|
49
|
+
# untouched
|
|
50
|
+
# @param strict [Boolean] when true, raise MissingVariablesError
|
|
51
|
+
# if any placeholder names a variable absent from +data+
|
|
52
|
+
# @return [FillResult] filled counts per source kind and missing names
|
|
53
|
+
# @raise [MissingVariablesError] when strict and variables are missing
|
|
54
|
+
def fill(data, strict: false)
|
|
55
|
+
@variables = flatten_data(data)
|
|
56
|
+
@result = FillResult.new
|
|
57
|
+
|
|
58
|
+
fill_container(@document.body) if @document.body
|
|
59
|
+
|
|
60
|
+
if strict && @result.missing.any?
|
|
61
|
+
raise MissingVariablesError, @result.missing
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Recursively fill one block-level container. Dispatch is
|
|
70
|
+
# explicit per container class (mirrors FindReplace::ParagraphWalker):
|
|
71
|
+
# Body maps structured_document_tags, TableCell maps no SDTs, and
|
|
72
|
+
# SDT Content maps sdts plus direct runs.
|
|
73
|
+
def fill_container(container)
|
|
74
|
+
case container
|
|
75
|
+
when Wordprocessingml::Body
|
|
76
|
+
fill_body(container)
|
|
77
|
+
when Wordprocessingml::TableCell
|
|
78
|
+
fill_cell(container)
|
|
79
|
+
when Wordprocessingml::StructuredDocumentTag::Content
|
|
80
|
+
fill_sdt_content(container)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fill_body(body)
|
|
85
|
+
Array(body.structured_document_tags).each { |sdt| fill_sdt(sdt) }
|
|
86
|
+
fill_paragraphs(body.paragraphs)
|
|
87
|
+
fill_tables(body.tables)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fill_cell(cell)
|
|
91
|
+
fill_paragraphs(cell.paragraphs)
|
|
92
|
+
fill_tables(cell.tables)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fill_sdt_content(content)
|
|
96
|
+
Array(content.sdts).each { |sdt| fill_sdt(sdt) }
|
|
97
|
+
fill_paragraphs(content.paragraphs)
|
|
98
|
+
fill_tables(content.tables)
|
|
99
|
+
fill_run_placeholders(Array(content.runs))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fill_tables(tables)
|
|
103
|
+
Array(tables).each do |table|
|
|
104
|
+
Array(table.rows).each do |row|
|
|
105
|
+
Array(row.cells).each { |cell| fill_container(cell) if cell }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fill_paragraphs(paragraphs)
|
|
111
|
+
Array(paragraphs).each { |para| fill_paragraph(para) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fill_paragraph(para)
|
|
115
|
+
fill_run_placeholders(Array(para.runs))
|
|
116
|
+
Array(para.sdts).each { |sdt| fill_sdt(sdt) }
|
|
117
|
+
Array(para.simple_fields).each { |field| fill_field(field) }
|
|
118
|
+
fill_hyperlink_placeholders(para)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fill_hyperlink_placeholders(para)
|
|
122
|
+
Array(para.hyperlinks).each do |link|
|
|
123
|
+
fill_run_placeholders(Array(link.runs))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Content control: alias wins over tag as the variable name.
|
|
128
|
+
def fill_sdt(sdt)
|
|
129
|
+
name = sdt_variable_name(sdt)
|
|
130
|
+
|
|
131
|
+
if name
|
|
132
|
+
if @variables.key?(name)
|
|
133
|
+
write_value(sdt_run_slots(sdt), stringify(@variables[name]),
|
|
134
|
+
sdt_append_target(sdt))
|
|
135
|
+
@result.add(:sdt)
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@result.record_missing(name)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
fill_container(sdt.content) if sdt.content
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Simple field: parse the MERGEFIELD instruction, fill when known.
|
|
146
|
+
def fill_field(field)
|
|
147
|
+
name = parse_mergefield(field.instr)
|
|
148
|
+
return unless name
|
|
149
|
+
|
|
150
|
+
if @variables.key?(name)
|
|
151
|
+
slots = Array(field.runs).map { |run| [field.runs, run] }
|
|
152
|
+
write_value(slots, stringify(@variables[name]), field.runs)
|
|
153
|
+
@result.add(:mergefield)
|
|
154
|
+
else
|
|
155
|
+
@result.record_missing(name)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# {{var}} substitution inside each run's text elements.
|
|
160
|
+
def fill_run_placeholders(runs)
|
|
161
|
+
runs.each do |run|
|
|
162
|
+
Array(run.text).each { |text_el| fill_text_object(text_el) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fill_text_object(text_el)
|
|
167
|
+
content = text_el.content.to_s
|
|
168
|
+
replaced = replace_placeholders(content)
|
|
169
|
+
return if replaced == content
|
|
170
|
+
|
|
171
|
+
text_el.content = replaced
|
|
172
|
+
preserve = Wordprocessingml::Text.preserve_whitespace?(replaced)
|
|
173
|
+
text_el.xml_space = preserve ? "preserve" : nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Substitute every {{var}} occurrence, recording missing names
|
|
177
|
+
# and leaving those placeholders verbatim.
|
|
178
|
+
def replace_placeholders(content)
|
|
179
|
+
content.gsub(PLACEHOLDER_PATTERN) do
|
|
180
|
+
match = Regexp.last_match
|
|
181
|
+
name = match[1]
|
|
182
|
+
|
|
183
|
+
if @variables.key?(name)
|
|
184
|
+
@result.add(:text)
|
|
185
|
+
stringify(@variables[name])
|
|
186
|
+
else
|
|
187
|
+
@result.record_missing(name)
|
|
188
|
+
match[0]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Run slots ([collection, run] pairs) inside a content control,
|
|
194
|
+
# in document order: direct content runs, then paragraph runs
|
|
195
|
+
# (including runs inside paragraph hyperlinks).
|
|
196
|
+
def sdt_run_slots(sdt)
|
|
197
|
+
content = sdt.content
|
|
198
|
+
return [] if content.nil?
|
|
199
|
+
|
|
200
|
+
slots = run_slots(content.runs)
|
|
201
|
+
Array(content.paragraphs).each do |para|
|
|
202
|
+
slots.concat(para_run_slots(para))
|
|
203
|
+
end
|
|
204
|
+
slots
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def run_slots(runs)
|
|
208
|
+
Array(runs).map { |run| [runs, run] }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def para_run_slots(para)
|
|
212
|
+
slots = run_slots(para.runs)
|
|
213
|
+
Array(para.hyperlinks).each do |link|
|
|
214
|
+
slots.concat(run_slots(link.runs))
|
|
215
|
+
end
|
|
216
|
+
slots
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Where a fresh run is appended when a content control has no
|
|
220
|
+
# runs at all: the first content paragraph, else the content's
|
|
221
|
+
# direct run collection.
|
|
222
|
+
def sdt_append_target(sdt)
|
|
223
|
+
content = sdt.content
|
|
224
|
+
return nil if content.nil?
|
|
225
|
+
|
|
226
|
+
first_para = Array(content.paragraphs).first
|
|
227
|
+
first_para ? first_para.runs : content.runs
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Write a value into run slots: keep the first run (and its
|
|
231
|
+
# properties) with the new text, drop the remaining runs. When
|
|
232
|
+
# there are no runs, append a fresh run unless the value is
|
|
233
|
+
# empty.
|
|
234
|
+
def write_value(slots, value, append_target)
|
|
235
|
+
if slots.empty?
|
|
236
|
+
return if value.empty? || append_target.nil?
|
|
237
|
+
|
|
238
|
+
append_target << Wordprocessingml::Run.new(text: [make_text(value)])
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
_owner, first = slots.first
|
|
243
|
+
first.text = [make_text(value)]
|
|
244
|
+
slots.drop(1).each { |owner, run| owner.delete(run) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def sdt_variable_name(sdt)
|
|
248
|
+
props = sdt.properties
|
|
249
|
+
return nil unless props
|
|
250
|
+
|
|
251
|
+
alias_name = props.alias_name&.value.to_s
|
|
252
|
+
return alias_name unless alias_name.empty?
|
|
253
|
+
|
|
254
|
+
tag = props.tag&.value.to_s
|
|
255
|
+
tag.empty? ? nil : tag
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def parse_mergefield(instr)
|
|
259
|
+
match = instr.to_s.match(MERGEFIELD_PATTERN)
|
|
260
|
+
return nil unless match
|
|
261
|
+
|
|
262
|
+
match[1] || match[2]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Text object following the run text convention: xml:space
|
|
266
|
+
# preserved when the value has leading/trailing spaces, tabs, or
|
|
267
|
+
# newlines (mirrors Run's own text handling).
|
|
268
|
+
def make_text(string)
|
|
269
|
+
text_el = Wordprocessingml::Text.new(content: string)
|
|
270
|
+
text_el.xml_space = "preserve" if Wordprocessingml::Text.preserve_whitespace?(string)
|
|
271
|
+
text_el
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def stringify(value)
|
|
275
|
+
value.nil? ? "" : value.to_s
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Flatten nested hashes into dotted-path string keys.
|
|
279
|
+
def flatten_data(hash, prefix = nil)
|
|
280
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
281
|
+
name = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
282
|
+
|
|
283
|
+
if value.is_a?(Hash)
|
|
284
|
+
out.update(flatten_data(value, name))
|
|
285
|
+
else
|
|
286
|
+
out[name] = value
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
data/lib/uniword/template.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Uniword
|
|
|
9
9
|
autoload :TemplateValidator, "#{__dir__}/template/template_validator"
|
|
10
10
|
autoload :TemplateContext, "#{__dir__}/template/template_context"
|
|
11
11
|
autoload :TemplateMarker, "#{__dir__}/template/template_marker"
|
|
12
|
+
autoload :Filler, "#{__dir__}/template/filler"
|
|
13
|
+
autoload :FillResult, "#{__dir__}/template/fill_result"
|
|
12
14
|
autoload :Helpers, "#{__dir__}/template/helpers"
|
|
13
15
|
end
|
|
14
16
|
end
|
|
@@ -211,7 +211,13 @@ module Uniword
|
|
|
211
211
|
style_attrs << "height:#{height_px}px" if height_px
|
|
212
212
|
style = style_attrs.empty? ? "" : " style='#{style_attrs.join(';')}'"
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
# No alt attribute at all when the drawing carries no description.
|
|
215
|
+
# An empty alt says "decorative", which is a claim about the image
|
|
216
|
+
# we have no grounds to make.
|
|
217
|
+
alt_text = drawing.alt_text
|
|
218
|
+
alt = alt_text ? %( alt="#{escape_xml(alt_text)}") : ""
|
|
219
|
+
|
|
220
|
+
%(<img src="#{image_target}"#{alt}#{style}>)
|
|
215
221
|
end
|
|
216
222
|
|
|
217
223
|
# Resolve image target path from image_parts
|
|
@@ -636,9 +642,9 @@ module Uniword
|
|
|
636
642
|
|
|
637
643
|
attrs << %(w:id="#{props.id.value}") if props.id&.value
|
|
638
644
|
|
|
639
|
-
attrs << 'w:showingPlcHdr="t"' if props.showing_placeholder_header
|
|
645
|
+
attrs << 'w:showingPlcHdr="t"' if props.showing_placeholder_header&.on?
|
|
640
646
|
|
|
641
|
-
attrs << 'w:temporary="t"' if props.temporary
|
|
647
|
+
attrs << 'w:temporary="t"' if props.temporary&.on?
|
|
642
648
|
|
|
643
649
|
if props.placeholder&.doc_part
|
|
644
650
|
doc_part = props.placeholder.doc_part
|
|
@@ -60,9 +60,14 @@ module Uniword
|
|
|
60
60
|
props = run.properties
|
|
61
61
|
return text unless props
|
|
62
62
|
|
|
63
|
-
# Apply inline formatting
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
# Apply inline formatting.
|
|
64
|
+
#
|
|
65
|
+
# w:b and w:i are ST_OnOff toggles, so they are read through
|
|
66
|
+
# BooleanElement#on? and an explicit off ("0"/"false"/"off") stays
|
|
67
|
+
# off. The reads below are value elements, not toggles: w:u carries
|
|
68
|
+
# an ST_Underline style, the rest carry plain values.
|
|
69
|
+
text = "<strong>#{text}</strong>" if props.bold&.on?
|
|
70
|
+
text = "<em>#{text}</em>" if props.italic&.on?
|
|
66
71
|
text = "<u>#{text}</u>" if props.underline&.value
|
|
67
72
|
text = "<span style=\"color:#{props.color&.value}\">#{text}</span>" if props.color&.value
|
|
68
73
|
text = "<span style=\"font-size:#{font_size_to_html(props.size&.value)}\">#{text}</span>" if props.size&.value
|
data/lib/uniword/version.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
module Wordprocessingml
|
|
7
|
+
# Deleted paragraph mark
|
|
8
|
+
#
|
|
9
|
+
# Element: <w:del> (CT_TrackChange) as it appears inside
|
|
10
|
+
# <w:pPr>/<w:rPr>: the paragraph mark itself was deleted while
|
|
11
|
+
# tracking changes, so accepting the revision merges the paragraph
|
|
12
|
+
# with the following one. Carries only the revision identity; the
|
|
13
|
+
# deleted text lives in sibling <w:del> run wrappers.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# mark = DeletedParagraphMark.new(id: "9", author: "Alice")
|
|
17
|
+
class DeletedParagraphMark < Lutaml::Model::Serializable
|
|
18
|
+
attribute :id, Ooxml::Types::WmlVal
|
|
19
|
+
attribute :author, Ooxml::Types::WmlVal
|
|
20
|
+
attribute :date, Ooxml::Types::WmlVal
|
|
21
|
+
|
|
22
|
+
xml do
|
|
23
|
+
element "del"
|
|
24
|
+
namespace Uniword::Ooxml::Namespaces::WordProcessingML
|
|
25
|
+
|
|
26
|
+
map_attribute "id", to: :id, render_nil: false
|
|
27
|
+
map_attribute "author", to: :author, render_nil: false
|
|
28
|
+
map_attribute "date", to: :date, render_nil: false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -292,16 +292,31 @@ module Uniword
|
|
|
292
292
|
body&.tables || []
|
|
293
293
|
end
|
|
294
294
|
|
|
295
|
+
# Every paragraph in the document, including the ones inside table
|
|
296
|
+
# cells and inside tables nested in those cells.
|
|
297
|
+
#
|
|
298
|
+
# Top-level paragraphs come first, then table paragraphs: w:p and w:tbl
|
|
299
|
+
# are separate collections on the model, so their interleaving is not
|
|
300
|
+
# recoverable here.
|
|
301
|
+
#
|
|
302
|
+
# @return [Array<Paragraph>] All paragraphs, top-level ones first
|
|
303
|
+
def all_paragraphs
|
|
304
|
+
return [] unless body
|
|
305
|
+
|
|
306
|
+
(body.paragraphs || []) +
|
|
307
|
+
(body.tables || []).flat_map { |table| table_paragraphs(table) }
|
|
308
|
+
end
|
|
309
|
+
|
|
295
310
|
# Get all drawings (image references) from the document.
|
|
296
|
-
# Walks
|
|
311
|
+
# Walks every paragraph, table cells included, and collects Drawing
|
|
312
|
+
# elements from runs. A drawing in a table cell is still an image the
|
|
313
|
+
# renderer emits, so the accessibility and quality rules must see it.
|
|
297
314
|
#
|
|
298
315
|
# @return [Array<Drawing>] All drawing elements in document
|
|
299
316
|
def images
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
(para.runs || []).flat_map(&:drawings)
|
|
304
|
-
end.compact
|
|
317
|
+
all_paragraphs
|
|
318
|
+
.flat_map { |para| (para.runs || []).flat_map(&:drawings) }
|
|
319
|
+
.compact
|
|
305
320
|
end
|
|
306
321
|
|
|
307
322
|
# @return [Hash] Document statistics (paragraphs, tables, images)
|
|
@@ -369,6 +384,25 @@ module Uniword
|
|
|
369
384
|
|
|
370
385
|
private
|
|
371
386
|
|
|
387
|
+
# Paragraphs held by a table, walking nested tables as well.
|
|
388
|
+
#
|
|
389
|
+
# @param table [Table] Table to walk
|
|
390
|
+
# @return [Array<Paragraph>] Paragraphs in that table
|
|
391
|
+
def table_paragraphs(table)
|
|
392
|
+
cells = (table.rows || []).flat_map { |row| row.cells || [] }
|
|
393
|
+
cells.flat_map { |cell| cell_paragraphs(cell) }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Paragraphs held by one table cell, including its nested tables.
|
|
397
|
+
#
|
|
398
|
+
# @param cell [TableCell] Cell to walk
|
|
399
|
+
# @return [Array<Paragraph>] Paragraphs in that cell
|
|
400
|
+
def cell_paragraphs(cell)
|
|
401
|
+
nested = (cell.tables || [])
|
|
402
|
+
.flat_map { |table| table_paragraphs(table) }
|
|
403
|
+
(cell.paragraphs || []) + nested
|
|
404
|
+
end
|
|
405
|
+
|
|
372
406
|
# Run model-level validation rules against this document.
|
|
373
407
|
#
|
|
374
408
|
# @return [Array<Validation::Report::ValidationIssue>] issues found
|
|
@@ -19,6 +19,57 @@ module Uniword
|
|
|
19
19
|
map_element "inline", to: :inline, render_nil: false
|
|
20
20
|
map_element "anchor", to: :anchor, render_nil: false
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Alternative text for the drawing.
|
|
24
|
+
#
|
|
25
|
+
# Alt text lives in the descr attribute of wp:docPr. ECMA-376 names
|
|
26
|
+
# descr the description of the object; title is the object's caption,
|
|
27
|
+
# which Word's modern Alt Text pane does not write and screen readers
|
|
28
|
+
# do not announce. Word documents in the ISO corpus carry their alt
|
|
29
|
+
# text in descr with no title at all, so title is deliberately not a
|
|
30
|
+
# fallback here — see #alt_title.
|
|
31
|
+
#
|
|
32
|
+
# CT_Drawing is a choice over wp:inline and wp:anchor, so a drawing may
|
|
33
|
+
# carry either or both. Take the first frame supplying a non-blank
|
|
34
|
+
# description. Surrounding whitespace is not description, so the value
|
|
35
|
+
# is stripped and a blank one counts as absent.
|
|
36
|
+
#
|
|
37
|
+
# @return [String, nil] Alternative text, or nil when absent
|
|
38
|
+
def alt_text
|
|
39
|
+
first_non_blank(:descr)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Set the alternative text on whichever frame this drawing carries.
|
|
43
|
+
# A drawing with neither frame has nowhere to put it.
|
|
44
|
+
#
|
|
45
|
+
# @param text [String, nil] Alternative text
|
|
46
|
+
def alt_text=(text)
|
|
47
|
+
frame = inline || anchor
|
|
48
|
+
unless frame.nil?
|
|
49
|
+
frame.doc_properties ||= WpDrawing::DocProperties.new
|
|
50
|
+
frame.doc_properties.descr = text
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The drawing's title (Word's legacy "Title" field), which is a caption
|
|
55
|
+
# rather than a text alternative. Reported so a rule can tell an author
|
|
56
|
+
# that their description is sitting in the wrong field.
|
|
57
|
+
#
|
|
58
|
+
# @return [String, nil] Title, or nil when absent
|
|
59
|
+
def alt_title
|
|
60
|
+
first_non_blank(:title)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# @param attribute [Symbol] docPr attribute to read
|
|
66
|
+
# @return [String, nil] First non-blank value across the frames
|
|
67
|
+
def first_non_blank(attribute)
|
|
68
|
+
[inline, anchor]
|
|
69
|
+
.filter_map { |frame| frame&.doc_properties&.public_send(attribute) }
|
|
70
|
+
.map(&:strip)
|
|
71
|
+
.find { |text| !text.empty? }
|
|
72
|
+
end
|
|
22
73
|
end
|
|
23
74
|
end
|
|
24
75
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
module Wordprocessingml
|
|
7
|
+
# Inserted paragraph mark
|
|
8
|
+
#
|
|
9
|
+
# Element: <w:ins> (CT_TrackChange) as it appears inside
|
|
10
|
+
# <w:pPr>/<w:rPr>: the paragraph mark itself was inserted while
|
|
11
|
+
# tracking changes, so the whole paragraph reads as a tracked
|
|
12
|
+
# insertion and rejecting the revision removes the paragraph.
|
|
13
|
+
# Carries only the revision identity.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# mark = InsertedParagraphMark.new(id: "10", author: "Alice")
|
|
17
|
+
class InsertedParagraphMark < Lutaml::Model::Serializable
|
|
18
|
+
attribute :id, Ooxml::Types::WmlVal
|
|
19
|
+
attribute :author, Ooxml::Types::WmlVal
|
|
20
|
+
attribute :date, Ooxml::Types::WmlVal
|
|
21
|
+
|
|
22
|
+
xml do
|
|
23
|
+
element "ins"
|
|
24
|
+
namespace Uniword::Ooxml::Namespaces::WordProcessingML
|
|
25
|
+
|
|
26
|
+
map_attribute "id", to: :id, render_nil: false
|
|
27
|
+
map_attribute "author", to: :author, render_nil: false
|
|
28
|
+
map_attribute "date", to: :date, render_nil: false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|