uniword 1.2.5 → 1.4.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +313 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +22 -14
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +11 -10
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +12 -18
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +91 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/image_part.rb +83 -0
  38. data/lib/uniword/docx/package.rb +141 -333
  39. data/lib/uniword/docx/package_defaults.rb +83 -99
  40. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  41. data/lib/uniword/docx/package_serialization.rb +144 -233
  42. data/lib/uniword/docx/part.rb +125 -0
  43. data/lib/uniword/docx/part_collection.rb +131 -0
  44. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  45. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  46. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  47. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  48. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  49. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  50. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  51. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  52. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  53. data/lib/uniword/docx/part_loader.rb +92 -0
  54. data/lib/uniword/docx/raw_part.rb +75 -0
  55. data/lib/uniword/docx/reconciler/body.rb +59 -68
  56. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  57. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  58. data/lib/uniword/docx/reconciler/helpers.rb +18 -31
  59. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  60. data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
  61. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  62. data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -123
  63. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  64. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  65. data/lib/uniword/docx/reconciler.rb +36 -14
  66. data/lib/uniword/docx.rb +13 -0
  67. data/lib/uniword/drawingml/blip.rb +0 -1
  68. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  69. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  70. data/lib/uniword/drawingml/theme.rb +12 -6
  71. data/lib/uniword/errors.rb +8 -1
  72. data/lib/uniword/footer.rb +4 -0
  73. data/lib/uniword/header.rb +4 -0
  74. data/lib/uniword/hyperlink.rb +1 -1
  75. data/lib/uniword/image.rb +6 -6
  76. data/lib/uniword/images/image_manager.rb +13 -13
  77. data/lib/uniword/lazy_loader.rb +1 -2
  78. data/lib/uniword/model_attribute_access.rb +20 -4
  79. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  80. data/lib/uniword/ooxml/element_order.rb +94 -0
  81. data/lib/uniword/ooxml/part_definition.rb +329 -0
  82. data/lib/uniword/ooxml/part_registry.rb +504 -0
  83. data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
  84. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  85. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  86. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  87. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  88. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  89. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  90. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  91. data/lib/uniword/ooxml/types.rb +10 -0
  92. data/lib/uniword/ooxml.rb +3 -0
  93. data/lib/uniword/properties/alignment.rb +8 -3
  94. data/lib/uniword/properties/border.rb +52 -6
  95. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  96. data/lib/uniword/properties/color_value.rb +8 -3
  97. data/lib/uniword/properties/highlight.rb +9 -5
  98. data/lib/uniword/properties/shading.rb +4 -3
  99. data/lib/uniword/properties/tab_stop.rb +6 -1
  100. data/lib/uniword/properties/table_justification.rb +4 -1
  101. data/lib/uniword/properties/underline.rb +4 -2
  102. data/lib/uniword/properties/vertical_align.rb +6 -3
  103. data/lib/uniword/review/review_manager.rb +23 -9
  104. data/lib/uniword/revision.rb +9 -9
  105. data/lib/uniword/schema/model_generator.rb +31 -15
  106. data/lib/uniword/shared_types/hex_color.rb +4 -1
  107. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  108. data/lib/uniword/shared_types/point_measure.rb +4 -1
  109. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  110. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  111. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  112. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  113. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  114. data/lib/uniword/template/variable_resolver.rb +40 -7
  115. data/lib/uniword/themes/theme_transformation.rb +55 -41
  116. data/lib/uniword/toc/toc_generator.rb +6 -5
  117. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  118. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  119. data/lib/uniword/validation/engine.rb +35 -0
  120. data/lib/uniword/validation/link_checker.rb +1 -1
  121. data/lib/uniword/validation/opc_validator.rb +16 -2
  122. data/lib/uniword/validation/report/layer_result.rb +0 -1
  123. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  124. data/lib/uniword/validation/report/verification_report.rb +0 -2
  125. data/lib/uniword/validation/report.rb +20 -0
  126. data/lib/uniword/validation/rules/base.rb +10 -2
  127. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  128. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  129. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  130. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  133. data/lib/uniword/validation/rules/document_context.rb +7 -0
  134. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  135. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  136. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  137. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  140. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  141. data/lib/uniword/validation/rules/model_context.rb +32 -0
  142. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  143. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  144. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  145. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  146. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  147. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  148. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  149. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  150. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  151. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  152. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  153. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  154. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  155. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  156. data/lib/uniword/validation/rules.rb +61 -23
  157. data/lib/uniword/validation/schema_registry.rb +11 -0
  158. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  159. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -2
  160. data/lib/uniword/validation/validators.rb +0 -9
  161. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  162. data/lib/uniword/validation.rb +7 -2
  163. data/lib/uniword/version.rb +1 -1
  164. data/lib/uniword/vml/imagedata.rb +0 -1
  165. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  166. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  167. data/lib/uniword/wordprocessingml/document_root.rb +121 -122
  168. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  169. data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
  170. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  171. data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
  172. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  173. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  174. data/lib/uniword/wordprocessingml/level.rb +6 -20
  175. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  176. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  177. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  178. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  179. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  180. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  181. data/lib/uniword/wordprocessingml/run.rb +3 -0
  182. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  183. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  184. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  185. data/lib/uniword/wordprocessingml/style.rb +2 -1
  186. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  187. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  188. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  189. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  190. data/lib/uniword/wordprocessingml.rb +5 -2
  191. data/lib/uniword.rb +27 -2
  192. metadata +52 -19
  193. data/config/validation_rules.yml +0 -60
  194. data/config/warning_rules.yml +0 -57
  195. data/lib/uniword/validation/document_validator.rb +0 -272
  196. data/lib/uniword/validation/structural_validator.rb +0 -116
  197. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  198. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  199. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  200. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  201. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  202. data/lib/uniword/validators/element_validator.rb +0 -93
  203. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  204. data/lib/uniword/validators/table_validator.rb +0 -134
  205. data/lib/uniword/validators.rb +0 -9
  206. data/lib/uniword/warnings/warning.rb +0 -130
  207. data/lib/uniword/warnings/warning_collector.rb +0 -234
  208. data/lib/uniword/warnings/warning_report.rb +0 -159
  209. data/lib/uniword/warnings.rb +0 -9
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Keyed collection of Docx::Part objects.
6
+ #
7
+ # Backs +chart_parts+ (keyed by relationship id) and +embeddings+
8
+ # (keyed by relationship target). Keeps the legacy Hash-like
9
+ # access patterns working: assignment accepts Part objects, the
10
+ # former raw hashes ({ xml:, target: }) and raw content Strings,
11
+ # normalizing them into Part objects.
12
+ #
13
+ # @example
14
+ # parts = PartCollection.new(:r_id)
15
+ # parts["rIdChart1"] = { xml: "...", target: "charts/chart1.xml" }
16
+ # parts.values.first[:target] # => "charts/chart1.xml"
17
+ class PartCollection
18
+ include Enumerable
19
+
20
+ # @param key_attribute [Symbol] part attribute serving as the
21
+ # collection key (:r_id for charts, :target for embeddings)
22
+ # @param part_class [Class] Part subclass used to wrap raw values
23
+ def initialize(key_attribute, part_class)
24
+ @key_attribute = key_attribute
25
+ @part_class = part_class
26
+ @parts = {}
27
+ end
28
+
29
+ # @param key [String] collection key (rId or target)
30
+ # @return [Part, nil]
31
+ def [](key)
32
+ @parts[key]
33
+ end
34
+
35
+ # Store a part, normalizing raw hashes and raw content.
36
+ #
37
+ # @param key [String] collection key
38
+ # @param value [Part, Hash, String]
39
+ def []=(key, value)
40
+ @parts[key] = wrap(key, value)
41
+ end
42
+
43
+ # Iterate over [key, Part] pairs.
44
+ def each(&block)
45
+ @parts.each(&block)
46
+ end
47
+
48
+ # Iterate over collection keys.
49
+ def each_key(&block)
50
+ @parts.each_key(&block)
51
+ end
52
+
53
+ # Iterate over stored parts.
54
+ def each_value(&block)
55
+ @parts.each_value(&block)
56
+ end
57
+
58
+ # @return [Array<Part>]
59
+ def values
60
+ @parts.values
61
+ end
62
+
63
+ # @return [Array<String>]
64
+ def keys
65
+ @parts.keys
66
+ end
67
+
68
+ # @return [Integer]
69
+ def size
70
+ @parts.size
71
+ end
72
+
73
+ # @return [Boolean]
74
+ def empty?
75
+ @parts.empty?
76
+ end
77
+
78
+ # @return [Boolean]
79
+ def key?(key)
80
+ @parts.key?(key)
81
+ end
82
+
83
+ # Hash-style alias for key? (RSpec's have_key matcher, legacy
84
+ # callers).
85
+ alias has_key? key?
86
+
87
+ # @return [Part, nil] the removed part
88
+ def delete(key)
89
+ @parts.delete(key)
90
+ end
91
+
92
+ # Replace the whole collection from a Hash (or another
93
+ # PartCollection); nil clears it.
94
+ #
95
+ # @param value [Hash, PartCollection, nil]
96
+ # @return [void]
97
+ def replace_all(value)
98
+ @parts.clear
99
+ return if value.nil?
100
+
101
+ value.each { |key, part| self[key] = part }
102
+ end
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
+
112
+ private
113
+
114
+ def wrap(key, value)
115
+ case value
116
+ when Part
117
+ value
118
+ when Hash
119
+ @part_class.from_hash(value).tap { |part| assign_key(part, key) }
120
+ else
121
+ @part_class.new(content: value).tap { |part| assign_key(part, key) }
122
+ end
123
+ end
124
+
125
+ def assign_key(part, key)
126
+ assigner = KEY_ASSIGNERS[@key_attribute]
127
+ assigner&.call(part, key.to_s)
128
+ end
129
+ end
130
+ end
131
+ 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