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
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Uniword
|
|
4
|
-
module Validation
|
|
5
|
-
# Validates in-memory DocumentRoot structure.
|
|
6
|
-
#
|
|
7
|
-
# Performs structural checks on the document model without requiring
|
|
8
|
-
# file access. Complements DocumentValidator (file-level 7-layer
|
|
9
|
-
# pipeline) for quick in-process validation.
|
|
10
|
-
#
|
|
11
|
-
# Follows Open/Closed: add new check methods and register them in
|
|
12
|
-
# CHECKS without modifying existing code.
|
|
13
|
-
#
|
|
14
|
-
# @example
|
|
15
|
-
# validator = StructuralValidator.new(document)
|
|
16
|
-
# validator.valid? # => true/false
|
|
17
|
-
# validator.errors # => ["bookmarkStart id='42' has no bookmarkEnd"]
|
|
18
|
-
# validator.warnings # => ["Empty paragraph at index 7"]
|
|
19
|
-
class StructuralValidator
|
|
20
|
-
# Registered check methods. Each returns an array of
|
|
21
|
-
# { severity:, message: } hashes.
|
|
22
|
-
CHECKS = %i[
|
|
23
|
-
check_body_present
|
|
24
|
-
check_bookmark_pairing
|
|
25
|
-
check_bookmark_uniqueness
|
|
26
|
-
check_empty_paragraphs
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
29
|
-
attr_reader :document
|
|
30
|
-
|
|
31
|
-
def initialize(document)
|
|
32
|
-
@document = document
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# @return [Boolean] true if no errors (warnings are non-fatal)
|
|
36
|
-
def valid?
|
|
37
|
-
errors.empty?
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# @return [Array<String>] Error messages (severity: error)
|
|
41
|
-
def errors
|
|
42
|
-
@errors ||= issues
|
|
43
|
-
.select { |i| i[:severity] == :error }
|
|
44
|
-
.map { |i| i[:message] }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @return [Array<String>] Warning messages (severity: warning)
|
|
48
|
-
def warnings
|
|
49
|
-
@warnings ||= issues
|
|
50
|
-
.select { |i| i[:severity] == :warning }
|
|
51
|
-
.map { |i| i[:message] }
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# @return [Array<Hash>] All issues
|
|
55
|
-
def issues
|
|
56
|
-
@issues ||= CHECKS.flat_map { |check| send(check) }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
60
|
-
|
|
61
|
-
def check_body_present
|
|
62
|
-
return [] if document.body
|
|
63
|
-
|
|
64
|
-
[{ severity: :error,
|
|
65
|
-
message: "Document body is missing" }]
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def check_bookmark_pairing
|
|
69
|
-
return [] unless document.body
|
|
70
|
-
|
|
71
|
-
starts = document.body.bookmark_starts || []
|
|
72
|
-
ends = document.body.bookmark_ends || []
|
|
73
|
-
end_ids = ends.filter_map(&:id).to_set
|
|
74
|
-
|
|
75
|
-
starts.filter_map do |bs|
|
|
76
|
-
next if end_ids.include?(bs.id)
|
|
77
|
-
|
|
78
|
-
{ severity: :error,
|
|
79
|
-
message: "bookmarkStart id='#{bs.id}' (name='#{bs.name}') " \
|
|
80
|
-
"has no matching bookmarkEnd" }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def check_bookmark_uniqueness
|
|
85
|
-
return [] unless document.body
|
|
86
|
-
|
|
87
|
-
starts = document.body.bookmark_starts || []
|
|
88
|
-
seen = {}
|
|
89
|
-
starts.filter_map do |bs|
|
|
90
|
-
next unless bs.name
|
|
91
|
-
next if bs.name.to_s == "_GoBack" # Word internal bookmark
|
|
92
|
-
|
|
93
|
-
if seen[bs.name.to_s]
|
|
94
|
-
{ severity: :warning,
|
|
95
|
-
message: "Duplicate bookmark name '#{bs.name}'" }
|
|
96
|
-
else
|
|
97
|
-
seen[bs.name.to_s] = true
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def check_empty_paragraphs
|
|
104
|
-
return [] unless document.body
|
|
105
|
-
|
|
106
|
-
paragraphs = document.body.paragraphs || []
|
|
107
|
-
paragraphs.each_with_index.filter_map do |para, idx|
|
|
108
|
-
next unless para.runs.nil? || para.runs.empty?
|
|
109
|
-
|
|
110
|
-
{ severity: :warning,
|
|
111
|
-
message: "Empty paragraph at index #{idx}" }
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "nokogiri"
|
|
5
|
-
# LayerValidator autoloaded via lib/uniword/validation.rb
|
|
6
|
-
|
|
7
|
-
module Uniword
|
|
8
|
-
module Validation
|
|
9
|
-
module Validators
|
|
10
|
-
# Validates content type consistency.
|
|
11
|
-
#
|
|
12
|
-
# Responsibility: Validate [Content_Types].xml declarations.
|
|
13
|
-
# Single Responsibility: Only validates content type definitions.
|
|
14
|
-
#
|
|
15
|
-
# This is Layer 6 validation - validates that content types are
|
|
16
|
-
# properly declared and consistent with actual file extensions.
|
|
17
|
-
#
|
|
18
|
-
# Checks:
|
|
19
|
-
# - [Content_Types].xml is well-formed
|
|
20
|
-
# - Extensions and overrides are consistent
|
|
21
|
-
# - Required content types are declared
|
|
22
|
-
#
|
|
23
|
-
# @example Validate content types
|
|
24
|
-
# validator = ContentTypeValidator.new(config)
|
|
25
|
-
# result = validator.validate('/path/to/document.docx')
|
|
26
|
-
# puts result.valid? # => true
|
|
27
|
-
class ContentTypeValidator < LayerValidator
|
|
28
|
-
CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
29
|
-
|
|
30
|
-
REQUIRED_CONTENT_TYPES = [
|
|
31
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
|
|
32
|
-
"application/vnd.openxmlformats-package.relationships+xml",
|
|
33
|
-
].freeze
|
|
34
|
-
|
|
35
|
-
def layer_name
|
|
36
|
-
"Content Types"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def validate(path)
|
|
40
|
-
result = LayerValidationResult.new(layer_name)
|
|
41
|
-
|
|
42
|
-
Zip::File.open(path) do |zip|
|
|
43
|
-
validate_content_types(zip, result)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
result
|
|
47
|
-
rescue Zip::Error => e
|
|
48
|
-
result.add_error(
|
|
49
|
-
"Cannot open ZIP file: #{e.message}",
|
|
50
|
-
critical: true,
|
|
51
|
-
)
|
|
52
|
-
result
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def validate_content_types(zip, result)
|
|
58
|
-
entry = zip.find_entry("[Content_Types].xml")
|
|
59
|
-
|
|
60
|
-
unless entry
|
|
61
|
-
result.add_error(
|
|
62
|
-
"Missing [Content_Types].xml",
|
|
63
|
-
critical: true,
|
|
64
|
-
)
|
|
65
|
-
return
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
validate_content_types_file(zip, entry, result)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def validate_content_types_file(zip, entry, result)
|
|
72
|
-
xml_content = entry.get_input_stream.read
|
|
73
|
-
doc = Nokogiri::XML(xml_content)
|
|
74
|
-
|
|
75
|
-
# Check for required content types
|
|
76
|
-
validate_required_types(doc, result) if check_consistency?
|
|
77
|
-
|
|
78
|
-
# Validate extensions match actual files
|
|
79
|
-
if validate_extensions?
|
|
80
|
-
validate_extension_consistency(zip, doc,
|
|
81
|
-
result)
|
|
82
|
-
end
|
|
83
|
-
rescue Nokogiri::XML::SyntaxError => e
|
|
84
|
-
result.add_error(
|
|
85
|
-
"Malformed [Content_Types].xml: #{e.message}",
|
|
86
|
-
critical: true,
|
|
87
|
-
)
|
|
88
|
-
rescue StandardError => e
|
|
89
|
-
result.add_error(
|
|
90
|
-
"Failed to validate content types: #{e.message}",
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def validate_required_types(doc, result)
|
|
95
|
-
declared_types = extract_declared_types(doc)
|
|
96
|
-
|
|
97
|
-
REQUIRED_CONTENT_TYPES.each do |required_type|
|
|
98
|
-
next if declared_types.include?(required_type)
|
|
99
|
-
|
|
100
|
-
result.add_warning(
|
|
101
|
-
"Required content type not declared: #{required_type}",
|
|
102
|
-
)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def extract_declared_types(doc)
|
|
107
|
-
types = []
|
|
108
|
-
|
|
109
|
-
# From Default elements
|
|
110
|
-
doc.xpath("//xmlns:Default",
|
|
111
|
-
"xmlns" => CONTENT_TYPES_NAMESPACE).each do |default|
|
|
112
|
-
types << default["ContentType"] if default["ContentType"]
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# From Override elements
|
|
116
|
-
doc.xpath("//xmlns:Override",
|
|
117
|
-
"xmlns" => CONTENT_TYPES_NAMESPACE).each do |override|
|
|
118
|
-
types << override["ContentType"] if override["ContentType"]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
types
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def validate_extension_consistency(zip, doc, result)
|
|
125
|
-
# Get declared extensions
|
|
126
|
-
extensions = {}
|
|
127
|
-
doc.xpath("//xmlns:Default",
|
|
128
|
-
"xmlns" => CONTENT_TYPES_NAMESPACE).each do |default|
|
|
129
|
-
ext = default["Extension"]
|
|
130
|
-
content_type = default["ContentType"]
|
|
131
|
-
extensions[ext] = content_type if ext && content_type
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Check if files with declared extensions exist
|
|
135
|
-
zip.entries.each do |entry|
|
|
136
|
-
next if entry.directory?
|
|
137
|
-
|
|
138
|
-
ext = File.extname(entry.name)[1..] # Remove leading dot
|
|
139
|
-
next unless ext
|
|
140
|
-
|
|
141
|
-
if extensions.key?(ext)
|
|
142
|
-
# Expected extension found
|
|
143
|
-
else
|
|
144
|
-
result.add_info(
|
|
145
|
-
"File #{entry.name} has undeclared extension: .#{ext}",
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def check_consistency?
|
|
152
|
-
layer_config[:check_consistency] != false
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def validate_extensions?
|
|
156
|
-
layer_config[:validate_extensions] != false
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def layer_config
|
|
160
|
-
@config[:content_types] || {}
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# LayerValidator autoloaded via lib/uniword/validation.rb
|
|
4
|
-
|
|
5
|
-
module Uniword
|
|
6
|
-
module Validation
|
|
7
|
-
module Validators
|
|
8
|
-
# Validates file structure (existence, readability, extension, size).
|
|
9
|
-
#
|
|
10
|
-
# Responsibility: Validate file-level properties.
|
|
11
|
-
# Single Responsibility: Only validates file structure.
|
|
12
|
-
#
|
|
13
|
-
# This is Layer 1 validation - the most basic check that must pass
|
|
14
|
-
# before any other validation can occur.
|
|
15
|
-
#
|
|
16
|
-
# Checks:
|
|
17
|
-
# - File exists
|
|
18
|
-
# - File is readable
|
|
19
|
-
# - File extension is appropriate
|
|
20
|
-
# - File size is reasonable
|
|
21
|
-
#
|
|
22
|
-
# @example Validate a file
|
|
23
|
-
# validator = FileStructureValidator.new(config)
|
|
24
|
-
# result = validator.validate('/path/to/document.docx')
|
|
25
|
-
# puts result.valid? # => true
|
|
26
|
-
class FileStructureValidator < LayerValidator
|
|
27
|
-
def layer_name
|
|
28
|
-
"File Structure"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def validate(path)
|
|
32
|
-
result = LayerValidationResult.new(layer_name)
|
|
33
|
-
|
|
34
|
-
# Check file exists
|
|
35
|
-
unless File.exist?(path)
|
|
36
|
-
return result.add_error("File does not exist",
|
|
37
|
-
critical: true)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Check readable
|
|
41
|
-
unless File.readable?(path)
|
|
42
|
-
return result.add_error("File is not readable",
|
|
43
|
-
critical: true)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Check extension
|
|
47
|
-
validate_extension(path, result) if check_extension?
|
|
48
|
-
|
|
49
|
-
# Check file size
|
|
50
|
-
validate_size(path, result) if check_size?
|
|
51
|
-
|
|
52
|
-
result
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def check_extension?
|
|
58
|
-
layer_config[:check_extension] != false
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def check_size?
|
|
62
|
-
layer_config[:check_size] != false
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def layer_config
|
|
66
|
-
@config[:file_structure] || {}
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def validate_extension(path, result)
|
|
70
|
-
allowed = layer_config[:allowed_extensions] || [".docx", ".doc"]
|
|
71
|
-
|
|
72
|
-
return if allowed.any? { |ext| path.end_with?(ext) }
|
|
73
|
-
|
|
74
|
-
result.add_warning(
|
|
75
|
-
"Unusual file extension (expected #{allowed.join(' or ')})",
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def validate_size(path, result)
|
|
80
|
-
size = File.size(path)
|
|
81
|
-
max_size = @config[:max_file_size] || 104_857_600 # 100MB default
|
|
82
|
-
|
|
83
|
-
if size.zero?
|
|
84
|
-
result.add_error("File is empty", critical: true)
|
|
85
|
-
elsif size > max_size
|
|
86
|
-
result.add_warning(
|
|
87
|
-
"File is very large (#{format_bytes(size)})",
|
|
88
|
-
)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def format_bytes(bytes)
|
|
93
|
-
if bytes < 1024
|
|
94
|
-
"#{bytes} bytes"
|
|
95
|
-
elsif bytes < 1024 * 1024
|
|
96
|
-
"#{(bytes / 1024.0).round(1)} KB"
|
|
97
|
-
else
|
|
98
|
-
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
# LayerValidator autoloaded via lib/uniword/validation.rb
|
|
5
|
-
|
|
6
|
-
module Uniword
|
|
7
|
-
module Validation
|
|
8
|
-
module Validators
|
|
9
|
-
# Validates OOXML part structure (required/optional parts).
|
|
10
|
-
#
|
|
11
|
-
# Responsibility: Validate OOXML parts exist per ISO/IEC 29500.
|
|
12
|
-
# Single Responsibility: Only validates part existence.
|
|
13
|
-
#
|
|
14
|
-
# This is Layer 3 validation - validates that all required OOXML
|
|
15
|
-
# parts are present in the document package.
|
|
16
|
-
#
|
|
17
|
-
# Checks:
|
|
18
|
-
# - Required parts exist (Content Types, document, relationships)
|
|
19
|
-
# - Optional but common parts (styles, numbering)
|
|
20
|
-
#
|
|
21
|
-
# @example Validate OOXML parts
|
|
22
|
-
# validator = OoxmlPartValidator.new(config)
|
|
23
|
-
# result = validator.validate('/path/to/document.docx')
|
|
24
|
-
# puts result.valid? # => true
|
|
25
|
-
class OoxmlPartValidator < LayerValidator
|
|
26
|
-
# Required parts per ISO/IEC 29500
|
|
27
|
-
REQUIRED_PARTS = {
|
|
28
|
-
"[Content_Types].xml" => "Content Types definition",
|
|
29
|
-
"word/document.xml" => "Main document content",
|
|
30
|
-
"_rels/.rels" => "Package relationships",
|
|
31
|
-
}.freeze
|
|
32
|
-
|
|
33
|
-
# Optional but commonly used parts
|
|
34
|
-
OPTIONAL_PARTS = {
|
|
35
|
-
"word/styles.xml" => "Styles definition",
|
|
36
|
-
"word/numbering.xml" => "Numbering definition",
|
|
37
|
-
"word/_rels/document.xml.rels" => "Document relationships",
|
|
38
|
-
"word/settings.xml" => "Document settings",
|
|
39
|
-
"word/fontTable.xml" => "Font table",
|
|
40
|
-
"word/theme/theme1.xml" => "Theme definition",
|
|
41
|
-
}.freeze
|
|
42
|
-
|
|
43
|
-
def layer_name
|
|
44
|
-
"OOXML Parts"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def validate(path)
|
|
48
|
-
result = LayerValidationResult.new(layer_name)
|
|
49
|
-
|
|
50
|
-
Zip::File.open(path) do |zip|
|
|
51
|
-
validate_required_parts(zip, result)
|
|
52
|
-
validate_optional_parts(zip, result) if warn_missing_optional?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
result
|
|
56
|
-
rescue Zip::Error => e
|
|
57
|
-
result.add_error(
|
|
58
|
-
"Cannot open ZIP file: #{e.message}",
|
|
59
|
-
critical: true,
|
|
60
|
-
)
|
|
61
|
-
result
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
def validate_required_parts(zip, result)
|
|
67
|
-
return unless require_content_types? ||
|
|
68
|
-
require_document? ||
|
|
69
|
-
require_relationships?
|
|
70
|
-
|
|
71
|
-
REQUIRED_PARTS.each do |part_name, description|
|
|
72
|
-
next unless should_check_part?(part_name)
|
|
73
|
-
|
|
74
|
-
next if zip.find_entry(part_name)
|
|
75
|
-
|
|
76
|
-
result.add_error(
|
|
77
|
-
"Missing #{description}: #{part_name}",
|
|
78
|
-
critical: true,
|
|
79
|
-
)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def validate_optional_parts(zip, result)
|
|
84
|
-
OPTIONAL_PARTS.each do |part_name, description|
|
|
85
|
-
next if zip.find_entry(part_name)
|
|
86
|
-
|
|
87
|
-
result.add_info(
|
|
88
|
-
"Missing #{description}: #{part_name} (optional)",
|
|
89
|
-
)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def should_check_part?(part_name)
|
|
94
|
-
case part_name
|
|
95
|
-
when "[Content_Types].xml"
|
|
96
|
-
require_content_types?
|
|
97
|
-
when "word/document.xml"
|
|
98
|
-
require_document?
|
|
99
|
-
when "_rels/.rels"
|
|
100
|
-
require_relationships?
|
|
101
|
-
else
|
|
102
|
-
true
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def require_content_types?
|
|
107
|
-
layer_config[:require_content_types] != false
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def require_document?
|
|
111
|
-
layer_config[:require_document] != false
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def require_relationships?
|
|
115
|
-
layer_config[:require_relationships] != false
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def warn_missing_optional?
|
|
119
|
-
layer_config[:warn_missing_optional] != false
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def layer_config
|
|
123
|
-
@config[:ooxml_parts] || {}
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "nokogiri"
|
|
5
|
-
# LayerValidator autoloaded via lib/uniword/validation.rb
|
|
6
|
-
|
|
7
|
-
module Uniword
|
|
8
|
-
module Validation
|
|
9
|
-
module Validators
|
|
10
|
-
# Validates relationship integrity (targets exist, no broken refs).
|
|
11
|
-
#
|
|
12
|
-
# Responsibility: Validate relationship files and targets.
|
|
13
|
-
# Single Responsibility: Only validates relationships.
|
|
14
|
-
#
|
|
15
|
-
# This is Layer 5 validation - validates that all relationship
|
|
16
|
-
# files are valid and their targets exist.
|
|
17
|
-
#
|
|
18
|
-
# Checks:
|
|
19
|
-
# - Relationship files are well-formed
|
|
20
|
-
# - Relationship targets exist
|
|
21
|
-
# - Relationship types are valid
|
|
22
|
-
#
|
|
23
|
-
# @example Validate relationships
|
|
24
|
-
# validator = RelationshipValidator.new(config)
|
|
25
|
-
# result = validator.validate('/path/to/document.docx')
|
|
26
|
-
# puts result.valid? # => true
|
|
27
|
-
class RelationshipValidator < LayerValidator
|
|
28
|
-
RELATIONSHIP_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
29
|
-
|
|
30
|
-
def layer_name
|
|
31
|
-
"Relationships"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def validate(path)
|
|
35
|
-
result = LayerValidationResult.new(layer_name)
|
|
36
|
-
|
|
37
|
-
Zip::File.open(path) do |zip|
|
|
38
|
-
validate_relationships(zip, result)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
result
|
|
42
|
-
rescue Zip::Error => e
|
|
43
|
-
result.add_error(
|
|
44
|
-
"Cannot open ZIP file: #{e.message}",
|
|
45
|
-
critical: true,
|
|
46
|
-
)
|
|
47
|
-
result
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
private
|
|
51
|
-
|
|
52
|
-
def validate_relationships(zip, result)
|
|
53
|
-
rel_entries = zip.entries.select { |e| e.name.end_with?(".rels") }
|
|
54
|
-
|
|
55
|
-
if rel_entries.empty?
|
|
56
|
-
result.add_warning("No relationship files found")
|
|
57
|
-
return
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
rel_entries.each do |entry|
|
|
61
|
-
validate_relationship_file(zip, entry, result)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def validate_relationship_file(zip, entry, result)
|
|
66
|
-
xml_content = entry.get_input_stream.read
|
|
67
|
-
doc = Nokogiri::XML(xml_content)
|
|
68
|
-
|
|
69
|
-
# Get the base directory for this .rels file
|
|
70
|
-
base_dir = File.dirname(entry.name.sub("/_rels/", "/"))
|
|
71
|
-
base_dir = "" if base_dir == "."
|
|
72
|
-
|
|
73
|
-
doc.xpath("//xmlns:Relationship",
|
|
74
|
-
"xmlns" => RELATIONSHIP_NAMESPACE).each do |rel|
|
|
75
|
-
validate_relationship(zip, rel, base_dir, result)
|
|
76
|
-
end
|
|
77
|
-
rescue Nokogiri::XML::SyntaxError => e
|
|
78
|
-
result.add_error(
|
|
79
|
-
"Malformed relationship file #{entry.name}: #{e.message}",
|
|
80
|
-
)
|
|
81
|
-
rescue StandardError => e
|
|
82
|
-
result.add_error(
|
|
83
|
-
"Failed to validate #{entry.name}: #{e.message}",
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def validate_relationship(zip, rel_node, base_dir, result)
|
|
88
|
-
target = rel_node["Target"]
|
|
89
|
-
type = rel_node["Type"]
|
|
90
|
-
target_mode = rel_node["TargetMode"]
|
|
91
|
-
|
|
92
|
-
# Skip external relationships
|
|
93
|
-
return if target_mode == "External"
|
|
94
|
-
|
|
95
|
-
# Check if target exists
|
|
96
|
-
if check_targets_exist? && target && !target.start_with?("#")
|
|
97
|
-
target_path = resolve_target_path(base_dir, target)
|
|
98
|
-
|
|
99
|
-
unless zip.find_entry(target_path)
|
|
100
|
-
result.add_error(
|
|
101
|
-
"Relationship target not found: #{target_path}",
|
|
102
|
-
)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Validate relationship type
|
|
107
|
-
return unless validate_types? && type
|
|
108
|
-
|
|
109
|
-
validate_relationship_type(type, result)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def resolve_target_path(base_dir, target)
|
|
113
|
-
# Handle absolute paths (starting with /)
|
|
114
|
-
return target[1..] if target.start_with?("/")
|
|
115
|
-
|
|
116
|
-
# Handle relative paths
|
|
117
|
-
if base_dir.empty?
|
|
118
|
-
target
|
|
119
|
-
else
|
|
120
|
-
File.join(base_dir, target)
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def validate_relationship_type(type, result)
|
|
125
|
-
# Just validate it's a URI-like string
|
|
126
|
-
return if type.include?("/")
|
|
127
|
-
|
|
128
|
-
result.add_warning(
|
|
129
|
-
"Unusual relationship type: #{type}",
|
|
130
|
-
)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def check_targets_exist?
|
|
134
|
-
layer_config[:check_targets_exist] != false
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def validate_types?
|
|
138
|
-
layer_config[:validate_types] != false
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def layer_config
|
|
142
|
-
@config[:relationships] || {}
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|