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,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Converter
|
|
5
|
+
# Plain-text editing round trip for Word documents (issue #80).
|
|
6
|
+
#
|
|
7
|
+
# Bridges Uniword::Wordprocessingml::DocumentRoot to Markdown and
|
|
8
|
+
# AsciiDoc through Coradoc's hub-and-spoke CoreModel:
|
|
9
|
+
#
|
|
10
|
+
# DocumentRoot -> Coradoc::Docx.parse_to_core -> core
|
|
11
|
+
# -> (Coradoc::Markdown | Coradoc::AsciiDoc) writer
|
|
12
|
+
#
|
|
13
|
+
# (Coradoc::Markdown | Coradoc::AsciiDoc) parser -> core
|
|
14
|
+
# -> Coradoc::Docx.serialize -> DocumentRoot
|
|
15
|
+
#
|
|
16
|
+
# The coradoc gems are OPTIONAL runtime dependencies: they are
|
|
17
|
+
# required lazily inside the conversion methods, never at file
|
|
18
|
+
# load. When missing, a DependencyError with an install hint is
|
|
19
|
+
# raised.
|
|
20
|
+
#
|
|
21
|
+
# == Fidelity boundary
|
|
22
|
+
#
|
|
23
|
+
# Supported (round-tripped through the CoreModel):
|
|
24
|
+
# * headings h1-h6, paragraphs
|
|
25
|
+
# * bold / italic / underline / strikethrough runs
|
|
26
|
+
# * bulleted and numbered lists
|
|
27
|
+
# * tables with a header row
|
|
28
|
+
# * links (bridge resolves relationship ids to target URLs)
|
|
29
|
+
# * bookmarks (heading/paragraph anchor id propagation)
|
|
30
|
+
#
|
|
31
|
+
# Dropped or degraded (recorded on PlainTextResult, warned about;
|
|
32
|
+
# +strict: true+ raises FidelityError):
|
|
33
|
+
# * headers/footers
|
|
34
|
+
# * section properties
|
|
35
|
+
# * comments
|
|
36
|
+
# * tracked changes (w:ins/w:del)
|
|
37
|
+
# * structured document tags (content survives, wrapper unwrapped)
|
|
38
|
+
# * math (OMML)
|
|
39
|
+
# * images (docx extractor does not carry drawings)
|
|
40
|
+
# * footnotes (coradoc-docx 0.1.4 cannot read the current Footnotes
|
|
41
|
+
# collection; the bridge sets it aside for the conversion)
|
|
42
|
+
#
|
|
43
|
+
# Never carried by any plain-text format (not detected per document):
|
|
44
|
+
# themes, settings, font table, and other package-level parts.
|
|
45
|
+
#
|
|
46
|
+
# From the plain-text side, links written to DOCX resolve to real
|
|
47
|
+
# External relationships on save. One upstream flattening remains:
|
|
48
|
+
# coradoc-markdown's CoreModel transform emits inline Markdown links
|
|
49
|
+
# as literal text, so from_markdown keeps links as text while
|
|
50
|
+
# from_asciidoc builds real hyperlinks.
|
|
51
|
+
#
|
|
52
|
+
# @example Document to Markdown
|
|
53
|
+
# md = Uniword::Converter::PlainText.new.to_markdown(document)
|
|
54
|
+
#
|
|
55
|
+
# @example Markdown to a DocumentRoot
|
|
56
|
+
# doc = Uniword::Converter::PlainText.new.from_markdown(md_text)
|
|
57
|
+
class PlainText
|
|
58
|
+
GEMS_HINT = "coradoc-docx coradoc-markdown coradoc-adoc"
|
|
59
|
+
|
|
60
|
+
# Convert a document to Markdown.
|
|
61
|
+
#
|
|
62
|
+
# @param document [Wordprocessingml::DocumentRoot] source document
|
|
63
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
64
|
+
# @return [String] the Markdown text
|
|
65
|
+
# @raise [FidelityError] in strict mode with dropped features
|
|
66
|
+
def to_markdown(document, strict: false)
|
|
67
|
+
result = to_markdown_result(document, strict: strict)
|
|
68
|
+
warn_dropped_features(result)
|
|
69
|
+
result.text
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Convert a document to Markdown with a full result record.
|
|
73
|
+
#
|
|
74
|
+
# @param document [Wordprocessingml::DocumentRoot] source document
|
|
75
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
76
|
+
# @return [PlainTextResult]
|
|
77
|
+
# @raise [FidelityError] in strict mode with dropped features
|
|
78
|
+
def to_markdown_result(document, strict: false)
|
|
79
|
+
require_gems!("coradoc/docx", "coradoc/markdown")
|
|
80
|
+
drops = detect_dropped_features(document)
|
|
81
|
+
assert_strict!(drops, :markdown) if strict
|
|
82
|
+
core = with_conversion_guards(document, drops) do
|
|
83
|
+
::Coradoc::Docx.parse_to_core(document)
|
|
84
|
+
end
|
|
85
|
+
build_result(format: :markdown, direction: :docx_to_markdown,
|
|
86
|
+
core: core, drops: drops, text:
|
|
87
|
+
::Coradoc::Markdown.serialize(core))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse Markdown into a document model.
|
|
91
|
+
#
|
|
92
|
+
# @param text [String] Markdown source
|
|
93
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
94
|
+
# @return [Wordprocessingml::DocumentRoot]
|
|
95
|
+
def from_markdown(text, strict: false)
|
|
96
|
+
result = from_markdown_result(text, strict: strict)
|
|
97
|
+
warn_dropped_features(result)
|
|
98
|
+
result.document
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parse Markdown into a document model with a full result record.
|
|
102
|
+
#
|
|
103
|
+
# @param text [String] Markdown source
|
|
104
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
105
|
+
# @return [PlainTextResult]
|
|
106
|
+
def from_markdown_result(text, strict: false)
|
|
107
|
+
require_gems!("coradoc/docx", "coradoc/markdown")
|
|
108
|
+
core = ::Coradoc::Markdown.parse_to_core(text)
|
|
109
|
+
finish_from_core(format: :markdown,
|
|
110
|
+
direction: :markdown_to_docx,
|
|
111
|
+
core: core, strict: strict)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Convert a document to AsciiDoc.
|
|
115
|
+
#
|
|
116
|
+
# @param document [Wordprocessingml::DocumentRoot] source document
|
|
117
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
118
|
+
# @return [String] the AsciiDoc text
|
|
119
|
+
# @raise [FidelityError] in strict mode with dropped features
|
|
120
|
+
def to_asciidoc(document, strict: false)
|
|
121
|
+
result = to_asciidoc_result(document, strict: strict)
|
|
122
|
+
warn_dropped_features(result)
|
|
123
|
+
result.text
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Convert a document to AsciiDoc with a full result record.
|
|
127
|
+
#
|
|
128
|
+
# @param document [Wordprocessingml::DocumentRoot] source document
|
|
129
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
130
|
+
# @return [PlainTextResult]
|
|
131
|
+
# @raise [FidelityError] in strict mode with dropped features
|
|
132
|
+
def to_asciidoc_result(document, strict: false)
|
|
133
|
+
require_gems!("coradoc/docx", "coradoc/asciidoc")
|
|
134
|
+
drops = detect_dropped_features(document)
|
|
135
|
+
assert_strict!(drops, :asciidoc) if strict
|
|
136
|
+
core = with_conversion_guards(document, drops) do
|
|
137
|
+
::Coradoc::Docx.parse_to_core(document)
|
|
138
|
+
end
|
|
139
|
+
build_result(format: :asciidoc, direction: :docx_to_asciidoc,
|
|
140
|
+
core: core, drops: drops, text:
|
|
141
|
+
::Coradoc::AsciiDoc.serialize(core))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse AsciiDoc into a document model.
|
|
145
|
+
#
|
|
146
|
+
# @param text [String] AsciiDoc source
|
|
147
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
148
|
+
# @return [Wordprocessingml::DocumentRoot]
|
|
149
|
+
def from_asciidoc(text, strict: false)
|
|
150
|
+
result = from_asciidoc_result(text, strict: strict)
|
|
151
|
+
warn_dropped_features(result)
|
|
152
|
+
result.document
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Parse AsciiDoc into a document model with a full result record.
|
|
156
|
+
#
|
|
157
|
+
# @param text [String] AsciiDoc source
|
|
158
|
+
# @param strict [Boolean] raise when features would be dropped
|
|
159
|
+
# @return [PlainTextResult]
|
|
160
|
+
def from_asciidoc_result(text, strict: false)
|
|
161
|
+
require_gems!("coradoc/docx", "coradoc/asciidoc")
|
|
162
|
+
core = ::Coradoc::AsciiDoc.parse_to_core(text)
|
|
163
|
+
finish_from_core(format: :asciidoc,
|
|
164
|
+
direction: :asciidoc_to_docx,
|
|
165
|
+
core: core, strict: strict)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Lazily load the coradoc gems; raise a DependencyError with an
|
|
171
|
+
# install hint when any is missing.
|
|
172
|
+
def require_gems!(*libs)
|
|
173
|
+
libs.each { |lib| require lib }
|
|
174
|
+
rescue LoadError
|
|
175
|
+
raise DependencyError.new(GEMS_HINT, "plain-text conversion")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Detect features present in the source document that the
|
|
179
|
+
# plain-text target drops or degrades. Order is presentation
|
|
180
|
+
# order; each check maps to a documented fidelity boundary.
|
|
181
|
+
def detect_dropped_features(document)
|
|
182
|
+
features = []
|
|
183
|
+
features << "headers/footers" unless document.header_footer_parts.empty?
|
|
184
|
+
unless document.body&.section_properties.nil?
|
|
185
|
+
features << "section properties"
|
|
186
|
+
end
|
|
187
|
+
features << "comments" if comments_present?(document)
|
|
188
|
+
if any_paragraph?(document) { |p| tracked?(p) }
|
|
189
|
+
features << "tracked changes"
|
|
190
|
+
end
|
|
191
|
+
if any_paragraph?(document) { |p| sdt?(p) } ||
|
|
192
|
+
Array(document.body&.structured_document_tags).any?
|
|
193
|
+
features << "structured document tags (unwrapped)"
|
|
194
|
+
end
|
|
195
|
+
features << "math (OMML)" if any_paragraph?(document) { |p| math?(p) }
|
|
196
|
+
features << "images" if any_paragraph?(document) { |p| drawing?(p) }
|
|
197
|
+
features << "footnotes" if user_footnotes?(document)
|
|
198
|
+
features
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def assert_strict!(drops, format)
|
|
202
|
+
return if drops.empty?
|
|
203
|
+
|
|
204
|
+
raise FidelityError.new(format, drops)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Temporarily set aside content that would break or pollute the
|
|
208
|
+
# conversion (footnotes crash coradoc-docx 0.1.4) and rewrite
|
|
209
|
+
# hyperlink rIds to their target URLs so the writers emit real
|
|
210
|
+
# links; every mutation is restored after the conversion.
|
|
211
|
+
def with_conversion_guards(document, drops)
|
|
212
|
+
stash = {}
|
|
213
|
+
if drops.include?("footnotes")
|
|
214
|
+
stash[:footnotes] = document.footnotes
|
|
215
|
+
document.footnotes = nil
|
|
216
|
+
end
|
|
217
|
+
rewrites = resolve_link_targets(document)
|
|
218
|
+
begin
|
|
219
|
+
yield
|
|
220
|
+
ensure
|
|
221
|
+
restore_link_targets(rewrites)
|
|
222
|
+
document.footnotes = stash[:footnotes] if stash.key?(:footnotes)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Rewrite w:hyperlink r:id attributes to their relationship
|
|
227
|
+
# targets so coradoc's writers emit real URLs instead of "rId1".
|
|
228
|
+
# Relationships live in document.xml.rels on loaded documents
|
|
229
|
+
# and in the IdAllocator on builder-built ones; both sources are
|
|
230
|
+
# consulted. Returns the rewrite log for restoration.
|
|
231
|
+
def resolve_link_targets(document)
|
|
232
|
+
rewrites = []
|
|
233
|
+
rels = relationship_lookup(document)
|
|
234
|
+
return rewrites if rels.empty?
|
|
235
|
+
|
|
236
|
+
each_document_hyperlink(document) do |hyperlink|
|
|
237
|
+
rel = rels[hyperlink.id.to_s]
|
|
238
|
+
next unless rel && rel[:target] &&
|
|
239
|
+
rel[:target_mode] == "External"
|
|
240
|
+
|
|
241
|
+
rewrites << [hyperlink, hyperlink.id]
|
|
242
|
+
hyperlink.id = rel[:target]
|
|
243
|
+
end
|
|
244
|
+
rewrites
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# rId => { target:, target_mode: } across both relationship
|
|
248
|
+
# sources; allocator entries never override explicit rels.
|
|
249
|
+
def relationship_lookup(document)
|
|
250
|
+
lookup = {}
|
|
251
|
+
Array(document.document_rels&.relationships).each do |rel|
|
|
252
|
+
lookup[rel.id.to_s] =
|
|
253
|
+
{ target: rel.target, target_mode: rel.target_mode }
|
|
254
|
+
end
|
|
255
|
+
Array(document.allocator&.all_rels).each do |entry|
|
|
256
|
+
lookup[entry[:id].to_s] ||=
|
|
257
|
+
{ target: entry[:target], target_mode: entry[:target_mode] }
|
|
258
|
+
end
|
|
259
|
+
lookup
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def restore_link_targets(rewrites)
|
|
263
|
+
rewrites.each { |hyperlink, original| hyperlink.id = original }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def build_result(format:, direction:, core:, drops:, text: nil,
|
|
267
|
+
document: nil)
|
|
268
|
+
PlainTextResult.new(
|
|
269
|
+
format: format,
|
|
270
|
+
direction: direction,
|
|
271
|
+
block_counts: count_blocks(core),
|
|
272
|
+
dropped_features: drops,
|
|
273
|
+
text: text,
|
|
274
|
+
document: document,
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Serialize the CoreModel into a DocumentRoot and wrap both in a
|
|
279
|
+
# result (from_* direction).
|
|
280
|
+
def finish_from_core(format:, direction:, core:, strict:)
|
|
281
|
+
document = ::Coradoc::Docx.serialize(core)
|
|
282
|
+
build_result(format: format, direction: direction, core: core,
|
|
283
|
+
drops: [], document: document).tap do |result|
|
|
284
|
+
assert_strict!(result.dropped_features, format) if strict
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def warn_dropped_features(result)
|
|
289
|
+
return if result.clean?
|
|
290
|
+
|
|
291
|
+
result.dropped_features.each do |feature|
|
|
292
|
+
Uniword.logger.warn(
|
|
293
|
+
"plain-text conversion (#{result.direction}) dropped: #{feature}",
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ---- CoreModel block counting ---------------------------------
|
|
299
|
+
|
|
300
|
+
def count_blocks(core)
|
|
301
|
+
counts = Hash.new(0)
|
|
302
|
+
Array(core).each do |root|
|
|
303
|
+
walk_core(root, skip_self: true) do |node|
|
|
304
|
+
case node
|
|
305
|
+
when ::Coradoc::CoreModel::StructuralElement
|
|
306
|
+
counts[:headings] += 1 if heading?(node)
|
|
307
|
+
when ::Coradoc::CoreModel::Block
|
|
308
|
+
counts[:paragraphs] += 1
|
|
309
|
+
when ::Coradoc::CoreModel::ListBlock
|
|
310
|
+
counts[:lists] += 1
|
|
311
|
+
when ::Coradoc::CoreModel::Table
|
|
312
|
+
counts[:tables] += 1
|
|
313
|
+
when ::Coradoc::CoreModel::InlineElement
|
|
314
|
+
counts[:links] += 1 if link?(node)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
counts
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def heading?(element)
|
|
322
|
+
element.title && element.heading_level.to_i >= 1
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def link?(inline)
|
|
326
|
+
inline.resolve_format_type == "link"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def walk_core(node, skip_self: false, &block)
|
|
330
|
+
yield node unless skip_self
|
|
331
|
+
children_of(node).each { |child| walk_core(child, &block) }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def children_of(node)
|
|
335
|
+
case node
|
|
336
|
+
when ::Coradoc::CoreModel::StructuralElement
|
|
337
|
+
Array(node.children)
|
|
338
|
+
when ::Coradoc::CoreModel::Block
|
|
339
|
+
content = node.renderable_content
|
|
340
|
+
content.is_a?(Array) ? content.grep(::Coradoc::CoreModel::Base) : []
|
|
341
|
+
when ::Coradoc::CoreModel::ListBlock
|
|
342
|
+
Array(node.items)
|
|
343
|
+
when ::Coradoc::CoreModel::Table
|
|
344
|
+
Array(node.rows).flat_map { |row| Array(row.cells) }
|
|
345
|
+
else
|
|
346
|
+
[]
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# ---- DocumentRoot content traversal ---------------------------
|
|
351
|
+
|
|
352
|
+
def any_paragraph?(document, &block)
|
|
353
|
+
each_document_paragraph(document).any?(&block)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def each_document_paragraph(document, &block)
|
|
357
|
+
return enum_for(:each_document_paragraph, document) unless block
|
|
358
|
+
|
|
359
|
+
body = document.body
|
|
360
|
+
return unless body
|
|
361
|
+
|
|
362
|
+
Array(body.paragraphs).each(&block)
|
|
363
|
+
Array(body.tables).each do |table|
|
|
364
|
+
each_table_paragraph(table, &block)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def each_table_paragraph(table, &block)
|
|
369
|
+
Array(table.rows).each do |row|
|
|
370
|
+
Array(row.cells).each do |cell|
|
|
371
|
+
Array(cell.paragraphs).each(&block)
|
|
372
|
+
Array(cell.tables).each do |nested|
|
|
373
|
+
each_table_paragraph(nested, &block)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def each_document_hyperlink(document, &block)
|
|
380
|
+
each_document_paragraph(document) do |paragraph|
|
|
381
|
+
Array(paragraph.hyperlinks).each(&block)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# ---- Feature predicates ---------------------------------------
|
|
386
|
+
|
|
387
|
+
def tracked?(paragraph)
|
|
388
|
+
Array(paragraph.insertions).any? ||
|
|
389
|
+
Array(paragraph.deletions).any?
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def sdt?(paragraph)
|
|
393
|
+
Array(paragraph.sdts).any?
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def math?(paragraph)
|
|
397
|
+
Array(paragraph.o_maths).any? || Array(paragraph.o_math_paras).any?
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def drawing?(paragraph)
|
|
401
|
+
paragraph.runs.any? { |run| Array(run.drawings).any? }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def comments_present?(document)
|
|
405
|
+
# nil (no comments part) and unexpected forms count as absent;
|
|
406
|
+
# the legacy Array form is tolerated.
|
|
407
|
+
case (comments = document.comments)
|
|
408
|
+
when Uniword::CommentsPart then !comments.empty?
|
|
409
|
+
when Array then comments.any?
|
|
410
|
+
else false
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def user_footnotes?(document)
|
|
415
|
+
footnotes = document.footnotes
|
|
416
|
+
unless footnotes.is_a?(Uniword::Wordprocessingml::Footnotes)
|
|
417
|
+
return false
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
footnotes.footnote_entries.any? do |entry|
|
|
421
|
+
entry.type != "separator" && entry.type != "continuationSeparator"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
module Converter
|
|
7
|
+
# Value object describing the outcome of a plain-text conversion
|
|
8
|
+
# (Converter::PlainText).
|
|
9
|
+
#
|
|
10
|
+
# Records how many blocks of each kind were converted and which
|
|
11
|
+
# document features were dropped or degraded on the way, so callers
|
|
12
|
+
# (and the CLI --json output) can surface the fidelity boundary
|
|
13
|
+
# explicitly instead of losing content silently.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# result = converter.to_markdown_result(document)
|
|
17
|
+
# puts result.summary
|
|
18
|
+
# result.dropped?("comments") # => true
|
|
19
|
+
class PlainTextResult
|
|
20
|
+
# The target or source plain-text format (:markdown or :asciidoc)
|
|
21
|
+
attr_reader :format
|
|
22
|
+
|
|
23
|
+
# The conversion direction, e.g. :docx_to_markdown
|
|
24
|
+
attr_reader :direction
|
|
25
|
+
|
|
26
|
+
# Converted block counts (e.g. { headings: 2, paragraphs: 3 })
|
|
27
|
+
attr_reader :block_counts
|
|
28
|
+
|
|
29
|
+
# Features dropped or degraded during the conversion
|
|
30
|
+
attr_reader :dropped_features
|
|
31
|
+
|
|
32
|
+
# Converted plain text (to_* conversions; nil otherwise)
|
|
33
|
+
attr_reader :text
|
|
34
|
+
|
|
35
|
+
# Converted document model (from_* conversions; nil otherwise)
|
|
36
|
+
attr_reader :document
|
|
37
|
+
|
|
38
|
+
# Initialize a conversion result.
|
|
39
|
+
#
|
|
40
|
+
# @param format [Symbol] :markdown or :asciidoc
|
|
41
|
+
# @param direction [Symbol] e.g. :docx_to_markdown
|
|
42
|
+
# @param block_counts [Hash<Symbol, Integer>] converted block counts
|
|
43
|
+
# @param dropped_features [Array<String>] dropped/degraded features
|
|
44
|
+
# @param text [String, nil] converted plain text
|
|
45
|
+
# @param document [Wordprocessingml::DocumentRoot, nil] converted model
|
|
46
|
+
def initialize(format:, direction:, block_counts: {},
|
|
47
|
+
dropped_features: [], text: nil, document: nil)
|
|
48
|
+
@format = format
|
|
49
|
+
@direction = direction
|
|
50
|
+
@block_counts = block_counts
|
|
51
|
+
@dropped_features = dropped_features
|
|
52
|
+
@text = text
|
|
53
|
+
@document = document
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether a feature was dropped or degraded.
|
|
57
|
+
#
|
|
58
|
+
# @param feature [String] feature name as recorded in dropped_features
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def dropped?(feature)
|
|
61
|
+
dropped_features.include?(feature)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether nothing was dropped or degraded.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def clean?
|
|
68
|
+
dropped_features.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Total number of converted blocks.
|
|
72
|
+
#
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
def total_blocks
|
|
75
|
+
block_counts.values.sum
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Human-readable summary of the conversion.
|
|
79
|
+
#
|
|
80
|
+
# @return [String]
|
|
81
|
+
def summary
|
|
82
|
+
parts = block_counts.filter_map do |kind, count|
|
|
83
|
+
"#{count} #{kind}" if count.positive?
|
|
84
|
+
end
|
|
85
|
+
summary = "#{direction}: converted #{parts.join(', ')}"
|
|
86
|
+
summary += "; dropped: #{dropped_features.join(', ')}" unless clean?
|
|
87
|
+
summary
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Serialize to a JSON string.
|
|
91
|
+
#
|
|
92
|
+
# @return [String] Pretty-printed JSON
|
|
93
|
+
def to_json(*_args)
|
|
94
|
+
JSON.pretty_generate(to_h)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Convert to a plain Hash suitable for serialization.
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash]
|
|
100
|
+
def to_h
|
|
101
|
+
{
|
|
102
|
+
format: format,
|
|
103
|
+
direction: direction,
|
|
104
|
+
blocks: block_counts,
|
|
105
|
+
total_blocks: total_blocks,
|
|
106
|
+
dropped_features: dropped_features,
|
|
107
|
+
summary: summary,
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
# Format converters beyond the docx/mhtml/html paths handled by
|
|
5
|
+
# FormatConverter. Currently hosts the plain-text editing round
|
|
6
|
+
# trip (Markdown / AsciiDoc) built on the optional Coradoc gems.
|
|
7
|
+
module Converter
|
|
8
|
+
autoload :PlainText, "uniword/converter/plain_text"
|
|
9
|
+
autoload :PlainTextResult, "uniword/converter/plain_text_result"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -222,94 +222,8 @@ module Uniword
|
|
|
222
222
|
# @param new_paras [Array<Paragraph>]
|
|
223
223
|
# @return [Array<Array(Symbol, Integer, Integer)>]
|
|
224
224
|
def align_paragraphs(old_paras, new_paras)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
lcs = compute_lcs(old_texts, new_texts)
|
|
229
|
-
|
|
230
|
-
result = []
|
|
231
|
-
old_i = 0
|
|
232
|
-
new_i = 0
|
|
233
|
-
lcs_idx = 0
|
|
234
|
-
|
|
235
|
-
while old_i < old_texts.size || new_i < new_texts.size
|
|
236
|
-
old_in_lcs = lcs_idx < lcs.size &&
|
|
237
|
-
lcs[lcs_idx][0] == old_i
|
|
238
|
-
new_in_lcs = lcs_idx < lcs.size &&
|
|
239
|
-
lcs[lcs_idx][1] == new_i
|
|
240
|
-
|
|
241
|
-
if old_in_lcs && new_in_lcs
|
|
242
|
-
# LCS match at both positions -- aligned
|
|
243
|
-
old_text = old_texts[old_i]
|
|
244
|
-
new_text = new_texts[new_i]
|
|
245
|
-
action = old_text == new_text ? :kept : :modified
|
|
246
|
-
result << [action, old_i, new_i]
|
|
247
|
-
old_i += 1
|
|
248
|
-
new_i += 1
|
|
249
|
-
lcs_idx += 1
|
|
250
|
-
elsif old_in_lcs
|
|
251
|
-
# Old is part of a future LCS match, insert new as added
|
|
252
|
-
result << [:added, nil, new_i]
|
|
253
|
-
new_i += 1
|
|
254
|
-
elsif new_in_lcs
|
|
255
|
-
# New is part of a future LCS match, remove old
|
|
256
|
-
result << [:removed, old_i, nil]
|
|
257
|
-
old_i += 1
|
|
258
|
-
elsif old_i < old_texts.size && new_i < new_texts.size
|
|
259
|
-
# Neither is in LCS at this point -- treat as modified
|
|
260
|
-
result << [:modified, old_i, new_i]
|
|
261
|
-
old_i += 1
|
|
262
|
-
new_i += 1
|
|
263
|
-
elsif old_i < old_texts.size
|
|
264
|
-
result << [:removed, old_i, nil]
|
|
265
|
-
old_i += 1
|
|
266
|
-
else
|
|
267
|
-
result << [:added, nil, new_i]
|
|
268
|
-
new_i += 1
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
result
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Compute the LCS table and return matched index pairs.
|
|
276
|
-
#
|
|
277
|
-
# @param old_texts [Array<String>]
|
|
278
|
-
# @param new_texts [Array<String>]
|
|
279
|
-
# @return [Array<Array(Integer, Integer)>] Matched (old_i, new_i) pairs
|
|
280
|
-
def compute_lcs(old_texts, new_texts)
|
|
281
|
-
m = old_texts.size
|
|
282
|
-
n = new_texts.size
|
|
283
|
-
|
|
284
|
-
# Build LCS length table
|
|
285
|
-
dp = Array.new(m + 1) { Array.new(n + 1, 0) }
|
|
286
|
-
(1..m).each do |i|
|
|
287
|
-
(1..n).each do |j|
|
|
288
|
-
dp[i][j] = if old_texts[i - 1] == new_texts[j - 1]
|
|
289
|
-
dp[i - 1][j - 1] + 1
|
|
290
|
-
else
|
|
291
|
-
[dp[i - 1][j], dp[i][j - 1]].max
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# Backtrack to find matches
|
|
297
|
-
matches = []
|
|
298
|
-
i = m
|
|
299
|
-
j = n
|
|
300
|
-
while i.positive? && j.positive?
|
|
301
|
-
if old_texts[i - 1] == new_texts[j - 1]
|
|
302
|
-
matches << [i - 1, j - 1]
|
|
303
|
-
i -= 1
|
|
304
|
-
j -= 1
|
|
305
|
-
elsif dp[i - 1][j] >= dp[i][j - 1]
|
|
306
|
-
i -= 1
|
|
307
|
-
else
|
|
308
|
-
j -= 1
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
matches.reverse
|
|
225
|
+
Lcs.alignment(old_paras.map { |p| p.text.to_s },
|
|
226
|
+
new_paras.map { |p| p.text.to_s })
|
|
313
227
|
end
|
|
314
228
|
|
|
315
229
|
# Compare paragraph-level formatting between two paragraphs.
|