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
data/lib/uniword/docx/package.rb
CHANGED
|
@@ -94,7 +94,7 @@ module Uniword
|
|
|
94
94
|
attribute :comments, Uniword::CommentsPart
|
|
95
95
|
|
|
96
96
|
# Non-serialized attributes (DOCX packaging helpers)
|
|
97
|
-
attr_accessor :
|
|
97
|
+
attr_accessor :profile
|
|
98
98
|
attr_accessor :settings_rels, :footnotes_rels, :endnotes_rels
|
|
99
99
|
|
|
100
100
|
# OLE/embedded object binaries (word/embeddings/*), keyed by target.
|
|
@@ -109,6 +109,22 @@ module Uniword
|
|
|
109
109
|
embeddings.replace_all(value)
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Raw passthrough parts no registry definition models, keyed by
|
|
113
|
+
# package path ("docProps/meta.xml", "word/glossary/document.xml",
|
|
114
|
+
# ...). Carried byte-for-byte from load to save — see
|
|
115
|
+
# PartLoader::RawPartLoader for claiming and
|
|
116
|
+
# PackageSerialization#serialize_raw_parts for emission.
|
|
117
|
+
#
|
|
118
|
+
# @return [PartCollection] package path => RawPart
|
|
119
|
+
def raw_parts
|
|
120
|
+
@raw_parts ||= PartCollection.new(:path, RawPart)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Bulk-assign raw parts (Hash of path => RawPart/hash; nil clears).
|
|
124
|
+
def raw_parts=(value)
|
|
125
|
+
raw_parts.replace_all(value)
|
|
126
|
+
end
|
|
127
|
+
|
|
112
128
|
# Central ID allocator — owns all rId, footnote, bookmark, etc. assignment.
|
|
113
129
|
# Seeded from template rels on load; used by builders during construction.
|
|
114
130
|
attr_accessor :allocator
|
|
@@ -144,374 +160,19 @@ module Uniword
|
|
|
144
160
|
|
|
145
161
|
# Create package from extracted ZIP content
|
|
146
162
|
#
|
|
163
|
+
# Parts load by iterating Ooxml::PartRegistry.loadable and
|
|
164
|
+
# dispatching each definition to its Docx::PartLoader strategy —
|
|
165
|
+
# see PartLoader for the load order and strategy registry.
|
|
166
|
+
#
|
|
147
167
|
# @param zip_content [Hash] Extracted ZIP files
|
|
148
168
|
# @param zip_path [String, nil] Original ZIP path for binary re-extraction
|
|
149
169
|
# @return [Package] Package object
|
|
150
170
|
def self.from_zip_content(zip_content, zip_path = nil)
|
|
151
171
|
package = new
|
|
152
|
-
|
|
153
|
-
# Parse Content Types
|
|
154
|
-
if zip_content["[Content_Types].xml"]
|
|
155
|
-
package.content_types = Uniword::ContentTypes::Types.from_xml(
|
|
156
|
-
zip_content["[Content_Types].xml"]
|
|
157
|
-
)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Parse Package Relationships
|
|
161
|
-
if zip_content["_rels/.rels"]
|
|
162
|
-
package.package_rels = Ooxml::Relationships::PackageRelationships.from_xml(
|
|
163
|
-
zip_content["_rels/.rels"]
|
|
164
|
-
)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Find the main document path from officeDocument relationship
|
|
168
|
-
main_doc_path = find_main_document_path(package.package_rels)
|
|
169
|
-
main_doc_rels_path = find_document_rels_path(main_doc_path)
|
|
170
|
-
|
|
171
|
-
# Parse Document Properties
|
|
172
|
-
if zip_content["docProps/core.xml"]
|
|
173
|
-
package.core_properties = Ooxml::CoreProperties.from_xml(
|
|
174
|
-
zip_content["docProps/core.xml"]
|
|
175
|
-
)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
if zip_content["docProps/app.xml"]
|
|
179
|
-
package.app_properties = Ooxml::AppProperties.from_xml(
|
|
180
|
-
zip_content["docProps/app.xml"]
|
|
181
|
-
)
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# Parse Custom Properties
|
|
185
|
-
if zip_content["docProps/custom.xml"]
|
|
186
|
-
package.custom_properties = Ooxml::CustomProperties.from_xml(
|
|
187
|
-
zip_content["docProps/custom.xml"]
|
|
188
|
-
)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Parse Custom XML Data items (customXml/item*.xml)
|
|
192
|
-
custom_xml_files = zip_content.keys.grep(%r{^customXml/item(\d+)\.xml$})
|
|
193
|
-
if custom_xml_files.any?
|
|
194
|
-
package.custom_xml_items = []
|
|
195
|
-
custom_xml_files.sort_by { |f| f[/item(\d+)/, 1].to_i }.each do |item_path|
|
|
196
|
-
index = item_path[/item(\d+)/, 1].to_i
|
|
197
|
-
props_path = "customXml/itemProps#{index}.xml"
|
|
198
|
-
rels_path = "customXml/_rels/item#{index}.xml.rels"
|
|
199
|
-
|
|
200
|
-
package.custom_xml_items << CustomXmlItem.new(
|
|
201
|
-
index: index,
|
|
202
|
-
xml_content: zip_content[item_path],
|
|
203
|
-
props_xml: zip_content[props_path],
|
|
204
|
-
rels_xml: zip_content[rels_path],
|
|
205
|
-
)
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Parse Document Parts - use dynamic path from package relationships
|
|
210
|
-
if main_doc_path && zip_content[main_doc_path]
|
|
211
|
-
package.document = Uniword::Wordprocessingml::DocumentRoot.from_xml(
|
|
212
|
-
zip_content[main_doc_path]
|
|
213
|
-
)
|
|
214
|
-
elsif zip_content["word/document.xml"]
|
|
215
|
-
package.document = Uniword::Wordprocessingml::DocumentRoot.from_xml(
|
|
216
|
-
zip_content["word/document.xml"]
|
|
217
|
-
)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
if zip_content["word/styles.xml"]
|
|
221
|
-
package.styles = Uniword::Wordprocessingml::StylesConfiguration.from_xml(
|
|
222
|
-
zip_content["word/styles.xml"]
|
|
223
|
-
)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
if zip_content["word/numbering.xml"]
|
|
227
|
-
package.numbering = Uniword::Wordprocessingml::NumberingConfiguration.from_xml(
|
|
228
|
-
zip_content["word/numbering.xml"]
|
|
229
|
-
)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
if zip_content["word/settings.xml"]
|
|
233
|
-
package.settings = Uniword::Wordprocessingml::Settings.from_xml(
|
|
234
|
-
zip_content["word/settings.xml"]
|
|
235
|
-
)
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
if zip_content["word/_rels/settings.xml.rels"]
|
|
239
|
-
package.settings_rels =
|
|
240
|
-
Ooxml::Relationships::PackageRelationships.from_xml(
|
|
241
|
-
zip_content["word/_rels/settings.xml.rels"]
|
|
242
|
-
)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
if zip_content["word/fontTable.xml"]
|
|
246
|
-
package.font_table = Uniword::Wordprocessingml::FontTable.from_xml(
|
|
247
|
-
zip_content["word/fontTable.xml"]
|
|
248
|
-
)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
if zip_content["word/webSettings.xml"]
|
|
252
|
-
package.web_settings = Uniword::Wordprocessingml::WebSettings.from_xml(
|
|
253
|
-
zip_content["word/webSettings.xml"]
|
|
254
|
-
)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# Parse document relationships - use dynamic path based on main document
|
|
258
|
-
if main_doc_rels_path && zip_content[main_doc_rels_path]
|
|
259
|
-
package.document_rels = Ooxml::Relationships::PackageRelationships.from_xml(
|
|
260
|
-
zip_content[main_doc_rels_path]
|
|
261
|
-
)
|
|
262
|
-
elsif zip_content["word/_rels/document.xml.rels"]
|
|
263
|
-
package.document_rels = Ooxml::Relationships::PackageRelationships.from_xml(
|
|
264
|
-
zip_content["word/_rels/document.xml.rels"]
|
|
265
|
-
)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Parse Theme
|
|
269
|
-
if zip_content["word/theme/theme1.xml"]
|
|
270
|
-
package.theme = Drawingml::Theme.from_xml(
|
|
271
|
-
zip_content["word/theme/theme1.xml"]
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
theme_media = extract_theme_media(zip_content)
|
|
275
|
-
package.theme.media_files = theme_media if theme_media.any?
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
if zip_content["word/theme/_rels/theme1.xml.rels"]
|
|
279
|
-
package.theme_rels = Ooxml::Relationships::PackageRelationships.from_xml(
|
|
280
|
-
zip_content["word/theme/_rels/theme1.xml.rels"]
|
|
281
|
-
)
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Parse Footnotes
|
|
285
|
-
if zip_content["word/footnotes.xml"]
|
|
286
|
-
package.footnotes = Uniword::Wordprocessingml::Footnotes.from_xml(
|
|
287
|
-
zip_content["word/footnotes.xml"]
|
|
288
|
-
)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Parse Endnotes
|
|
292
|
-
if zip_content["word/endnotes.xml"]
|
|
293
|
-
package.endnotes = Uniword::Wordprocessingml::Endnotes.from_xml(
|
|
294
|
-
zip_content["word/endnotes.xml"]
|
|
295
|
-
)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Parse Comments
|
|
299
|
-
if zip_content["word/comments.xml"]
|
|
300
|
-
package.comments = Uniword::CommentsPart.from_xml(
|
|
301
|
-
zip_content["word/comments.xml"]
|
|
302
|
-
)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Parse note part relationships (r:id refs inside notes, e.g.
|
|
306
|
-
# hyperlinks in footnotes) so they survive the round-trip.
|
|
307
|
-
if zip_content["word/_rels/footnotes.xml.rels"]
|
|
308
|
-
package.footnotes_rels =
|
|
309
|
-
Ooxml::Relationships::PackageRelationships.from_xml(
|
|
310
|
-
zip_content["word/_rels/footnotes.xml.rels"]
|
|
311
|
-
)
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
if zip_content["word/_rels/endnotes.xml.rels"]
|
|
315
|
-
package.endnotes_rels =
|
|
316
|
-
Ooxml::Relationships::PackageRelationships.from_xml(
|
|
317
|
-
zip_content["word/_rels/endnotes.xml.rels"]
|
|
318
|
-
)
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# Parse Header and Footer parts
|
|
322
|
-
extract_header_footer_parts(zip_content, package)
|
|
323
|
-
|
|
324
|
-
# Parse Chart parts
|
|
325
|
-
chart_files = zip_content.keys.grep(%r{^word/charts/chart\d+\.xml$})
|
|
326
|
-
if chart_files.any? && package.document_rels
|
|
327
|
-
chart_files.each do |chart_path|
|
|
328
|
-
chart_target = chart_path.sub("word/", "")
|
|
329
|
-
rel = package.document_rels.relationships.find do |r|
|
|
330
|
-
r.target == chart_target &&
|
|
331
|
-
r.type.to_s.include?("officeDocument/2006/relationships/chart")
|
|
332
|
-
end
|
|
333
|
-
next unless rel
|
|
334
|
-
|
|
335
|
-
package.document.chart_parts[rel.id] = {
|
|
336
|
-
xml: zip_content[chart_path],
|
|
337
|
-
target: chart_target
|
|
338
|
-
}
|
|
339
|
-
end
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# Extract image parts from word/media/ directory
|
|
343
|
-
extract_image_parts(zip_content, package, zip_path)
|
|
344
|
-
|
|
345
|
-
# Extract OLE/embedded object binaries from word/embeddings/
|
|
346
|
-
embedding_files = zip_content.keys.grep(%r{^word/embeddings/.+$})
|
|
347
|
-
if embedding_files.any?
|
|
348
|
-
package.embeddings = {}
|
|
349
|
-
embedding_files.each do |emb_path|
|
|
350
|
-
target = emb_path.sub("word/", "")
|
|
351
|
-
package.embeddings[target] = zip_content[emb_path]
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
|
|
172
|
+
PartLoader.load(zip_content, package, zip_path: zip_path)
|
|
355
173
|
package
|
|
356
174
|
end
|
|
357
175
|
|
|
358
|
-
def self.extract_header_footer_parts(zip_content, package)
|
|
359
|
-
return unless package.document && package.document_rels
|
|
360
|
-
|
|
361
|
-
header_files = zip_content.keys.grep(%r{^word/header\d+\.xml$})
|
|
362
|
-
footer_files = zip_content.keys.grep(%r{^word/footer\d+\.xml$})
|
|
363
|
-
|
|
364
|
-
return if header_files.empty? && footer_files.empty?
|
|
365
|
-
|
|
366
|
-
store = package.document.header_footer_parts
|
|
367
|
-
ref_types = header_footer_reference_types(package.document)
|
|
368
|
-
|
|
369
|
-
header_files.sort.each do |path|
|
|
370
|
-
target = path.sub("word/", "")
|
|
371
|
-
rel = package.document_rels.relationships.find do |r|
|
|
372
|
-
r.target == target &&
|
|
373
|
-
r.type.to_s.include?("officeDocument/2006/relationships/header")
|
|
374
|
-
end
|
|
375
|
-
next unless rel
|
|
376
|
-
|
|
377
|
-
store << HeaderFooterPart.new(
|
|
378
|
-
kind: :header,
|
|
379
|
-
r_id: rel.id,
|
|
380
|
-
target: target,
|
|
381
|
-
rel_type: rel.type,
|
|
382
|
-
type: ref_types[rel.id],
|
|
383
|
-
content: Uniword::Wordprocessingml::Header.from_xml(zip_content[path]),
|
|
384
|
-
loaded: true,
|
|
385
|
-
)
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
footer_files.sort.each do |path|
|
|
389
|
-
target = path.sub("word/", "")
|
|
390
|
-
rel = package.document_rels.relationships.find do |r|
|
|
391
|
-
r.target == target &&
|
|
392
|
-
r.type.to_s.include?("officeDocument/2006/relationships/footer")
|
|
393
|
-
end
|
|
394
|
-
next unless rel
|
|
395
|
-
|
|
396
|
-
store << HeaderFooterPart.new(
|
|
397
|
-
kind: :footer,
|
|
398
|
-
r_id: rel.id,
|
|
399
|
-
target: target,
|
|
400
|
-
rel_type: rel.type,
|
|
401
|
-
type: ref_types[rel.id],
|
|
402
|
-
content: Uniword::Wordprocessingml::Footer.from_xml(zip_content[path]),
|
|
403
|
-
loaded: true,
|
|
404
|
-
)
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
# Map relationship id => sectPr reference type
|
|
409
|
-
# ("default"/"first"/"even") from every section properties
|
|
410
|
-
# element (body-level and paragraph-level).
|
|
411
|
-
def self.header_footer_reference_types(document)
|
|
412
|
-
types = {}
|
|
413
|
-
section_properties_of(document).each do |sect_pr|
|
|
414
|
-
(sect_pr.header_references || []).each do |ref|
|
|
415
|
-
types[ref.r_id] = ref.type if ref.r_id
|
|
416
|
-
end
|
|
417
|
-
(sect_pr.footer_references || []).each do |ref|
|
|
418
|
-
types[ref.r_id] = ref.type if ref.r_id
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
types
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def self.section_properties_of(document)
|
|
425
|
-
body = document.body
|
|
426
|
-
return [] unless body
|
|
427
|
-
|
|
428
|
-
sect_prs = []
|
|
429
|
-
sect_prs << body.section_properties if body.section_properties
|
|
430
|
-
(body.paragraphs || []).each do |para|
|
|
431
|
-
sect_pr = para.properties&.section_properties
|
|
432
|
-
sect_prs << sect_pr if sect_pr
|
|
433
|
-
end
|
|
434
|
-
sect_prs
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
# Extract image files from word/media/ directory in DOCX
|
|
438
|
-
#
|
|
439
|
-
# @param zip_content [Hash] Extracted ZIP content (may have corrupted binary)
|
|
440
|
-
# @param package [Package] Package to populate
|
|
441
|
-
# @param zip_path [String, nil] Original ZIP path for binary re-extraction
|
|
442
|
-
def self.extract_image_parts(zip_content, package, zip_path = nil)
|
|
443
|
-
return unless package.document
|
|
444
|
-
|
|
445
|
-
media_files = zip_content.keys.grep(%r{^word/media/.+$})
|
|
446
|
-
return if media_files.empty?
|
|
447
|
-
|
|
448
|
-
package.document.image_parts ||= {}
|
|
449
|
-
|
|
450
|
-
media_files.each do |media_path|
|
|
451
|
-
filename = File.basename(media_path)
|
|
452
|
-
ext = File.extname(filename).delete(".").downcase
|
|
453
|
-
content_type = case ext
|
|
454
|
-
when "jpg", "jpeg" then "image/jpeg"
|
|
455
|
-
when "png" then "image/png"
|
|
456
|
-
when "gif" then "image/gif"
|
|
457
|
-
when "bmp" then "image/bmp"
|
|
458
|
-
when "tiff", "tif" then "image/tiff"
|
|
459
|
-
when "svg" then "image/svg+xml"
|
|
460
|
-
else "image/#{ext}"
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
# Key the part by the rId the loaded document rels assign to
|
|
464
|
-
# this target, so downstream consumers (MHTML rendering, image
|
|
465
|
-
# manager) resolve r:embed references correctly. Media with no
|
|
466
|
-
# document-level rel (e.g. theme-only images) gets a synthetic
|
|
467
|
-
# key; it never becomes a relationship.
|
|
468
|
-
r_id = loaded_image_rid(package, filename) ||
|
|
469
|
-
next_synthetic_image_key(package)
|
|
470
|
-
|
|
471
|
-
binary_data = if zip_path
|
|
472
|
-
read_binary_from_zip(zip_path, media_path)
|
|
473
|
-
else
|
|
474
|
-
zip_content[media_path]
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
package.document.image_parts[r_id] = {
|
|
478
|
-
data: binary_data,
|
|
479
|
-
target: "media/#{filename}",
|
|
480
|
-
content_type: content_type
|
|
481
|
-
}
|
|
482
|
-
end
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# The rId a loaded document relationship assigns to
|
|
486
|
-
# "media/<filename>", or nil when no image rel targets it.
|
|
487
|
-
def self.loaded_image_rid(package, filename)
|
|
488
|
-
image_type = Ooxml::PartRegistry.find_by_key(:image).rel_type
|
|
489
|
-
rel = package.document_rels&.relationships&.find do |r|
|
|
490
|
-
r.target == "media/#{filename}" && r.type.to_s == image_type
|
|
491
|
-
end
|
|
492
|
-
rel&.id
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
# Synthetic image-part key that cannot collide with a loaded
|
|
496
|
-
# relationship id or an already-keyed image part.
|
|
497
|
-
def self.next_synthetic_image_key(package)
|
|
498
|
-
taken = package.document_rels&.relationships&.map(&:id) || []
|
|
499
|
-
n = package.document.image_parts.size + 1
|
|
500
|
-
n += 1 while taken.include?("rId#{n}")
|
|
501
|
-
"rId#{n}"
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
# Read binary data directly from ZIP file without UTF-8 encoding
|
|
505
|
-
def self.read_binary_from_zip(zip_path, entry_path)
|
|
506
|
-
require "zip"
|
|
507
|
-
Zip::File.open(zip_path) do |zip_file|
|
|
508
|
-
entry = zip_file.find_entry(entry_path)
|
|
509
|
-
return nil unless entry
|
|
510
|
-
|
|
511
|
-
entry.get_input_stream.read
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
|
|
515
176
|
# Save document to file (class method for DocumentWriter compatibility)
|
|
516
177
|
#
|
|
517
178
|
# @param document [Wordprocessingml::DocumentRoot] Document to save
|
|
@@ -554,24 +215,6 @@ module Uniword
|
|
|
554
215
|
allocator
|
|
555
216
|
end
|
|
556
217
|
|
|
557
|
-
# Extract media files from word/theme/media/ directory
|
|
558
|
-
def self.extract_theme_media(zip_content)
|
|
559
|
-
media = {}
|
|
560
|
-
|
|
561
|
-
zip_content.each_key do |file_path|
|
|
562
|
-
next unless file_path =~ %r{^word/theme/media/(.+)$}
|
|
563
|
-
|
|
564
|
-
filename = Regexp.last_match(1)
|
|
565
|
-
media[filename] = Uniword::Themes::MediaFile.new(
|
|
566
|
-
filename: filename,
|
|
567
|
-
content: zip_content[file_path],
|
|
568
|
-
source_path: file_path
|
|
569
|
-
)
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
media
|
|
573
|
-
end
|
|
574
|
-
|
|
575
218
|
# Save package to file
|
|
576
219
|
#
|
|
577
220
|
# @param path [String] Output file path
|
|
@@ -673,29 +316,6 @@ module Uniword
|
|
|
673
316
|
document&.styles_configuration
|
|
674
317
|
end
|
|
675
318
|
|
|
676
|
-
# Find the main document path from package relationships
|
|
677
|
-
def self.find_main_document_path(package_rels)
|
|
678
|
-
return nil unless package_rels&.relationships
|
|
679
|
-
|
|
680
|
-
rel = package_rels.relationships.find do |r|
|
|
681
|
-
r.type.to_s.include?("officeDocument/2006/relationships/officeDocument")
|
|
682
|
-
end
|
|
683
|
-
return nil unless rel&.target
|
|
684
|
-
|
|
685
|
-
path = rel.target.dup
|
|
686
|
-
path.sub!(%r{^/}, "")
|
|
687
|
-
path
|
|
688
|
-
end
|
|
689
|
-
|
|
690
|
-
# Find the document relationships path from the main document path
|
|
691
|
-
def self.find_document_rels_path(doc_path)
|
|
692
|
-
return nil unless doc_path
|
|
693
|
-
|
|
694
|
-
dir = File.dirname(doc_path)
|
|
695
|
-
basename = File.basename(doc_path)
|
|
696
|
-
File.join(dir, "_rels", "#{basename}.rels")
|
|
697
|
-
end
|
|
698
|
-
|
|
699
319
|
private
|
|
700
320
|
|
|
701
321
|
# Log each applied fix via Uniword.logger when policy allows.
|
|
@@ -31,43 +31,20 @@ module Uniword
|
|
|
31
31
|
%i[styles settings web_settings font_table].freeze
|
|
32
32
|
|
|
33
33
|
# Copy parts from document to package for round-trip preservation
|
|
34
|
+
#
|
|
35
|
+
# Registry-driven: every PartDefinition naming both a document
|
|
36
|
+
# and a package attribute is mirrored, honoring the
|
|
37
|
+
# definition's optional +to_package_guard+ predicate (e.g. the
|
|
38
|
+
# lazily-initialized numbering configuration) and
|
|
39
|
+
# +to_package_type+ value constraint (e.g. comments).
|
|
34
40
|
def copy_document_parts_to_package(document, package)
|
|
35
41
|
return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
package.font_table = document.font_table if document.font_table
|
|
40
|
-
package.web_settings = document.web_settings if document.web_settings
|
|
41
|
-
package.theme = document.theme if document.theme
|
|
42
|
-
package.core_properties = document.core_properties if document.core_properties
|
|
43
|
-
package.app_properties = document.app_properties if document.app_properties
|
|
44
|
-
package.custom_properties = document.custom_properties if document.custom_properties
|
|
45
|
-
package.document_rels = document.document_rels if document.document_rels
|
|
46
|
-
package.theme_rels = document.theme_rels if document.theme_rels
|
|
47
|
-
package.package_rels = document.package_rels if document.package_rels
|
|
48
|
-
if document.settings_rels
|
|
49
|
-
package.settings_rels = document.settings_rels
|
|
43
|
+
Ooxml::PartRegistry.copied_to_package.each do |definition|
|
|
44
|
+
copy_part_to_package(document, package, definition)
|
|
50
45
|
end
|
|
51
|
-
if document.footnotes_rels
|
|
52
|
-
package.footnotes_rels = document.footnotes_rels
|
|
53
|
-
end
|
|
54
|
-
if document.endnotes_rels
|
|
55
|
-
package.endnotes_rels = document.endnotes_rels
|
|
56
|
-
end
|
|
57
|
-
package.content_types = document.content_types if document.content_types
|
|
58
|
-
package.footnotes = document.footnotes if document.footnotes
|
|
59
|
-
package.endnotes = document.endnotes if document.endnotes
|
|
60
|
-
if document.comments.is_a?(Uniword::CommentsPart)
|
|
61
|
-
package.comments = document.comments
|
|
62
|
-
end
|
|
63
|
-
package.allocator = document.allocator if document.allocator
|
|
64
46
|
|
|
65
|
-
package.
|
|
66
|
-
|
|
67
|
-
package.chart_parts = document.chart_parts if document.chart_parts
|
|
68
|
-
package.custom_xml_items = document.custom_xml_items if document.custom_xml_items
|
|
69
|
-
package.bibliography_sources = document.bibliography_sources if document.bibliography_sources
|
|
70
|
-
package.embeddings = document.embeddings if document.embeddings
|
|
47
|
+
package.allocator = document.allocator if document.allocator
|
|
71
48
|
end
|
|
72
49
|
|
|
73
50
|
# Create minimal content types for a valid DOCX
|
|
@@ -102,6 +79,33 @@ module Uniword
|
|
|
102
79
|
|
|
103
80
|
private
|
|
104
81
|
|
|
82
|
+
# Copy one document part to the package when the definition's
|
|
83
|
+
# guard predicate and value type constraint allow it.
|
|
84
|
+
def copy_part_to_package(document, package, definition)
|
|
85
|
+
return unless copyable_to_package?(document, definition)
|
|
86
|
+
|
|
87
|
+
value = document.method(definition.document_attribute).call
|
|
88
|
+
return if value.nil?
|
|
89
|
+
return unless copyable_type?(definition, value)
|
|
90
|
+
|
|
91
|
+
package.method(:"#{definition.package_attribute}=").call(value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The guard predicate named by the definition (a method on the
|
|
95
|
+
# document, e.g. :numbering_configuration_loaded?) decides;
|
|
96
|
+
# without a guard the part copies.
|
|
97
|
+
def copyable_to_package?(document, definition)
|
|
98
|
+
guard = definition.to_package_guard
|
|
99
|
+
guard.nil? || document.method(guard).call
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# When the definition constrains the value type (e.g.
|
|
103
|
+
# CommentsPart for comments), the value must satisfy it.
|
|
104
|
+
def copyable_type?(definition, value)
|
|
105
|
+
type = definition.to_package_type
|
|
106
|
+
type.nil? || value.is_a?(type)
|
|
107
|
+
end
|
|
108
|
+
|
|
105
109
|
# Build a Relationships part with sequential rIds (rId1..rIdN)
|
|
106
110
|
# for the given registry part keys, in the given order.
|
|
107
111
|
def build_minimal_rels(part_keys)
|
|
@@ -50,6 +50,7 @@ document_rels)
|
|
|
50
50
|
inject_theme(content_types, document_rels)
|
|
51
51
|
inject_numbering(content_types, document_rels)
|
|
52
52
|
inject_embeddings(content_types, document_rels)
|
|
53
|
+
inject_raw_part_content_types(content_types)
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# Serialize all package parts to XML and add to content hash
|
|
@@ -155,6 +156,9 @@ document_rels)
|
|
|
155
156
|
|
|
156
157
|
# OLE/embedded object binaries
|
|
157
158
|
serialize_embeddings(content)
|
|
159
|
+
|
|
160
|
+
# Raw passthrough parts (unmodelled; byte-for-byte)
|
|
161
|
+
serialize_raw_parts(content)
|
|
158
162
|
end
|
|
159
163
|
|
|
160
164
|
# Serialize an OOXML document part with standard encoding, single-line output.
|
|
@@ -198,17 +202,17 @@ document_rels)
|
|
|
198
202
|
def inject_image_parts(content, content_types, _document_rels)
|
|
199
203
|
return unless document&.image_parts && !document.image_parts.empty?
|
|
200
204
|
|
|
201
|
-
document.image_parts.each_value do |
|
|
202
|
-
ext = File.extname(
|
|
205
|
+
document.image_parts.each_value do |part|
|
|
206
|
+
ext = File.extname(part.target.to_s).delete(".")
|
|
203
207
|
next if content_types.defaults.any? { |d| d.extension == ext }
|
|
204
208
|
|
|
205
209
|
content_types.defaults << Uniword::ContentTypes::Default.new(
|
|
206
|
-
extension: ext, content_type:
|
|
210
|
+
extension: ext, content_type: part.content_type,
|
|
207
211
|
)
|
|
208
212
|
end
|
|
209
213
|
|
|
210
|
-
document.image_parts.each_value do |
|
|
211
|
-
content["word/#{
|
|
214
|
+
document.image_parts.each_value do |part|
|
|
215
|
+
content["word/#{part.target}"] = part.data
|
|
212
216
|
end
|
|
213
217
|
end
|
|
214
218
|
|
|
@@ -225,8 +229,8 @@ document_rels)
|
|
|
225
229
|
)
|
|
226
230
|
end
|
|
227
231
|
|
|
228
|
-
document.chart_parts.each_value do |
|
|
229
|
-
content["word/#{
|
|
232
|
+
document.chart_parts.each_value do |part|
|
|
233
|
+
content["word/#{part.target}"] = part.xml
|
|
230
234
|
end
|
|
231
235
|
end
|
|
232
236
|
|
|
@@ -391,6 +395,41 @@ document_rels)
|
|
|
391
395
|
content["word/#{target}"] = part.content
|
|
392
396
|
end
|
|
393
397
|
end
|
|
398
|
+
|
|
399
|
+
# Content types for raw-carried parts. Source Overrides survive
|
|
400
|
+
# reconciliation untouched (non-standard entries are preserved);
|
|
401
|
+
# a part whose content type came from a Default entry needs an
|
|
402
|
+
# Override once the reconciler rebuilds the standard Defaults.
|
|
403
|
+
def inject_raw_part_content_types(content_types)
|
|
404
|
+
return if raw_parts.empty?
|
|
405
|
+
|
|
406
|
+
raw_parts.each_value do |part|
|
|
407
|
+
next unless part.path && part.content_type
|
|
408
|
+
|
|
409
|
+
ext = File.extname(part.path)[1..]
|
|
410
|
+
next if ext && content_types.defaults.any? do |d|
|
|
411
|
+
d.extension == ext
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
part_name = "/#{part.path}"
|
|
415
|
+
next if content_types.overrides.any? { |o| o.part_name == part_name }
|
|
416
|
+
|
|
417
|
+
content_types.overrides << Uniword::ContentTypes::Override.new(
|
|
418
|
+
part_name: part_name, content_type: part.content_type,
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Emit raw-carried parts byte-for-byte. Model emission always
|
|
424
|
+
# wins: raw claiming already excludes registry paths, and the
|
|
425
|
+
# key guard makes double emission impossible by construction.
|
|
426
|
+
def serialize_raw_parts(content)
|
|
427
|
+
return if raw_parts.empty?
|
|
428
|
+
|
|
429
|
+
raw_parts.each do |path, part|
|
|
430
|
+
content[path] = part.content unless content.key?(path)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
394
433
|
end
|
|
395
434
|
end
|
|
396
435
|
end
|
data/lib/uniword/docx/part.rb
CHANGED
|
@@ -60,6 +60,17 @@ module Uniword
|
|
|
60
60
|
end
|
|
61
61
|
# rubocop:enable Metrics/ParameterLists
|
|
62
62
|
|
|
63
|
+
# Wrap a legacy raw-hash entry ({ content:/xml:, target: }) into
|
|
64
|
+
# a Part. Subclasses override for family-specific keys (e.g.
|
|
65
|
+
# ImagePart's { data:, content_type:, path: }). Used by
|
|
66
|
+
# PartCollection to normalize hash assignments.
|
|
67
|
+
#
|
|
68
|
+
# @param hash [Hash] legacy hash entry
|
|
69
|
+
# @return [Part]
|
|
70
|
+
def self.from_hash(hash)
|
|
71
|
+
new(target: hash[:target], content: hash[:content] || hash[:xml])
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
# @return [String, nil] relationship type URI
|
|
64
75
|
def rel_type
|
|
65
76
|
@rel_type || definition&.rel_type
|
|
@@ -79,6 +90,14 @@ module Uniword
|
|
|
79
90
|
target && "word/#{target}"
|
|
80
91
|
end
|
|
81
92
|
|
|
93
|
+
# Package paths this part emits (one for plain parts; subclasses
|
|
94
|
+
# with sidecar parts override).
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<String>] emitted package paths
|
|
97
|
+
def package_paths
|
|
98
|
+
[path].compact
|
|
99
|
+
end
|
|
100
|
+
|
|
82
101
|
# Verbatim overrides carried from a loaded package.
|
|
83
102
|
attr_writer :rel_type, :content_type
|
|
84
103
|
|