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
@@ -5,9 +5,11 @@ module Uniword
5
5
  # Declarative description of one OOXML package part kind.
6
6
  #
7
7
  # A PartDefinition is the single source of truth for a part's
8
- # package path, content type, relationship type, and the form its
8
+ # package path, content type, relationship type, the form its
9
9
  # [Content_Types].xml entry takes (Default by extension or Override
10
- # by part name). Instances are immutable value objects registered
10
+ # by part name), how it is loaded from extracted ZIP content, and
11
+ # where it lives on both Wordprocessingml::DocumentRoot and
12
+ # Docx::Package. Instances are immutable value objects registered
11
13
  # in {PartRegistry}; consumers derive every literal from them.
12
14
  #
13
15
  # Parts with instance-numbered paths (headers, footers, charts,
@@ -15,12 +17,21 @@ module Uniword
15
17
  # +format+-style placeholders (e.g. "word/header%<counter>d.xml")
16
18
  # instead of a fixed path.
17
19
  #
20
+ # Load behavior beyond this data (parse rules for special part
21
+ # kinds) lives in named Docx::PartLoader strategy classes selected
22
+ # by +loader+; the definition itself stays a value object.
23
+ #
18
24
  # @example Fixed part
19
25
  # PartDefinition.new(
20
26
  # key: :styles, path: "word/styles.xml", target: "styles.xml",
21
27
  # content_type: "...styles+xml",
22
28
  # rel_type: ".../relationships/styles",
23
29
  # required: true, kind: :override, rels_scope: :document,
30
+ # loader: :xml_model,
31
+ # loader_model: Uniword::Wordprocessingml::StylesConfiguration,
32
+ # load_priority: 50,
33
+ # package_attribute: :styles,
34
+ # document_attribute: :styles_configuration,
24
35
  # )
25
36
  class PartDefinition
26
37
  # @return [Symbol] unique registry key (e.g. :styles)
@@ -65,11 +76,60 @@ module Uniword
65
76
  # the comprehensive [Content_Types].xml for new DOCX packages
66
77
  attr_reader :standard
67
78
 
68
- # rubocop:disable Metrics/ParameterLists
79
+ # @return [Symbol, nil] Docx::PartLoader strategy key
80
+ # (:xml_model, :custom_xml, :header_footer, :chart, :image,
81
+ # :embedding, :theme_media); nil when the part is not loaded
82
+ # from ZIP content
83
+ attr_reader :loader
84
+
85
+ # @return [Class, nil] model class parsing the part's XML
86
+ # (responds to +.from_xml+); used by loaders that parse
87
+ attr_reader :loader_model
88
+
89
+ # @return [Symbol, nil] dynamic path resolution rule:
90
+ # :office_document (resolve the main document path from the
91
+ # package-level officeDocument relationship) or
92
+ # :office_document_rels (sidecar .rels of the resolved main
93
+ # document); nil for fixed paths
94
+ attr_reader :path_resolution
95
+
96
+ # @return [Integer, nil] load ordering weight — lower loads
97
+ # first; entries sharing a priority load in registration order
98
+ attr_reader :load_priority
99
+
100
+ # @return [Symbol, nil] attribute (reader/writer) holding this
101
+ # part on Wordprocessingml::DocumentRoot
102
+ attr_reader :document_attribute
103
+
104
+ # @return [Symbol, nil] attribute (reader/writer) holding this
105
+ # part on Docx::Package
106
+ attr_reader :package_attribute
107
+
108
+ # @return [Boolean] whether the package→document copy includes
109
+ # this part (false when the loader already places the part on
110
+ # the document, or the part is never read from ZIP content)
111
+ attr_reader :copy_to_document
112
+
113
+ # @return [Symbol, nil] predicate on the document guarding the
114
+ # document→package copy (e.g. :numbering_configuration_loaded?
115
+ # for the lazily-initialized numbering configuration)
116
+ attr_reader :to_package_guard
117
+
118
+ # @return [Class, nil] class the value must be an instance of
119
+ # for the document→package copy (e.g. CommentsPart, since
120
+ # DocumentRoot#comments can hold non-part values)
121
+ attr_reader :to_package_type
122
+
123
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
124
+ # rubocop:disable Metrics/AbcSize
69
125
  def initialize(key:, kind:, path: nil, path_pattern: nil,
70
126
  target: nil, target_pattern: nil,
71
127
  content_type: nil, rel_type: nil, extension: nil,
72
- required: false, rels_scope: nil, standard: false)
128
+ required: false, rels_scope: nil, standard: false,
129
+ loader: nil, loader_model: nil, path_resolution: nil,
130
+ load_priority: nil, document_attribute: nil,
131
+ package_attribute: nil, copy_to_document: true,
132
+ to_package_guard: nil, to_package_type: nil)
73
133
  @key = key.to_sym
74
134
  @kind = kind
75
135
  @path = path
@@ -82,8 +142,18 @@ module Uniword
82
142
  @required = required
83
143
  @rels_scope = rels_scope
84
144
  @standard = standard
145
+ @loader = loader
146
+ @loader_model = loader_model
147
+ @path_resolution = path_resolution
148
+ @load_priority = load_priority
149
+ @document_attribute = document_attribute
150
+ @package_attribute = package_attribute
151
+ @copy_to_document = copy_to_document
152
+ @to_package_guard = to_package_guard
153
+ @to_package_type = to_package_type
85
154
  end
86
- # rubocop:enable Metrics/ParameterLists
155
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
156
+ # rubocop:enable Metrics/AbcSize
87
157
 
88
158
  # @return [Boolean] true for [Content_Types].xml Override entries
89
159
  def override?
@@ -110,6 +180,40 @@ module Uniword
110
180
  rels_scope == :package
111
181
  end
112
182
 
183
+ # @return [Boolean] true when the part loads from ZIP content
184
+ def loadable?
185
+ !loader.nil?
186
+ end
187
+
188
+ # @return [Boolean] true when the package→document copy mirrors
189
+ # this part onto Wordprocessingml::DocumentRoot
190
+ def copy_to_document?
191
+ !!copy_to_document
192
+ end
193
+
194
+ # Whether a package path matches this definition's fixed path or
195
+ # path pattern. Accepts both "word/styles.xml" and
196
+ # "/word/styles.xml" forms, and matches numbered paths against
197
+ # the pattern ("word/header2.xml" → :header).
198
+ #
199
+ # @param candidate [String] package-relative path
200
+ # @return [Boolean]
201
+ def match_path?(candidate)
202
+ normalized = candidate.to_s.delete_prefix("/")
203
+ return true if path == normalized
204
+ return false unless path_pattern
205
+
206
+ normalized.match?(template_regex(path_pattern))
207
+ end
208
+
209
+ # Static prefix of the path pattern up to the first placeholder
210
+ # (e.g. "word/theme/media/" for "word/theme/media/%<name>s").
211
+ #
212
+ # @return [String, nil] nil for fixed-path definitions
213
+ def pattern_prefix
214
+ path_pattern&.split(/%<\w+>[ds]/)&.first
215
+ end
216
+
113
217
  # Resolve the package-relative path, interpolating pattern
114
218
  # placeholders for instance-numbered parts.
115
219
  #
@@ -143,6 +247,21 @@ module Uniword
143
247
  path && "/#{path}"
144
248
  end
145
249
 
250
+ # Package paths this part kind emits for the given package.
251
+ #
252
+ # Single-instance kinds emit their fixed +path+ when the backing
253
+ # model is present; collection families emit each stored part's
254
+ # own paths (each part object answers +package_paths+).
255
+ #
256
+ # @param package [Docx::Package] Package to enumerate for
257
+ # @return [Array<String>] emitted package paths (possibly empty)
258
+ def emitted_paths(package)
259
+ source = attribute_source(package)
260
+ return [] if source.nil?
261
+
262
+ collection? ? collection_paths(source) : [path]
263
+ end
264
+
146
265
  # Two definitions are equal when every field matches.
147
266
  def ==(other)
148
267
  other.is_a?(PartDefinition) && fields == other.fields
@@ -152,20 +271,59 @@ module Uniword
152
271
 
153
272
  # @return [Array] field values in declaration order, used for
154
273
  # value equality
274
+ # rubocop:disable Metrics/AbcSize
155
275
  def fields
156
276
  [key, kind, path, path_pattern, target, target_pattern,
157
277
  content_type, rel_type, extension, required, rels_scope,
158
- standard]
278
+ standard, loader, loader_model, path_resolution,
279
+ load_priority, document_attribute, package_attribute,
280
+ copy_to_document, to_package_guard, to_package_type]
159
281
  end
282
+ # rubocop:enable Metrics/AbcSize
160
283
 
161
284
  private
162
285
 
286
+ # The model object backing this part kind on the package: the
287
+ # package attribute when set, else the document attribute read
288
+ # through the package's document.
289
+ def attribute_source(package)
290
+ value = package_attribute && package.method(package_attribute).call
291
+ return value if value
292
+
293
+ document_attribute &&
294
+ package.document&.method(document_attribute)&.call
295
+ end
296
+
297
+ # Families (path_pattern, no fixed path) enumerate their parts;
298
+ # single-instance kinds do not.
299
+ def collection?
300
+ path.nil?
301
+ end
302
+
303
+ def collection_paths(source)
304
+ values = source.is_a?(Docx::PartCollection) ? source.values : source
305
+ values.flat_map(&:package_paths)
306
+ end
307
+
163
308
  def expand(template, vars)
164
309
  return nil unless template
165
310
  return template if vars.empty?
166
311
 
167
312
  format(template, vars)
168
313
  end
314
+
315
+ # Convert a pattern like "word/header%<counter>d.xml" into a
316
+ # matching regexp: %<name>d → digits, %<name>s → any path run.
317
+ def template_regex(template)
318
+ source = template.split(/(%<\w+>[ds])/).map do |part|
319
+ case part
320
+ when /%<\w+>d/ then "\\d+"
321
+ when /%<\w+>s/ then ".+?"
322
+ else Regexp.escape(part)
323
+ end
324
+ end.join
325
+ /\A#{source}\z/
326
+ end
169
327
  end
170
328
  end
171
329
  end
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Uniword
4
6
  module Ooxml
5
7
  # Single source of truth for OOXML package part metadata.
6
8
  #
7
- # Maps every part kind the library writes to its package path,
8
- # content type, relationship type, and [Content_Types].xml entry
9
- # form (Default vs Override). Content type and relationship type
10
- # literals live ONLY here; consumers (Uniword::ContentTypes,
11
- # Docx::PackageDefaults, Docx::Reconciler::PackageStructure, and
12
- # the Docx::PackageSerialization inject_* methods) derive from
13
- # this registry instead of holding their own literals.
9
+ # Maps every part kind the library reads or writes to its package
10
+ # path, content type, relationship type, [Content_Types].xml entry
11
+ # form (Default vs Override), loader strategy, and document/package
12
+ # attributes. Content type and relationship type literals live ONLY
13
+ # here; consumers (Uniword::ContentTypes, Docx::PackageDefaults,
14
+ # Docx::Reconciler::PackageStructure, Docx::PartLoader,
15
+ # DocumentFactory, and the Docx::PackageSerialization inject_*
16
+ # methods) derive from this registry instead of holding their own
17
+ # literals.
14
18
  #
15
19
  # Open/closed: a new part kind is added by registering a
16
20
  # PartDefinition — consumers need no further changes.
@@ -46,6 +50,14 @@ module Uniword
46
50
  # jpeg, png, gif, rels, xml — then overrides in the historic
47
51
  # order). Non-standard parts follow; consumers select their own
48
52
  # ordered subsets by key.
53
+ #
54
+ # Loader metadata: +loader+ selects the Docx::PartLoader
55
+ # strategy, +loader_model+ the parsing model class,
56
+ # +load_priority+ orders the load (content types and package
57
+ # rels first; the main document before the parts its rels
58
+ # describe), and +document_attribute+/+package_attribute+ name
59
+ # where the part lives on Wordprocessingml::DocumentRoot and
60
+ # Docx::Package for the two registry-driven copy loops.
49
61
  BUILT_INS = [
50
62
  # -- Content-type Defaults (extension → content type) --
51
63
  { key: :jpeg, kind: :default, extension: "jpeg",
@@ -65,104 +77,189 @@ module Uniword
65
77
  { key: :document, kind: :override, path: "word/document.xml",
66
78
  content_type: "#{CT_WML}.document.main+xml",
67
79
  rel_type: "#{OFFICE_REL_BASE}/officeDocument",
68
- required: true, standard: true, rels_scope: :package },
80
+ required: true, standard: true, rels_scope: :package,
81
+ loader: :xml_model,
82
+ loader_model: Uniword::Wordprocessingml::DocumentRoot,
83
+ path_resolution: :office_document, load_priority: 40,
84
+ package_attribute: :document },
69
85
  { key: :numbering, kind: :override, path: "word/numbering.xml",
70
86
  target: "numbering.xml", content_type: "#{CT_WML}.numbering+xml",
71
87
  rel_type: "#{OFFICE_REL_BASE}/numbering",
72
- standard: true, rels_scope: :document },
88
+ standard: true, rels_scope: :document,
89
+ loader: :xml_model,
90
+ loader_model: Uniword::Wordprocessingml::NumberingConfiguration,
91
+ load_priority: 50,
92
+ package_attribute: :numbering,
93
+ document_attribute: :numbering_configuration,
94
+ to_package_guard: :numbering_configuration_loaded? },
73
95
  { key: :styles, kind: :override, path: "word/styles.xml",
74
96
  target: "styles.xml", content_type: "#{CT_WML}.styles+xml",
75
97
  rel_type: "#{OFFICE_REL_BASE}/styles",
76
- required: true, standard: true, rels_scope: :document },
98
+ required: true, standard: true, rels_scope: :document,
99
+ loader: :xml_model,
100
+ loader_model: Uniword::Wordprocessingml::StylesConfiguration,
101
+ load_priority: 50,
102
+ package_attribute: :styles,
103
+ document_attribute: :styles_configuration },
77
104
  { key: :settings, kind: :override, path: "word/settings.xml",
78
105
  target: "settings.xml", content_type: "#{CT_WML}.settings+xml",
79
106
  rel_type: "#{OFFICE_REL_BASE}/settings",
80
- required: true, standard: true, rels_scope: :document },
107
+ required: true, standard: true, rels_scope: :document,
108
+ loader: :xml_model,
109
+ loader_model: Uniword::Wordprocessingml::Settings,
110
+ load_priority: 50,
111
+ package_attribute: :settings, document_attribute: :settings },
81
112
  { key: :web_settings, kind: :override, path: "word/webSettings.xml",
82
113
  target: "webSettings.xml",
83
114
  content_type: "#{CT_WML}.webSettings+xml",
84
115
  rel_type: "#{OFFICE_REL_BASE}/webSettings",
85
- required: true, standard: true, rels_scope: :document },
116
+ required: true, standard: true, rels_scope: :document,
117
+ loader: :xml_model,
118
+ loader_model: Uniword::Wordprocessingml::WebSettings,
119
+ load_priority: 50,
120
+ package_attribute: :web_settings,
121
+ document_attribute: :web_settings },
86
122
  { key: :font_table, kind: :override, path: "word/fontTable.xml",
87
123
  target: "fontTable.xml",
88
124
  content_type: "#{CT_WML}.fontTable+xml",
89
125
  rel_type: "#{OFFICE_REL_BASE}/fontTable",
90
- required: true, standard: true, rels_scope: :document },
126
+ required: true, standard: true, rels_scope: :document,
127
+ loader: :xml_model,
128
+ loader_model: Uniword::Wordprocessingml::FontTable,
129
+ load_priority: 50,
130
+ package_attribute: :font_table,
131
+ document_attribute: :font_table },
91
132
  { key: :theme, kind: :override, path: "word/theme/theme1.xml",
92
133
  target: "theme/theme1.xml",
93
134
  content_type: "#{CT_OFFICE}.theme+xml",
94
135
  rel_type: "#{OFFICE_REL_BASE}/theme",
95
- standard: true, rels_scope: :document },
136
+ standard: true, rels_scope: :document,
137
+ loader: :xml_model, loader_model: Uniword::Drawingml::Theme,
138
+ load_priority: 70,
139
+ package_attribute: :theme, document_attribute: :theme },
96
140
  { key: :core_properties, kind: :override, path: "docProps/core.xml",
97
141
  content_type: "#{CT_PACKAGE}.core-properties+xml",
98
142
  rel_type: "#{PACKAGE_REL_BASE}/metadata/core-properties",
99
- required: true, standard: true, rels_scope: :package },
143
+ required: true, standard: true, rels_scope: :package,
144
+ loader: :xml_model, loader_model: CoreProperties,
145
+ load_priority: 30,
146
+ package_attribute: :core_properties,
147
+ document_attribute: :core_properties },
100
148
  { key: :app_properties, kind: :override, path: "docProps/app.xml",
101
149
  content_type: "#{CT_OFFICE}.extended-properties+xml",
102
150
  rel_type: "#{OFFICE_REL_BASE}/extended-properties",
103
- required: true, standard: true, rels_scope: :package },
151
+ required: true, standard: true, rels_scope: :package,
152
+ loader: :xml_model, loader_model: AppProperties,
153
+ load_priority: 30,
154
+ package_attribute: :app_properties,
155
+ document_attribute: :app_properties },
104
156
 
105
157
  # -- Optional document parts --
106
158
  { key: :footnotes, kind: :override, path: "word/footnotes.xml",
107
159
  target: "footnotes.xml",
108
160
  content_type: "#{CT_WML}.footnotes+xml",
109
161
  rel_type: "#{OFFICE_REL_BASE}/footnotes",
110
- rels_scope: :document },
162
+ rels_scope: :document,
163
+ loader: :xml_model,
164
+ loader_model: Uniword::Wordprocessingml::Footnotes,
165
+ load_priority: 80,
166
+ package_attribute: :footnotes, document_attribute: :footnotes },
111
167
  { key: :endnotes, kind: :override, path: "word/endnotes.xml",
112
168
  target: "endnotes.xml",
113
169
  content_type: "#{CT_WML}.endnotes+xml",
114
170
  rel_type: "#{OFFICE_REL_BASE}/endnotes",
115
- rels_scope: :document },
171
+ rels_scope: :document,
172
+ loader: :xml_model,
173
+ loader_model: Uniword::Wordprocessingml::Endnotes,
174
+ load_priority: 80,
175
+ package_attribute: :endnotes, document_attribute: :endnotes },
116
176
  { key: :comments, kind: :override, path: "word/comments.xml",
117
177
  target: "comments.xml",
118
178
  content_type: "#{CT_WML}.comments+xml",
119
179
  rel_type: "#{OFFICE_REL_BASE}/comments",
120
- rels_scope: :document },
180
+ rels_scope: :document,
181
+ loader: :xml_model, loader_model: Uniword::CommentsPart,
182
+ load_priority: 80,
183
+ package_attribute: :comments, document_attribute: :comments,
184
+ to_package_type: Uniword::CommentsPart },
185
+ # Bibliography sources live only on the document (single home:
186
+ # DocumentRoot#bibliography_sources); nothing reads
187
+ # word/sources.xml back, so there is no loader.
121
188
  { key: :bibliography, kind: :override, path: "word/sources.xml",
122
189
  target: "sources.xml",
123
190
  content_type: "#{CT_OFFICE}.bibliography+xml",
124
191
  rel_type: "#{OFFICE_REL_BASE}/bibliography",
125
- rels_scope: :document },
192
+ rels_scope: :document,
193
+ document_attribute: :bibliography_sources },
126
194
  { key: :header, kind: :override,
127
195
  path_pattern: "word/header%<counter>d.xml",
128
196
  target_pattern: "header%<counter>d.xml",
129
197
  content_type: "#{CT_WML}.header+xml",
130
198
  rel_type: "#{OFFICE_REL_BASE}/header",
131
- rels_scope: :document },
199
+ rels_scope: :document,
200
+ loader: :header_footer,
201
+ loader_model: Uniword::Wordprocessingml::Header,
202
+ load_priority: 90,
203
+ document_attribute: :header_footer_parts },
132
204
  { key: :footer, kind: :override,
133
205
  path_pattern: "word/footer%<counter>d.xml",
134
206
  target_pattern: "footer%<counter>d.xml",
135
207
  content_type: "#{CT_WML}.footer+xml",
136
208
  rel_type: "#{OFFICE_REL_BASE}/footer",
137
- rels_scope: :document },
209
+ rels_scope: :document,
210
+ loader: :header_footer,
211
+ loader_model: Uniword::Wordprocessingml::Footer,
212
+ load_priority: 90,
213
+ document_attribute: :header_footer_parts },
138
214
  { key: :custom_properties, kind: :override,
139
215
  path: "docProps/custom.xml",
140
216
  content_type: "#{CT_OFFICE}.custom-properties+xml",
141
217
  rel_type: "#{OFFICE_REL_BASE}/custom-properties",
142
- rels_scope: :package },
218
+ rels_scope: :package,
219
+ loader: :xml_model, loader_model: CustomProperties,
220
+ load_priority: 30,
221
+ package_attribute: :custom_properties,
222
+ document_attribute: :custom_properties },
223
+ # The chart loader keys parts by document relationship id, so
224
+ # they land directly on the document — the single home for
225
+ # chart parts (DocumentRoot#chart_parts).
143
226
  { key: :chart, kind: :override,
144
227
  path_pattern: "word/charts/chart%<n>d.xml",
145
228
  target_pattern: "charts/chart%<n>d.xml",
146
229
  content_type: "#{CT_OFFICE}.drawingml.chart+xml",
147
230
  rel_type: "#{OFFICE_REL_BASE}/chart",
148
- rels_scope: :document },
231
+ rels_scope: :document,
232
+ loader: :chart, load_priority: 100,
233
+ document_attribute: :chart_parts },
149
234
  # Images carry a per-extension Default content type resolved
150
235
  # from the image data at save time, so content_type is nil.
151
236
  { key: :image, kind: :default,
152
237
  path_pattern: "word/media/%<name>s",
153
238
  target_pattern: "media/%<name>s",
154
239
  rel_type: "#{OFFICE_REL_BASE}/image",
155
- rels_scope: :document },
240
+ rels_scope: :document,
241
+ loader: :image, load_priority: 110,
242
+ document_attribute: :image_parts },
243
+ # The embedding loader keys parts by target on the package's
244
+ # embeddings collection; no package→document copy.
156
245
  { key: :ole_object, kind: :override,
157
246
  path_pattern: "word/embeddings/%<name>s",
158
247
  target_pattern: "embeddings/%<name>s",
159
248
  content_type: "#{CT_OFFICE}.oleObject",
160
249
  rel_type: "#{OFFICE_REL_BASE}/oleObject",
161
- rels_scope: :document },
250
+ rels_scope: :document,
251
+ loader: :embedding, load_priority: 120,
252
+ package_attribute: :embeddings,
253
+ document_attribute: :embeddings,
254
+ copy_to_document: false },
162
255
  # customXml items themselves fall under the "xml" Default;
163
- # only their itemProps parts get an Override.
256
+ # only their itemProps parts get an Override. The :custom_xml
257
+ # loader also pulls each item's itemProps and .rels sidecars.
164
258
  { key: :custom_xml_item, kind: :none,
165
- path_pattern: "customXml/item%<index>d.xml" },
259
+ path_pattern: "customXml/item%<index>d.xml",
260
+ loader: :custom_xml, load_priority: 30,
261
+ package_attribute: :custom_xml_items,
262
+ document_attribute: :custom_xml_items },
166
263
  { key: :custom_xml_item_props, kind: :override,
167
264
  path_pattern: "customXml/itemProps%<index>d.xml",
168
265
  content_type: "#{CT_OFFICE}.customXmlProperties+xml" },
@@ -174,6 +271,73 @@ module Uniword
174
271
  # Theme part of THMX (theme) packages, rooted at /theme.
175
272
  { key: :thmx_theme, kind: :override, path: "theme/theme1.xml",
176
273
  content_type: "#{CT_OFFICE}.theme+xml", required: true },
274
+
275
+ # -- Read-side infrastructure parts (no content-type entry
276
+ # of their own; kind :none keeps emission untouched) --
277
+ { key: :content_types, kind: :none, path: "[Content_Types].xml",
278
+ loader: :xml_model, loader_model: Uniword::ContentTypes::Types,
279
+ load_priority: 10,
280
+ package_attribute: :content_types,
281
+ document_attribute: :content_types },
282
+ { key: :package_rels, kind: :none, path: "_rels/.rels",
283
+ loader: :xml_model,
284
+ loader_model: Relationships::PackageRelationships,
285
+ load_priority: 20,
286
+ package_attribute: :package_rels,
287
+ document_attribute: :package_rels },
288
+ { key: :document_rels, kind: :none,
289
+ path: "word/_rels/document.xml.rels",
290
+ loader: :xml_model,
291
+ loader_model: Relationships::PackageRelationships,
292
+ path_resolution: :office_document_rels, load_priority: 60,
293
+ package_attribute: :document_rels,
294
+ document_attribute: :document_rels },
295
+ { key: :settings_rels, kind: :none,
296
+ path: "word/_rels/settings.xml.rels",
297
+ loader: :xml_model,
298
+ loader_model: Relationships::PackageRelationships,
299
+ load_priority: 50,
300
+ package_attribute: :settings_rels,
301
+ document_attribute: :settings_rels },
302
+ { key: :theme_rels, kind: :none,
303
+ path: "word/theme/_rels/theme1.xml.rels",
304
+ loader: :xml_model,
305
+ loader_model: Relationships::PackageRelationships,
306
+ load_priority: 75,
307
+ package_attribute: :theme_rels,
308
+ document_attribute: :theme_rels },
309
+ { key: :footnotes_rels, kind: :none,
310
+ path: "word/_rels/footnotes.xml.rels",
311
+ loader: :xml_model,
312
+ loader_model: Relationships::PackageRelationships,
313
+ load_priority: 85,
314
+ package_attribute: :footnotes_rels,
315
+ document_attribute: :footnotes_rels },
316
+ { key: :endnotes_rels, kind: :none,
317
+ path: "word/_rels/endnotes.xml.rels",
318
+ loader: :xml_model,
319
+ loader_model: Relationships::PackageRelationships,
320
+ load_priority: 85,
321
+ package_attribute: :endnotes_rels,
322
+ document_attribute: :endnotes_rels },
323
+ # Theme media files live on the theme's media_files, not on a
324
+ # package/document attribute; loads only when a theme part was
325
+ # parsed (see PartLoader::ThemeMediaLoader).
326
+ { key: :theme_media, kind: :none,
327
+ path_pattern: "word/theme/media/%<name>s",
328
+ loader: :theme_media, load_priority: 72 },
329
+ # Fallback passthrough for parts no definition claims
330
+ # (unmodelled parts such as docProps/meta.xml, glossary
331
+ # documents, VBA projects): the :raw loader runs last and
332
+ # carries the remainder byte-for-byte on the package's
333
+ # raw_parts collection. Emission is derived from the stored
334
+ # parts like any collection family, so emitted_paths — and
335
+ # with it the reconciler's dangling-relationship sweep — sees
336
+ # raw-carried parts as first-class.
337
+ { key: :raw_parts, kind: :none,
338
+ loader: :raw, load_priority: 130,
339
+ package_attribute: :raw_parts,
340
+ document_attribute: :raw_parts },
177
341
  ].freeze
178
342
 
179
343
  class << self
@@ -278,6 +442,51 @@ module Uniword
278
442
  definitions.filter_map { |d| d.rel_type if d.package_rel? }
279
443
  end
280
444
 
445
+ # Definitions loadable from extracted ZIP content, in load
446
+ # order: ascending PartDefinition#load_priority, registration
447
+ # order within a priority (explicit index tiebreak — Ruby's
448
+ # sort_by is not stable).
449
+ #
450
+ # @return [Array<PartDefinition>]
451
+ def loadable
452
+ definitions.select(&:loadable?).each_with_index
453
+ .sort_by { |d, i| [d.load_priority || 100, i] }
454
+ .map(&:first)
455
+ end
456
+
457
+ # Definitions mirrored package→document by
458
+ # DocumentFactory.copy_package_parts_to_document.
459
+ #
460
+ # @return [Array<PartDefinition>] in registration order
461
+ def copied_to_document
462
+ definitions.select do |d|
463
+ d.document_attribute && d.package_attribute &&
464
+ d.copy_to_document?
465
+ end
466
+ end
467
+
468
+ # Definitions mirrored document→package by
469
+ # Docx::PackageDefaults.copy_document_parts_to_package.
470
+ #
471
+ # @return [Array<PartDefinition>] in registration order
472
+ def copied_to_package
473
+ definitions.select { |d| d.document_attribute && d.package_attribute }
474
+ end
475
+
476
+ # Package paths the given package will emit, derived from the
477
+ # definitions: single-instance kinds by model presence,
478
+ # collection families by their stored parts.
479
+ #
480
+ # Single authority for "will this part be emitted?" — used by
481
+ # the reconciler's dangling-relationship sweep.
482
+ #
483
+ # @param package [Docx::Package] Package to enumerate for
484
+ # @return [Set<String>] emitted package paths
485
+ def emitted_paths(package)
486
+ all.flat_map { |definition| definition.emitted_paths(package) }
487
+ .compact.to_set
488
+ end
489
+
281
490
  private
282
491
 
283
492
  def definitions
@@ -285,23 +494,7 @@ module Uniword
285
494
  end
286
495
 
287
496
  def path_match?(definition, normalized_path)
288
- return true if definition.path == normalized_path
289
-
290
- pattern = definition.path_pattern
291
- !pattern.nil? && normalized_path.match?(template_regex(pattern))
292
- end
293
-
294
- # Convert a pattern like "word/header%<counter>d.xml" into a
295
- # matching regexp: %<name>d → digits, %<name>s → any path run.
296
- def template_regex(template)
297
- source = template.split(/(%<\w+>[ds])/).map do |part|
298
- case part
299
- when /%<\w+>d/ then "\\d+"
300
- when /%<\w+>s/ then ".+?"
301
- else Regexp.escape(part)
302
- end
303
- end.join
304
- /\A#{source}\z/
497
+ definition.match_path?(normalized_path)
305
498
  end
306
499
  end
307
500
 
@@ -9,9 +9,12 @@ module Uniword
9
9
  #
10
10
  # Used for image relationships in documents
11
11
  class ImageRelationship < Relationship
12
- def initialize(target:)
12
+ # @param id [String] relationship ID (allocated by
13
+ # Docx::IdAllocator — the single rId authority)
14
+ # @param target [String] image part target (e.g. "media/image1.png")
15
+ def initialize(id:, target:)
13
16
  super(
14
- id: "rId#{SecureRandom.hex(4)}",
17
+ id: id,
15
18
  type: PartRegistry.find_by_key(:image).rel_type,
16
19
  target: target
17
20
  )
data/lib/uniword/ooxml.rb CHANGED
@@ -10,6 +10,7 @@ module Uniword
10
10
  module Ooxml
11
11
  # Core OOXML packages
12
12
  autoload :DotxPackage, "#{__dir__}/ooxml/dotx_package"
13
+ autoload :ElementOrder, "#{__dir__}/ooxml/element_order"
13
14
  autoload :ThmxPackage, "#{__dir__}/ooxml/thmx_package"
14
15
  autoload :MhtmlPackage, "#{__dir__}/ooxml/mhtml_package"
15
16
  autoload :StylesetPackage, "#{__dir__}/ooxml/styleset_package"