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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +19 -0
  4. data/.rubocop_todo.yml +197 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/CONTRIBUTING.md +323 -0
  7. data/Gemfile +13 -0
  8. data/LICENSE +43 -0
  9. data/README.adoc +859 -0
  10. data/Rakefile +10 -0
  11. data/SECURITY.md +147 -0
  12. data/docs/ARCHITECTURE.adoc +681 -0
  13. data/docs/CHUNK_TYPES.adoc +450 -0
  14. data/docs/CLI_OPTIONS.adoc +913 -0
  15. data/docs/COMPATIBILITY.adoc +616 -0
  16. data/examples/README.adoc +398 -0
  17. data/examples/advanced_usage.rb +304 -0
  18. data/examples/basic_usage.rb +210 -0
  19. data/exe/png_conform +6 -0
  20. data/lib/png_conform/analyzers/comparison_analyzer.rb +230 -0
  21. data/lib/png_conform/analyzers/metrics_analyzer.rb +176 -0
  22. data/lib/png_conform/analyzers/optimization_analyzer.rb +190 -0
  23. data/lib/png_conform/analyzers/resolution_analyzer.rb +274 -0
  24. data/lib/png_conform/bindata/chunk_structure.rb +153 -0
  25. data/lib/png_conform/bindata/jng_file.rb +79 -0
  26. data/lib/png_conform/bindata/mng_file.rb +97 -0
  27. data/lib/png_conform/bindata/png_file.rb +162 -0
  28. data/lib/png_conform/cli.rb +116 -0
  29. data/lib/png_conform/commands/check_command.rb +323 -0
  30. data/lib/png_conform/commands/list_command.rb +67 -0
  31. data/lib/png_conform/models/chunk.rb +84 -0
  32. data/lib/png_conform/models/chunk_info.rb +71 -0
  33. data/lib/png_conform/models/compression_info.rb +49 -0
  34. data/lib/png_conform/models/decoded_chunk_data.rb +143 -0
  35. data/lib/png_conform/models/file_analysis.rb +181 -0
  36. data/lib/png_conform/models/file_info.rb +91 -0
  37. data/lib/png_conform/models/image_info.rb +52 -0
  38. data/lib/png_conform/models/validation_error.rb +89 -0
  39. data/lib/png_conform/models/validation_result.rb +137 -0
  40. data/lib/png_conform/readers/full_load_reader.rb +113 -0
  41. data/lib/png_conform/readers/streaming_reader.rb +180 -0
  42. data/lib/png_conform/reporters/base_reporter.rb +53 -0
  43. data/lib/png_conform/reporters/color_reporter.rb +65 -0
  44. data/lib/png_conform/reporters/json_reporter.rb +18 -0
  45. data/lib/png_conform/reporters/palette_reporter.rb +48 -0
  46. data/lib/png_conform/reporters/quiet_reporter.rb +18 -0
  47. data/lib/png_conform/reporters/reporter_factory.rb +108 -0
  48. data/lib/png_conform/reporters/summary_reporter.rb +65 -0
  49. data/lib/png_conform/reporters/text_reporter.rb +66 -0
  50. data/lib/png_conform/reporters/verbose_reporter.rb +87 -0
  51. data/lib/png_conform/reporters/very_verbose_reporter.rb +33 -0
  52. data/lib/png_conform/reporters/visual_elements.rb +66 -0
  53. data/lib/png_conform/reporters/yaml_reporter.rb +18 -0
  54. data/lib/png_conform/services/profile_manager.rb +242 -0
  55. data/lib/png_conform/services/validation_service.rb +457 -0
  56. data/lib/png_conform/services/zlib_validator.rb +270 -0
  57. data/lib/png_conform/validators/ancillary/bkgd_validator.rb +140 -0
  58. data/lib/png_conform/validators/ancillary/chrm_validator.rb +178 -0
  59. data/lib/png_conform/validators/ancillary/cicp_validator.rb +202 -0
  60. data/lib/png_conform/validators/ancillary/gama_validator.rb +105 -0
  61. data/lib/png_conform/validators/ancillary/hist_validator.rb +147 -0
  62. data/lib/png_conform/validators/ancillary/iccp_validator.rb +243 -0
  63. data/lib/png_conform/validators/ancillary/itxt_validator.rb +280 -0
  64. data/lib/png_conform/validators/ancillary/mdcv_validator.rb +201 -0
  65. data/lib/png_conform/validators/ancillary/offs_validator.rb +132 -0
  66. data/lib/png_conform/validators/ancillary/pcal_validator.rb +289 -0
  67. data/lib/png_conform/validators/ancillary/phys_validator.rb +107 -0
  68. data/lib/png_conform/validators/ancillary/sbit_validator.rb +176 -0
  69. data/lib/png_conform/validators/ancillary/scal_validator.rb +180 -0
  70. data/lib/png_conform/validators/ancillary/splt_validator.rb +223 -0
  71. data/lib/png_conform/validators/ancillary/srgb_validator.rb +117 -0
  72. data/lib/png_conform/validators/ancillary/ster_validator.rb +111 -0
  73. data/lib/png_conform/validators/ancillary/text_validator.rb +129 -0
  74. data/lib/png_conform/validators/ancillary/time_validator.rb +132 -0
  75. data/lib/png_conform/validators/ancillary/trns_validator.rb +154 -0
  76. data/lib/png_conform/validators/ancillary/ztxt_validator.rb +173 -0
  77. data/lib/png_conform/validators/apng/actl_validator.rb +81 -0
  78. data/lib/png_conform/validators/apng/fctl_validator.rb +155 -0
  79. data/lib/png_conform/validators/apng/fdat_validator.rb +117 -0
  80. data/lib/png_conform/validators/base_validator.rb +241 -0
  81. data/lib/png_conform/validators/chunk_registry.rb +219 -0
  82. data/lib/png_conform/validators/critical/idat_validator.rb +77 -0
  83. data/lib/png_conform/validators/critical/iend_validator.rb +68 -0
  84. data/lib/png_conform/validators/critical/ihdr_validator.rb +160 -0
  85. data/lib/png_conform/validators/critical/plte_validator.rb +120 -0
  86. data/lib/png_conform/validators/jng/jdat_validator.rb +66 -0
  87. data/lib/png_conform/validators/jng/jhdr_validator.rb +116 -0
  88. data/lib/png_conform/validators/jng/jsep_validator.rb +66 -0
  89. data/lib/png_conform/validators/mng/back_validator.rb +87 -0
  90. data/lib/png_conform/validators/mng/clip_validator.rb +65 -0
  91. data/lib/png_conform/validators/mng/clon_validator.rb +45 -0
  92. data/lib/png_conform/validators/mng/defi_validator.rb +104 -0
  93. data/lib/png_conform/validators/mng/dhdr_validator.rb +104 -0
  94. data/lib/png_conform/validators/mng/disc_validator.rb +44 -0
  95. data/lib/png_conform/validators/mng/endl_validator.rb +65 -0
  96. data/lib/png_conform/validators/mng/fram_validator.rb +91 -0
  97. data/lib/png_conform/validators/mng/loop_validator.rb +75 -0
  98. data/lib/png_conform/validators/mng/mend_validator.rb +31 -0
  99. data/lib/png_conform/validators/mng/mhdr_validator.rb +69 -0
  100. data/lib/png_conform/validators/mng/move_validator.rb +61 -0
  101. data/lib/png_conform/validators/mng/save_validator.rb +39 -0
  102. data/lib/png_conform/validators/mng/seek_validator.rb +42 -0
  103. data/lib/png_conform/validators/mng/show_validator.rb +52 -0
  104. data/lib/png_conform/validators/mng/term_validator.rb +84 -0
  105. data/lib/png_conform/version.rb +5 -0
  106. data/lib/png_conform.rb +101 -0
  107. data/png_conform.gemspec +43 -0
  108. 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