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