uniword 1.3.0 → 1.4.1
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/CHANGELOG.md +90 -0
- data/lib/uniword/builder/chart_builder.rb +3 -5
- data/lib/uniword/builder/image_builder.rb +5 -5
- data/lib/uniword/document_factory.rb +10 -24
- data/lib/uniword/docx/custom_xml_item.rb +8 -0
- data/lib/uniword/docx/image_part.rb +83 -0
- data/lib/uniword/docx/package.rb +22 -402
- data/lib/uniword/docx/package_defaults.rb +36 -32
- data/lib/uniword/docx/package_serialization.rb +46 -7
- data/lib/uniword/docx/part.rb +19 -0
- data/lib/uniword/docx/part_collection.rb +15 -7
- data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
- data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
- data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
- data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
- data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
- data/lib/uniword/docx/part_loader/load_context.rb +85 -0
- data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
- data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
- data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
- data/lib/uniword/docx/part_loader.rb +92 -0
- data/lib/uniword/docx/raw_part.rb +75 -0
- data/lib/uniword/docx/reconciler/helpers.rb +9 -30
- data/lib/uniword/docx/reconciler/package_structure.rb +4 -5
- data/lib/uniword/docx/reconciler/referential_integrity.rb +4 -79
- data/lib/uniword/docx.rb +3 -0
- data/lib/uniword/images/image_manager.rb +13 -13
- data/lib/uniword/ooxml/element_order.rb +94 -0
- data/lib/uniword/ooxml/part_definition.rb +164 -6
- data/lib/uniword/ooxml/part_registry.rb +238 -45
- data/lib/uniword/ooxml/relationships/image_relationship.rb +5 -2
- data/lib/uniword/ooxml.rb +1 -0
- data/lib/uniword/toc/toc_generator.rb +6 -7
- data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
- data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
- data/lib/uniword/validation/opc_validator.rb +16 -1
- data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -0
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/document_root.rb +29 -220
- data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
- data/lib/uniword/wordprocessingml/endnotes.rb +2 -3
- data/lib/uniword/wordprocessingml/footnotes.rb +2 -3
- data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
- data/lib/uniword/wordprocessingml.rb +1 -2
- metadata +16 -2
|
@@ -80,6 +80,10 @@ module Uniword
|
|
|
80
80
|
@parts.key?(key)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Hash-style alias for key? (RSpec's have_key matcher, legacy
|
|
84
|
+
# callers).
|
|
85
|
+
alias has_key? key?
|
|
86
|
+
|
|
83
87
|
# @return [Part, nil] the removed part
|
|
84
88
|
def delete(key)
|
|
85
89
|
@parts.delete(key)
|
|
@@ -97,6 +101,14 @@ module Uniword
|
|
|
97
101
|
value.each { |key, part| self[key] = part }
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# Key attribute => assigner lambda filling the attribute from
|
|
105
|
+
# the collection key when the part does not carry it.
|
|
106
|
+
KEY_ASSIGNERS = {
|
|
107
|
+
r_id: ->(part, key) { part.r_id ||= key },
|
|
108
|
+
target: ->(part, key) { part.target ||= key },
|
|
109
|
+
path: ->(part, key) { part.path ||= key },
|
|
110
|
+
}.freeze
|
|
111
|
+
|
|
100
112
|
private
|
|
101
113
|
|
|
102
114
|
def wrap(key, value)
|
|
@@ -104,19 +116,15 @@ module Uniword
|
|
|
104
116
|
when Part
|
|
105
117
|
value
|
|
106
118
|
when Hash
|
|
107
|
-
@part_class.
|
|
108
|
-
target: value[:target], content: value[:content] || value[:xml],
|
|
109
|
-
).tap { |part| assign_key(part, key) }
|
|
119
|
+
@part_class.from_hash(value).tap { |part| assign_key(part, key) }
|
|
110
120
|
else
|
|
111
121
|
@part_class.new(content: value).tap { |part| assign_key(part, key) }
|
|
112
122
|
end
|
|
113
123
|
end
|
|
114
124
|
|
|
115
125
|
def assign_key(part, key)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
when :target then part.target ||= key.to_s
|
|
119
|
-
end
|
|
126
|
+
assigner = KEY_ASSIGNERS[@key_attribute]
|
|
127
|
+
assigner&.call(part, key.to_s)
|
|
120
128
|
end
|
|
121
129
|
end
|
|
122
130
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads chart parts (word/charts/chartN.xml) into the document's
|
|
7
|
+
# chart_parts collection, keyed by the id of the document
|
|
8
|
+
# relationship targeting each chart. Charts without a matching
|
|
9
|
+
# document relationship are skipped.
|
|
10
|
+
class ChartLoader
|
|
11
|
+
# @param context [LoadContext] shared load state
|
|
12
|
+
# @param definition [Ooxml::PartDefinition] :chart
|
|
13
|
+
# @return [void]
|
|
14
|
+
def load(context, definition)
|
|
15
|
+
package = context.package
|
|
16
|
+
return unless package.document_rels
|
|
17
|
+
|
|
18
|
+
paths = context.matching_paths(definition)
|
|
19
|
+
return if paths.empty?
|
|
20
|
+
|
|
21
|
+
paths.each do |path|
|
|
22
|
+
load_chart(context, definition, path)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def load_chart(context, definition, path)
|
|
29
|
+
target = path.delete_prefix("word/")
|
|
30
|
+
rel = find_chart_relationship(context.package, definition, target)
|
|
31
|
+
return unless rel
|
|
32
|
+
|
|
33
|
+
context.package.document.chart_parts[rel.id] = ChartPart.new(
|
|
34
|
+
r_id: rel.id,
|
|
35
|
+
target: target,
|
|
36
|
+
content: context.zip_content[path],
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The document relationship of the definition's type targeting
|
|
41
|
+
# the chart, or nil when none targets it.
|
|
42
|
+
def find_chart_relationship(package, definition, target)
|
|
43
|
+
package.document_rels.relationships.find do |r|
|
|
44
|
+
r.target == target && r.type.to_s.include?(definition.rel_type)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads customXml data items (customXml/itemN.xml) together with
|
|
7
|
+
# their itemProps and relationships sidecars into
|
|
8
|
+
# CustomXmlItem objects on the package.
|
|
9
|
+
class CustomXmlLoader
|
|
10
|
+
# @param context [LoadContext] shared load state
|
|
11
|
+
# @param definition [Ooxml::PartDefinition] :custom_xml_item
|
|
12
|
+
# @return [void]
|
|
13
|
+
def load(context, definition)
|
|
14
|
+
item_paths = context.matching_paths(definition)
|
|
15
|
+
return if item_paths.empty?
|
|
16
|
+
|
|
17
|
+
items = item_paths.sort_by { |path| item_index(path) }.map do |path|
|
|
18
|
+
build_item(context, path)
|
|
19
|
+
end
|
|
20
|
+
context.package.custom_xml_items = items
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_item(context, item_path)
|
|
26
|
+
index = item_index(item_path)
|
|
27
|
+
CustomXmlItem.new(
|
|
28
|
+
index: index,
|
|
29
|
+
xml_content: context.zip_content[item_path],
|
|
30
|
+
props_xml: context.zip_content[props_path(index)],
|
|
31
|
+
rels_xml: context.zip_content[sidecar_rels_path(item_path)],
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def item_index(path)
|
|
36
|
+
path[/item(\d+)/, 1].to_i
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def props_path(index)
|
|
40
|
+
Ooxml::PartRegistry.find_by_key(:custom_xml_item_props)
|
|
41
|
+
.path_for(index: index)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# OPC relationships sidecar of the item part
|
|
45
|
+
# ("customXml/item1.xml" → "customXml/_rels/item1.xml.rels").
|
|
46
|
+
def sidecar_rels_path(item_path)
|
|
47
|
+
File.join(File.dirname(item_path), "_rels",
|
|
48
|
+
"#{File.basename(item_path)}.rels")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads OLE/embedded object binaries (word/embeddings/*) into
|
|
7
|
+
# the package's embeddings collection, keyed by relationship
|
|
8
|
+
# target (path relative to word/).
|
|
9
|
+
class EmbeddingLoader
|
|
10
|
+
# @param context [LoadContext] shared load state
|
|
11
|
+
# @param definition [Ooxml::PartDefinition] :ole_object
|
|
12
|
+
# @return [void]
|
|
13
|
+
def load(context, definition)
|
|
14
|
+
paths = context.matching_paths(definition)
|
|
15
|
+
return if paths.empty?
|
|
16
|
+
|
|
17
|
+
embeddings = context.package.embeddings
|
|
18
|
+
paths.each do |path|
|
|
19
|
+
embeddings[path.delete_prefix("word/")] =
|
|
20
|
+
context.zip_content[path]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads header and footer parts (word/headerN.xml,
|
|
7
|
+
# word/footerN.xml) into HeaderFooterPart objects on the
|
|
8
|
+
# document's unified header/footer store. Registered under the
|
|
9
|
+
# :header_footer loader key for both the :header and :footer
|
|
10
|
+
# definitions; the definition selects kind, path pattern,
|
|
11
|
+
# relationship type, and content model.
|
|
12
|
+
#
|
|
13
|
+
# Each part keeps its loaded relationship id, target, and
|
|
14
|
+
# rel_type verbatim, plus the sectPr reference type
|
|
15
|
+
# ("default"/"first"/"even") collected from every section
|
|
16
|
+
# properties element, so the save path can leave loaded
|
|
17
|
+
# relationships untouched.
|
|
18
|
+
class HeaderFooterLoader
|
|
19
|
+
# @param context [LoadContext] shared load state
|
|
20
|
+
# @param definition [Ooxml::PartDefinition] :header or :footer
|
|
21
|
+
# @return [void]
|
|
22
|
+
def load(context, definition)
|
|
23
|
+
package = context.package
|
|
24
|
+
return unless package.document && package.document_rels
|
|
25
|
+
|
|
26
|
+
paths = context.matching_paths(definition)
|
|
27
|
+
return if paths.empty?
|
|
28
|
+
|
|
29
|
+
load_parts(context, definition, package, paths.sort)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Parse each path and append its part to the document's
|
|
35
|
+
# header/footer store.
|
|
36
|
+
def load_parts(context, definition, package, paths)
|
|
37
|
+
ref_types = reference_types(package.document)
|
|
38
|
+
store = package.document.header_footer_parts
|
|
39
|
+
paths.each do |path|
|
|
40
|
+
part = build_part(context, definition, path, ref_types)
|
|
41
|
+
store << part if part
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build the part for one path, or nil when no document
|
|
46
|
+
# relationship of the definition's type targets it.
|
|
47
|
+
def build_part(context, definition, path, ref_types)
|
|
48
|
+
target = path.delete_prefix("word/")
|
|
49
|
+
rel = find_relationship(context.package, definition, target)
|
|
50
|
+
return nil unless rel
|
|
51
|
+
|
|
52
|
+
HeaderFooterPart.new(
|
|
53
|
+
kind: definition.key,
|
|
54
|
+
r_id: rel.id,
|
|
55
|
+
target: target,
|
|
56
|
+
rel_type: rel.type,
|
|
57
|
+
type: ref_types[rel.id],
|
|
58
|
+
content: definition.loader_model.from_xml(
|
|
59
|
+
context.zip_content[path],
|
|
60
|
+
),
|
|
61
|
+
loaded: true,
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def find_relationship(package, definition, target)
|
|
66
|
+
package.document_rels.relationships.find do |r|
|
|
67
|
+
r.target == target && r.type.to_s.include?(definition.rel_type)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Map relationship id => sectPr reference type
|
|
72
|
+
# ("default"/"first"/"even") from every section properties
|
|
73
|
+
# element (body-level and paragraph-level).
|
|
74
|
+
def reference_types(document)
|
|
75
|
+
types = {}
|
|
76
|
+
section_properties_of(document).each do |sect_pr|
|
|
77
|
+
collect_reference_types(types, sect_pr.header_references)
|
|
78
|
+
collect_reference_types(types, sect_pr.footer_references)
|
|
79
|
+
end
|
|
80
|
+
types
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def collect_reference_types(types, references)
|
|
84
|
+
(references || []).each do |ref|
|
|
85
|
+
types[ref.r_id] = ref.type if ref.r_id
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def section_properties_of(document)
|
|
90
|
+
body = document.body
|
|
91
|
+
return [] unless body
|
|
92
|
+
|
|
93
|
+
from_paragraphs = (body.paragraphs || []).filter_map do |para|
|
|
94
|
+
para.properties&.section_properties
|
|
95
|
+
end
|
|
96
|
+
[body.section_properties, *from_paragraphs].compact
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads image parts (word/media/*) into the document's
|
|
7
|
+
# image_parts collection, keyed by the relationship id the loaded
|
|
8
|
+
# document rels assign to each media target so downstream
|
|
9
|
+
# consumers (MHTML rendering, image manager) resolve r:embed
|
|
10
|
+
# references correctly. Media with no document-level rel (e.g.
|
|
11
|
+
# theme-only images) gets a synthetic key; it never becomes a
|
|
12
|
+
# relationship.
|
|
13
|
+
#
|
|
14
|
+
# The content type is derived from the file extension; the
|
|
15
|
+
# extension mapping is wider than the registry's Default entries
|
|
16
|
+
# (bmp, tiff, svg) and preserved verbatim from the historic
|
|
17
|
+
# load path.
|
|
18
|
+
class ImageLoader
|
|
19
|
+
# Extension → content type mapping (wider than the registry's
|
|
20
|
+
# Default entries; preserved verbatim from the historic load
|
|
21
|
+
# path).
|
|
22
|
+
CONTENT_TYPES = {
|
|
23
|
+
"jpg" => "image/jpeg", "jpeg" => "image/jpeg",
|
|
24
|
+
"png" => "image/png", "gif" => "image/gif",
|
|
25
|
+
"bmp" => "image/bmp",
|
|
26
|
+
"tiff" => "image/tiff", "tif" => "image/tiff",
|
|
27
|
+
"svg" => "image/svg+xml"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param context [LoadContext] shared load state
|
|
31
|
+
# @param definition [Ooxml::PartDefinition] :image
|
|
32
|
+
# @return [void]
|
|
33
|
+
def load(context, definition)
|
|
34
|
+
package = context.package
|
|
35
|
+
return unless package.document
|
|
36
|
+
|
|
37
|
+
paths = context.matching_paths(definition)
|
|
38
|
+
return if paths.empty?
|
|
39
|
+
|
|
40
|
+
paths.each do |path|
|
|
41
|
+
load_image(context, definition, path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def load_image(context, definition, path)
|
|
48
|
+
package = context.package
|
|
49
|
+
filename = File.basename(path)
|
|
50
|
+
r_id = loaded_image_rid(package, definition, filename) ||
|
|
51
|
+
next_synthetic_image_key(package)
|
|
52
|
+
|
|
53
|
+
package.document.image_parts[r_id] = ImagePart.new(
|
|
54
|
+
r_id: r_id,
|
|
55
|
+
data: binary_data(context, path),
|
|
56
|
+
target: "media/#{filename}",
|
|
57
|
+
content_type: content_type_for(filename),
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The rId a loaded document relationship assigns to
|
|
62
|
+
# "media/<filename>", or nil when no image rel targets it.
|
|
63
|
+
def loaded_image_rid(package, definition, filename)
|
|
64
|
+
rel = package.document_rels&.relationships&.find do |r|
|
|
65
|
+
r.target == "media/#{filename}" &&
|
|
66
|
+
r.type.to_s == definition.rel_type
|
|
67
|
+
end
|
|
68
|
+
rel&.id
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Synthetic image-part key that cannot collide with a loaded
|
|
72
|
+
# relationship id or an already-keyed image part.
|
|
73
|
+
def next_synthetic_image_key(package)
|
|
74
|
+
taken = package.document_rels&.relationships&.map(&:id) || []
|
|
75
|
+
n = package.document.image_parts.size + 1
|
|
76
|
+
n += 1 while taken.include?("rId#{n}")
|
|
77
|
+
"rId#{n}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Re-read bytes from the original ZIP when available (the
|
|
81
|
+
# extracted hash may carry corrupted UTF-8 binary).
|
|
82
|
+
def binary_data(context, path)
|
|
83
|
+
return context.zip_content[path] unless context.zip_path
|
|
84
|
+
|
|
85
|
+
read_binary_from_zip(context.zip_path, path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_binary_from_zip(zip_path, entry_path)
|
|
89
|
+
require "zip"
|
|
90
|
+
Zip::File.open(zip_path) do |zip_file|
|
|
91
|
+
entry = zip_file.find_entry(entry_path)
|
|
92
|
+
return nil unless entry
|
|
93
|
+
|
|
94
|
+
entry.get_input_stream.read
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def content_type_for(filename)
|
|
99
|
+
ext = File.extname(filename).delete(".").downcase
|
|
100
|
+
CONTENT_TYPES.fetch(ext, "image/#{ext}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Shared state for one package load: the extracted ZIP entries,
|
|
7
|
+
# the package being populated, and the main-document paths
|
|
8
|
+
# resolved from the package-level relationships.
|
|
9
|
+
class LoadContext
|
|
10
|
+
# @return [Hash] extracted ZIP entries (package path => content)
|
|
11
|
+
attr_reader :zip_content
|
|
12
|
+
|
|
13
|
+
# @return [Package] package being populated
|
|
14
|
+
attr_reader :package
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] original ZIP path for binary
|
|
17
|
+
# re-extraction
|
|
18
|
+
attr_reader :zip_path
|
|
19
|
+
|
|
20
|
+
# @param zip_content [Hash] extracted ZIP entries
|
|
21
|
+
# @param package [Package] package being populated
|
|
22
|
+
# @param zip_path [String, nil] original ZIP path
|
|
23
|
+
def initialize(zip_content:, package:, zip_path: nil)
|
|
24
|
+
@zip_content = zip_content
|
|
25
|
+
@package = package
|
|
26
|
+
@zip_path = zip_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Zip entry paths matching the definition's fixed path or
|
|
30
|
+
# path pattern (numbered part families).
|
|
31
|
+
#
|
|
32
|
+
# @param definition [Ooxml::PartDefinition]
|
|
33
|
+
# @return [Array<String>] in zip_content order
|
|
34
|
+
def matching_paths(definition)
|
|
35
|
+
zip_content.keys.select { |path| definition.match_path?(path) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Main document part path, resolved from the package-level
|
|
39
|
+
# officeDocument relationship.
|
|
40
|
+
#
|
|
41
|
+
# @return [String, nil] e.g. "word/document.xml"
|
|
42
|
+
def main_document_path
|
|
43
|
+
return @main_document_path if defined?(@main_document_path)
|
|
44
|
+
|
|
45
|
+
@main_document_path = find_main_document_path
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Relationships part path of the main document, derived from
|
|
49
|
+
# its resolved path.
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil] e.g. "word/_rels/document.xml.rels"
|
|
52
|
+
def main_document_rels_path
|
|
53
|
+
return @main_document_rels_path if defined?(@main_document_rels_path)
|
|
54
|
+
|
|
55
|
+
@main_document_rels_path = sidecar_rels_path(main_document_path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def find_main_document_path
|
|
61
|
+
relationships = package.package_rels&.relationships
|
|
62
|
+
return nil unless relationships
|
|
63
|
+
|
|
64
|
+
office_document = Ooxml::PartRegistry.find_by_key(:document).rel_type
|
|
65
|
+
rel = relationships.find do |r|
|
|
66
|
+
r.type.to_s.include?(office_document)
|
|
67
|
+
end
|
|
68
|
+
return nil unless rel&.target
|
|
69
|
+
|
|
70
|
+
rel.target.dup.delete_prefix("/")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# OPC relationships sidecar: sibling "_rels" directory named
|
|
74
|
+
# after the part ("word/document.xml" →
|
|
75
|
+
# "word/_rels/document.xml.rels").
|
|
76
|
+
def sidecar_rels_path(part_path)
|
|
77
|
+
return nil unless part_path
|
|
78
|
+
|
|
79
|
+
File.join(File.dirname(part_path), "_rels",
|
|
80
|
+
"#{File.basename(part_path)}.rels")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Fallback loader: preserves ZIP entries no registry definition
|
|
7
|
+
# claims as raw parts (RawPart) on the package, so unmodelled
|
|
8
|
+
# parts (docProps/meta.xml, glossary documents, VBA projects,
|
|
9
|
+
# unmodelled .rels sidecars, ...) round-trip byte-for-byte with
|
|
10
|
+
# their content types and relationships intact.
|
|
11
|
+
#
|
|
12
|
+
# Runs last (load_priority 130 on the :raw_parts definition), so
|
|
13
|
+
# the registry claims every path it models first and this loader
|
|
14
|
+
# carries only the remainder — no double emission. A path is
|
|
15
|
+
# claimed when any PartDefinition matches it (loadable or not),
|
|
16
|
+
# when it is the resolved main document part (or its rels
|
|
17
|
+
# sidecar) at a non-standard location, or when it is a customXml
|
|
18
|
+
# item rels sidecar consumed by CustomXmlLoader.
|
|
19
|
+
#
|
|
20
|
+
# Bytes are re-read from the original ZIP when available (the
|
|
21
|
+
# extracted hash may carry corrupted UTF-8 binary); content
|
|
22
|
+
# types come from the loaded [Content_Types].xml model.
|
|
23
|
+
class RawPartLoader
|
|
24
|
+
# customXml item rels sidecars are consumed by CustomXmlLoader
|
|
25
|
+
# together with their item part (captured group: item index).
|
|
26
|
+
CUSTOM_XML_ITEM_RELS =
|
|
27
|
+
%r{\AcustomXml/_rels/item(\d+)\.xml\.rels\z}
|
|
28
|
+
|
|
29
|
+
# @param context [LoadContext] shared load state
|
|
30
|
+
# @param _definition [Ooxml::PartDefinition] :raw_parts
|
|
31
|
+
# (strategy interface; the loader claims by remainder, not
|
|
32
|
+
# by definition matching)
|
|
33
|
+
# @return [void]
|
|
34
|
+
def load(context, _definition)
|
|
35
|
+
paths = unclaimed_paths(context)
|
|
36
|
+
return if paths.empty?
|
|
37
|
+
|
|
38
|
+
bytes = raw_bytes(context, paths)
|
|
39
|
+
paths.each do |path|
|
|
40
|
+
next unless bytes[path]
|
|
41
|
+
|
|
42
|
+
context.package.raw_parts[path] = build_part(
|
|
43
|
+
context.package, path, bytes[path]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @return [Array<String>] ZIP entries claimed by no registry
|
|
51
|
+
# loader, in zip_content order
|
|
52
|
+
def unclaimed_paths(context)
|
|
53
|
+
context.zip_content.keys.reject { |path| claimed?(context, path) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def claimed?(context, path)
|
|
57
|
+
return true if Ooxml::PartRegistry.find_by_path(path)
|
|
58
|
+
return true if path == context.main_document_path
|
|
59
|
+
return true if path == context.main_document_rels_path
|
|
60
|
+
|
|
61
|
+
custom_xml_item_rels?(context, path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A customXml item rels sidecar is claimed (via CustomXmlItem)
|
|
65
|
+
# when its item part is present in the ZIP.
|
|
66
|
+
def custom_xml_item_rels?(context, path)
|
|
67
|
+
match = CUSTOM_XML_ITEM_RELS.match(path)
|
|
68
|
+
return false unless match
|
|
69
|
+
|
|
70
|
+
context.zip_content.key?("customXml/item#{match[1]}.xml")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [RawPart]
|
|
74
|
+
def build_part(package, path, content)
|
|
75
|
+
rel = referencing_relationship(package, path)
|
|
76
|
+
RawPart.new(
|
|
77
|
+
path: path,
|
|
78
|
+
content: content,
|
|
79
|
+
content_type: content_type_for(package, path),
|
|
80
|
+
r_id: rel&.id,
|
|
81
|
+
rel_type: rel&.type,
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Bytes for each path: re-read from the original ZIP when
|
|
86
|
+
# available (binary-safe), else the extracted content.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] package path => bytes
|
|
89
|
+
def raw_bytes(context, paths)
|
|
90
|
+
unless context.zip_path
|
|
91
|
+
return paths.to_h { |p| [p, context.zip_content[p]] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
read_from_zip(context.zip_path, paths)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_from_zip(zip_path, paths)
|
|
98
|
+
require "zip"
|
|
99
|
+
Zip::File.open(zip_path) do |zip_file|
|
|
100
|
+
paths.to_h do |path|
|
|
101
|
+
entry = zip_file.find_entry(path)
|
|
102
|
+
[path, entry&.get_input_stream&.read]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Content type the source [Content_Types].xml declared for the
|
|
108
|
+
# part: its Override first, else the Default for its extension.
|
|
109
|
+
#
|
|
110
|
+
# @return [String, nil] nil when the source declared neither
|
|
111
|
+
def content_type_for(package, path)
|
|
112
|
+
content_types = package.content_types
|
|
113
|
+
return nil unless content_types
|
|
114
|
+
|
|
115
|
+
override_type(content_types, path) ||
|
|
116
|
+
default_type(content_types, path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def override_type(content_types, path)
|
|
120
|
+
part_name = "/#{path}"
|
|
121
|
+
content_types.overrides.find do |o|
|
|
122
|
+
o.part_name == part_name
|
|
123
|
+
end&.content_type
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_type(content_types, path)
|
|
127
|
+
ext = File.extname(path)[1..]
|
|
128
|
+
return nil unless ext
|
|
129
|
+
|
|
130
|
+
content_types.defaults.find do |d|
|
|
131
|
+
d.extension == ext
|
|
132
|
+
end&.content_type
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The first relationship in a modelled rels part whose
|
|
136
|
+
# resolved target is this part, or nil. Recorded as metadata
|
|
137
|
+
# only; the rel itself stays in its rels collection.
|
|
138
|
+
def referencing_relationship(package, path)
|
|
139
|
+
rels_collections(package).each do |rels, base_dir|
|
|
140
|
+
rel = rels&.relationships&.find do |r|
|
|
141
|
+
resolve_target(base_dir, r.target.to_s) == path
|
|
142
|
+
end
|
|
143
|
+
return rel if rel
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# [rels collection, base directory] pairs mirroring the rels
|
|
149
|
+
# parts the package models.
|
|
150
|
+
def rels_collections(package)
|
|
151
|
+
[
|
|
152
|
+
[package.package_rels, ""],
|
|
153
|
+
[package.document_rels, "word"],
|
|
154
|
+
[package.settings_rels, "word"],
|
|
155
|
+
[package.theme_rels, "word/theme"],
|
|
156
|
+
[package.footnotes_rels, "word"],
|
|
157
|
+
[package.endnotes_rels, "word"],
|
|
158
|
+
]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Resolve a relationship target to a normalized package path,
|
|
162
|
+
# handling package-absolute (leading slash) targets and "..".
|
|
163
|
+
def resolve_target(base_dir, target)
|
|
164
|
+
return target[1..] if target.start_with?("/")
|
|
165
|
+
|
|
166
|
+
normalize_package_path(File.join(base_dir, target))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Lexically normalize a package-relative path (resolve "." and
|
|
170
|
+
# ".." segments).
|
|
171
|
+
def normalize_package_path(path)
|
|
172
|
+
segments = []
|
|
173
|
+
path.split("/").each do |segment|
|
|
174
|
+
next if segment.empty? || segment == "."
|
|
175
|
+
|
|
176
|
+
segment == ".." ? segments.pop : segments << segment
|
|
177
|
+
end
|
|
178
|
+
segments.join("/")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads theme media files (word/theme/media/*) into the parsed
|
|
7
|
+
# theme's media_files. Runs only when a theme part was loaded —
|
|
8
|
+
# theme media without a theme is ignored, as before.
|
|
9
|
+
class ThemeMediaLoader
|
|
10
|
+
# @param context [LoadContext] shared load state
|
|
11
|
+
# @param definition [Ooxml::PartDefinition] :theme_media
|
|
12
|
+
# @return [void]
|
|
13
|
+
def load(context, definition)
|
|
14
|
+
theme = context.package.theme
|
|
15
|
+
return unless theme
|
|
16
|
+
|
|
17
|
+
media = extract_media(context, definition)
|
|
18
|
+
theme.media_files = media if media.any?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @return [Hash] filename => Themes::MediaFile
|
|
24
|
+
def extract_media(context, definition)
|
|
25
|
+
prefix = definition.pattern_prefix
|
|
26
|
+
paths = context.matching_paths(definition)
|
|
27
|
+
paths.each_with_object({}) do |path, media|
|
|
28
|
+
filename = path.delete_prefix(prefix)
|
|
29
|
+
media[filename] = Uniword::Themes::MediaFile.new(
|
|
30
|
+
filename: filename,
|
|
31
|
+
content: context.zip_content[path],
|
|
32
|
+
source_path: path,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|