uniword 1.6.0 → 1.7.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/exe/uniword +9 -0
  3. data/lib/uniword/accessibility/accessibility_report.rb +8 -0
  4. data/lib/uniword/builder/chart_builder.rb +129 -134
  5. data/lib/uniword/cli/completions.rb +99 -0
  6. data/lib/uniword/cli/generate_cli.rb +5 -0
  7. data/lib/uniword/cli/help_switch.rb +45 -0
  8. data/lib/uniword/cli/helpers.rb +1 -0
  9. data/lib/uniword/cli/main.rb +118 -21
  10. data/lib/uniword/cli/no_color.rb +21 -0
  11. data/lib/uniword/diff/package_differ.rb +16 -15
  12. data/lib/uniword/document_factory.rb +2 -2
  13. data/lib/uniword/document_writer.rb +32 -10
  14. data/lib/uniword/docx/package_integrity_checker.rb +10 -11
  15. data/lib/uniword/docx/reconciler/body.rb +1 -0
  16. data/lib/uniword/docx/reconciler/helpers.rb +27 -12
  17. data/lib/uniword/docx/reconciler/parts.rb +5 -0
  18. data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
  19. data/lib/uniword/find_replace/scope.rb +7 -4
  20. data/lib/uniword/infrastructure/mime_packager.rb +3 -10
  21. data/lib/uniword/infrastructure/mime_parser.rb +23 -1
  22. data/lib/uniword/math/plurimath_adapter.rb +28 -24
  23. data/lib/uniword/math_equation.rb +1 -1
  24. data/lib/uniword/mhtml/html_part.rb +19 -13
  25. data/lib/uniword/mhtml/math_converter.rb +18 -12
  26. data/lib/uniword/mhtml/mime_part.rb +5 -16
  27. data/lib/uniword/mhtml/xml_part.rb +3 -3
  28. data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
  29. data/lib/uniword/ooxml/element_order.rb +21 -0
  30. data/lib/uniword/ooxml/schema/element_serializer.rb +27 -35
  31. data/lib/uniword/ooxml/schema_library.rb +2 -2
  32. data/lib/uniword/ooxml/types/characteristics_val.rb +16 -0
  33. data/lib/uniword/ooxml/types/schema_library_val.rb +17 -0
  34. data/lib/uniword/ooxml/types/variant_types.rb +10 -2
  35. data/lib/uniword/ooxml/types/vt_attr.rb +19 -0
  36. data/lib/uniword/ooxml/types/wml_int.rb +17 -0
  37. data/lib/uniword/ooxml/types/wml_val.rb +19 -0
  38. data/lib/uniword/ooxml/types.rb +12 -0
  39. data/lib/uniword/properties/alignment.rb +4 -0
  40. data/lib/uniword/properties/boolean_element_factory.rb +1 -1
  41. data/lib/uniword/properties/font_size.rb +3 -0
  42. data/lib/uniword/review/review_manager.rb +34 -5
  43. data/lib/uniword/review/revision_resolver.rb +190 -0
  44. data/lib/uniword/review.rb +1 -0
  45. data/lib/uniword/revision.rb +3 -3
  46. data/lib/uniword/serialization/ooxml_serializer.rb +3 -3
  47. data/lib/uniword/streaming_parser.rb +22 -20
  48. data/lib/uniword/theme/theme_xml_parser.rb +4 -6
  49. data/lib/uniword/transformation/html_element_builder.rb +62 -48
  50. data/lib/uniword/transformation/html_formatting_mapper.rb +3 -3
  51. data/lib/uniword/transformation/html_to_ooxml_converter.rb +11 -11
  52. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -2
  53. data/lib/uniword/validation/opc_validator.rb +6 -7
  54. data/lib/uniword/validation/rules/document_context.rb +3 -3
  55. data/lib/uniword/validation/rules/images_rule.rb +1 -1
  56. data/lib/uniword/validation/rules/theme_rule.rb +2 -2
  57. data/lib/uniword/validation/schema_registry.rb +10 -17
  58. data/lib/uniword/validation/validators/xml_schema_validator.rb +75 -26
  59. data/lib/uniword/validation/verify_orchestrator.rb +1 -1
  60. data/lib/uniword/version.rb +1 -1
  61. data/lib/uniword/wordprocessingml/deletion.rb +41 -0
  62. data/lib/uniword/wordprocessingml/insertion.rb +41 -0
  63. data/lib/uniword/wordprocessingml/page_margins.rb +7 -7
  64. data/lib/uniword/wordprocessingml/paragraph.rb +30 -4
  65. data/lib/uniword/wordprocessingml/settings.rb +3 -4
  66. data/lib/uniword/wordprocessingml/style_cleanup.rb +5 -14
  67. data/lib/uniword/wordprocessingml/table_borders.rb +1 -1
  68. data/lib/uniword/wordprocessingml/table_cell_borders.rb +1 -1
  69. data/lib/uniword/wordprocessingml/text.rb +0 -1
  70. data/lib/uniword/wordprocessingml.rb +5 -0
  71. data/lib/uniword/wp_drawing/position_h.rb +1 -1
  72. data/lib/uniword/wp_drawing/position_v.rb +1 -1
  73. data/lib/uniword/wp_drawing/size_rel_h.rb +1 -1
  74. data/lib/uniword/wp_drawing/size_rel_v.rb +1 -1
  75. data/lib/uniword.rb +9 -7
  76. metadata +19 -15
  77. data/lib/uniword/infrastructure/xml_namespace_normalizer.rb +0 -107
@@ -2,15 +2,21 @@
2
2
 
3
3
  module Uniword
4
4
  module FindReplace
5
- # Walks an enumerable of containers (Body, TableCell, SDT, ...)
6
- # and yields every Paragraph reachable. Used by all scopes that
7
- # traverse paragraph-bearing parts (body, headers, footers,
8
- # footnotes, endnotes, comments).
5
+ # Walks an enumerable of containers (Body, TableCell, Header,
6
+ # Footer, Footnote, Endnote, Comment) and yields every Paragraph
7
+ # reachable. Used by all scopes that traverse paragraph-bearing
8
+ # parts (body, headers, footers, footnotes, endnotes, comments).
9
+ #
10
+ # Container classes map different subsets of OOXML block-level
11
+ # content, so dispatch is explicit per class (mirrors
12
+ # Docx::DocumentStatistics):
13
+ # - Body maps paragraphs, tables, and structured document tags
14
+ # - TableCell, Header, Footer map paragraphs and tables
15
+ # - Footnote, Endnote, Comment map paragraphs only
9
16
  module ParagraphWalker
10
17
  module_function
11
18
 
12
- # @param containers [Enumerable<#paragraphs, #tables,
13
- # #structured_document_tags>] containers to walk
19
+ # @param containers [Enumerable<#paragraphs>] containers to walk
14
20
  # @yieldparam paragraph [Wordprocessingml::Paragraph]
15
21
  # @return [void]
16
22
  def each_paragraph(containers, &block)
@@ -23,13 +29,37 @@ module Uniword
23
29
  # @yieldparam paragraph [Wordprocessingml::Paragraph]
24
30
  # @return [void]
25
31
  def walk_container(container, &block)
26
- container.paragraphs&.each(&block)
27
- walk_tables(container.tables, &block) if container.tables
28
- if container.structured_document_tags
29
- walk_sdts(container.structured_document_tags, &block)
32
+ case container
33
+ when Wordprocessingml::Body
34
+ walk_body(container, &block)
35
+ else
36
+ walk_non_body(container, &block)
30
37
  end
31
38
  end
32
39
 
40
+ # Body is the only container class that maps structured
41
+ # document tags.
42
+ def walk_body(body, &block)
43
+ body.paragraphs&.each(&block)
44
+ walk_tables(body.tables, &block)
45
+ walk_sdts(body.structured_document_tags, &block)
46
+ end
47
+
48
+ # TableCell, Header, and Footer map tables; Footnote, Endnote,
49
+ # and Comment map paragraphs only.
50
+ def walk_non_body(container, &block)
51
+ container.paragraphs&.each(&block)
52
+ return unless table_bearing?(container)
53
+
54
+ walk_tables(container.tables, &block)
55
+ end
56
+
57
+ def table_bearing?(container)
58
+ container.is_a?(Wordprocessingml::TableCell) ||
59
+ container.is_a?(Wordprocessingml::Header) ||
60
+ container.is_a?(Wordprocessingml::Footer)
61
+ end
62
+
33
63
  def walk_tables(tables, &block)
34
64
  tables.each do |table|
35
65
  walk_table_rows(table, &block) if table.rows
@@ -48,13 +78,20 @@ module Uniword
48
78
  end
49
79
  end
50
80
 
81
+ # SDT content (sdtContent) is itself a block-level container
82
+ # with paragraphs, tables, and nested SDTs.
51
83
  def walk_sdts(sdts, &block)
52
84
  sdts.each do |sdt|
53
- sdt.paragraphs&.each(&block)
85
+ next unless sdt.content
86
+
87
+ sdt.content.paragraphs&.each(&block)
88
+ walk_tables(sdt.content.tables, &block)
89
+ walk_sdts(sdt.content.sdts, &block)
54
90
  end
55
91
  end
56
92
 
57
- private_class_method :walk_container, :walk_tables, :walk_table_rows,
93
+ private_class_method :walk_container, :walk_body, :walk_non_body,
94
+ :table_bearing?, :walk_tables, :walk_table_rows,
58
95
  :walk_table_cells, :walk_sdts
59
96
  end
60
97
  end
@@ -59,16 +59,19 @@ module Uniword
59
59
 
60
60
  # Walk every paragraph in `containers` and yield each run's
61
61
  # text elements. Shared by body / headers / footers / footnotes
62
- # / endnotes / comments scopes.
62
+ # / endnotes / comments scopes. Runs inside tracked insertions
63
+ # are live content and included; runs inside deletions are
64
+ # deleted content and left untouched.
63
65
  #
64
- # @param containers [Enumerable<#paragraphs, #tables,
65
- # #structured_document_tags>]
66
+ # @param containers [Enumerable<#paragraphs>]
66
67
  # @yieldparam text_element [Wordprocessingml::Text]
67
68
  # @yieldparam accessor [TextAccessor]
68
69
  # @return [void]
69
70
  def each_text_in_containers(containers)
70
71
  ParagraphWalker.each_paragraph(containers) do |paragraph|
71
- paragraph.runs&.each do |run|
72
+ runs = paragraph.runs +
73
+ paragraph.insertions.flat_map(&:runs)
74
+ runs.each do |run|
72
75
  each_text_in_run(run) { |*a| yield(*a) }
73
76
  end
74
77
  end
@@ -92,19 +92,12 @@ module Uniword
92
92
  # Empty line separating headers from body
93
93
  lines << ""
94
94
 
95
- # Body — encode if needed
95
+ # Body — encode if needed (part content is always decoded)
96
96
  encoding = part.content_transfer_encoding
97
97
  if encoding == "quoted-printable"
98
- # Use decoded_content to avoid double-encoding on round-trip
99
- body = quoted_printable_encode(part.decoded_content)
98
+ body = quoted_printable_encode(part.raw_content)
100
99
  elsif encoding == "base64"
101
- begin
102
- # Decoded content (round-trip: raw_content is already base64)
103
- body = Base64.strict_encode64(part.decoded_content)
104
- rescue ArgumentError
105
- # New part: raw_content is raw binary data
106
- body = Base64.strict_encode64(part.raw_content)
107
- end
100
+ body = Base64.strict_encode64(part.raw_content)
108
101
  body = body.gsub(/.{1,76}/, "\\0\r\n").rstrip
109
102
  else
110
103
  body = part.raw_content
@@ -96,11 +96,33 @@ module Uniword
96
96
  mime_part.content_location = content_location
97
97
  mime_part.content_transfer_encoding = encoding
98
98
  mime_part.content_id = content_id&.gsub(/[<>]/, "")
99
- mime_part.raw_content = body.rstrip
99
+ mime_part.decoded_content = decode_body(body.rstrip, encoding)
100
100
 
101
101
  mime_part
102
102
  end
103
103
 
104
+ # Decode a wire body into its content, per the declared
105
+ # Content-Transfer-Encoding. Stored content is always decoded.
106
+ # Tolerant of real-world corruption (Word truncates some base64
107
+ # tails), so base64 uses the non-strict decoder.
108
+ def decode_body(body, encoding)
109
+ case encoding&.downcase
110
+ when "base64"
111
+ Base64.decode64(body)
112
+ when "quoted-printable"
113
+ # Decode in BINARY so hex escapes map 1:1 to bytes; single-pass
114
+ # gsub, so an already-decoded byte is never re-decoded.
115
+ body.force_encoding("BINARY")
116
+ .gsub(/=\r?\n/, "")
117
+ .gsub(/=([0-9A-Fa-f]{2})/) do
118
+ [::Regexp.last_match(1).hex].pack("C")
119
+ end
120
+ .force_encoding("UTF-8")
121
+ else
122
+ body&.force_encoding("UTF-8") || body
123
+ end
124
+ end
125
+
104
126
  # Create a typed MimePart based on content type.
105
127
  def create_typed_part(content_type)
106
128
  return nil unless content_type
@@ -34,7 +34,7 @@ module Uniword
34
34
 
35
35
  # Create a MathEquation from OMML XML
36
36
  #
37
- # @param omml_xml [String, Nokogiri::XML::Node] OMML XML content
37
+ # @param omml_xml [String, Moxml::Node] OMML XML content
38
38
  # @return [MathEquation] New equation instance with parsed formula
39
39
  #
40
40
  # @example Parse OMML
@@ -94,7 +94,7 @@ module Uniword
94
94
 
95
95
  # Parse OMML to Plurimath formula
96
96
  #
97
- # @param node [Nokogiri::XML::Node] OMML node
97
+ # @param node [Moxml::Node] OMML node
98
98
  # @return [Plurimath::Math::Formula] Parsed formula
99
99
  #
100
100
  # @api private
@@ -131,7 +131,7 @@ module Uniword
131
131
 
132
132
  # Determine if equation is inline or block display
133
133
  #
134
- # @param node [Nokogiri::XML::Node] OMML node
134
+ # @param node [Moxml::Node] OMML node
135
135
  # @return [String] "inline" or "block"
136
136
  #
137
137
  # @api private
@@ -143,7 +143,7 @@ module Uniword
143
143
 
144
144
  # Extract equation properties from OMML
145
145
  #
146
- # @param node [Nokogiri::XML::Node] OMML node
146
+ # @param node [Moxml::Node] OMML node
147
147
  # @return [Hash] Extracted properties
148
148
  #
149
149
  # @api private
@@ -220,21 +220,26 @@ module Uniword
220
220
  #
221
221
  # @api private
222
222
  def self.wrap_block_equation(omml_content, equation)
223
- builder = Nokogiri::XML::Builder.new do |xml|
224
- xml["m"].oMathPara("xmlns:m" => OMML_NAMESPACE) do
225
- # Add paragraph properties if needed
226
- if equation.alignment || equation.break_enabled
227
- xml["m"].oMathParaPr do
228
- xml["m"].jc("m:val" => equation.alignment) if equation.alignment
229
- end
230
- end
223
+ doc = Moxml.new.create_document
224
+ para = doc.create_element("m:oMathPara")
225
+ para.add_namespace("m", OMML_NAMESPACE)
226
+ doc.root = para
231
227
 
232
- # Add the math content
233
- xml.parent << Nokogiri::XML.fragment(omml_content)
228
+ # Add paragraph properties if needed
229
+ if equation.alignment || equation.break_enabled
230
+ props = doc.create_element("m:oMathParaPr")
231
+ if equation.alignment
232
+ jc = doc.create_element("m:jc")
233
+ jc["m:val"] = equation.alignment
234
+ props.add_child(jc)
234
235
  end
236
+ para.add_child(props)
235
237
  end
236
238
 
237
- builder.to_xml
239
+ # Add the math content
240
+ para.add_child(Moxml.parse(omml_content).root)
241
+
242
+ doc.to_xml
238
243
  end
239
244
 
240
245
  # Format OMML output
@@ -248,33 +253,32 @@ module Uniword
248
253
  def self.format_omml(omml, options = {})
249
254
  return omml unless options[:pretty]
250
255
 
251
- doc = Nokogiri::XML(omml)
252
- doc.to_xml(indent: 2)
256
+ Moxml.parse(omml).to_xml(indent: 2)
253
257
  end
254
258
 
255
259
  # Parse XML node from string or node
256
260
  #
257
- # @param xml [String, Nokogiri::XML::Node] XML input
258
- # @return [Nokogiri::XML::Node] Parsed node
261
+ # @param xml [String, Moxml::Node] XML input
262
+ # @return [Moxml::Node] Parsed node
259
263
  #
260
264
  # @api private
261
265
  def self.parse_xml_node(xml)
262
266
  case xml
263
- when Nokogiri::XML::Node
267
+ when Moxml::Node
264
268
  xml
265
269
  when String
266
- doc = Nokogiri::XML(xml)
270
+ doc = Moxml.parse(xml)
267
271
  doc.root || raise(ArgumentError, "Invalid XML: no root element")
268
272
  else
269
273
  raise ArgumentError,
270
- "Expected String or Nokogiri::XML::Node, got #{xml.class}"
274
+ "Expected String or Moxml::Node, got #{xml.class}"
271
275
  end
272
276
  end
273
277
 
274
278
  # Extract the math node (m:oMath) from container
275
279
  #
276
- # @param node [Nokogiri::XML::Node] OMML node (could be oMath or oMathPara)
277
- # @return [Nokogiri::XML::Node] The m:oMath node
280
+ # @param node [Moxml::Node] OMML node (could be oMath or oMathPara)
281
+ # @return [Moxml::Node] The m:oMath node
278
282
  #
279
283
  # @api private
280
284
  def self.extract_math_node(node)
@@ -52,7 +52,7 @@ module Uniword
52
52
 
53
53
  # Create a MathEquation from OMML XML
54
54
  #
55
- # @param omml_xml [String, Nokogiri::XML::Node] OMML XML content
55
+ # @param omml_xml [String, Moxml::Node] OMML XML content
56
56
  # @return [MathEquation] New equation instance
57
57
  #
58
58
  # @example
@@ -7,26 +7,26 @@ module Uniword
7
7
  # Contains the Word HTML document with embedded XML metadata
8
8
  # (DocumentProperties, WordDocument settings, LatentStyles).
9
9
  class HtmlPart < MimePart
10
- # Parse the decoded HTML with Nokogiri
10
+ # Parse the decoded HTML with Moxml (Leptris HTML parser)
11
11
  def html_document
12
- @html_document ||= Nokogiri::HTML(decoded_content)
12
+ @html_document ||= Moxml.new.parse_html(decoded_content)
13
13
  end
14
14
 
15
15
  # Extract the <head> element as string
16
16
  def head_html
17
- node = html_document.at_css("head")
18
- node ? node.to_s : ""
17
+ node = html_document.at_xpath(".//head")
18
+ node ? node.to_xml : ""
19
19
  end
20
20
 
21
21
  # Extract the <body> element inner HTML
22
22
  def body_html
23
- node = html_document.at_css("body")
24
- node ? node.inner_html : ""
23
+ node = html_document.at_xpath(".//body")
24
+ node ? node.children.map(&:to_xml).join : ""
25
25
  end
26
26
 
27
27
  # Extract inline CSS styles from <style> tags
28
28
  def css_styles
29
- html_document.css("style").map(&:content).join("\n")
29
+ html_document.xpath(".//style").map(&:content).join("\n")
30
30
  end
31
31
 
32
32
  # Extract DocumentProperties XML from HTML head comments.
@@ -56,9 +56,15 @@ module Uniword
56
56
  "urn:schemas-microsoft-com:office:word", "w")
57
57
  end
58
58
 
59
+ # The document head element.
60
+ def head_element
61
+ html_document.at_xpath(".//head")
62
+ end
63
+
59
64
  # Extract all <xml> blocks from head
60
65
  def xml_blocks
61
- html_document.at_css("head")&.xpath("comment()")&.filter_map do |comment|
66
+ head = head_element
67
+ head&.xpath("comment()")&.filter_map do |comment|
62
68
  text = comment.text
63
69
  ::Regexp.last_match(1).strip if text =~ %r{<xml>(.*?)</xml>}m
64
70
  end || []
@@ -82,23 +88,23 @@ module Uniword
82
88
  # This method finds the requested root element and wraps it
83
89
  # with proper namespace declarations for lutaml-model.
84
90
  def extract_office_xml(element_name, namespace_uri, prefix)
85
- head = html_document.at_css("head")
91
+ head = head_element
86
92
  return nil unless head
87
93
 
88
94
  head.xpath("comment()").each do |comment|
89
95
  text = comment.text
90
- next unless text.include?("<#{prefix}:#{element_name}")
96
+ tag = "#{prefix}:#{element_name}"
97
+ next unless text.include?("<#{tag}")
91
98
 
92
99
  # Extract the specific element with its content
93
- pattern = %r{(<#{prefix}:#{element_name}[^>]*>.*?</#{prefix}:#{element_name}>)}m
100
+ pattern = %r{(<#{tag}[^>]*>.*?</#{tag}>)}m
94
101
  next unless text =~ pattern
95
102
 
96
103
  xml = ::Regexp.last_match(1)
97
104
  # Ensure namespace declaration is present
98
105
  ns_decl = "xmlns:#{prefix}=\"#{namespace_uri}\""
99
106
  unless xml.include?(ns_decl)
100
- xml = xml.sub(/(<#{prefix}:#{element_name})/,
101
- "\\1 #{ns_decl}")
107
+ xml = xml.sub(/(<#{tag})/, "\\1 #{ns_decl}")
102
108
  end
103
109
  return xml
104
110
  end
@@ -111,45 +111,51 @@ module Uniword
111
111
  #
112
112
  # Identifies math elements by tag name or special markers.
113
113
  #
114
- # @param element [Nokogiri::XML::Element] The HTML element
114
+ # @param element [Moxml::Element] The HTML element
115
115
  # @return [Boolean] true if element contains math content
116
116
  #
117
117
  # @example Check if element is math
118
118
  # is_math = MathConverter.math_element?(element)
119
119
  def self.math_element?(element)
120
- return false unless element.is_a?(Nokogiri::XML::Element)
120
+ return false unless element.is_a?(Moxml::Element)
121
121
 
122
122
  math_tags = %w[math mml:math m:oMath m:oMathPara]
123
123
  return true if math_tags.include?(element.name)
124
- return true if element["class"]&.include?("math")
125
- return true if element["data-mathml"]
126
124
 
127
- false
125
+ css_class = attribute_value(element, "class").to_s
126
+ return true if css_class.include?("math")
127
+
128
+ !attribute_value(element, "data-mathml").nil?
128
129
  end
129
130
 
130
131
  # Extract math content from element.
131
132
  #
132
133
  # Extracts the mathematical content from various element types.
133
134
  #
134
- # @param element [Nokogiri::XML::Element] The HTML element
135
+ # @param element [Moxml::Element] The HTML element
135
136
  # @return [Hash] Hash with :type and :content
136
137
  #
137
138
  # @example Extract math from element
138
139
  # math = MathConverter.extract_math(element)
139
140
  # # => { type: :mathml, content: "<math>...</math>" }
140
141
  def self.extract_math(element)
141
- if element["data-mathml"]
142
- { type: :mathml, content: element["data-mathml"] }
142
+ if (mathml = attribute_value(element, "data-mathml"))
143
+ { type: :mathml, content: mathml }
143
144
  elsif /^(math|mml:math)$/.match?(element.name)
144
- { type: :mathml, content: element.to_s }
145
+ { type: :mathml, content: element.to_xml }
145
146
  elsif /^m:(oMath|oMathPara)$/.match?(element.name)
146
- { type: :omml, content: element.to_s }
147
- elsif element["class"]&.include?("asciimath")
147
+ { type: :omml, content: element.to_xml }
148
+ elsif attribute_value(element, "class").to_s.include?("asciimath")
148
149
  { type: :asciimath, content: element.text }
149
150
  else
150
- { type: :unknown, content: element.to_s }
151
+ { type: :unknown, content: element.to_xml }
151
152
  end
152
153
  end
154
+
155
+ # Read an attribute value from a Moxml element.
156
+ def self.attribute_value(element, name)
157
+ element.attribute(name)&.value
158
+ end
153
159
  end
154
160
  end
155
161
  end
@@ -7,7 +7,10 @@ module Uniword
7
7
  # Base class for MIME parts in an MHTML document.
8
8
  #
9
9
  # Each MIME part has headers (Content-Type, Content-Location,
10
- # Content-Transfer-Encoding) and a body (raw content).
10
+ # Content-Transfer-Encoding) and a body (raw_content, always in
11
+ # DECODED form: the wire encoding declared by
12
+ # content_transfer_encoding is applied by MimePackager and removed
13
+ # by MimeParser, never here).
11
14
  #
12
15
  # Subclasses: HtmlPart, XmlPart, ImagePart, FileListPart, HeaderPart
13
16
  class MimePart < Lutaml::Model::Serializable
@@ -18,21 +21,7 @@ module Uniword
18
21
  attribute :raw_content, :string
19
22
 
20
23
  def decoded_content
21
- case content_transfer_encoding&.downcase
22
- when "base64"
23
- Base64.strict_decode64(raw_content.gsub(/\s+/, ""))
24
- when "quoted-printable"
25
- # Work in BINARY encoding to avoid gsub encoding conflicts,
26
- # then force UTF-8 at the end. Use .b to get BINARY encoding
27
- # without altering bytes.
28
- content = raw_content.dup.force_encoding("BINARY")
29
- content
30
- .gsub(/=\r?\n/, "")
31
- .gsub(/=([0-9A-Fa-f]{2})/) { [::Regexp.last_match(1).hex].pack("C") }
32
- .force_encoding("UTF-8")
33
- else
34
- raw_content
35
- end
24
+ raw_content
36
25
  end
37
26
 
38
27
  def decoded_content=(value)
@@ -5,9 +5,9 @@ module Uniword
5
5
  # XML MIME part — filelist.xml, props*.xml, colorschememapping.xml, etc.
6
6
  class XmlPart < MimePart
7
7
  def xml_content
8
- @xml_content ||= Nokogiri::XML(decoded_content) { |config| config.strict.noblanks }
9
- rescue Nokogiri::XML::SyntaxError
10
- @xml_content ||= Nokogiri::XML(decoded_content)
8
+ @xml_content ||= Moxml.parse(decoded_content)
9
+ rescue Moxml::ParseError
10
+ @xml_content ||= Moxml.parse(decoded_content)
11
11
  end
12
12
 
13
13
  def to_xml
@@ -11,10 +11,10 @@ module Uniword
11
11
  #
12
12
  # Namespace: http://schemas.openxmlformats.org/officeDocument/2006/characteristics
13
13
  class Characteristic < Lutaml::Model::Serializable
14
- attribute :name, :string
15
- attribute :relation, :string
16
- attribute :val, :string
17
- attribute :vocabulary, :string
14
+ attribute :name, Types::CharacteristicsVal
15
+ attribute :relation, Types::CharacteristicsVal
16
+ attribute :val, Types::CharacteristicsVal
17
+ attribute :vocabulary, Types::CharacteristicsVal
18
18
 
19
19
  xml do
20
20
  element "characteristic"
@@ -77,6 +77,27 @@ module Uniword
77
77
  end
78
78
  module_function :insert_once
79
79
 
80
+ # Reposition an existing singleton entry after (or before) the
81
+ # given anchor. No-op when the entry or model order is absent.
82
+ # Falls back to end (after: missing) or start (before: missing).
83
+ #
84
+ # @param model [Lutaml::Model::Serializable] model to mutate
85
+ # @param name [String] element name to move
86
+ # @param after [String, nil] place after this element name
87
+ # @param before [String, nil] place before this element name
88
+ # @return [Array, nil] the mutated order
89
+ def move_once(model, name, after: nil, before: nil)
90
+ order = mutable_order(model)
91
+ return unless order
92
+
93
+ entry = order.find { |e| e.name == name }
94
+ return unless entry
95
+
96
+ order.delete_at(order.index { |e| e.name == name })
97
+ order.insert(insertion_index(order, after, before, nil), entry)
98
+ end
99
+ module_function :move_once
100
+
80
101
  # @return [Integer] index for the insert
81
102
  def insertion_index(order, after, before, position)
82
103
  if after