uniword 1.2.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +313 -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 +22 -14
- 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 +11 -10
- 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 +12 -18
- 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 +91 -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/image_part.rb +83 -0
- data/lib/uniword/docx/package.rb +141 -333
- data/lib/uniword/docx/package_defaults.rb +83 -99
- data/lib/uniword/docx/package_integrity_checker.rb +312 -0
- data/lib/uniword/docx/package_serialization.rb +144 -233
- data/lib/uniword/docx/part.rb +125 -0
- data/lib/uniword/docx/part_collection.rb +131 -0
- 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/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 +18 -31
- data/lib/uniword/docx/reconciler/notes.rb +17 -8
- data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
- data/lib/uniword/docx/reconciler/parts.rb +36 -13
- data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -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 +13 -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/images/image_manager.rb +13 -13
- 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/element_order.rb +94 -0
- data/lib/uniword/ooxml/part_definition.rb +329 -0
- data/lib/uniword/ooxml/part_registry.rb +504 -0
- data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
- 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 +3 -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 +6 -5
- data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
- data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
- data/lib/uniword/validation/engine.rb +35 -0
- data/lib/uniword/validation/link_checker.rb +1 -1
- data/lib/uniword/validation/opc_validator.rb +16 -2
- 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 +46 -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 +121 -122
- data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
- data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
- data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
- data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
- 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/numbering_elements.rb +0 -15
- 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 +5 -2
- data/lib/uniword.rb +27 -2
- metadata +52 -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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Fallback loader: preserves ZIP entries no registry definition
|
|
7
|
+
# claims as raw parts (RawPart) on the package, so unmodelled
|
|
8
|
+
# parts (docProps/meta.xml, glossary documents, VBA projects,
|
|
9
|
+
# unmodelled .rels sidecars, ...) round-trip byte-for-byte with
|
|
10
|
+
# their content types and relationships intact.
|
|
11
|
+
#
|
|
12
|
+
# Runs last (load_priority 130 on the :raw_parts definition), so
|
|
13
|
+
# the registry claims every path it models first and this loader
|
|
14
|
+
# carries only the remainder — no double emission. A path is
|
|
15
|
+
# claimed when any PartDefinition matches it (loadable or not),
|
|
16
|
+
# when it is the resolved main document part (or its rels
|
|
17
|
+
# sidecar) at a non-standard location, or when it is a customXml
|
|
18
|
+
# item rels sidecar consumed by CustomXmlLoader.
|
|
19
|
+
#
|
|
20
|
+
# Bytes are re-read from the original ZIP when available (the
|
|
21
|
+
# extracted hash may carry corrupted UTF-8 binary); content
|
|
22
|
+
# types come from the loaded [Content_Types].xml model.
|
|
23
|
+
class RawPartLoader
|
|
24
|
+
# customXml item rels sidecars are consumed by CustomXmlLoader
|
|
25
|
+
# together with their item part (captured group: item index).
|
|
26
|
+
CUSTOM_XML_ITEM_RELS =
|
|
27
|
+
%r{\AcustomXml/_rels/item(\d+)\.xml\.rels\z}
|
|
28
|
+
|
|
29
|
+
# @param context [LoadContext] shared load state
|
|
30
|
+
# @param _definition [Ooxml::PartDefinition] :raw_parts
|
|
31
|
+
# (strategy interface; the loader claims by remainder, not
|
|
32
|
+
# by definition matching)
|
|
33
|
+
# @return [void]
|
|
34
|
+
def load(context, _definition)
|
|
35
|
+
paths = unclaimed_paths(context)
|
|
36
|
+
return if paths.empty?
|
|
37
|
+
|
|
38
|
+
bytes = raw_bytes(context, paths)
|
|
39
|
+
paths.each do |path|
|
|
40
|
+
next unless bytes[path]
|
|
41
|
+
|
|
42
|
+
context.package.raw_parts[path] = build_part(
|
|
43
|
+
context.package, path, bytes[path]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @return [Array<String>] ZIP entries claimed by no registry
|
|
51
|
+
# loader, in zip_content order
|
|
52
|
+
def unclaimed_paths(context)
|
|
53
|
+
context.zip_content.keys.reject { |path| claimed?(context, path) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def claimed?(context, path)
|
|
57
|
+
return true if Ooxml::PartRegistry.find_by_path(path)
|
|
58
|
+
return true if path == context.main_document_path
|
|
59
|
+
return true if path == context.main_document_rels_path
|
|
60
|
+
|
|
61
|
+
custom_xml_item_rels?(context, path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A customXml item rels sidecar is claimed (via CustomXmlItem)
|
|
65
|
+
# when its item part is present in the ZIP.
|
|
66
|
+
def custom_xml_item_rels?(context, path)
|
|
67
|
+
match = CUSTOM_XML_ITEM_RELS.match(path)
|
|
68
|
+
return false unless match
|
|
69
|
+
|
|
70
|
+
context.zip_content.key?("customXml/item#{match[1]}.xml")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [RawPart]
|
|
74
|
+
def build_part(package, path, content)
|
|
75
|
+
rel = referencing_relationship(package, path)
|
|
76
|
+
RawPart.new(
|
|
77
|
+
path: path,
|
|
78
|
+
content: content,
|
|
79
|
+
content_type: content_type_for(package, path),
|
|
80
|
+
r_id: rel&.id,
|
|
81
|
+
rel_type: rel&.type,
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Bytes for each path: re-read from the original ZIP when
|
|
86
|
+
# available (binary-safe), else the extracted content.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] package path => bytes
|
|
89
|
+
def raw_bytes(context, paths)
|
|
90
|
+
unless context.zip_path
|
|
91
|
+
return paths.to_h { |p| [p, context.zip_content[p]] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
read_from_zip(context.zip_path, paths)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_from_zip(zip_path, paths)
|
|
98
|
+
require "zip"
|
|
99
|
+
Zip::File.open(zip_path) do |zip_file|
|
|
100
|
+
paths.to_h do |path|
|
|
101
|
+
entry = zip_file.find_entry(path)
|
|
102
|
+
[path, entry&.get_input_stream&.read]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Content type the source [Content_Types].xml declared for the
|
|
108
|
+
# part: its Override first, else the Default for its extension.
|
|
109
|
+
#
|
|
110
|
+
# @return [String, nil] nil when the source declared neither
|
|
111
|
+
def content_type_for(package, path)
|
|
112
|
+
content_types = package.content_types
|
|
113
|
+
return nil unless content_types
|
|
114
|
+
|
|
115
|
+
override_type(content_types, path) ||
|
|
116
|
+
default_type(content_types, path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def override_type(content_types, path)
|
|
120
|
+
part_name = "/#{path}"
|
|
121
|
+
content_types.overrides.find do |o|
|
|
122
|
+
o.part_name == part_name
|
|
123
|
+
end&.content_type
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_type(content_types, path)
|
|
127
|
+
ext = File.extname(path)[1..]
|
|
128
|
+
return nil unless ext
|
|
129
|
+
|
|
130
|
+
content_types.defaults.find do |d|
|
|
131
|
+
d.extension == ext
|
|
132
|
+
end&.content_type
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The first relationship in a modelled rels part whose
|
|
136
|
+
# resolved target is this part, or nil. Recorded as metadata
|
|
137
|
+
# only; the rel itself stays in its rels collection.
|
|
138
|
+
def referencing_relationship(package, path)
|
|
139
|
+
rels_collections(package).each do |rels, base_dir|
|
|
140
|
+
rel = rels&.relationships&.find do |r|
|
|
141
|
+
resolve_target(base_dir, r.target.to_s) == path
|
|
142
|
+
end
|
|
143
|
+
return rel if rel
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# [rels collection, base directory] pairs mirroring the rels
|
|
149
|
+
# parts the package models.
|
|
150
|
+
def rels_collections(package)
|
|
151
|
+
[
|
|
152
|
+
[package.package_rels, ""],
|
|
153
|
+
[package.document_rels, "word"],
|
|
154
|
+
[package.settings_rels, "word"],
|
|
155
|
+
[package.theme_rels, "word/theme"],
|
|
156
|
+
[package.footnotes_rels, "word"],
|
|
157
|
+
[package.endnotes_rels, "word"],
|
|
158
|
+
]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Resolve a relationship target to a normalized package path,
|
|
162
|
+
# handling package-absolute (leading slash) targets and "..".
|
|
163
|
+
def resolve_target(base_dir, target)
|
|
164
|
+
return target[1..] if target.start_with?("/")
|
|
165
|
+
|
|
166
|
+
normalize_package_path(File.join(base_dir, target))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Lexically normalize a package-relative path (resolve "." and
|
|
170
|
+
# ".." segments).
|
|
171
|
+
def normalize_package_path(path)
|
|
172
|
+
segments = []
|
|
173
|
+
path.split("/").each do |segment|
|
|
174
|
+
next if segment.empty? || segment == "."
|
|
175
|
+
|
|
176
|
+
segment == ".." ? segments.pop : segments << segment
|
|
177
|
+
end
|
|
178
|
+
segments.join("/")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads theme media files (word/theme/media/*) into the parsed
|
|
7
|
+
# theme's media_files. Runs only when a theme part was loaded —
|
|
8
|
+
# theme media without a theme is ignored, as before.
|
|
9
|
+
class ThemeMediaLoader
|
|
10
|
+
# @param context [LoadContext] shared load state
|
|
11
|
+
# @param definition [Ooxml::PartDefinition] :theme_media
|
|
12
|
+
# @return [void]
|
|
13
|
+
def load(context, definition)
|
|
14
|
+
theme = context.package.theme
|
|
15
|
+
return unless theme
|
|
16
|
+
|
|
17
|
+
media = extract_media(context, definition)
|
|
18
|
+
theme.media_files = media if media.any?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @return [Hash] filename => Themes::MediaFile
|
|
24
|
+
def extract_media(context, definition)
|
|
25
|
+
prefix = definition.pattern_prefix
|
|
26
|
+
paths = context.matching_paths(definition)
|
|
27
|
+
paths.each_with_object({}) do |path, media|
|
|
28
|
+
filename = path.delete_prefix(prefix)
|
|
29
|
+
media[filename] = Uniword::Themes::MediaFile.new(
|
|
30
|
+
filename: filename,
|
|
31
|
+
content: context.zip_content[path],
|
|
32
|
+
source_path: path,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class PartLoader
|
|
6
|
+
# Loads fixed-path XML parts: parses the part's XML with the
|
|
7
|
+
# definition's loader_model and assigns the model to the
|
|
8
|
+
# definition's package attribute. Covers every part whose load
|
|
9
|
+
# is "parse one part into one attribute": the content types,
|
|
10
|
+
# all rels parts, docProps, the main document, styles,
|
|
11
|
+
# numbering, settings, font table, web settings, theme,
|
|
12
|
+
# footnotes, endnotes, and comments.
|
|
13
|
+
#
|
|
14
|
+
# Definitions with a path_resolution rule resolve their path
|
|
15
|
+
# dynamically (main document via the package officeDocument
|
|
16
|
+
# relationship; its rels part as the sidecar of the resolved
|
|
17
|
+
# path), falling back to the definition's fixed path.
|
|
18
|
+
class XmlModelLoader
|
|
19
|
+
# @param context [LoadContext] shared load state
|
|
20
|
+
# @param definition [Ooxml::PartDefinition] part to load
|
|
21
|
+
# @return [void]
|
|
22
|
+
def load(context, definition)
|
|
23
|
+
content = read_content(context, definition)
|
|
24
|
+
return unless content
|
|
25
|
+
|
|
26
|
+
model = definition.loader_model.from_xml(content)
|
|
27
|
+
context.package.method(:"#{definition.package_attribute}=")
|
|
28
|
+
.call(model)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Part content from the first candidate path present in the
|
|
34
|
+
# ZIP, preserving the historic resolved-then-fixed fallback.
|
|
35
|
+
def read_content(context, definition)
|
|
36
|
+
candidate_paths(context, definition).each do |path|
|
|
37
|
+
content = context.zip_content[path]
|
|
38
|
+
return content if content
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def candidate_paths(context, definition)
|
|
44
|
+
case definition.path_resolution
|
|
45
|
+
when :office_document
|
|
46
|
+
[context.main_document_path, definition.path].compact
|
|
47
|
+
when :office_document_rels
|
|
48
|
+
[context.main_document_rels_path, definition.path].compact
|
|
49
|
+
else
|
|
50
|
+
[definition.path]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Registry-driven loader for DOCX package parts.
|
|
6
|
+
#
|
|
7
|
+
# Replaces the former hand-written per-part sequence in
|
|
8
|
+
# Package.from_zip_content: parts load by iterating
|
|
9
|
+
# Ooxml::PartRegistry.loadable (ordered by
|
|
10
|
+
# Ooxml::PartDefinition#load_priority) and dispatching each
|
|
11
|
+
# definition to the loader strategy registered under
|
|
12
|
+
# Ooxml::PartDefinition#loader.
|
|
13
|
+
#
|
|
14
|
+
# Open/closed: a new part kind adds a strategy class plus a
|
|
15
|
+
# register_loader call and a PartRegistry registration — this loop
|
|
16
|
+
# never changes.
|
|
17
|
+
#
|
|
18
|
+
# @example Load parts into a package
|
|
19
|
+
# package = Package.new
|
|
20
|
+
# PartLoader.load(zip_content, package, zip_path: "doc.docx")
|
|
21
|
+
class PartLoader
|
|
22
|
+
autoload :LoadContext, "#{__dir__}/part_loader/load_context"
|
|
23
|
+
autoload :XmlModelLoader, "#{__dir__}/part_loader/xml_model_loader"
|
|
24
|
+
autoload :CustomXmlLoader,
|
|
25
|
+
"#{__dir__}/part_loader/custom_xml_loader"
|
|
26
|
+
autoload :HeaderFooterLoader,
|
|
27
|
+
"#{__dir__}/part_loader/header_footer_loader"
|
|
28
|
+
autoload :ChartLoader, "#{__dir__}/part_loader/chart_loader"
|
|
29
|
+
autoload :ImageLoader, "#{__dir__}/part_loader/image_loader"
|
|
30
|
+
autoload :EmbeddingLoader,
|
|
31
|
+
"#{__dir__}/part_loader/embedding_loader"
|
|
32
|
+
autoload :ThemeMediaLoader,
|
|
33
|
+
"#{__dir__}/part_loader/theme_media_loader"
|
|
34
|
+
autoload :RawPartLoader,
|
|
35
|
+
"#{__dir__}/part_loader/raw_part_loader"
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Load every registered part from extracted ZIP content into
|
|
39
|
+
# the package.
|
|
40
|
+
#
|
|
41
|
+
# @param zip_content [Hash] extracted ZIP entries
|
|
42
|
+
# (package path => content)
|
|
43
|
+
# @param package [Package] package to populate
|
|
44
|
+
# @param zip_path [String, nil] original ZIP path for binary
|
|
45
|
+
# re-extraction (image bytes)
|
|
46
|
+
# @return [Package] the populated package
|
|
47
|
+
def load(zip_content, package, zip_path: nil)
|
|
48
|
+
context = LoadContext.new(zip_content: zip_content,
|
|
49
|
+
package: package, zip_path: zip_path)
|
|
50
|
+
Ooxml::PartRegistry.loadable.each do |definition|
|
|
51
|
+
loader_for(definition.loader).load(context, definition)
|
|
52
|
+
end
|
|
53
|
+
package
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Register a loader strategy under a key.
|
|
57
|
+
#
|
|
58
|
+
# @param key [Symbol] PartDefinition#loader value
|
|
59
|
+
# @param strategy [#load] strategy responding to
|
|
60
|
+
# +load(context, definition)+
|
|
61
|
+
# @return [Object] the registered strategy
|
|
62
|
+
def register_loader(key, strategy)
|
|
63
|
+
loaders[key.to_sym] = strategy
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param key [Symbol] PartDefinition#loader value
|
|
67
|
+
# @return [#load] the registered strategy
|
|
68
|
+
# @raise [ArgumentError] when no strategy is registered
|
|
69
|
+
def loader_for(key)
|
|
70
|
+
loaders.fetch(key.to_sym) do
|
|
71
|
+
raise ArgumentError, "unknown part loader: #{key.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def loaders
|
|
78
|
+
@loaders ||= {}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
register_loader(:xml_model, XmlModelLoader.new)
|
|
83
|
+
register_loader(:custom_xml, CustomXmlLoader.new)
|
|
84
|
+
register_loader(:header_footer, HeaderFooterLoader.new)
|
|
85
|
+
register_loader(:chart, ChartLoader.new)
|
|
86
|
+
register_loader(:image, ImageLoader.new)
|
|
87
|
+
register_loader(:embedding, EmbeddingLoader.new)
|
|
88
|
+
register_loader(:theme_media, ThemeMediaLoader.new)
|
|
89
|
+
register_loader(:raw, RawPartLoader.new)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# A package part no Ooxml::PartDefinition models, carried verbatim
|
|
6
|
+
# for round-trip fidelity (e.g. docProps/meta.xml, glossary
|
|
7
|
+
# documents, VBA projects, unmodelled .rels sidecars).
|
|
8
|
+
#
|
|
9
|
+
# Unlike document-scoped parts (charts, images, embeddings), a raw
|
|
10
|
+
# part keeps its full package-relative +path+ — raw parts live
|
|
11
|
+
# anywhere in the package, not only under word/. Content is the
|
|
12
|
+
# exact source byte stream (binary-safe; never parsed or
|
|
13
|
+
# re-encoded), plus the content type the source
|
|
14
|
+
# [Content_Types].xml declared for it (Override first, then
|
|
15
|
+
# Default by extension; nil when the source declared neither).
|
|
16
|
+
#
|
|
17
|
+
# The relationship referencing the part (when one exists in a
|
|
18
|
+
# modelled .rels part) is recorded as +r_id+/+rel_type+ metadata
|
|
19
|
+
# for introspection parity with other part kinds; emission never
|
|
20
|
+
# uses it — relationships re-serialize from the loaded rels
|
|
21
|
+
# models, so the source rel survives untouched.
|
|
22
|
+
#
|
|
23
|
+
# Raw parts are a fallback: PartLoader claims every registry-known
|
|
24
|
+
# path first and RawPartLoader carries only the remainder, so a
|
|
25
|
+
# part is never emitted twice.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# part = RawPart.new(
|
|
29
|
+
# path: "docProps/meta.xml",
|
|
30
|
+
# content: bytes,
|
|
31
|
+
# content_type: "application/xml",
|
|
32
|
+
# )
|
|
33
|
+
# part.package_paths # => ["docProps/meta.xml"]
|
|
34
|
+
class RawPart < Part
|
|
35
|
+
# @return [String, nil] package-relative path, verbatim
|
|
36
|
+
attr_writer :path
|
|
37
|
+
|
|
38
|
+
# @param path [String, nil] package-relative path
|
|
39
|
+
# (e.g. "docProps/meta.xml")
|
|
40
|
+
# @param content [String, nil] raw part bytes (binary-safe)
|
|
41
|
+
# @param content_type [String, nil] content type declared by the
|
|
42
|
+
# source [Content_Types].xml
|
|
43
|
+
# @param r_id [String, nil] id of the relationship referencing
|
|
44
|
+
# this part (metadata only)
|
|
45
|
+
# @param rel_type [String, nil] type of the relationship
|
|
46
|
+
# referencing this part (metadata only)
|
|
47
|
+
def initialize(path: nil, content: nil, content_type: nil,
|
|
48
|
+
r_id: nil, rel_type: nil)
|
|
49
|
+
super(content: content, content_type: content_type,
|
|
50
|
+
r_id: r_id, rel_type: rel_type)
|
|
51
|
+
@path = path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wrap a raw-hash entry ({ path:, content:, content_type: })
|
|
55
|
+
# into a RawPart. Used by PartCollection to normalize hash
|
|
56
|
+
# assignments.
|
|
57
|
+
#
|
|
58
|
+
# @param hash [Hash] raw hash entry
|
|
59
|
+
# @return [RawPart]
|
|
60
|
+
def self.from_hash(hash)
|
|
61
|
+
new(path: hash[:path], content: hash[:content],
|
|
62
|
+
content_type: hash[:content_type],
|
|
63
|
+
r_id: hash[:r_id], rel_type: hash[:rel_type])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Package-relative path of the emitted part, carried verbatim
|
|
67
|
+
# (overrides Part's word/-prefixed target derivation).
|
|
68
|
+
#
|
|
69
|
+
# @return [String, nil] e.g. "word/glossary/document.xml"
|
|
70
|
+
def path
|
|
71
|
+
@path
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -21,7 +21,8 @@ module Uniword
|
|
|
21
21
|
doc_grid: Wordprocessingml::PageDefaults.default_doc_grid,
|
|
22
22
|
)
|
|
23
23
|
record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
|
|
24
|
-
"Added default section properties with US Letter page size"
|
|
24
|
+
"Added default section properties with US Letter page size",
|
|
25
|
+
part: "word/document.xml")
|
|
25
26
|
return
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -41,70 +42,82 @@ module Uniword
|
|
|
41
42
|
end
|
|
42
43
|
if fixed
|
|
43
44
|
record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
|
|
44
|
-
"Filled missing pgSz/pgMar/cols in existing section properties"
|
|
45
|
+
"Filled missing pgSz/pgMar/cols in existing section properties",
|
|
46
|
+
part: "word/document.xml")
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def reconcile_headers_footers
|
|
51
|
+
parts = package.document&.header_footer_parts
|
|
49
52
|
ignorable = Ooxml::Types::McIgnorable.new(FULL_IGNORABLE)
|
|
50
|
-
set_header_footer_ignorable(package.document&.headers, ignorable)
|
|
51
|
-
set_header_footer_ignorable(package.document&.footers, ignorable)
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
backfill_header_footer_parts(package.document&.footers, rsid, "ftr")
|
|
54
|
+
parts&.each do |part|
|
|
55
|
+
next unless part.content
|
|
56
|
+
next if part.loaded?
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
part.content.mc_ignorable = ignorable
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless builder_managed?
|
|
62
|
+
backfill_header_footer_paragraphs(parts, generate_rsid)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
wire_header_footer_parts
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def backfill_header_footer_parts(parts, rsid, prefix)
|
|
70
|
+
# Assign rsid/paraId defaults to header/footer paragraphs
|
|
71
|
+
# (legacy, non-builder-managed path only). Seeds preserve the
|
|
72
|
+
# historic per-kind ("hdr:N"/"ftr:N") and per-store ("hfp:N")
|
|
73
|
+
# schemes so generated IDs stay stable across the unified store.
|
|
74
|
+
def backfill_header_footer_paragraphs(parts, rsid)
|
|
76
75
|
return unless parts
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
fresh_counts = Hash.new(0)
|
|
78
|
+
parts.each_with_index do |part, pidx|
|
|
79
|
+
next unless part.content
|
|
80
|
+
|
|
81
|
+
if part.loaded?
|
|
82
|
+
backfill_paragraphs(part.content.paragraphs, rsid, "hfp:#{pidx}")
|
|
83
|
+
else
|
|
84
|
+
abbrev = part.kind == :footer ? "ftr" : "hdr"
|
|
85
|
+
idx = fresh_counts[part.kind]
|
|
86
|
+
fresh_counts[part.kind] += 1
|
|
87
|
+
backfill_paragraphs(part.content.paragraphs, rsid,
|
|
88
|
+
"#{abbrev}:#{idx}")
|
|
89
|
+
end
|
|
80
90
|
end
|
|
81
91
|
end
|
|
82
92
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
def
|
|
93
|
+
# Wire builder-added (fresh) header/footer parts into
|
|
94
|
+
# document_rels and sectPr during reconciliation — the single
|
|
95
|
+
# wiring implementation. Loaded parts keep the relationships
|
|
96
|
+
# and section references they arrived with. rIds come from the
|
|
97
|
+
# allocator, the single rId authority.
|
|
98
|
+
def wire_header_footer_parts
|
|
89
99
|
doc = package.document
|
|
90
100
|
return unless doc&.body
|
|
91
|
-
|
|
101
|
+
|
|
102
|
+
parts = doc.header_footer_parts
|
|
103
|
+
return if parts.nil? || parts.empty?
|
|
92
104
|
|
|
93
105
|
rels = package.document_rels
|
|
94
106
|
return unless rels
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
wire_sect_pr_reference(doc, :header, type, r_id)
|
|
100
|
-
end
|
|
108
|
+
wired = false
|
|
109
|
+
parts.each do |part|
|
|
110
|
+
next if part.loaded? || part.target.nil?
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
wire_sect_pr_reference(doc,
|
|
112
|
+
r_id = allocator.alloc_rid(target: part.target,
|
|
113
|
+
type: part.rel_type)
|
|
114
|
+
part.r_id = r_id
|
|
115
|
+
wire_sect_pr_reference(doc, part.kind, part.type, r_id) if part.type
|
|
116
|
+
wired = true
|
|
106
117
|
end
|
|
107
118
|
|
|
119
|
+
return unless wired
|
|
120
|
+
|
|
108
121
|
# Clear element_order so header/footer references serialize correctly.
|
|
109
122
|
# Ordered mode only outputs elements in element_order; the template's
|
|
110
123
|
# order may not include newly-added references.
|
|
@@ -112,33 +125,6 @@ module Uniword
|
|
|
112
125
|
sect_pr.element_order = nil if sect_pr&.element_order
|
|
113
126
|
end
|
|
114
127
|
|
|
115
|
-
def wire_parts_to_rels(parts, rels, rel_type, file_prefix, counter)
|
|
116
|
-
return unless parts && !parts.empty?
|
|
117
|
-
|
|
118
|
-
parts.each_key do |type|
|
|
119
|
-
counter += 1
|
|
120
|
-
target = "#{file_prefix}#{counter}.xml"
|
|
121
|
-
r_id = if allocator
|
|
122
|
-
allocator.alloc_rid(target: target, type: rel_type)
|
|
123
|
-
else
|
|
124
|
-
find_or_create_rel(rels, rel_type, target)
|
|
125
|
-
end
|
|
126
|
-
yield type, r_id
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def find_or_create_rel(rels, rel_type, target)
|
|
131
|
-
existing = rels.relationships.find { |r| r.target == target }
|
|
132
|
-
return existing.id if existing
|
|
133
|
-
|
|
134
|
-
r_id = Ooxml::Relationships::PackageRelationships
|
|
135
|
-
.next_available_rid(rels)
|
|
136
|
-
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
137
|
-
id: r_id, type: rel_type, target: target,
|
|
138
|
-
)
|
|
139
|
-
r_id
|
|
140
|
-
end
|
|
141
|
-
|
|
142
128
|
def wire_sect_pr_reference(doc, kind, type, r_id)
|
|
143
129
|
sect_pr = doc.body.section_properties
|
|
144
130
|
return unless sect_pr
|
|
@@ -169,14 +155,15 @@ module Uniword
|
|
|
169
155
|
doc = package.document
|
|
170
156
|
set_mc_ignorable(doc, prefixes: FULL_IGNORABLE)
|
|
171
157
|
|
|
172
|
-
record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body"
|
|
158
|
+
record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body",
|
|
159
|
+
part: "word/document.xml")
|
|
173
160
|
|
|
174
161
|
body = doc.body
|
|
175
162
|
rsid = generate_rsid
|
|
176
163
|
|
|
177
164
|
body.paragraphs.each_with_index do |para, idx|
|
|
178
165
|
strip_empty_runs(para)
|
|
179
|
-
next if
|
|
166
|
+
next if builder_managed?
|
|
180
167
|
|
|
181
168
|
para.rsid_r ||= rsid
|
|
182
169
|
para.rsid_r_default ||= "00000000"
|
|
@@ -184,7 +171,11 @@ module Uniword
|
|
|
184
171
|
para.text_id ||= "77777777"
|
|
185
172
|
end
|
|
186
173
|
|
|
187
|
-
|
|
174
|
+
unless builder_managed?
|
|
175
|
+
record_fix(FixCodes::PARAGRAPH_BACKFILL,
|
|
176
|
+
"Assigned rsid and paraId to paragraphs",
|
|
177
|
+
part: "word/document.xml")
|
|
178
|
+
end
|
|
188
179
|
|
|
189
180
|
sect_pr = body.section_properties
|
|
190
181
|
return unless sect_pr
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class Reconciler
|
|
6
|
+
# A single repair applied by the Reconciler during save.
|
|
7
|
+
#
|
|
8
|
+
# Immutable value object exposed as the reconciliation report via
|
|
9
|
+
# `Package#applied_fixes` after a save. The Reconciler stays the only
|
|
10
|
+
# mutating pass; this report is a by-product value, not a global.
|
|
11
|
+
#
|
|
12
|
+
# @example Inspect fixes after save
|
|
13
|
+
# package.to_file("out.docx")
|
|
14
|
+
# package.applied_fixes.each do |fix|
|
|
15
|
+
# puts "#{fix.code} [#{fix.part}] #{fix.message}"
|
|
16
|
+
# end
|
|
17
|
+
class Fix
|
|
18
|
+
# @return [String] Fix code (see Reconciler::FixCodes)
|
|
19
|
+
attr_reader :code
|
|
20
|
+
|
|
21
|
+
# @return [String] Human-readable description of the repair
|
|
22
|
+
attr_reader :message
|
|
23
|
+
|
|
24
|
+
# @return [String, nil] Package part the repair applies to
|
|
25
|
+
attr_reader :part
|
|
26
|
+
|
|
27
|
+
# Create a fix record.
|
|
28
|
+
#
|
|
29
|
+
# @param code [String] Fix code (a FixCodes constant value)
|
|
30
|
+
# @param message [String] Human-readable description of the repair
|
|
31
|
+
# @param part [String, nil] Package part the repair applies to
|
|
32
|
+
def initialize(code:, message:, part: nil)
|
|
33
|
+
@code = code
|
|
34
|
+
@message = message
|
|
35
|
+
@part = part
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String] Compact single-line representation
|
|
39
|
+
def to_s
|
|
40
|
+
location = part ? " [#{part}]" : ""
|
|
41
|
+
"#{code}#{location}: #{message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|