png_conform 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.rubocop_todo.yml +197 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +323 -0
- data/Gemfile +13 -0
- data/LICENSE +43 -0
- data/README.adoc +859 -0
- data/Rakefile +10 -0
- data/SECURITY.md +147 -0
- data/docs/ARCHITECTURE.adoc +681 -0
- data/docs/CHUNK_TYPES.adoc +450 -0
- data/docs/CLI_OPTIONS.adoc +913 -0
- data/docs/COMPATIBILITY.adoc +616 -0
- data/examples/README.adoc +398 -0
- data/examples/advanced_usage.rb +304 -0
- data/examples/basic_usage.rb +210 -0
- data/exe/png_conform +6 -0
- data/lib/png_conform/analyzers/comparison_analyzer.rb +230 -0
- data/lib/png_conform/analyzers/metrics_analyzer.rb +176 -0
- data/lib/png_conform/analyzers/optimization_analyzer.rb +190 -0
- data/lib/png_conform/analyzers/resolution_analyzer.rb +274 -0
- data/lib/png_conform/bindata/chunk_structure.rb +153 -0
- data/lib/png_conform/bindata/jng_file.rb +79 -0
- data/lib/png_conform/bindata/mng_file.rb +97 -0
- data/lib/png_conform/bindata/png_file.rb +162 -0
- data/lib/png_conform/cli.rb +116 -0
- data/lib/png_conform/commands/check_command.rb +323 -0
- data/lib/png_conform/commands/list_command.rb +67 -0
- data/lib/png_conform/models/chunk.rb +84 -0
- data/lib/png_conform/models/chunk_info.rb +71 -0
- data/lib/png_conform/models/compression_info.rb +49 -0
- data/lib/png_conform/models/decoded_chunk_data.rb +143 -0
- data/lib/png_conform/models/file_analysis.rb +181 -0
- data/lib/png_conform/models/file_info.rb +91 -0
- data/lib/png_conform/models/image_info.rb +52 -0
- data/lib/png_conform/models/validation_error.rb +89 -0
- data/lib/png_conform/models/validation_result.rb +137 -0
- data/lib/png_conform/readers/full_load_reader.rb +113 -0
- data/lib/png_conform/readers/streaming_reader.rb +180 -0
- data/lib/png_conform/reporters/base_reporter.rb +53 -0
- data/lib/png_conform/reporters/color_reporter.rb +65 -0
- data/lib/png_conform/reporters/json_reporter.rb +18 -0
- data/lib/png_conform/reporters/palette_reporter.rb +48 -0
- data/lib/png_conform/reporters/quiet_reporter.rb +18 -0
- data/lib/png_conform/reporters/reporter_factory.rb +108 -0
- data/lib/png_conform/reporters/summary_reporter.rb +65 -0
- data/lib/png_conform/reporters/text_reporter.rb +66 -0
- data/lib/png_conform/reporters/verbose_reporter.rb +87 -0
- data/lib/png_conform/reporters/very_verbose_reporter.rb +33 -0
- data/lib/png_conform/reporters/visual_elements.rb +66 -0
- data/lib/png_conform/reporters/yaml_reporter.rb +18 -0
- data/lib/png_conform/services/profile_manager.rb +242 -0
- data/lib/png_conform/services/validation_service.rb +457 -0
- data/lib/png_conform/services/zlib_validator.rb +270 -0
- data/lib/png_conform/validators/ancillary/bkgd_validator.rb +140 -0
- data/lib/png_conform/validators/ancillary/chrm_validator.rb +178 -0
- data/lib/png_conform/validators/ancillary/cicp_validator.rb +202 -0
- data/lib/png_conform/validators/ancillary/gama_validator.rb +105 -0
- data/lib/png_conform/validators/ancillary/hist_validator.rb +147 -0
- data/lib/png_conform/validators/ancillary/iccp_validator.rb +243 -0
- data/lib/png_conform/validators/ancillary/itxt_validator.rb +280 -0
- data/lib/png_conform/validators/ancillary/mdcv_validator.rb +201 -0
- data/lib/png_conform/validators/ancillary/offs_validator.rb +132 -0
- data/lib/png_conform/validators/ancillary/pcal_validator.rb +289 -0
- data/lib/png_conform/validators/ancillary/phys_validator.rb +107 -0
- data/lib/png_conform/validators/ancillary/sbit_validator.rb +176 -0
- data/lib/png_conform/validators/ancillary/scal_validator.rb +180 -0
- data/lib/png_conform/validators/ancillary/splt_validator.rb +223 -0
- data/lib/png_conform/validators/ancillary/srgb_validator.rb +117 -0
- data/lib/png_conform/validators/ancillary/ster_validator.rb +111 -0
- data/lib/png_conform/validators/ancillary/text_validator.rb +129 -0
- data/lib/png_conform/validators/ancillary/time_validator.rb +132 -0
- data/lib/png_conform/validators/ancillary/trns_validator.rb +154 -0
- data/lib/png_conform/validators/ancillary/ztxt_validator.rb +173 -0
- data/lib/png_conform/validators/apng/actl_validator.rb +81 -0
- data/lib/png_conform/validators/apng/fctl_validator.rb +155 -0
- data/lib/png_conform/validators/apng/fdat_validator.rb +117 -0
- data/lib/png_conform/validators/base_validator.rb +241 -0
- data/lib/png_conform/validators/chunk_registry.rb +219 -0
- data/lib/png_conform/validators/critical/idat_validator.rb +77 -0
- data/lib/png_conform/validators/critical/iend_validator.rb +68 -0
- data/lib/png_conform/validators/critical/ihdr_validator.rb +160 -0
- data/lib/png_conform/validators/critical/plte_validator.rb +120 -0
- data/lib/png_conform/validators/jng/jdat_validator.rb +66 -0
- data/lib/png_conform/validators/jng/jhdr_validator.rb +116 -0
- data/lib/png_conform/validators/jng/jsep_validator.rb +66 -0
- data/lib/png_conform/validators/mng/back_validator.rb +87 -0
- data/lib/png_conform/validators/mng/clip_validator.rb +65 -0
- data/lib/png_conform/validators/mng/clon_validator.rb +45 -0
- data/lib/png_conform/validators/mng/defi_validator.rb +104 -0
- data/lib/png_conform/validators/mng/dhdr_validator.rb +104 -0
- data/lib/png_conform/validators/mng/disc_validator.rb +44 -0
- data/lib/png_conform/validators/mng/endl_validator.rb +65 -0
- data/lib/png_conform/validators/mng/fram_validator.rb +91 -0
- data/lib/png_conform/validators/mng/loop_validator.rb +75 -0
- data/lib/png_conform/validators/mng/mend_validator.rb +31 -0
- data/lib/png_conform/validators/mng/mhdr_validator.rb +69 -0
- data/lib/png_conform/validators/mng/move_validator.rb +61 -0
- data/lib/png_conform/validators/mng/save_validator.rb +39 -0
- data/lib/png_conform/validators/mng/seek_validator.rb +42 -0
- data/lib/png_conform/validators/mng/show_validator.rb +52 -0
- data/lib/png_conform/validators/mng/term_validator.rb +84 -0
- data/lib/png_conform/version.rb +5 -0
- data/lib/png_conform.rb +101 -0
- data/png_conform.gemspec +43 -0
- metadata +201 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Validators
|
|
7
|
+
module Ancillary
|
|
8
|
+
# Validator for PNG tEXt (Textual Data) chunk
|
|
9
|
+
#
|
|
10
|
+
# tEXt contains textual information as keyword/text pairs:
|
|
11
|
+
# - Keyword (1-79 bytes, Latin-1)
|
|
12
|
+
# - Null separator (1 byte)
|
|
13
|
+
# - Text (0+ bytes, Latin-1)
|
|
14
|
+
#
|
|
15
|
+
# Validation rules from PNG spec:
|
|
16
|
+
# - Keyword must be 1-79 characters
|
|
17
|
+
# - Keyword must contain only Latin-1 printable characters
|
|
18
|
+
# - Keyword must not have leading/trailing spaces
|
|
19
|
+
# - Keyword must not have consecutive spaces
|
|
20
|
+
# - Null separator must be present
|
|
21
|
+
# - Multiple tEXt chunks allowed with different keywords
|
|
22
|
+
class TextValidator < BaseValidator
|
|
23
|
+
# Maximum keyword length
|
|
24
|
+
MAX_KEYWORD_LENGTH = 79
|
|
25
|
+
|
|
26
|
+
# Latin-1 printable characters (space to tilde + high ASCII)
|
|
27
|
+
PRINTABLE_LATIN1 = (32..126).to_a + (161..255).to_a
|
|
28
|
+
|
|
29
|
+
# Validate tEXt chunk
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean] True if validation passed
|
|
32
|
+
def validate
|
|
33
|
+
return false unless check_crc
|
|
34
|
+
return false unless check_structure
|
|
35
|
+
return false unless check_keyword
|
|
36
|
+
|
|
37
|
+
store_text_info
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Check tEXt chunk structure
|
|
44
|
+
def check_structure
|
|
45
|
+
data = chunk.chunk_data
|
|
46
|
+
|
|
47
|
+
# Must contain at least keyword + null
|
|
48
|
+
if data.length < 2
|
|
49
|
+
add_error("tEXt chunk too short (minimum 2 bytes)")
|
|
50
|
+
return false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Must contain null separator
|
|
54
|
+
null_pos = data.index("\0")
|
|
55
|
+
unless null_pos
|
|
56
|
+
add_error("tEXt chunk missing null separator")
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check keyword validity
|
|
64
|
+
def check_keyword
|
|
65
|
+
data = chunk.chunk_data
|
|
66
|
+
null_pos = data.index("\0")
|
|
67
|
+
keyword = data[0, null_pos]
|
|
68
|
+
|
|
69
|
+
# Check keyword length
|
|
70
|
+
if keyword.empty?
|
|
71
|
+
add_error("tEXt chunk has empty keyword")
|
|
72
|
+
return false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if keyword.length > MAX_KEYWORD_LENGTH
|
|
76
|
+
add_error("tEXt keyword too long (#{keyword.length}, " \
|
|
77
|
+
"max #{MAX_KEYWORD_LENGTH})")
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check for Latin-1 printable characters
|
|
82
|
+
keyword.bytes.each do |byte|
|
|
83
|
+
next if PRINTABLE_LATIN1.include?(byte)
|
|
84
|
+
|
|
85
|
+
add_error("tEXt keyword contains non-printable character " \
|
|
86
|
+
"(0x#{byte.to_s(16)})")
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check for leading/trailing spaces
|
|
91
|
+
if keyword.start_with?(" ")
|
|
92
|
+
add_error("tEXt keyword has leading space")
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if keyword.end_with?(" ")
|
|
97
|
+
add_error("tEXt keyword has trailing space")
|
|
98
|
+
return false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check for consecutive spaces
|
|
102
|
+
if keyword.include?(" ")
|
|
103
|
+
add_error("tEXt keyword has consecutive spaces")
|
|
104
|
+
return false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Store text information in context
|
|
111
|
+
def store_text_info
|
|
112
|
+
data = chunk.chunk_data
|
|
113
|
+
null_pos = data.index("\0")
|
|
114
|
+
keyword = data[0, null_pos]
|
|
115
|
+
text = data[(null_pos + 1)..] || ""
|
|
116
|
+
|
|
117
|
+
# Store in context (allow multiple text chunks)
|
|
118
|
+
texts = context.retrieve(:text_chunks) || []
|
|
119
|
+
texts << { keyword: keyword, text: text, compressed: false }
|
|
120
|
+
context.store(:text_chunks, texts)
|
|
121
|
+
|
|
122
|
+
# Add info about the text chunk
|
|
123
|
+
text_preview = text.length > 40 ? "#{text[0, 40]}..." : text
|
|
124
|
+
add_info("tEXt: #{keyword} = \"#{text_preview}\"")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Validators
|
|
7
|
+
module Ancillary
|
|
8
|
+
# Validator for PNG tIME (Image Last-Modification Time) chunk
|
|
9
|
+
#
|
|
10
|
+
# tIME specifies the time of the last image modification:
|
|
11
|
+
# - Year (2 bytes, complete; e.g., 1995, not 95)
|
|
12
|
+
# - Month (1 byte, 1-12)
|
|
13
|
+
# - Day (1 byte, 1-31)
|
|
14
|
+
# - Hour (1 byte, 0-23)
|
|
15
|
+
# - Minute (1 byte, 0-59)
|
|
16
|
+
# - Second (1 byte, 0-60, to allow for leap seconds)
|
|
17
|
+
#
|
|
18
|
+
# Validation rules from PNG spec:
|
|
19
|
+
# - Must be exactly 7 bytes
|
|
20
|
+
# - Only one tIME chunk allowed
|
|
21
|
+
# - Month must be 1-12
|
|
22
|
+
# - Day must be 1-31
|
|
23
|
+
# - Hour must be 0-23
|
|
24
|
+
# - Minute must be 0-59
|
|
25
|
+
# - Second must be 0-60 (60 for leap seconds)
|
|
26
|
+
class TimeValidator < BaseValidator
|
|
27
|
+
# Validate tIME chunk
|
|
28
|
+
#
|
|
29
|
+
# @return [Boolean] True if validation passed
|
|
30
|
+
def validate
|
|
31
|
+
return false unless check_crc
|
|
32
|
+
return false unless check_length(7)
|
|
33
|
+
return false unless check_uniqueness
|
|
34
|
+
return false unless check_datetime
|
|
35
|
+
|
|
36
|
+
store_time_info
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Check that only one tIME chunk is present
|
|
43
|
+
def check_uniqueness
|
|
44
|
+
if context.seen?("tIME")
|
|
45
|
+
add_error("duplicate tIME chunk")
|
|
46
|
+
return false
|
|
47
|
+
end
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check date and time values
|
|
52
|
+
def check_datetime
|
|
53
|
+
data = chunk.chunk_data
|
|
54
|
+
year = data[0, 2].unpack1("n")
|
|
55
|
+
month = data[2].unpack1("C")
|
|
56
|
+
day = data[3].unpack1("C")
|
|
57
|
+
hour = data[4].unpack1("C")
|
|
58
|
+
minute = data[5].unpack1("C")
|
|
59
|
+
second = data[6].unpack1("C")
|
|
60
|
+
|
|
61
|
+
valid = true
|
|
62
|
+
|
|
63
|
+
# PNG was created in 1996, so years before that are invalid
|
|
64
|
+
if year < 1996
|
|
65
|
+
add_error("invalid tIME year (before PNG existed!) (#{year})")
|
|
66
|
+
valid = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
valid &= check_range(month, 1, 12, "month")
|
|
70
|
+
valid &= check_range(day, 1, 31, "day")
|
|
71
|
+
valid &= check_range(hour, 0, 23, "hour")
|
|
72
|
+
valid &= check_range(minute, 0, 59, "minute")
|
|
73
|
+
valid &= check_range(second, 0, 60, "second")
|
|
74
|
+
|
|
75
|
+
# Additional day validation based on month
|
|
76
|
+
if valid && !valid_day_for_month?(year, month, day)
|
|
77
|
+
add_error("invalid day (#{day}) for month #{month}")
|
|
78
|
+
valid = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
valid
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if day is valid for given month and year
|
|
85
|
+
def valid_day_for_month?(year, month, day)
|
|
86
|
+
days_in_month = case month
|
|
87
|
+
when 2
|
|
88
|
+
leap_year?(year) ? 29 : 28
|
|
89
|
+
when 4, 6, 9, 11
|
|
90
|
+
30
|
|
91
|
+
else
|
|
92
|
+
31
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
day <= days_in_month
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if year is a leap year
|
|
99
|
+
def leap_year?(year)
|
|
100
|
+
(year % 4).zero? && ((year % 100 != 0) || (year % 400).zero?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Store time information in context
|
|
104
|
+
def store_time_info
|
|
105
|
+
data = chunk.chunk_data
|
|
106
|
+
year = data[0, 2].unpack1("n")
|
|
107
|
+
month = data[2].unpack1("C")
|
|
108
|
+
day = data[3].unpack1("C")
|
|
109
|
+
hour = data[4].unpack1("C")
|
|
110
|
+
minute = data[5].unpack1("C")
|
|
111
|
+
second = data[6].unpack1("C")
|
|
112
|
+
|
|
113
|
+
timestamp = {
|
|
114
|
+
year: year,
|
|
115
|
+
month: month,
|
|
116
|
+
day: day,
|
|
117
|
+
hour: hour,
|
|
118
|
+
minute: minute,
|
|
119
|
+
second: second,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
context.store(:modification_time, timestamp)
|
|
123
|
+
|
|
124
|
+
# Format as ISO 8601
|
|
125
|
+
iso_time = format("%04d-%02d-%02d %02d:%02d:%02d UTC",
|
|
126
|
+
year, month, day, hour, minute, second)
|
|
127
|
+
add_info("tIME: #{iso_time}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Validators
|
|
7
|
+
module Ancillary
|
|
8
|
+
# Validator for PNG tRNS (Transparency) chunk
|
|
9
|
+
#
|
|
10
|
+
# tRNS specifies transparency information:
|
|
11
|
+
# - For grayscale: 2-byte gray sample value
|
|
12
|
+
# - For truecolor: 6-byte RGB sample values
|
|
13
|
+
# - For indexed-color: alpha values for palette entries
|
|
14
|
+
#
|
|
15
|
+
# Validation rules from PNG spec:
|
|
16
|
+
# - Must appear before IDAT
|
|
17
|
+
# - Only one tRNS chunk allowed
|
|
18
|
+
# - For indexed-color, must appear after PLTE
|
|
19
|
+
# - For indexed-color, length must not exceed palette size
|
|
20
|
+
# - Not allowed for grayscale+alpha or truecolor+alpha
|
|
21
|
+
class TrnsValidator < BaseValidator
|
|
22
|
+
# Validate tRNS chunk
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] True if validation passed
|
|
25
|
+
def validate
|
|
26
|
+
return false unless check_crc
|
|
27
|
+
return false unless check_position
|
|
28
|
+
return false unless check_uniqueness
|
|
29
|
+
return false unless check_color_type_compatibility
|
|
30
|
+
return false unless check_length_for_color_type
|
|
31
|
+
|
|
32
|
+
store_transparency_info
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check tRNS position relative to other chunks
|
|
39
|
+
def check_position
|
|
40
|
+
valid = true
|
|
41
|
+
|
|
42
|
+
# tRNS must appear before IDAT
|
|
43
|
+
if context.seen?("IDAT")
|
|
44
|
+
add_error("tRNS chunk after IDAT (must be before)")
|
|
45
|
+
valid = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# For indexed-color, tRNS must appear after PLTE
|
|
49
|
+
color_type = context.retrieve(:color_type)
|
|
50
|
+
if color_type == 3 && !context.seen?("PLTE")
|
|
51
|
+
add_error("tRNS chunk before PLTE for indexed-color image")
|
|
52
|
+
valid = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
valid
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check that only one tRNS chunk is present
|
|
59
|
+
def check_uniqueness
|
|
60
|
+
if context.retrieve(:has_transparency)
|
|
61
|
+
add_error("duplicate tRNS chunk")
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check tRNS compatibility with color type
|
|
68
|
+
def check_color_type_compatibility
|
|
69
|
+
color_type = context.retrieve(:color_type)
|
|
70
|
+
return true unless color_type # IHDR not validated yet
|
|
71
|
+
|
|
72
|
+
case color_type
|
|
73
|
+
when 0, 2, 3
|
|
74
|
+
# Grayscale, truecolor, indexed-color: tRNS allowed
|
|
75
|
+
true
|
|
76
|
+
when 4, 6
|
|
77
|
+
# Grayscale+alpha, truecolor+alpha: tRNS forbidden
|
|
78
|
+
add_error("tRNS chunk not allowed for color type with " \
|
|
79
|
+
"alpha channel")
|
|
80
|
+
false
|
|
81
|
+
else
|
|
82
|
+
add_warning("tRNS chunk present but color type unknown")
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check tRNS length for color type
|
|
88
|
+
def check_length_for_color_type
|
|
89
|
+
color_type = context.retrieve(:color_type)
|
|
90
|
+
return true unless color_type
|
|
91
|
+
|
|
92
|
+
length = chunk.chunk_data.length
|
|
93
|
+
|
|
94
|
+
case color_type
|
|
95
|
+
when 0
|
|
96
|
+
# Grayscale: must be 2 bytes
|
|
97
|
+
check_length(2)
|
|
98
|
+
when 2
|
|
99
|
+
# Truecolor: must be 6 bytes (RGB)
|
|
100
|
+
check_length(6)
|
|
101
|
+
when 3
|
|
102
|
+
# Indexed-color: 1-256 bytes (alpha for each palette entry)
|
|
103
|
+
palette_entries = context.retrieve(:palette_entries)
|
|
104
|
+
|
|
105
|
+
# Check if palette exists
|
|
106
|
+
unless context.retrieve(:has_palette)
|
|
107
|
+
add_error("tRNS chunk for indexed-color without PLTE chunk")
|
|
108
|
+
return false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check length doesn't exceed palette size
|
|
112
|
+
if palette_entries && length > palette_entries
|
|
113
|
+
add_error("tRNS has more entries than palette " \
|
|
114
|
+
"(#{length} > #{palette_entries})")
|
|
115
|
+
return false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return true if length >= 1 && length <= 256
|
|
119
|
+
|
|
120
|
+
add_error("invalid tRNS length for indexed-color " \
|
|
121
|
+
"(#{length}, must be 1-256)")
|
|
122
|
+
false
|
|
123
|
+
else
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Store transparency information in context
|
|
129
|
+
def store_transparency_info
|
|
130
|
+
context.store(:has_transparency, true)
|
|
131
|
+
|
|
132
|
+
color_type = context.retrieve(:color_type)
|
|
133
|
+
data = chunk.chunk_data
|
|
134
|
+
|
|
135
|
+
case color_type
|
|
136
|
+
when 0
|
|
137
|
+
# Grayscale: store gray value
|
|
138
|
+
gray = data.unpack1("n")
|
|
139
|
+
context.store(:transparent_gray, gray)
|
|
140
|
+
when 2
|
|
141
|
+
# Truecolor: store RGB values
|
|
142
|
+
r, g, b = data.unpack("nnn")
|
|
143
|
+
context.store(:transparent_color, { r: r, g: g, b: b })
|
|
144
|
+
when 3
|
|
145
|
+
# Indexed-color: store alpha values
|
|
146
|
+
alphas = data.unpack("C*")
|
|
147
|
+
context.store(:palette_alphas, alphas)
|
|
148
|
+
context.store(:transparent_entries, alphas.length)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module PngConform
|
|
7
|
+
module Validators
|
|
8
|
+
module Ancillary
|
|
9
|
+
# Validator for PNG zTXt (Compressed Textual Data) chunk
|
|
10
|
+
#
|
|
11
|
+
# zTXt contains compressed textual information as keyword/text pairs:
|
|
12
|
+
# - Keyword (1-79 bytes, Latin-1)
|
|
13
|
+
# - Null separator (1 byte)
|
|
14
|
+
# - Compression method (1 byte, must be 0)
|
|
15
|
+
# - Compressed text (0+ bytes, deflate compressed)
|
|
16
|
+
#
|
|
17
|
+
# Validation rules from PNG spec:
|
|
18
|
+
# - Keyword must be 1-79 characters
|
|
19
|
+
# - Keyword must contain only Latin-1 printable characters
|
|
20
|
+
# - Keyword must not have leading/trailing spaces
|
|
21
|
+
# - Keyword must not have consecutive spaces
|
|
22
|
+
# - Compression method must be 0 (deflate)
|
|
23
|
+
# - Text must be successfully decompressible
|
|
24
|
+
# - Multiple zTXt chunks allowed with different keywords
|
|
25
|
+
class ZtxtValidator < BaseValidator
|
|
26
|
+
# Maximum keyword length
|
|
27
|
+
MAX_KEYWORD_LENGTH = 79
|
|
28
|
+
|
|
29
|
+
# Latin-1 printable characters (space to tilde + high ASCII)
|
|
30
|
+
PRINTABLE_LATIN1 = (32..126).to_a + (161..255).to_a
|
|
31
|
+
|
|
32
|
+
# Valid compression method
|
|
33
|
+
COMPRESSION_DEFLATE = 0
|
|
34
|
+
|
|
35
|
+
# Validate zTXt chunk
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean] True if validation passed
|
|
38
|
+
def validate
|
|
39
|
+
return false unless check_crc
|
|
40
|
+
return false unless check_structure
|
|
41
|
+
return false unless check_keyword
|
|
42
|
+
return false unless check_compression_method
|
|
43
|
+
return false unless check_decompression
|
|
44
|
+
|
|
45
|
+
store_text_info
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Check zTXt chunk structure
|
|
52
|
+
def check_structure
|
|
53
|
+
data = chunk.chunk_data
|
|
54
|
+
|
|
55
|
+
# Must contain at least keyword + null + compression method
|
|
56
|
+
if data.length < 3
|
|
57
|
+
add_error("zTXt chunk too short (minimum 3 bytes)")
|
|
58
|
+
return false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Must contain null separator
|
|
62
|
+
null_pos = data.index("\0")
|
|
63
|
+
unless null_pos
|
|
64
|
+
add_error("zTXt chunk missing null separator")
|
|
65
|
+
return false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check keyword validity
|
|
72
|
+
def check_keyword
|
|
73
|
+
data = chunk.chunk_data
|
|
74
|
+
null_pos = data.index("\0")
|
|
75
|
+
keyword = data[0, null_pos]
|
|
76
|
+
|
|
77
|
+
# Check keyword length
|
|
78
|
+
if keyword.empty?
|
|
79
|
+
add_error("zTXt chunk has empty keyword")
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if keyword.length > MAX_KEYWORD_LENGTH
|
|
84
|
+
add_error("zTXt keyword too long (#{keyword.length}, " \
|
|
85
|
+
"max #{MAX_KEYWORD_LENGTH})")
|
|
86
|
+
return false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check for Latin-1 printable characters
|
|
90
|
+
keyword.bytes.each do |byte|
|
|
91
|
+
next if PRINTABLE_LATIN1.include?(byte)
|
|
92
|
+
|
|
93
|
+
add_error("zTXt keyword contains non-printable character " \
|
|
94
|
+
"(0x#{byte.to_s(16)})")
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check for leading/trailing spaces
|
|
99
|
+
if keyword.start_with?(" ")
|
|
100
|
+
add_error("zTXt keyword has leading space")
|
|
101
|
+
return false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if keyword.end_with?(" ")
|
|
105
|
+
add_error("zTXt keyword has trailing space")
|
|
106
|
+
return false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check for consecutive spaces
|
|
110
|
+
if keyword.include?(" ")
|
|
111
|
+
add_error("zTXt keyword has consecutive spaces")
|
|
112
|
+
return false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check compression method
|
|
119
|
+
def check_compression_method
|
|
120
|
+
data = chunk.chunk_data
|
|
121
|
+
null_pos = data.index("\0")
|
|
122
|
+
compression_method = data[null_pos + 1].ord
|
|
123
|
+
|
|
124
|
+
unless compression_method == COMPRESSION_DEFLATE
|
|
125
|
+
add_error("zTXt invalid compression method " \
|
|
126
|
+
"(#{compression_method}, must be 0)")
|
|
127
|
+
return false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check that compressed data can be decompressed
|
|
134
|
+
def check_decompression
|
|
135
|
+
data = chunk.chunk_data
|
|
136
|
+
null_pos = data.index("\0")
|
|
137
|
+
compressed_data = data[(null_pos + 2)..] || ""
|
|
138
|
+
|
|
139
|
+
# Try to decompress
|
|
140
|
+
begin
|
|
141
|
+
Zlib::Inflate.inflate(compressed_data)
|
|
142
|
+
rescue Zlib::Error => e
|
|
143
|
+
add_error("zTXt decompression failed: #{e.message}")
|
|
144
|
+
return false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Store text information in context
|
|
151
|
+
def store_text_info
|
|
152
|
+
data = chunk.chunk_data
|
|
153
|
+
null_pos = data.index("\0")
|
|
154
|
+
keyword = data[0, null_pos]
|
|
155
|
+
compressed_data = data[(null_pos + 2)..] || ""
|
|
156
|
+
|
|
157
|
+
# Decompress text
|
|
158
|
+
text = Zlib::Inflate.inflate(compressed_data)
|
|
159
|
+
|
|
160
|
+
# Store in context (allow multiple text chunks)
|
|
161
|
+
texts = context.retrieve(:text_chunks) || []
|
|
162
|
+
texts << { keyword: keyword, text: text, compressed: true }
|
|
163
|
+
context.store(:text_chunks, texts)
|
|
164
|
+
|
|
165
|
+
# Add info about the text chunk
|
|
166
|
+
text_preview = text.length > 40 ? "#{text[0, 40]}..." : text
|
|
167
|
+
add_info("zTXt: #{keyword} = \"#{text_preview}\" " \
|
|
168
|
+
"(compressed from #{compressed_data.length} bytes)")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Validators
|
|
5
|
+
module Apng
|
|
6
|
+
# Validator for acTL (Animation Control) chunk
|
|
7
|
+
#
|
|
8
|
+
# The acTL chunk is an ancillary chunk that contains information about
|
|
9
|
+
# the animation: the number of frames and the number of times to loop.
|
|
10
|
+
#
|
|
11
|
+
# Structure:
|
|
12
|
+
# - num_frames (4 bytes): Number of frames (unsigned int)
|
|
13
|
+
# - num_plays (4 bytes): Number of times to loop (0 = infinite)
|
|
14
|
+
#
|
|
15
|
+
# Constraints:
|
|
16
|
+
# - Must appear before IDAT
|
|
17
|
+
# - Must appear before any fcTL chunks
|
|
18
|
+
# - num_frames must be > 0
|
|
19
|
+
# - If present, at least one fcTL chunk must exist
|
|
20
|
+
#
|
|
21
|
+
class ActlValidator < BaseValidator
|
|
22
|
+
CHUNK_TYPE = "acTL"
|
|
23
|
+
EXPECTED_LENGTH = 8
|
|
24
|
+
|
|
25
|
+
def validate
|
|
26
|
+
return unless check_crc
|
|
27
|
+
return unless check_length(EXPECTED_LENGTH)
|
|
28
|
+
|
|
29
|
+
validate_structure
|
|
30
|
+
validate_ordering
|
|
31
|
+
validate_animation_parameters
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_structure
|
|
37
|
+
data = chunk.chunk_data
|
|
38
|
+
|
|
39
|
+
# Extract fields
|
|
40
|
+
num_frames = data[0..3].unpack1("N")
|
|
41
|
+
num_plays = data[4..7].unpack1("N")
|
|
42
|
+
|
|
43
|
+
# Store in context for cross-chunk validation
|
|
44
|
+
context.store(:actl_num_frames, num_frames)
|
|
45
|
+
context.store(:actl_num_plays, num_plays)
|
|
46
|
+
|
|
47
|
+
# Validate num_frames
|
|
48
|
+
if num_frames.zero?
|
|
49
|
+
add_error("acTL num_frames must be > 0")
|
|
50
|
+
return false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# num_plays can be 0 (infinite) or any positive number
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_ordering
|
|
58
|
+
# acTL must appear before IDAT
|
|
59
|
+
if context.seen?("IDAT")
|
|
60
|
+
add_error("acTL must appear before IDAT")
|
|
61
|
+
return false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# acTL must appear before any fcTL
|
|
65
|
+
if context.seen?("fcTL")
|
|
66
|
+
add_error("acTL must appear before first fcTL")
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_animation_parameters
|
|
74
|
+
# Check if this is truly an animated PNG
|
|
75
|
+
# (will be validated after all chunks are processed)
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|