uniword 1.7.0 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/lib/uniword/accessibility/rules/image_alt_text_rule.rb +36 -13
  4. data/lib/uniword/builder/image_builder.rb +2 -0
  5. data/lib/uniword/builder/paragraph_builder.rb +5 -6
  6. data/lib/uniword/builder/sdt_builder.rb +12 -4
  7. data/lib/uniword/builder/style_builder.rb +5 -5
  8. data/lib/uniword/builder/table_builder.rb +2 -2
  9. data/lib/uniword/builder/table_cell_builder.rb +3 -3
  10. data/lib/uniword/builder/table_row_builder.rb +2 -1
  11. data/lib/uniword/cli/comments_cli.rb +173 -0
  12. data/lib/uniword/cli/diff_cli.rb +60 -0
  13. data/lib/uniword/cli/main.rb +104 -0
  14. data/lib/uniword/cli/plain_text_command.rb +101 -0
  15. data/lib/uniword/cli/review_cli.rb +173 -34
  16. data/lib/uniword/cli/revisions_cli.rb +88 -0
  17. data/lib/uniword/cli/template_cli.rb +81 -0
  18. data/lib/uniword/converter/plain_text.rb +426 -0
  19. data/lib/uniword/converter/plain_text_result.rb +112 -0
  20. data/lib/uniword/converter.rb +11 -0
  21. data/lib/uniword/diff/document_differ.rb +2 -88
  22. data/lib/uniword/diff/lcs.rb +101 -0
  23. data/lib/uniword/diff/redline_result.rb +94 -0
  24. data/lib/uniword/diff/redliner.rb +319 -0
  25. data/lib/uniword/diff.rb +3 -0
  26. data/lib/uniword/docx/package_integrity_checker.rb +1 -1
  27. data/lib/uniword/errors.rb +33 -0
  28. data/lib/uniword/mhtml/word_css.rb +16 -8
  29. data/lib/uniword/ooxml/types/ooxml_boolean.rb +19 -7
  30. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +4 -8
  31. data/lib/uniword/properties/boolean_element_factory.rb +48 -3
  32. data/lib/uniword/properties/boolean_formatting.rb +4 -0
  33. data/lib/uniword/properties/outline.rb +4 -13
  34. data/lib/uniword/quality/rules/image_alt_text_rule.rb +22 -43
  35. data/lib/uniword/review/review_manager.rb +128 -1
  36. data/lib/uniword/template/fill_result.rb +121 -0
  37. data/lib/uniword/template/filler.rb +292 -0
  38. data/lib/uniword/template.rb +2 -0
  39. data/lib/uniword/transformation/mhtml_element_renderer.rb +9 -3
  40. data/lib/uniword/transformation/ooxml_to_html_converter.rb +8 -3
  41. data/lib/uniword/validation/opc_validator.rb +3 -3
  42. data/lib/uniword/validation/validators/xml_schema_validator.rb +1 -1
  43. data/lib/uniword/version.rb +1 -1
  44. data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
  45. data/lib/uniword/wordprocessingml/document_root.rb +40 -6
  46. data/lib/uniword/wordprocessingml/drawing.rb +51 -0
  47. data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
  48. data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
  49. data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
  50. data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
  51. data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
  52. data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
  53. data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
  54. data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
  55. data/lib/uniword/wordprocessingml/style.rb +43 -40
  56. data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
  57. data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
  58. data/lib/uniword/wordprocessingml.rb +17 -4
  59. data/lib/uniword.rb +9 -0
  60. 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
- old_texts = old_paras.map { |p| p.text.to_s }
226
- new_texts = new_paras.map { |p| p.text.to_s }
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.