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.
- checksums.yaml +4 -4
- data/exe/uniword +9 -0
- data/lib/uniword/accessibility/accessibility_report.rb +8 -0
- data/lib/uniword/builder/chart_builder.rb +129 -134
- data/lib/uniword/cli/completions.rb +99 -0
- data/lib/uniword/cli/generate_cli.rb +5 -0
- data/lib/uniword/cli/help_switch.rb +45 -0
- data/lib/uniword/cli/helpers.rb +1 -0
- data/lib/uniword/cli/main.rb +118 -21
- data/lib/uniword/cli/no_color.rb +21 -0
- data/lib/uniword/diff/package_differ.rb +16 -15
- data/lib/uniword/document_factory.rb +2 -2
- data/lib/uniword/document_writer.rb +32 -10
- data/lib/uniword/docx/package_integrity_checker.rb +10 -11
- data/lib/uniword/docx/reconciler/body.rb +1 -0
- data/lib/uniword/docx/reconciler/helpers.rb +27 -12
- data/lib/uniword/docx/reconciler/parts.rb +5 -0
- data/lib/uniword/find_replace/paragraph_walker.rb +49 -12
- data/lib/uniword/find_replace/scope.rb +7 -4
- data/lib/uniword/infrastructure/mime_packager.rb +3 -10
- data/lib/uniword/infrastructure/mime_parser.rb +23 -1
- data/lib/uniword/math/plurimath_adapter.rb +28 -24
- data/lib/uniword/math_equation.rb +1 -1
- data/lib/uniword/mhtml/html_part.rb +19 -13
- data/lib/uniword/mhtml/math_converter.rb +18 -12
- data/lib/uniword/mhtml/mime_part.rb +5 -16
- data/lib/uniword/mhtml/xml_part.rb +3 -3
- data/lib/uniword/ooxml/additional_characteristics.rb +4 -4
- data/lib/uniword/ooxml/element_order.rb +21 -0
- data/lib/uniword/ooxml/schema/element_serializer.rb +27 -35
- data/lib/uniword/ooxml/schema_library.rb +2 -2
- data/lib/uniword/ooxml/types/characteristics_val.rb +16 -0
- data/lib/uniword/ooxml/types/schema_library_val.rb +17 -0
- data/lib/uniword/ooxml/types/variant_types.rb +10 -2
- data/lib/uniword/ooxml/types/vt_attr.rb +19 -0
- data/lib/uniword/ooxml/types/wml_int.rb +17 -0
- data/lib/uniword/ooxml/types/wml_val.rb +19 -0
- data/lib/uniword/ooxml/types.rb +12 -0
- data/lib/uniword/properties/alignment.rb +4 -0
- data/lib/uniword/properties/boolean_element_factory.rb +1 -1
- data/lib/uniword/properties/font_size.rb +3 -0
- data/lib/uniword/review/review_manager.rb +34 -5
- data/lib/uniword/review/revision_resolver.rb +190 -0
- data/lib/uniword/review.rb +1 -0
- data/lib/uniword/revision.rb +3 -3
- data/lib/uniword/serialization/ooxml_serializer.rb +3 -3
- data/lib/uniword/streaming_parser.rb +22 -20
- data/lib/uniword/theme/theme_xml_parser.rb +4 -6
- data/lib/uniword/transformation/html_element_builder.rb +62 -48
- data/lib/uniword/transformation/html_formatting_mapper.rb +3 -3
- data/lib/uniword/transformation/html_to_ooxml_converter.rb +11 -11
- data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -2
- data/lib/uniword/validation/opc_validator.rb +6 -7
- data/lib/uniword/validation/rules/document_context.rb +3 -3
- data/lib/uniword/validation/rules/images_rule.rb +1 -1
- data/lib/uniword/validation/rules/theme_rule.rb +2 -2
- data/lib/uniword/validation/schema_registry.rb +10 -17
- data/lib/uniword/validation/validators/xml_schema_validator.rb +75 -26
- data/lib/uniword/validation/verify_orchestrator.rb +1 -1
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/deletion.rb +41 -0
- data/lib/uniword/wordprocessingml/insertion.rb +41 -0
- data/lib/uniword/wordprocessingml/page_margins.rb +7 -7
- data/lib/uniword/wordprocessingml/paragraph.rb +30 -4
- data/lib/uniword/wordprocessingml/settings.rb +3 -4
- data/lib/uniword/wordprocessingml/style_cleanup.rb +5 -14
- data/lib/uniword/wordprocessingml/table_borders.rb +1 -1
- data/lib/uniword/wordprocessingml/table_cell_borders.rb +1 -1
- data/lib/uniword/wordprocessingml/text.rb +0 -1
- data/lib/uniword/wordprocessingml.rb +5 -0
- data/lib/uniword/wp_drawing/position_h.rb +1 -1
- data/lib/uniword/wp_drawing/position_v.rb +1 -1
- data/lib/uniword/wp_drawing/size_rel_h.rb +1 -1
- data/lib/uniword/wp_drawing/size_rel_v.rb +1 -1
- data/lib/uniword.rb +9 -7
- metadata +19 -15
- 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,
|
|
6
|
-
# and yields every Paragraph
|
|
7
|
-
# traverse paragraph-bearing
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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, :
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
body = quoted_printable_encode(part.decoded_content)
|
|
98
|
+
body = quoted_printable_encode(part.raw_content)
|
|
100
99
|
elsif encoding == "base64"
|
|
101
|
-
|
|
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.
|
|
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,
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
258
|
-
# @return [
|
|
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
|
|
267
|
+
when Moxml::Node
|
|
264
268
|
xml
|
|
265
269
|
when String
|
|
266
|
-
doc =
|
|
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
|
|
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 [
|
|
277
|
-
# @return [
|
|
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,
|
|
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
|
|
10
|
+
# Parse the decoded HTML with Moxml (Leptris HTML parser)
|
|
11
11
|
def html_document
|
|
12
|
-
@html_document ||=
|
|
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.
|
|
18
|
-
node ? node.
|
|
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.
|
|
24
|
-
node ? node.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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{(<#{
|
|
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(/(<#{
|
|
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 [
|
|
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?(
|
|
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
|
-
|
|
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 [
|
|
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
|
|
142
|
-
{ type: :mathml, content:
|
|
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.
|
|
145
|
+
{ type: :mathml, content: element.to_xml }
|
|
145
146
|
elsif /^m:(oMath|oMathPara)$/.match?(element.name)
|
|
146
|
-
{ type: :omml, content: element.
|
|
147
|
-
elsif element
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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 ||=
|
|
9
|
-
rescue
|
|
10
|
-
@xml_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,
|
|
15
|
-
attribute :relation,
|
|
16
|
-
attribute :val,
|
|
17
|
-
attribute :vocabulary,
|
|
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
|