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
|
@@ -6,43 +6,21 @@ module Uniword
|
|
|
6
6
|
# Cross-part referential integrity enforcement.
|
|
7
7
|
#
|
|
8
8
|
# Verifies and repairs references between document.xml and other
|
|
9
|
-
# package parts.
|
|
10
|
-
# -
|
|
11
|
-
#
|
|
9
|
+
# package parts. Both the allocator (builder) path and the legacy
|
|
10
|
+
# (template-loaded) path repair by stripping: identical input yields
|
|
11
|
+
# identical referential outcome regardless of path. Unrepairable
|
|
12
|
+
# leftovers are rejected by the write-time PackageIntegrityChecker.
|
|
13
|
+
# - For removable inline refs (notes, hyperlinks, drawings): strip
|
|
14
|
+
# - For structural refs (styles, numbering): strip
|
|
12
15
|
# - For relationship refs (rId): reconcile against actual parts
|
|
16
|
+
# - For relationship targets: strip rels whose target part the
|
|
17
|
+
# package does not carry (the part is never re-emitted, so the
|
|
18
|
+
# rel would dangle in the saved package)
|
|
13
19
|
# - For uniqueness constraints: deduplicate with deterministic resolution
|
|
14
20
|
module ReferentialIntegrity
|
|
15
21
|
def reconcile_referential_integrity
|
|
16
22
|
return unless package.document&.body
|
|
17
23
|
|
|
18
|
-
if allocator
|
|
19
|
-
validate_builder_references
|
|
20
|
-
else
|
|
21
|
-
repair_all_references
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
reconcile_style_references
|
|
25
|
-
reconcile_style_inheritance
|
|
26
|
-
reconcile_numbering_body_references
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
# Allocator present: builders produce correct output.
|
|
32
|
-
# Validate builder-constrained references — warn on issues
|
|
33
|
-
# but don't silently strip (indicates a builder bug).
|
|
34
|
-
def validate_builder_references
|
|
35
|
-
validate_note_references(:footnote)
|
|
36
|
-
validate_note_references(:endnote)
|
|
37
|
-
reconcile_image_references
|
|
38
|
-
validate_hyperlink_references
|
|
39
|
-
ensure_para_id_uniqueness
|
|
40
|
-
ensure_rid_uniqueness
|
|
41
|
-
reconcile_sect_pr_references
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# No allocator: legacy repair path for template-loaded content.
|
|
45
|
-
def repair_all_references
|
|
46
24
|
reconcile_note_body_references(:footnote)
|
|
47
25
|
reconcile_note_body_references(:endnote)
|
|
48
26
|
reconcile_sect_pr_references
|
|
@@ -50,72 +28,14 @@ module Uniword
|
|
|
50
28
|
reconcile_hyperlink_references
|
|
51
29
|
ensure_para_id_uniqueness
|
|
52
30
|
ensure_rid_uniqueness
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# Validate that note references in the body match existing notes.
|
|
56
|
-
# Logs warnings but does NOT strip — builder should produce correct refs.
|
|
57
|
-
def validate_note_references(type)
|
|
58
|
-
notes = notes_collection_for(type)
|
|
59
|
-
return unless notes
|
|
60
|
-
|
|
61
|
-
entries = note_entries_for(notes, type)
|
|
62
|
-
defined_ids = entries
|
|
63
|
-
.reject { |e| VALID_NOTE_TYPES.include?(e.type) }
|
|
64
|
-
.filter_map(&:id).to_set
|
|
65
|
-
|
|
66
|
-
dangling = 0
|
|
67
|
-
walk_body_paragraphs(package.document.body) do |para|
|
|
68
|
-
para.runs.each do |run|
|
|
69
|
-
ref = note_reference_from_run(run, type)
|
|
70
|
-
next unless ref&.id
|
|
71
|
-
next if defined_ids.include?(ref.id)
|
|
72
|
-
|
|
73
|
-
dangling += 1
|
|
74
|
-
Uniword.logger&.warn do
|
|
75
|
-
"Dangling #{type}note reference id=#{ref.id} in body — " \
|
|
76
|
-
"builder produced invalid reference"
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
31
|
+
reconcile_relationship_targets
|
|
80
32
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"WARNING: Found #{dangling} dangling #{type}note reference(s) " \
|
|
85
|
-
"in body (allocator path — builder bug)")
|
|
33
|
+
reconcile_style_references
|
|
34
|
+
reconcile_style_inheritance
|
|
35
|
+
reconcile_numbering_body_references
|
|
86
36
|
end
|
|
87
37
|
|
|
88
|
-
|
|
89
|
-
# Logs warnings but does NOT strip — builder should register hyperlinks.
|
|
90
|
-
def validate_hyperlink_references
|
|
91
|
-
rels = package.document_rels
|
|
92
|
-
return unless rels
|
|
93
|
-
|
|
94
|
-
valid_rids = rels.relationships.to_set(&:id)
|
|
95
|
-
return if valid_rids.empty?
|
|
96
|
-
|
|
97
|
-
dangling = 0
|
|
98
|
-
walk_body_paragraphs(package.document.body) do |para|
|
|
99
|
-
(para.hyperlinks || []).each do |hl|
|
|
100
|
-
next if hl.anchor
|
|
101
|
-
next unless hl.id
|
|
102
|
-
next unless hl.id.match?(/\ArId\d+\z/i)
|
|
103
|
-
next if valid_rids.include?(hl.id)
|
|
104
|
-
|
|
105
|
-
dangling += 1
|
|
106
|
-
Uniword.logger&.warn do
|
|
107
|
-
"Dangling hyperlink r:id=#{hl.id} in body — " \
|
|
108
|
-
"builder failed to register hyperlink with allocator"
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
return unless dangling.positive?
|
|
114
|
-
|
|
115
|
-
record_fix(FixCodes::DANGLING_HYPERLINK_WARNING,
|
|
116
|
-
"WARNING: Found #{dangling} dangling hyperlink reference(s) " \
|
|
117
|
-
"in body (allocator path — builder bug)")
|
|
118
|
-
end
|
|
38
|
+
private
|
|
119
39
|
|
|
120
40
|
# -- Note references (body → footnotes.xml / endnotes.xml) --
|
|
121
41
|
|
|
@@ -144,17 +64,21 @@ module Uniword
|
|
|
144
64
|
return unless removed.positive?
|
|
145
65
|
|
|
146
66
|
record_fix(FixCodes::DANGLING_NOTE_REFERENCE_REMOVED,
|
|
147
|
-
"Removed #{removed} dangling #{type}note reference(s) in body"
|
|
67
|
+
"Removed #{removed} dangling #{type}note reference(s) in body",
|
|
68
|
+
part: "word/document.xml")
|
|
148
69
|
end
|
|
149
70
|
|
|
150
71
|
# -- Section property references (sectPr → document.xml.rels) --
|
|
151
72
|
|
|
73
|
+
# Strip header/footer references whose rId does not resolve.
|
|
74
|
+
# Builder-registered headers/footers are wired into document_rels
|
|
75
|
+
# during Group 1 (wire_header_footer_parts), so any reference
|
|
76
|
+
# still dangling here has no backing part in the unified store.
|
|
152
77
|
def reconcile_sect_pr_references
|
|
153
78
|
sect_pr = package.document&.body&.section_properties
|
|
154
79
|
return unless sect_pr
|
|
155
80
|
|
|
156
81
|
valid_rids = collect_valid_header_footer_rids
|
|
157
|
-
return if valid_rids.empty?
|
|
158
82
|
|
|
159
83
|
removed = 0
|
|
160
84
|
[sect_pr.header_references, sect_pr.footer_references].each do |refs|
|
|
@@ -167,14 +91,15 @@ module Uniword
|
|
|
167
91
|
return unless removed.positive?
|
|
168
92
|
|
|
169
93
|
record_fix(FixCodes::DANGLING_HEADER_FOOTER_REMOVED,
|
|
170
|
-
"Removed #{removed} dangling header/footer reference(s) from sectPr"
|
|
94
|
+
"Removed #{removed} dangling header/footer reference(s) from sectPr",
|
|
95
|
+
part: "word/document.xml")
|
|
171
96
|
end
|
|
172
97
|
|
|
173
98
|
def collect_valid_header_footer_rids
|
|
174
99
|
rids = Set.new
|
|
175
100
|
|
|
176
101
|
(package.document&.header_footer_parts || []).each do |part|
|
|
177
|
-
rids << part
|
|
102
|
+
rids << part.r_id if part.r_id
|
|
178
103
|
end
|
|
179
104
|
|
|
180
105
|
collect_rels_by_type(rids,
|
|
@@ -212,7 +137,8 @@ module Uniword
|
|
|
212
137
|
return unless removed.positive?
|
|
213
138
|
|
|
214
139
|
record_fix(FixCodes::DANGLING_STYLE_REFERENCE_REMOVED,
|
|
215
|
-
"Removed #{removed} dangling style reference(s) from body"
|
|
140
|
+
"Removed #{removed} dangling style reference(s) from body",
|
|
141
|
+
part: "word/document.xml")
|
|
216
142
|
end
|
|
217
143
|
|
|
218
144
|
# -- Style inheritance (styles.xml self-references) --
|
|
@@ -241,7 +167,8 @@ module Uniword
|
|
|
241
167
|
return unless stripped.positive?
|
|
242
168
|
|
|
243
169
|
record_fix(FixCodes::DANGLING_BASED_ON_REMOVED,
|
|
244
|
-
"Removed #{stripped} dangling basedOn/link reference(s) in styles"
|
|
170
|
+
"Removed #{stripped} dangling basedOn/link reference(s) in styles",
|
|
171
|
+
part: "word/styles.xml")
|
|
245
172
|
end
|
|
246
173
|
|
|
247
174
|
# -- Numbering references (body → numbering.xml) --
|
|
@@ -271,11 +198,16 @@ module Uniword
|
|
|
271
198
|
return unless removed.positive?
|
|
272
199
|
|
|
273
200
|
record_fix(FixCodes::DANGLING_NUMBERING_REMOVED,
|
|
274
|
-
"Removed #{removed} dangling numbering reference(s) from body"
|
|
201
|
+
"Removed #{removed} dangling numbering reference(s) from body",
|
|
202
|
+
part: "word/document.xml")
|
|
275
203
|
end
|
|
276
204
|
|
|
277
205
|
# -- Image references (blip/@r:embed → document.xml.rels) --
|
|
278
206
|
|
|
207
|
+
# Remove drawings whose r:embed has no backing image: either no
|
|
208
|
+
# matching relationship, or a relationship whose target part the
|
|
209
|
+
# package does not carry (the rel is R32-stripped below, so both
|
|
210
|
+
# repairs complete in a single pass regardless of order).
|
|
279
211
|
def reconcile_image_references
|
|
280
212
|
rels = package.document_rels
|
|
281
213
|
return unless rels
|
|
@@ -283,28 +215,57 @@ module Uniword
|
|
|
283
215
|
valid_rids = rels.relationships.to_set(&:id)
|
|
284
216
|
return if valid_rids.empty?
|
|
285
217
|
|
|
286
|
-
|
|
218
|
+
carried = carried_part_paths
|
|
219
|
+
targets_by_id = rels.relationships.to_h { |r| [r.id, r.target] }
|
|
220
|
+
removed = 0
|
|
287
221
|
|
|
288
222
|
walk_body_paragraphs(package.document.body) do |para|
|
|
289
223
|
(para.runs || []).each do |run|
|
|
290
|
-
|
|
291
|
-
drawing_embed_rids(drawing).each do |rid|
|
|
292
|
-
next if valid_rids.include?(rid)
|
|
224
|
+
next unless run.drawings
|
|
293
225
|
|
|
294
|
-
|
|
295
|
-
|
|
226
|
+
kept = run.drawings.reject do |drawing|
|
|
227
|
+
dangling_drawing?(drawing, valid_rids, targets_by_id,
|
|
228
|
+
carried)
|
|
296
229
|
end
|
|
230
|
+
removed += run.drawings.size - kept.size
|
|
231
|
+
run.drawings = kept
|
|
297
232
|
end
|
|
298
233
|
end
|
|
299
234
|
|
|
300
|
-
return unless
|
|
235
|
+
return unless removed.positive?
|
|
301
236
|
|
|
302
237
|
record_fix(FixCodes::DANGLING_DRAWING_REMOVED,
|
|
303
|
-
"
|
|
238
|
+
"Removed #{removed} drawing(s) with dangling image reference(s)",
|
|
239
|
+
part: "word/document.xml")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A drawing is dangling when it carries embed references and
|
|
243
|
+
# none of them resolves to a relationship with a carried target.
|
|
244
|
+
def dangling_drawing?(drawing, valid_rids, targets_by_id, carried)
|
|
245
|
+
rids = drawing_embed_rids(drawing)
|
|
246
|
+
return false if rids.empty?
|
|
247
|
+
|
|
248
|
+
rids.none? do |rid|
|
|
249
|
+
valid_rids.include?(rid) &&
|
|
250
|
+
carried_relationship_target?(targets_by_id[rid], carried)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A rel target backs the rId only when the package emits it
|
|
255
|
+
# (External and fragment targets do not need a part).
|
|
256
|
+
def carried_relationship_target?(target, carried)
|
|
257
|
+
target = target.to_s
|
|
258
|
+
return true if target.empty? || target.start_with?("#")
|
|
259
|
+
|
|
260
|
+
carried.include?(resolve_relationship_target("word", target))
|
|
304
261
|
end
|
|
305
262
|
|
|
306
263
|
# -- Hyperlink references (hyperlink/@r:id → document.xml.rels) --
|
|
307
264
|
|
|
265
|
+
# Repair hyperlink references: dangling rIds are stripped (like
|
|
266
|
+
# other referential repairs); literal URLs placed in r:id by
|
|
267
|
+
# Builder.hyperlink's target= are promoted to proper External
|
|
268
|
+
# relationships so the package stays valid and the link survives.
|
|
308
269
|
def reconcile_hyperlink_references
|
|
309
270
|
rels = package.document_rels
|
|
310
271
|
return unless rels
|
|
@@ -313,23 +274,58 @@ module Uniword
|
|
|
313
274
|
return if valid_rids.empty?
|
|
314
275
|
|
|
315
276
|
removed = 0
|
|
277
|
+
promoted = 0
|
|
316
278
|
|
|
317
279
|
walk_body_paragraphs(package.document.body) do |para|
|
|
318
280
|
(para.hyperlinks || []).reject! do |hl|
|
|
319
281
|
next false if hl.anchor
|
|
320
282
|
next false unless hl.id
|
|
321
|
-
next false unless hl.id.match?(/\ArId\d+\z/i)
|
|
322
283
|
next false if valid_rids.include?(hl.id)
|
|
323
284
|
|
|
324
|
-
|
|
325
|
-
|
|
285
|
+
if hl.id.match?(/\ArId\d+\z/i)
|
|
286
|
+
removed += 1
|
|
287
|
+
true
|
|
288
|
+
else
|
|
289
|
+
promote_literal_hyperlink(rels, hl, valid_rids)
|
|
290
|
+
promoted += 1
|
|
291
|
+
false
|
|
292
|
+
end
|
|
326
293
|
end
|
|
327
294
|
end
|
|
328
295
|
|
|
296
|
+
if promoted.positive?
|
|
297
|
+
record_fix(FixCodes::HYPERLINK_RELATIONSHIP_CREATED,
|
|
298
|
+
"Promoted #{promoted} literal hyperlink target(s) " \
|
|
299
|
+
"to external relationship(s)",
|
|
300
|
+
part: "word/_rels/document.xml.rels")
|
|
301
|
+
end
|
|
302
|
+
|
|
329
303
|
return unless removed.positive?
|
|
330
304
|
|
|
331
305
|
record_fix(FixCodes::DANGLING_HYPERLINK_REMOVED,
|
|
332
|
-
"Removed #{removed} dangling hyperlink reference(s) from body"
|
|
306
|
+
"Removed #{removed} dangling hyperlink reference(s) from body",
|
|
307
|
+
part: "word/document.xml")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Promote a hyperlink whose r:id holds a literal URL to a proper
|
|
311
|
+
# External relationship, repointing the hyperlink at the new rId.
|
|
312
|
+
# The rId comes from the allocator (single authority); repeated
|
|
313
|
+
# literals for the same URL share one relationship.
|
|
314
|
+
def promote_literal_hyperlink(rels, hyperlink, valid_rids)
|
|
315
|
+
url = hyperlink.id.to_s
|
|
316
|
+
rel_type = Ooxml::PartRegistry.find_by_key(:hyperlink).rel_type
|
|
317
|
+
new_rid = allocator.alloc_rid(target: url, type: rel_type,
|
|
318
|
+
target_mode: "External")
|
|
319
|
+
unless valid_rids.include?(new_rid)
|
|
320
|
+
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
321
|
+
id: new_rid,
|
|
322
|
+
type: rel_type,
|
|
323
|
+
target: url,
|
|
324
|
+
target_mode: "External",
|
|
325
|
+
)
|
|
326
|
+
valid_rids << new_rid
|
|
327
|
+
end
|
|
328
|
+
hyperlink.id = new_rid
|
|
333
329
|
end
|
|
334
330
|
|
|
335
331
|
# -- Uniqueness constraints --
|
|
@@ -353,9 +349,15 @@ module Uniword
|
|
|
353
349
|
return unless collisions.positive?
|
|
354
350
|
|
|
355
351
|
record_fix(FixCodes::PARAGRAPH_BACKFILL,
|
|
356
|
-
"Resolved #{collisions} paraId collision(s)"
|
|
352
|
+
"Resolved #{collisions} paraId collision(s)",
|
|
353
|
+
part: "word/document.xml")
|
|
357
354
|
end
|
|
358
355
|
|
|
356
|
+
# rId uniqueness safety net. With the allocator as single
|
|
357
|
+
# authority, duplicate rIds are impossible by construction; this
|
|
358
|
+
# pass only fires on pathological source input (duplicate ids
|
|
359
|
+
# in the loaded rels). Duplicates are renamed — never
|
|
360
|
+
# renumbered wholesale — with fresh ids from the allocator.
|
|
359
361
|
def ensure_rid_uniqueness
|
|
360
362
|
rels = package.document_rels
|
|
361
363
|
return unless rels
|
|
@@ -375,14 +377,192 @@ module Uniword
|
|
|
375
377
|
|
|
376
378
|
return if duplicates.empty?
|
|
377
379
|
|
|
380
|
+
rename_duplicate_rids(duplicates)
|
|
381
|
+
|
|
382
|
+
record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
|
|
383
|
+
"Resolved #{duplicates.size} rId collision(s)",
|
|
384
|
+
part: "word/_rels/document.xml.rels")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Rename duplicate rels with fresh allocator ids and register
|
|
388
|
+
# the new bindings so later lookups stay consistent.
|
|
389
|
+
def rename_duplicate_rids(duplicates)
|
|
378
390
|
duplicates.each do |rel|
|
|
379
391
|
old_id = rel.id
|
|
380
|
-
rel.id =
|
|
392
|
+
rel.id = allocator.register_rid(
|
|
393
|
+
allocator.next_free_rid,
|
|
394
|
+
target: rel.target, type: rel.type,
|
|
395
|
+
target_mode: rel.target_mode
|
|
396
|
+
)
|
|
381
397
|
record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
|
|
382
|
-
"Deduplicated rId #{old_id} → #{rel.id}"
|
|
398
|
+
"Deduplicated rId #{old_id} → #{rel.id}",
|
|
399
|
+
part: "word/_rels/document.xml.rels")
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# -- Relationship targets (rels → emitted parts) --
|
|
404
|
+
|
|
405
|
+
# Strip relationships whose target part the package does not
|
|
406
|
+
# carry. Group 3 preserves non-standard source rels verbatim,
|
|
407
|
+
# including rels to parts uniword does not model (e.g.
|
|
408
|
+
# docProps/meta.xml); those parts are never re-emitted, so the
|
|
409
|
+
# rel would dangle (OPC-006) in the saved package.
|
|
410
|
+
def reconcile_relationship_targets
|
|
411
|
+
carried = carried_part_paths
|
|
412
|
+
|
|
413
|
+
rels_collections.each do |rels, base_dir, part|
|
|
414
|
+
strip_dangling_relationship_targets(rels, base_dir, carried,
|
|
415
|
+
part)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# [rels, base directory, .rels part path] for every relationships
|
|
420
|
+
# part the package serializes.
|
|
421
|
+
def rels_collections
|
|
422
|
+
[
|
|
423
|
+
[package.package_rels, "", "_rels/.rels"],
|
|
424
|
+
[package.document_rels, "word", "word/_rels/document.xml.rels"],
|
|
425
|
+
[package.settings_rels, "word", "word/_rels/settings.xml.rels"],
|
|
426
|
+
[package.theme_rels, "word/theme",
|
|
427
|
+
"word/theme/_rels/theme1.xml.rels"],
|
|
428
|
+
[package.footnotes_rels, "word", "word/_rels/footnotes.xml.rels"],
|
|
429
|
+
[package.endnotes_rels, "word", "word/_rels/endnotes.xml.rels"],
|
|
430
|
+
]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def strip_dangling_relationship_targets(rels, base_dir, carried,
|
|
434
|
+
part)
|
|
435
|
+
return unless rels&.relationships
|
|
436
|
+
|
|
437
|
+
dangling = rels.relationships.select do |rel|
|
|
438
|
+
dangling_relationship_target?(rel, base_dir, carried)
|
|
439
|
+
end
|
|
440
|
+
return if dangling.empty?
|
|
441
|
+
|
|
442
|
+
dangling.each { |rel| rels.relationships.delete(rel) }
|
|
443
|
+
record_target_removal(dangling, part)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def record_target_removal(dangling, part)
|
|
447
|
+
targets = dangling.map { |rel| rel.target.to_s }.join(", ")
|
|
448
|
+
record_fix(FixCodes::DANGLING_RELATIONSHIP_TARGET_REMOVED,
|
|
449
|
+
"Removed #{dangling.size} relationship(s) to parts " \
|
|
450
|
+
"not carried by the package: #{targets}",
|
|
451
|
+
part: part)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# A relationship dangles when it is internal and its resolved
|
|
455
|
+
# target is not among the parts the save path emits.
|
|
456
|
+
def dangling_relationship_target?(rel, base_dir, carried)
|
|
457
|
+
return false if rel.target_mode.to_s == "External"
|
|
458
|
+
|
|
459
|
+
target = rel.target.to_s
|
|
460
|
+
return false if target.empty? || target.start_with?("#")
|
|
461
|
+
|
|
462
|
+
!carried.include?(resolve_relationship_target(base_dir, target))
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Resolve a relationship target to a normalized package path,
|
|
466
|
+
# handling package-absolute (leading slash) targets and "..".
|
|
467
|
+
def resolve_relationship_target(base_dir, target)
|
|
468
|
+
return target[1..] if target.start_with?("/")
|
|
469
|
+
|
|
470
|
+
normalize_package_path(File.join(base_dir, target))
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Lexically normalize a package-relative path (resolve "." and
|
|
474
|
+
# ".." segments). Deliberately avoids File.expand_path, which
|
|
475
|
+
# prepends a drive letter on Windows.
|
|
476
|
+
def normalize_package_path(path)
|
|
477
|
+
segments = []
|
|
478
|
+
path.split("/").each do |segment|
|
|
479
|
+
next if segment.empty? || segment == "."
|
|
480
|
+
|
|
481
|
+
segment == ".." ? segments.pop : segments << segment
|
|
482
|
+
end
|
|
483
|
+
segments.join("/")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Package paths the save path emits, derived from the model —
|
|
487
|
+
# mirrors serialize_package_parts / inject_* emission.
|
|
488
|
+
def carried_part_paths
|
|
489
|
+
paths = carried_word_parts + carried_docprops_parts
|
|
490
|
+
paths.concat(custom_xml_item_paths)
|
|
491
|
+
paths.concat(document_part_paths)
|
|
492
|
+
paths.to_set
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# [model, package path] pairs for single-instance word/ parts.
|
|
496
|
+
def carried_word_parts
|
|
497
|
+
pairs = core_word_pairs + note_word_pairs
|
|
498
|
+
pairs.filter_map { |model, path| path if model }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def core_word_pairs
|
|
502
|
+
[
|
|
503
|
+
[package.document, "word/document.xml"],
|
|
504
|
+
[package.styles, "word/styles.xml"],
|
|
505
|
+
[package.numbering, "word/numbering.xml"],
|
|
506
|
+
[package.settings, "word/settings.xml"],
|
|
507
|
+
[package.font_table, "word/fontTable.xml"],
|
|
508
|
+
[package.web_settings, "word/webSettings.xml"],
|
|
509
|
+
]
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def note_word_pairs
|
|
513
|
+
[
|
|
514
|
+
[package.theme, "word/theme/theme1.xml"],
|
|
515
|
+
[package.footnotes, "word/footnotes.xml"],
|
|
516
|
+
[package.endnotes, "word/endnotes.xml"],
|
|
517
|
+
[package.comments, "word/comments.xml"],
|
|
518
|
+
[package.document&.bibliography_sources, "word/sources.xml"],
|
|
519
|
+
]
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# [model, package path] pairs for single-instance docProps/ parts.
|
|
523
|
+
def carried_docprops_parts
|
|
524
|
+
pairs = [
|
|
525
|
+
[package.core_properties, "docProps/core.xml"],
|
|
526
|
+
[package.app_properties, "docProps/app.xml"],
|
|
527
|
+
[package.custom_properties, "docProps/custom.xml"],
|
|
528
|
+
]
|
|
529
|
+
pairs.filter_map { |model, path| path if model }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def custom_xml_item_paths
|
|
533
|
+
(package.custom_xml_items || []).flat_map do |item|
|
|
534
|
+
paths = ["customXml/item#{item[:index]}.xml"]
|
|
535
|
+
if item[:props_xml]
|
|
536
|
+
paths << "customXml/itemProps#{item[:index]}.xml"
|
|
537
|
+
end
|
|
538
|
+
paths
|
|
383
539
|
end
|
|
540
|
+
end
|
|
384
541
|
|
|
385
|
-
|
|
542
|
+
# Paths emitted from the document model: media, charts,
|
|
543
|
+
# embeddings, headers and footers (unified part store).
|
|
544
|
+
def document_part_paths
|
|
545
|
+
doc = package.document
|
|
546
|
+
return [] unless doc
|
|
547
|
+
|
|
548
|
+
media_chart_paths(doc) + header_footer_paths(doc)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def media_chart_paths(doc)
|
|
552
|
+
paths = [doc.image_parts, doc.chart_parts].compact.flat_map do |parts|
|
|
553
|
+
parts.values.map { |data| "word/#{data[:target]}" }
|
|
554
|
+
end
|
|
555
|
+
paths.concat(embedding_paths)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def embedding_paths
|
|
559
|
+
(package.embeddings || {}).keys.map { |target| "word/#{target}" }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def header_footer_paths(doc)
|
|
563
|
+
doc.header_footer_parts.filter_map do |part|
|
|
564
|
+
part.target && "word/#{part.target}"
|
|
565
|
+
end
|
|
386
566
|
end
|
|
387
567
|
|
|
388
568
|
# -- Traversal helpers --
|
|
@@ -437,12 +617,6 @@ module Uniword
|
|
|
437
617
|
rids << rel.id if type_fragments.any? { |frag| t.include?(frag) }
|
|
438
618
|
end
|
|
439
619
|
end
|
|
440
|
-
|
|
441
|
-
def derive_unique_rid(relationships, _current_rel)
|
|
442
|
-
Ooxml::Relationships::PackageRelationships.next_available_rid(
|
|
443
|
-
relationships,
|
|
444
|
-
)
|
|
445
|
-
end
|
|
446
620
|
end
|
|
447
621
|
end
|
|
448
622
|
end
|