uniword 1.2.5 → 1.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +223 -0
- data/CONTRIBUTING.md +1 -1
- data/config/ooxml/schemas/shared_types.yml +5 -4
- data/data/schemas/ecma/dc.xsd +118 -0
- data/data/schemas/ecma/dcmitype.xsd +50 -0
- data/data/schemas/ecma/dcterms.xsd +331 -0
- data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
- data/data/schemas/ecma/xml.xsd +117 -0
- data/lib/uniword/builder/bibliography_builder.rb +1 -1
- data/lib/uniword/builder/chart_builder.rb +19 -9
- data/lib/uniword/builder/comment_anchorer.rb +221 -0
- data/lib/uniword/builder/document_builder.rb +97 -6
- data/lib/uniword/builder/image_builder.rb +6 -5
- data/lib/uniword/builder/run_utils.rb +2 -0
- data/lib/uniword/builder.rb +1 -0
- data/lib/uniword/cli/fonts_cli.rb +52 -0
- data/lib/uniword/cli/main.rb +110 -11
- data/lib/uniword/cli/page_cli.rb +85 -0
- data/lib/uniword/cli/styles_cli.rb +204 -0
- data/lib/uniword/cli/theme_cli.rb +88 -0
- data/lib/uniword/cli/toc_cli.rb +22 -2
- data/lib/uniword/comment.rb +20 -15
- data/lib/uniword/comment_range.rb +3 -3
- data/lib/uniword/comments_part.rb +51 -19
- data/lib/uniword/configuration/configuration_loader.rb +5 -3
- data/lib/uniword/configuration.rb +93 -1
- data/lib/uniword/content_types.rb +19 -39
- data/lib/uniword/document_factory.rb +9 -1
- data/lib/uniword/document_writer.rb +16 -5
- data/lib/uniword/docx/chart_part.rb +34 -0
- data/lib/uniword/docx/custom_xml_item.rb +83 -0
- data/lib/uniword/docx/header_footer_part.rb +140 -0
- data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
- data/lib/uniword/docx/header_footer_view.rb +225 -0
- data/lib/uniword/docx/id_allocator.rb +130 -36
- data/lib/uniword/docx/package.rb +223 -35
- data/lib/uniword/docx/package_defaults.rb +58 -78
- data/lib/uniword/docx/package_integrity_checker.rb +312 -0
- data/lib/uniword/docx/package_serialization.rb +103 -231
- data/lib/uniword/docx/part.rb +106 -0
- data/lib/uniword/docx/part_collection.rb +123 -0
- data/lib/uniword/docx/reconciler/body.rb +59 -68
- data/lib/uniword/docx/reconciler/fix.rb +46 -0
- data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
- data/lib/uniword/docx/reconciler/helpers.rb +9 -1
- data/lib/uniword/docx/reconciler/notes.rb +17 -8
- data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
- data/lib/uniword/docx/reconciler/parts.rb +36 -13
- data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
- data/lib/uniword/docx/reconciler/tables.rb +78 -16
- data/lib/uniword/docx/reconciler/theme.rb +7 -4
- data/lib/uniword/docx/reconciler.rb +36 -14
- data/lib/uniword/docx.rb +10 -0
- data/lib/uniword/drawingml/blip.rb +0 -1
- data/lib/uniword/drawingml/color_scheme.rb +5 -6
- data/lib/uniword/drawingml/font_scheme.rb +4 -8
- data/lib/uniword/drawingml/theme.rb +12 -6
- data/lib/uniword/errors.rb +8 -1
- data/lib/uniword/footer.rb +4 -0
- data/lib/uniword/header.rb +4 -0
- data/lib/uniword/hyperlink.rb +1 -1
- data/lib/uniword/image.rb +6 -6
- data/lib/uniword/lazy_loader.rb +1 -2
- data/lib/uniword/model_attribute_access.rb +20 -4
- data/lib/uniword/ooxml/dotx_package.rb +42 -2
- data/lib/uniword/ooxml/part_definition.rb +171 -0
- data/lib/uniword/ooxml/part_registry.rb +311 -0
- data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
- data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
- data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
- data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
- data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
- data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
- data/lib/uniword/ooxml/types.rb +10 -0
- data/lib/uniword/ooxml.rb +2 -0
- data/lib/uniword/properties/alignment.rb +8 -3
- data/lib/uniword/properties/border.rb +52 -6
- data/lib/uniword/properties/cell_vertical_align.rb +4 -1
- data/lib/uniword/properties/color_value.rb +8 -3
- data/lib/uniword/properties/highlight.rb +9 -5
- data/lib/uniword/properties/shading.rb +4 -3
- data/lib/uniword/properties/tab_stop.rb +6 -1
- data/lib/uniword/properties/table_justification.rb +4 -1
- data/lib/uniword/properties/underline.rb +4 -2
- data/lib/uniword/properties/vertical_align.rb +6 -3
- data/lib/uniword/review/review_manager.rb +23 -9
- data/lib/uniword/revision.rb +9 -9
- data/lib/uniword/schema/model_generator.rb +31 -15
- data/lib/uniword/shared_types/hex_color.rb +4 -1
- data/lib/uniword/shared_types/pixel_measure.rb +4 -1
- data/lib/uniword/shared_types/point_measure.rb +4 -1
- data/lib/uniword/shared_types/text_alignment.rb +6 -1
- data/lib/uniword/shared_types/twips_measure.rb +4 -1
- data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
- data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
- data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
- data/lib/uniword/template/variable_resolver.rb +40 -7
- data/lib/uniword/themes/theme_transformation.rb +55 -41
- data/lib/uniword/toc/toc_generator.rb +2 -0
- data/lib/uniword/validation/engine.rb +35 -0
- data/lib/uniword/validation/link_checker.rb +1 -1
- data/lib/uniword/validation/opc_validator.rb +0 -1
- data/lib/uniword/validation/report/layer_result.rb +0 -1
- data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
- data/lib/uniword/validation/report/verification_report.rb +0 -2
- data/lib/uniword/validation/report.rb +20 -0
- data/lib/uniword/validation/rules/base.rb +10 -2
- data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
- data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
- data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
- data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
- data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
- data/lib/uniword/validation/rules/document_context.rb +7 -0
- data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
- data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
- data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
- data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
- data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
- data/lib/uniword/validation/rules/images_rule.rb +0 -2
- data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/model_context.rb +32 -0
- data/lib/uniword/validation/rules/model_rule.rb +34 -0
- data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
- data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
- data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
- data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
- data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
- data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
- data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
- data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
- data/lib/uniword/validation/rules/tables_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_rule.rb +0 -2
- data/lib/uniword/validation/rules.rb +61 -23
- data/lib/uniword/validation/schema_registry.rb +11 -0
- data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
- data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
- data/lib/uniword/validation/validators.rb +0 -9
- data/lib/uniword/validation/verify_orchestrator.rb +0 -7
- data/lib/uniword/validation.rb +7 -2
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/vml/imagedata.rb +0 -1
- data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
- data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
- data/lib/uniword/wordprocessingml/document_root.rb +211 -21
- data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
- data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
- data/lib/uniword/wordprocessingml/level.rb +6 -20
- data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
- data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
- data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
- data/lib/uniword/wordprocessingml/rsids.rb +0 -2
- data/lib/uniword/wordprocessingml/run.rb +3 -0
- data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
- data/lib/uniword/wordprocessingml/settings.rb +8 -34
- data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/style.rb +2 -1
- data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
- data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
- data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
- data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
- data/lib/uniword/wordprocessingml.rb +4 -0
- data/lib/uniword.rb +27 -2
- metadata +38 -19
- data/config/validation_rules.yml +0 -60
- data/config/warning_rules.yml +0 -57
- data/lib/uniword/validation/document_validator.rb +0 -272
- data/lib/uniword/validation/structural_validator.rb +0 -116
- data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
- data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
- data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
- data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
- data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
- data/lib/uniword/validators/element_validator.rb +0 -93
- data/lib/uniword/validators/paragraph_validator.rb +0 -116
- data/lib/uniword/validators/table_validator.rb +0 -134
- data/lib/uniword/validators.rb +0 -9
- data/lib/uniword/warnings/warning.rb +0 -130
- data/lib/uniword/warnings/warning_collector.rb +0 -234
- data/lib/uniword/warnings/warning_report.rb +0 -159
- data/lib/uniword/warnings.rb +0 -9
data/lib/uniword/docx/package.rb
CHANGED
|
@@ -44,7 +44,15 @@ module Uniword
|
|
|
44
44
|
attribute :custom_properties, Ooxml::CustomProperties
|
|
45
45
|
|
|
46
46
|
# Custom XML data items (customXml/item*.xml)
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<CustomXmlItem>, nil]
|
|
49
|
+
attr_reader :custom_xml_items
|
|
50
|
+
|
|
51
|
+
# Assign custom XML items; accepts CustomXmlItem objects and
|
|
52
|
+
# legacy hashes ({ index:, xml_content:, props_xml:, rels_xml: }).
|
|
53
|
+
def custom_xml_items=(items)
|
|
54
|
+
@custom_xml_items = items && items.map { |i| CustomXmlItem.wrap(i) }
|
|
55
|
+
end
|
|
48
56
|
|
|
49
57
|
# === Document Parts (word/) ===
|
|
50
58
|
# Main document content (word/document.xml)
|
|
@@ -82,9 +90,24 @@ module Uniword
|
|
|
82
90
|
# Endnotes (word/endnotes.xml)
|
|
83
91
|
attribute :endnotes, Uniword::Wordprocessingml::Endnotes
|
|
84
92
|
|
|
93
|
+
# Comments (word/comments.xml)
|
|
94
|
+
attribute :comments, Uniword::CommentsPart
|
|
95
|
+
|
|
85
96
|
# Non-serialized attributes (DOCX packaging helpers)
|
|
86
97
|
attr_accessor :chart_parts, :bibliography_sources, :profile
|
|
87
|
-
attr_accessor :settings_rels, :
|
|
98
|
+
attr_accessor :settings_rels, :footnotes_rels, :endnotes_rels
|
|
99
|
+
|
|
100
|
+
# OLE/embedded object binaries (word/embeddings/*), keyed by target.
|
|
101
|
+
#
|
|
102
|
+
# @return [PartCollection] target => Part
|
|
103
|
+
def embeddings
|
|
104
|
+
@embeddings ||= PartCollection.new(:target, Part)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Bulk-assign embeddings (Hash of target => Part/binary; nil clears).
|
|
108
|
+
def embeddings=(value)
|
|
109
|
+
embeddings.replace_all(value)
|
|
110
|
+
end
|
|
88
111
|
|
|
89
112
|
# Central ID allocator — owns all rId, footnote, bookmark, etc. assignment.
|
|
90
113
|
# Seeded from template rels on load; used by builders during construction.
|
|
@@ -98,6 +121,15 @@ module Uniword
|
|
|
98
121
|
# Paths that have been modified and should not use raw XML passthrough.
|
|
99
122
|
attr_accessor :modified_part_paths
|
|
100
123
|
|
|
124
|
+
# Audit trail of repairs applied by the Reconciler during the most
|
|
125
|
+
# recent save (to_zip_content). Empty before the first save and when
|
|
126
|
+
# the package needed no repairs.
|
|
127
|
+
#
|
|
128
|
+
# @return [Array<Reconciler::Fix>] Fixes from the most recent save
|
|
129
|
+
def applied_fixes
|
|
130
|
+
@applied_fixes ||= []
|
|
131
|
+
end
|
|
132
|
+
|
|
101
133
|
# Load DOCX package from file
|
|
102
134
|
#
|
|
103
135
|
# @param path [String] Path to .docx file
|
|
@@ -162,18 +194,15 @@ module Uniword
|
|
|
162
194
|
package.custom_xml_items = []
|
|
163
195
|
custom_xml_files.sort_by { |f| f[/item(\d+)/, 1].to_i }.each do |item_path|
|
|
164
196
|
index = item_path[/item(\d+)/, 1].to_i
|
|
165
|
-
item = {
|
|
166
|
-
index: index,
|
|
167
|
-
xml_content: zip_content[item_path]
|
|
168
|
-
}
|
|
169
|
-
|
|
170
197
|
props_path = "customXml/itemProps#{index}.xml"
|
|
171
|
-
item[:props_xml] = zip_content[props_path] if zip_content[props_path]
|
|
172
|
-
|
|
173
198
|
rels_path = "customXml/_rels/item#{index}.xml.rels"
|
|
174
|
-
item[:rels_xml] = zip_content[rels_path] if zip_content[rels_path]
|
|
175
199
|
|
|
176
|
-
package.custom_xml_items <<
|
|
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
|
+
)
|
|
177
206
|
end
|
|
178
207
|
end
|
|
179
208
|
|
|
@@ -266,13 +295,35 @@ module Uniword
|
|
|
266
295
|
)
|
|
267
296
|
end
|
|
268
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
|
+
|
|
269
321
|
# Parse Header and Footer parts
|
|
270
322
|
extract_header_footer_parts(zip_content, package)
|
|
271
323
|
|
|
272
324
|
# Parse Chart parts
|
|
273
325
|
chart_files = zip_content.keys.grep(%r{^word/charts/chart\d+\.xml$})
|
|
274
326
|
if chart_files.any? && package.document_rels
|
|
275
|
-
package.document.chart_parts ||= {}
|
|
276
327
|
chart_files.each do |chart_path|
|
|
277
328
|
chart_target = chart_path.sub("word/", "")
|
|
278
329
|
rel = package.document_rels.relationships.find do |r|
|
|
@@ -312,7 +363,8 @@ module Uniword
|
|
|
312
363
|
|
|
313
364
|
return if header_files.empty? && footer_files.empty?
|
|
314
365
|
|
|
315
|
-
package.document.header_footer_parts
|
|
366
|
+
store = package.document.header_footer_parts
|
|
367
|
+
ref_types = header_footer_reference_types(package.document)
|
|
316
368
|
|
|
317
369
|
header_files.sort.each do |path|
|
|
318
370
|
target = path.sub("word/", "")
|
|
@@ -322,13 +374,15 @@ module Uniword
|
|
|
322
374
|
end
|
|
323
375
|
next unless rel
|
|
324
376
|
|
|
325
|
-
|
|
377
|
+
store << HeaderFooterPart.new(
|
|
378
|
+
kind: :header,
|
|
326
379
|
r_id: rel.id,
|
|
327
380
|
target: target,
|
|
328
381
|
rel_type: rel.type,
|
|
329
|
-
|
|
382
|
+
type: ref_types[rel.id],
|
|
330
383
|
content: Uniword::Wordprocessingml::Header.from_xml(zip_content[path]),
|
|
331
|
-
|
|
384
|
+
loaded: true,
|
|
385
|
+
)
|
|
332
386
|
end
|
|
333
387
|
|
|
334
388
|
footer_files.sort.each do |path|
|
|
@@ -339,14 +393,45 @@ module Uniword
|
|
|
339
393
|
end
|
|
340
394
|
next unless rel
|
|
341
395
|
|
|
342
|
-
|
|
396
|
+
store << HeaderFooterPart.new(
|
|
397
|
+
kind: :footer,
|
|
343
398
|
r_id: rel.id,
|
|
344
399
|
target: target,
|
|
345
400
|
rel_type: rel.type,
|
|
346
|
-
|
|
401
|
+
type: ref_types[rel.id],
|
|
347
402
|
content: Uniword::Wordprocessingml::Footer.from_xml(zip_content[path]),
|
|
348
|
-
|
|
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
|
|
349
433
|
end
|
|
434
|
+
sect_prs
|
|
350
435
|
end
|
|
351
436
|
|
|
352
437
|
# Extract image files from word/media/ directory in DOCX
|
|
@@ -375,14 +460,13 @@ module Uniword
|
|
|
375
460
|
else "image/#{ext}"
|
|
376
461
|
end
|
|
377
462
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
end
|
|
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)
|
|
386
470
|
|
|
387
471
|
binary_data = if zip_path
|
|
388
472
|
read_binary_from_zip(zip_path, media_path)
|
|
@@ -398,6 +482,25 @@ module Uniword
|
|
|
398
482
|
end
|
|
399
483
|
end
|
|
400
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
|
+
|
|
401
504
|
# Read binary data directly from ZIP file without UTF-8 encoding
|
|
402
505
|
def self.read_binary_from_zip(zip_path, entry_path)
|
|
403
506
|
require "zip"
|
|
@@ -410,7 +513,14 @@ module Uniword
|
|
|
410
513
|
end
|
|
411
514
|
|
|
412
515
|
# Save document to file (class method for DocumentWriter compatibility)
|
|
413
|
-
|
|
516
|
+
#
|
|
517
|
+
# @param document [Wordprocessingml::DocumentRoot] Document to save
|
|
518
|
+
# @param path [String] Output file path
|
|
519
|
+
# @param profile [Profile, nil] Reconciliation profile
|
|
520
|
+
# @param validate [Boolean, nil] Run the package integrity gate before
|
|
521
|
+
# writing; nil falls back to Uniword.configuration.validate_on_save
|
|
522
|
+
# @return [void]
|
|
523
|
+
def self.to_file(document, path, profile: nil, validate: nil)
|
|
414
524
|
package = new
|
|
415
525
|
package.document = document
|
|
416
526
|
package.profile = profile || Profile.defaults
|
|
@@ -421,7 +531,7 @@ module Uniword
|
|
|
421
531
|
package.settings ||= Uniword::Wordprocessingml::Settings.new
|
|
422
532
|
package.font_table ||= Uniword::Wordprocessingml::FontTable.new
|
|
423
533
|
package.web_settings ||= Uniword::Wordprocessingml::WebSettings.new
|
|
424
|
-
package.to_file(path)
|
|
534
|
+
package.to_file(path, validate: validate)
|
|
425
535
|
end
|
|
426
536
|
|
|
427
537
|
# Populate the allocator from all existing template data.
|
|
@@ -430,6 +540,20 @@ module Uniword
|
|
|
430
540
|
@allocator = IdAllocator.populate_from_package(self)
|
|
431
541
|
end
|
|
432
542
|
|
|
543
|
+
# Guarantee the single rId authority before reconciliation: reuse
|
|
544
|
+
# the package's allocator (loaded/builder-seeded) or start one,
|
|
545
|
+
# then seed it from the package's current relationships. Seeding
|
|
546
|
+
# preserves loaded rIds verbatim; earlier builder allocations keep
|
|
547
|
+
# their ids — a seeded rel whose id collides is reallocated
|
|
548
|
+
# deterministically (IdAllocator#seed_from_rels).
|
|
549
|
+
def prepare_allocator
|
|
550
|
+
self.allocator ||= IdAllocator.new
|
|
551
|
+
allocator.seed_from_rels(package_rels&.relationships,
|
|
552
|
+
scope: :package)
|
|
553
|
+
allocator.seed_from_rels(document_rels&.relationships)
|
|
554
|
+
allocator
|
|
555
|
+
end
|
|
556
|
+
|
|
433
557
|
# Extract media files from word/theme/media/ directory
|
|
434
558
|
def self.extract_theme_media(zip_content)
|
|
435
559
|
media = {}
|
|
@@ -449,14 +573,31 @@ module Uniword
|
|
|
449
573
|
end
|
|
450
574
|
|
|
451
575
|
# Save package to file
|
|
452
|
-
|
|
453
|
-
|
|
576
|
+
#
|
|
577
|
+
# @param path [String] Output file path
|
|
578
|
+
# @param validate [Boolean, nil] Run the package integrity gate before
|
|
579
|
+
# writing; nil falls back to Uniword.configuration.validate_on_save
|
|
580
|
+
# @return [void]
|
|
581
|
+
# @raise [Uniword::ValidationError] when the gate is enabled and the
|
|
582
|
+
# generated package content is invalid
|
|
583
|
+
def to_file(path, validate: nil)
|
|
584
|
+
zip_content = to_zip_content(validate: validate)
|
|
454
585
|
packager = Infrastructure::ZipPackager.new
|
|
455
586
|
packager.package(zip_content, path)
|
|
456
587
|
end
|
|
457
588
|
|
|
458
589
|
# Generate ZIP content hash
|
|
459
|
-
|
|
590
|
+
#
|
|
591
|
+
# Runs the Reconciler (the only mutating pass), records its repair
|
|
592
|
+
# report on #applied_fixes, and — unless validation is disabled —
|
|
593
|
+
# refuses invalid output via the PackageIntegrityChecker gate.
|
|
594
|
+
#
|
|
595
|
+
# @param validate [Boolean, nil] Run the package integrity gate;
|
|
596
|
+
# nil falls back to Uniword.configuration.validate_on_save
|
|
597
|
+
# @return [Hash] File paths => content
|
|
598
|
+
# @raise [Uniword::ValidationError] when the gate is enabled and the
|
|
599
|
+
# generated package content is invalid
|
|
600
|
+
def to_zip_content(validate: nil)
|
|
460
601
|
content = {}
|
|
461
602
|
|
|
462
603
|
self.content_types ||= self.class.minimal_content_types
|
|
@@ -467,15 +608,28 @@ module Uniword
|
|
|
467
608
|
self.font_table ||= Uniword::Wordprocessingml::FontTable.new
|
|
468
609
|
self.web_settings ||= Uniword::Wordprocessingml::WebSettings.new
|
|
469
610
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
611
|
+
# An allocator carried into the save (builder-managed document
|
|
612
|
+
# or loaded package) selects light-touch repairs; documents
|
|
613
|
+
# without one get the full normalization repertoire. rIds flow
|
|
614
|
+
# through the allocator either way.
|
|
615
|
+
builder_managed = !allocator.nil?
|
|
616
|
+
prepare_allocator
|
|
617
|
+
|
|
618
|
+
reconciler = Reconciler.new(self,
|
|
619
|
+
profile: profile || Profile.defaults,
|
|
620
|
+
allocator: allocator,
|
|
621
|
+
builder_managed: builder_managed)
|
|
622
|
+
reconciler.reconcile
|
|
623
|
+
@applied_fixes = reconciler.applied_fixes
|
|
624
|
+
log_applied_fixes
|
|
473
625
|
|
|
474
626
|
inject_part_relationships(content, content_types, package_rels, document_rels)
|
|
475
627
|
serialize_package_parts(content, content_types, package_rels, document_rels)
|
|
476
628
|
|
|
477
629
|
# OOXML requires [Content_Types].xml as the first ZIP entry.
|
|
478
630
|
reorder_content_hash(content)
|
|
631
|
+
|
|
632
|
+
enforce_package_integrity(content, validate)
|
|
479
633
|
content
|
|
480
634
|
end
|
|
481
635
|
|
|
@@ -541,6 +695,40 @@ module Uniword
|
|
|
541
695
|
basename = File.basename(doc_path)
|
|
542
696
|
File.join(dir, "_rels", "#{basename}.rels")
|
|
543
697
|
end
|
|
698
|
+
|
|
699
|
+
private
|
|
700
|
+
|
|
701
|
+
# Log each applied fix via Uniword.logger when policy allows.
|
|
702
|
+
#
|
|
703
|
+
# @return [void]
|
|
704
|
+
def log_applied_fixes
|
|
705
|
+
return if @applied_fixes.empty?
|
|
706
|
+
return unless Uniword.configuration.log_save_fixes
|
|
707
|
+
|
|
708
|
+
@applied_fixes.each do |fix|
|
|
709
|
+
Uniword.logger&.info { "Reconciler fix #{fix}" }
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Write-time integrity gate: refuse invalid package content.
|
|
714
|
+
#
|
|
715
|
+
# @param content [Hash] File paths => content
|
|
716
|
+
# @param validate [Boolean, nil] explicit override; nil reads policy
|
|
717
|
+
# @return [void]
|
|
718
|
+
# @raise [Uniword::ValidationError] listing all integrity issues
|
|
719
|
+
def enforce_package_integrity(content, validate)
|
|
720
|
+
validate = Uniword.configuration.validate_on_save if validate.nil?
|
|
721
|
+
return unless validate
|
|
722
|
+
|
|
723
|
+
issues = PackageIntegrityChecker.new.check(content)
|
|
724
|
+
return if issues.empty?
|
|
725
|
+
|
|
726
|
+
raise Uniword::ValidationError.new(
|
|
727
|
+
self,
|
|
728
|
+
issues.map { |issue| "#{issue.code} (#{issue.part}): #{issue.message}" },
|
|
729
|
+
issues: issues,
|
|
730
|
+
)
|
|
731
|
+
end
|
|
544
732
|
end
|
|
545
733
|
end
|
|
546
734
|
end
|
|
@@ -15,6 +15,21 @@ module Uniword
|
|
|
15
15
|
|
|
16
16
|
# Class methods for package construction
|
|
17
17
|
module ClassMethods
|
|
18
|
+
# Part kinds in a minimal [Content_Types].xml, emission order.
|
|
19
|
+
MINIMAL_CT_DEFAULT_PARTS = %i[rels xml].freeze
|
|
20
|
+
MINIMAL_CT_OVERRIDE_PARTS =
|
|
21
|
+
%i[document styles font_table settings web_settings
|
|
22
|
+
app_properties core_properties].freeze
|
|
23
|
+
|
|
24
|
+
# Part kinds in a minimal _rels/.rels, in rId order.
|
|
25
|
+
MINIMAL_PACKAGE_REL_PARTS =
|
|
26
|
+
%i[document core_properties app_properties].freeze
|
|
27
|
+
|
|
28
|
+
# Part kinds in a minimal word/_rels/document.xml.rels,
|
|
29
|
+
# in rId order.
|
|
30
|
+
MINIMAL_DOCUMENT_REL_PARTS =
|
|
31
|
+
%i[styles settings web_settings font_table].freeze
|
|
32
|
+
|
|
18
33
|
# Copy parts from document to package for round-trip preservation
|
|
19
34
|
def copy_document_parts_to_package(document, package)
|
|
20
35
|
return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
|
|
@@ -30,9 +45,21 @@ module Uniword
|
|
|
30
45
|
package.document_rels = document.document_rels if document.document_rels
|
|
31
46
|
package.theme_rels = document.theme_rels if document.theme_rels
|
|
32
47
|
package.package_rels = document.package_rels if document.package_rels
|
|
48
|
+
if document.settings_rels
|
|
49
|
+
package.settings_rels = document.settings_rels
|
|
50
|
+
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
|
|
33
57
|
package.content_types = document.content_types if document.content_types
|
|
34
58
|
package.footnotes = document.footnotes if document.footnotes
|
|
35
59
|
package.endnotes = document.endnotes if document.endnotes
|
|
60
|
+
if document.comments.is_a?(Uniword::CommentsPart)
|
|
61
|
+
package.comments = document.comments
|
|
62
|
+
end
|
|
36
63
|
package.allocator = document.allocator if document.allocator
|
|
37
64
|
|
|
38
65
|
package.numbering = document.numbering_configuration if document.numbering_configuration_loaded?
|
|
@@ -46,94 +73,47 @@ module Uniword
|
|
|
46
73
|
# Create minimal content types for a valid DOCX
|
|
47
74
|
def minimal_content_types
|
|
48
75
|
ct = Uniword::ContentTypes::Types.new
|
|
49
|
-
ct.defaults
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
65
|
-
part_name: "/word/styles.xml",
|
|
66
|
-
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
|
|
67
|
-
)
|
|
68
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
69
|
-
part_name: "/word/fontTable.xml",
|
|
70
|
-
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml",
|
|
71
|
-
)
|
|
72
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
73
|
-
part_name: "/word/settings.xml",
|
|
74
|
-
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
|
|
75
|
-
)
|
|
76
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
77
|
-
part_name: "/word/webSettings.xml",
|
|
78
|
-
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml",
|
|
79
|
-
)
|
|
80
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
81
|
-
part_name: "/docProps/app.xml",
|
|
82
|
-
content_type: "application/vnd.openxmlformats-officedocument.extended-properties+xml",
|
|
83
|
-
)
|
|
84
|
-
ct.overrides << Uniword::ContentTypes::Override.new(
|
|
85
|
-
part_name: "/docProps/core.xml",
|
|
86
|
-
content_type: "application/vnd.openxmlformats-package.core-properties+xml",
|
|
87
|
-
)
|
|
76
|
+
ct.defaults = MINIMAL_CT_DEFAULT_PARTS.map do |key|
|
|
77
|
+
defn = Ooxml::PartRegistry.find_by_key(key)
|
|
78
|
+
Uniword::ContentTypes::Default.new(
|
|
79
|
+
extension: defn.extension,
|
|
80
|
+
content_type: defn.content_type,
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
ct.overrides = MINIMAL_CT_OVERRIDE_PARTS.map do |key|
|
|
84
|
+
defn = Ooxml::PartRegistry.find_by_key(key)
|
|
85
|
+
Uniword::ContentTypes::Override.new(
|
|
86
|
+
part_name: defn.part_name,
|
|
87
|
+
content_type: defn.content_type,
|
|
88
|
+
)
|
|
89
|
+
end
|
|
88
90
|
ct
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
# Create minimal package relationships for a valid DOCX
|
|
92
94
|
def minimal_package_rels
|
|
93
|
-
|
|
94
|
-
rels.relationships ||= []
|
|
95
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
96
|
-
id: "rId1",
|
|
97
|
-
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
|
|
98
|
-
target: "word/document.xml",
|
|
99
|
-
)
|
|
100
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
101
|
-
id: "rId2",
|
|
102
|
-
type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
|
|
103
|
-
target: "docProps/core.xml",
|
|
104
|
-
)
|
|
105
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
106
|
-
id: "rId3",
|
|
107
|
-
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
|
|
108
|
-
target: "docProps/app.xml",
|
|
109
|
-
)
|
|
110
|
-
rels
|
|
95
|
+
build_minimal_rels(MINIMAL_PACKAGE_REL_PARTS)
|
|
111
96
|
end
|
|
112
97
|
|
|
113
98
|
# Create minimal document relationships for a valid DOCX
|
|
114
99
|
def minimal_document_rels
|
|
100
|
+
build_minimal_rels(MINIMAL_DOCUMENT_REL_PARTS)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Build a Relationships part with sequential rIds (rId1..rIdN)
|
|
106
|
+
# for the given registry part keys, in the given order.
|
|
107
|
+
def build_minimal_rels(part_keys)
|
|
115
108
|
rels = Ooxml::Relationships::PackageRelationships.new
|
|
116
|
-
rels.relationships
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings",
|
|
125
|
-
target: "settings.xml",
|
|
126
|
-
)
|
|
127
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
128
|
-
id: "rId3",
|
|
129
|
-
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings",
|
|
130
|
-
target: "webSettings.xml",
|
|
131
|
-
)
|
|
132
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
133
|
-
id: "rId4",
|
|
134
|
-
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable",
|
|
135
|
-
target: "fontTable.xml",
|
|
136
|
-
)
|
|
109
|
+
rels.relationships = part_keys.each_with_index.map do |key, idx|
|
|
110
|
+
defn = Ooxml::PartRegistry.find_by_key(key)
|
|
111
|
+
Ooxml::Relationships::Relationship.new(
|
|
112
|
+
id: "rId#{idx + 1}",
|
|
113
|
+
type: defn.rel_type,
|
|
114
|
+
target: defn.target,
|
|
115
|
+
)
|
|
116
|
+
end
|
|
137
117
|
rels
|
|
138
118
|
end
|
|
139
119
|
end
|