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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/lib/uniword/builder/chart_builder.rb +3 -5
  4. data/lib/uniword/builder/image_builder.rb +5 -5
  5. data/lib/uniword/document_factory.rb +10 -24
  6. data/lib/uniword/docx/custom_xml_item.rb +8 -0
  7. data/lib/uniword/docx/image_part.rb +83 -0
  8. data/lib/uniword/docx/package.rb +22 -402
  9. data/lib/uniword/docx/package_defaults.rb +36 -32
  10. data/lib/uniword/docx/package_serialization.rb +46 -7
  11. data/lib/uniword/docx/part.rb +19 -0
  12. data/lib/uniword/docx/part_collection.rb +15 -7
  13. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  14. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  15. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  16. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  17. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  18. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  19. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  20. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  21. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  22. data/lib/uniword/docx/part_loader.rb +92 -0
  23. data/lib/uniword/docx/raw_part.rb +75 -0
  24. data/lib/uniword/docx/reconciler/helpers.rb +9 -30
  25. data/lib/uniword/docx/reconciler/package_structure.rb +4 -5
  26. data/lib/uniword/docx/reconciler/referential_integrity.rb +4 -79
  27. data/lib/uniword/docx.rb +3 -0
  28. data/lib/uniword/images/image_manager.rb +13 -13
  29. data/lib/uniword/ooxml/element_order.rb +94 -0
  30. data/lib/uniword/ooxml/part_definition.rb +164 -6
  31. data/lib/uniword/ooxml/part_registry.rb +238 -45
  32. data/lib/uniword/ooxml/relationships/image_relationship.rb +5 -2
  33. data/lib/uniword/ooxml.rb +1 -0
  34. data/lib/uniword/toc/toc_generator.rb +6 -7
  35. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  36. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  37. data/lib/uniword/validation/opc_validator.rb +16 -1
  38. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -0
  39. data/lib/uniword/version.rb +1 -1
  40. data/lib/uniword/wordprocessingml/document_root.rb +29 -220
  41. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  42. data/lib/uniword/wordprocessingml/endnotes.rb +2 -3
  43. data/lib/uniword/wordprocessingml/footnotes.rb +2 -3
  44. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  45. data/lib/uniword/wordprocessingml.rb +1 -2
  46. 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.new(
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
- case @key_attribute
117
- when :r_id then part.r_id ||= key.to_s
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