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