uniword 1.7.1 → 1.7.3

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 (58) 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 +425 -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/errors.rb +33 -0
  27. data/lib/uniword/mhtml/word_css.rb +16 -8
  28. data/lib/uniword/omml_integration.rb +62 -0
  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/version.rb +1 -1
  42. data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
  43. data/lib/uniword/wordprocessingml/document_root.rb +40 -6
  44. data/lib/uniword/wordprocessingml/drawing.rb +51 -0
  45. data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
  46. data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
  47. data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
  48. data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
  49. data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
  50. data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
  51. data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
  52. data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
  53. data/lib/uniword/wordprocessingml/style.rb +43 -40
  54. data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
  55. data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
  56. data/lib/uniword/wordprocessingml.rb +17 -4
  57. data/lib/uniword.rb +19 -0
  58. metadata +21 -6
@@ -0,0 +1,425 @@
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
+ # * footnotes (coradoc-docx 0.1.6 carries them into the core
31
+ # model; to md/adoc emits GFM [^id] + definitions)
32
+ #
33
+ # Dropped or degraded (recorded on PlainTextResult, warned about;
34
+ # +strict: true+ raises FidelityError):
35
+ # * headers/footers
36
+ # * section properties
37
+ # * comments
38
+ # * tracked changes (w:ins/w:del)
39
+ # * structured document tags (content survives, wrapper unwrapped)
40
+ # * math (OMML)
41
+ # * images (docx extractor does not carry drawings; coradoc#322)
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. Upstream flattenings that remain:
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 (coradoc#320); the same
51
+ # inline parser gap leaves from_markdown footnote REFERENCES as
52
+ # literal text (definitions do convert, coradoc#320).
53
+ #
54
+ # @example Document to Markdown
55
+ # md = Uniword::Converter::PlainText.new.to_markdown(document)
56
+ #
57
+ # @example Markdown to a DocumentRoot
58
+ # doc = Uniword::Converter::PlainText.new.from_markdown(md_text)
59
+ class PlainText
60
+ GEMS_HINT = "coradoc-docx coradoc-markdown coradoc-adoc"
61
+
62
+ # Convert a document to Markdown.
63
+ #
64
+ # @param document [Wordprocessingml::DocumentRoot] source document
65
+ # @param strict [Boolean] raise when features would be dropped
66
+ # @return [String] the Markdown text
67
+ # @raise [FidelityError] in strict mode with dropped features
68
+ def to_markdown(document, strict: false)
69
+ result = to_markdown_result(document, strict: strict)
70
+ warn_dropped_features(result)
71
+ result.text
72
+ end
73
+
74
+ # Convert a document to Markdown with a full result record.
75
+ #
76
+ # @param document [Wordprocessingml::DocumentRoot] source document
77
+ # @param strict [Boolean] raise when features would be dropped
78
+ # @return [PlainTextResult]
79
+ # @raise [FidelityError] in strict mode with dropped features
80
+ def to_markdown_result(document, strict: false)
81
+ require_gems!("coradoc/docx", "coradoc/markdown")
82
+ drops = detect_dropped_features(document)
83
+ assert_strict!(drops, :markdown) if strict
84
+ core = with_conversion_guards(document, drops) do
85
+ ::Coradoc::Docx.parse_to_core(document)
86
+ end
87
+ build_result(format: :markdown, direction: :docx_to_markdown,
88
+ core: core, drops: drops, text:
89
+ ::Coradoc::Markdown.serialize(core))
90
+ end
91
+
92
+ # Parse Markdown into a document model.
93
+ #
94
+ # @param text [String] Markdown source
95
+ # @param strict [Boolean] raise when features would be dropped
96
+ # @return [Wordprocessingml::DocumentRoot]
97
+ def from_markdown(text, strict: false)
98
+ result = from_markdown_result(text, strict: strict)
99
+ warn_dropped_features(result)
100
+ result.document
101
+ end
102
+
103
+ # Parse Markdown into a document model with a full result record.
104
+ #
105
+ # @param text [String] Markdown source
106
+ # @param strict [Boolean] raise when features would be dropped
107
+ # @return [PlainTextResult]
108
+ def from_markdown_result(text, strict: false)
109
+ require_gems!("coradoc/docx", "coradoc/markdown")
110
+ core = ::Coradoc::Markdown.parse_to_core(text)
111
+ finish_from_core(format: :markdown,
112
+ direction: :markdown_to_docx,
113
+ core: core, strict: strict)
114
+ end
115
+
116
+ # Convert a document to AsciiDoc.
117
+ #
118
+ # @param document [Wordprocessingml::DocumentRoot] source document
119
+ # @param strict [Boolean] raise when features would be dropped
120
+ # @return [String] the AsciiDoc text
121
+ # @raise [FidelityError] in strict mode with dropped features
122
+ def to_asciidoc(document, strict: false)
123
+ result = to_asciidoc_result(document, strict: strict)
124
+ warn_dropped_features(result)
125
+ result.text
126
+ end
127
+
128
+ # Convert a document to AsciiDoc with a full result record.
129
+ #
130
+ # @param document [Wordprocessingml::DocumentRoot] source document
131
+ # @param strict [Boolean] raise when features would be dropped
132
+ # @return [PlainTextResult]
133
+ # @raise [FidelityError] in strict mode with dropped features
134
+ def to_asciidoc_result(document, strict: false)
135
+ require_gems!("coradoc/docx", "coradoc/asciidoc")
136
+ drops = detect_dropped_features(document)
137
+ assert_strict!(drops, :asciidoc) if strict
138
+ core = with_conversion_guards(document, drops) do
139
+ ::Coradoc::Docx.parse_to_core(document)
140
+ end
141
+ adoc_model = ::Coradoc::AsciiDoc::Transform::FromCoreModel
142
+ .transform(core)
143
+ normalize_emphasis_constraint!(adoc_model)
144
+ build_result(format: :asciidoc, direction: :docx_to_asciidoc,
145
+ core: core, drops: drops, text: adoc_model.to_adoc)
146
+ end
147
+
148
+ # CoreModel carries no emphasis constraint, and the framework's
149
+ # flank guess can downgrade boundary-flanked runs to the
150
+ # constrained spelling, which does not reparse mid-word. The
151
+ # DOCX contract emits the unconstrained forms (`**bold**`,
152
+ # `__italic__`) — they parse unconditionally — so the
153
+ # converter restores the emphasis models' own unconstrained
154
+ # default after the transform.
155
+ def normalize_emphasis_constraint!(model)
156
+ model.visit do |element, _phase|
157
+ element.unconstrained = true if element.is_a?(
158
+ ::Coradoc::AsciiDoc::Model::Inline::Bold,
159
+ ) || element.is_a?(::Coradoc::AsciiDoc::Model::Inline::Italic)
160
+ element
161
+ end
162
+ model
163
+ end
164
+
165
+ # Parse AsciiDoc into a document model.
166
+ #
167
+ # @param text [String] AsciiDoc source
168
+ # @param strict [Boolean] raise when features would be dropped
169
+ # @return [Wordprocessingml::DocumentRoot]
170
+ def from_asciidoc(text, strict: false)
171
+ result = from_asciidoc_result(text, strict: strict)
172
+ warn_dropped_features(result)
173
+ result.document
174
+ end
175
+
176
+ # Parse AsciiDoc into a document model with a full result record.
177
+ #
178
+ # @param text [String] AsciiDoc source
179
+ # @param strict [Boolean] raise when features would be dropped
180
+ # @return [PlainTextResult]
181
+ def from_asciidoc_result(text, strict: false)
182
+ require_gems!("coradoc/docx", "coradoc/asciidoc")
183
+ core = ::Coradoc::AsciiDoc.parse_to_core(text)
184
+ finish_from_core(format: :asciidoc,
185
+ direction: :asciidoc_to_docx,
186
+ core: core, strict: strict)
187
+ end
188
+
189
+ private
190
+
191
+ # Lazily load the coradoc gems; raise a DependencyError with an
192
+ # install hint when any is missing.
193
+ def require_gems!(*libs)
194
+ libs.each { |lib| require lib }
195
+ rescue LoadError
196
+ raise DependencyError.new(GEMS_HINT, "plain-text conversion")
197
+ end
198
+
199
+ # Detect features present in the source document that the
200
+ # plain-text target drops or degrades. Order is presentation
201
+ # order; each check maps to a documented fidelity boundary.
202
+ def detect_dropped_features(document)
203
+ features = []
204
+ features << "headers/footers" unless document.header_footer_parts.empty?
205
+ unless document.body&.section_properties.nil?
206
+ features << "section properties"
207
+ end
208
+ features << "comments" if comments_present?(document)
209
+ if any_paragraph?(document) { |p| tracked?(p) }
210
+ features << "tracked changes"
211
+ end
212
+ if any_paragraph?(document) { |p| sdt?(p) } ||
213
+ Array(document.body&.structured_document_tags).any?
214
+ features << "structured document tags (unwrapped)"
215
+ end
216
+ features << "math (OMML)" if any_paragraph?(document) { |p| math?(p) }
217
+ features << "images" if any_paragraph?(document) { |p| drawing?(p) }
218
+ features
219
+ end
220
+
221
+ def assert_strict!(drops, format)
222
+ return if drops.empty?
223
+
224
+ raise FidelityError.new(format, drops)
225
+ end
226
+
227
+ # Rewrite hyperlink rIds to their target URLs so coradoc's
228
+ # writers emit real links; every mutation is restored after the
229
+ # conversion.
230
+ def with_conversion_guards(document, _drops)
231
+ rewrites = resolve_link_targets(document)
232
+ yield.tap { restore_link_targets(rewrites) }
233
+ end
234
+
235
+ # Rewrite w:hyperlink r:id attributes to their relationship
236
+ # targets so coradoc's writers emit real URLs instead of "rId1".
237
+ # Relationships live in document.xml.rels on loaded documents
238
+ # and in the IdAllocator on builder-built ones; both sources are
239
+ # consulted. Returns the rewrite log for restoration.
240
+ def resolve_link_targets(document)
241
+ rewrites = []
242
+ rels = relationship_lookup(document)
243
+ return rewrites if rels.empty?
244
+
245
+ each_document_hyperlink(document) do |hyperlink|
246
+ rel = rels[hyperlink.id.to_s]
247
+ next unless rel && rel[:target] &&
248
+ rel[:target_mode] == "External"
249
+
250
+ rewrites << [hyperlink, hyperlink.id]
251
+ hyperlink.id = rel[:target]
252
+ end
253
+ rewrites
254
+ end
255
+
256
+ # rId => { target:, target_mode: } across both relationship
257
+ # sources; allocator entries never override explicit rels.
258
+ def relationship_lookup(document)
259
+ lookup = {}
260
+ Array(document.document_rels&.relationships).each do |rel|
261
+ lookup[rel.id.to_s] =
262
+ { target: rel.target, target_mode: rel.target_mode }
263
+ end
264
+ Array(document.allocator&.all_rels).each do |entry|
265
+ lookup[entry[:id].to_s] ||=
266
+ { target: entry[:target], target_mode: entry[:target_mode] }
267
+ end
268
+ lookup
269
+ end
270
+
271
+ def restore_link_targets(rewrites)
272
+ rewrites.each { |hyperlink, original| hyperlink.id = original }
273
+ end
274
+
275
+ def build_result(format:, direction:, core:, drops:, text: nil,
276
+ document: nil)
277
+ PlainTextResult.new(
278
+ format: format,
279
+ direction: direction,
280
+ block_counts: count_blocks(core),
281
+ dropped_features: drops,
282
+ text: text,
283
+ document: document,
284
+ )
285
+ end
286
+
287
+ # Serialize the CoreModel into a DocumentRoot and wrap both in a
288
+ # result (from_* direction).
289
+ def finish_from_core(format:, direction:, core:, strict:)
290
+ document = ::Coradoc::Docx.serialize(core)
291
+ build_result(format: format, direction: direction, core: core,
292
+ drops: [], document: document).tap do |result|
293
+ assert_strict!(result.dropped_features, format) if strict
294
+ end
295
+ end
296
+
297
+ def warn_dropped_features(result)
298
+ return if result.clean?
299
+
300
+ result.dropped_features.each do |feature|
301
+ Uniword.logger.warn(
302
+ "plain-text conversion (#{result.direction}) dropped: #{feature}",
303
+ )
304
+ end
305
+ end
306
+
307
+ # ---- CoreModel block counting ---------------------------------
308
+
309
+ def count_blocks(core)
310
+ counts = Hash.new(0)
311
+ Array(core).each do |root|
312
+ walk_core(root, skip_self: true) do |node|
313
+ case node
314
+ when ::Coradoc::CoreModel::StructuralElement
315
+ counts[:headings] += 1 if heading?(node)
316
+ when ::Coradoc::CoreModel::Block
317
+ counts[:paragraphs] += 1
318
+ when ::Coradoc::CoreModel::ListBlock
319
+ counts[:lists] += 1
320
+ when ::Coradoc::CoreModel::Table
321
+ counts[:tables] += 1
322
+ when ::Coradoc::CoreModel::InlineElement
323
+ counts[:links] += 1 if link?(node)
324
+ end
325
+ end
326
+ end
327
+ counts
328
+ end
329
+
330
+ def heading?(element)
331
+ element.title && element.heading_level.to_i >= 1
332
+ end
333
+
334
+ def link?(inline)
335
+ inline.resolve_format_type == "link"
336
+ end
337
+
338
+ def walk_core(node, skip_self: false, &block)
339
+ yield node unless skip_self
340
+ children_of(node).each { |child| walk_core(child, &block) }
341
+ end
342
+
343
+ def children_of(node)
344
+ case node
345
+ when ::Coradoc::CoreModel::StructuralElement
346
+ Array(node.children)
347
+ when ::Coradoc::CoreModel::Block
348
+ content = node.renderable_content
349
+ content.is_a?(Array) ? content.grep(::Coradoc::CoreModel::Base) : []
350
+ when ::Coradoc::CoreModel::ListBlock
351
+ Array(node.items)
352
+ when ::Coradoc::CoreModel::Table
353
+ Array(node.rows).flat_map { |row| Array(row.cells) }
354
+ else
355
+ []
356
+ end
357
+ end
358
+
359
+ # ---- DocumentRoot content traversal ---------------------------
360
+
361
+ def any_paragraph?(document, &block)
362
+ each_document_paragraph(document).any?(&block)
363
+ end
364
+
365
+ def each_document_paragraph(document, &block)
366
+ return enum_for(:each_document_paragraph, document) unless block
367
+
368
+ body = document.body
369
+ return unless body
370
+
371
+ Array(body.paragraphs).each(&block)
372
+ Array(body.tables).each do |table|
373
+ each_table_paragraph(table, &block)
374
+ end
375
+ end
376
+
377
+ def each_table_paragraph(table, &block)
378
+ Array(table.rows).each do |row|
379
+ Array(row.cells).each do |cell|
380
+ Array(cell.paragraphs).each(&block)
381
+ Array(cell.tables).each do |nested|
382
+ each_table_paragraph(nested, &block)
383
+ end
384
+ end
385
+ end
386
+ end
387
+
388
+ def each_document_hyperlink(document, &block)
389
+ each_document_paragraph(document) do |paragraph|
390
+ Array(paragraph.hyperlinks).each(&block)
391
+ end
392
+ end
393
+
394
+ # ---- Feature predicates ---------------------------------------
395
+
396
+ def tracked?(paragraph)
397
+ Array(paragraph.insertions).any? ||
398
+ Array(paragraph.deletions).any?
399
+ end
400
+
401
+ def sdt?(paragraph)
402
+ Array(paragraph.sdts).any?
403
+ end
404
+
405
+ def math?(paragraph)
406
+ Array(paragraph.o_maths).any? || Array(paragraph.o_math_paras).any?
407
+ end
408
+
409
+ def drawing?(paragraph)
410
+ paragraph.runs.any? { |run| Array(run.drawings).any? }
411
+ end
412
+
413
+ def comments_present?(document)
414
+ # nil (no comments part) and unexpected forms count as absent;
415
+ # the legacy Array form is tolerated.
416
+ case (comments = document.comments)
417
+ when Uniword::CommentsPart then !comments.empty?
418
+ when Array then comments.any?
419
+ else false
420
+ end
421
+ end
422
+
423
+ end
424
+ end
425
+ 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.