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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Analyzers
5
+ # Analyzes PNG files for optimization opportunities
6
+ class OptimizationAnalyzer
7
+ # Chunks that are often unnecessary for web/mobile use
8
+ UNNECESSARY_FOR_WEB = %w[tIME pHYs oFFs pCAL sCAL sTER].freeze
9
+
10
+ # Text chunk types
11
+ TEXT_CHUNKS = %w[tEXt zTXt iTXt].freeze
12
+
13
+ # Metadata chunk types
14
+ METADATA_CHUNKS = %w[tEXt zTXt iTXt tIME pHYs].freeze
15
+
16
+ def initialize(result)
17
+ @result = result
18
+ @suggestions = []
19
+ end
20
+
21
+ def analyze
22
+ check_unnecessary_chunks
23
+ check_color_depth
24
+ check_palette_opportunity
25
+ check_interlacing
26
+ check_text_chunks
27
+ check_metadata_size
28
+
29
+ {
30
+ suggestions: @suggestions,
31
+ potential_savings_bytes: calculate_total_savings,
32
+ potential_savings_percent: calculate_savings_percentage,
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def check_unnecessary_chunks
39
+ unnecessary = @result.chunks.select do |c|
40
+ UNNECESSARY_FOR_WEB.include?(c.type)
41
+ end
42
+ return if unnecessary.empty?
43
+
44
+ # Each chunk has: 4 bytes length + 4 bytes type + data + 4 bytes CRC
45
+ savings = unnecessary.sum { |c| c.length + 12 }
46
+ @suggestions << {
47
+ type: :remove_chunks,
48
+ priority: :medium,
49
+ savings_bytes: savings,
50
+ description: "Remove #{unnecessary.count} unnecessary chunks " \
51
+ "(#{unnecessary.map(&:type).join(', ')})",
52
+ chunks: unnecessary.map(&:type),
53
+ }
54
+ end
55
+
56
+ def check_color_depth
57
+ # Get bit depth from IHDR chunk
58
+ ihdr = @result.ihdr_chunk
59
+ return unless ihdr && get_bit_depth(ihdr) == 16
60
+
61
+ # Estimate if 8-bit would be sufficient
62
+ if could_use_8_bit?
63
+ current_size = @result.file_size
64
+ estimated_savings = (current_size * 0.45).to_i # ~45% reduction
65
+
66
+ @suggestions << {
67
+ type: :reduce_bit_depth,
68
+ priority: :high,
69
+ savings_bytes: estimated_savings,
70
+ description: "Convert from 16-bit to 8-bit depth " \
71
+ "(estimated ~45% file size reduction)",
72
+ current: "16-bit",
73
+ recommended: "8-bit",
74
+ }
75
+ end
76
+ end
77
+
78
+ def check_palette_opportunity
79
+ # Get color type from IHDR
80
+ ihdr = @result.ihdr_chunk
81
+ return unless ihdr && get_color_type(ihdr) == 2 # RGB
82
+ return if @result.file_size < 10_000 # Skip small files
83
+
84
+ # If it's RGB but could be palette
85
+ @suggestions << {
86
+ type: :convert_to_palette,
87
+ priority: :medium,
88
+ savings_bytes: (@result.file_size * 0.30).to_i,
89
+ description: "Consider converting to palette mode if using limited colors " \
90
+ "(potential ~30% reduction)",
91
+ current: "RGB (Truecolor)",
92
+ recommended: "Indexed (Palette)",
93
+ }
94
+ end
95
+
96
+ def check_interlacing
97
+ # Get interlace method from IHDR
98
+ ihdr = @result.ihdr_chunk
99
+ return unless ihdr && get_interlace_method(ihdr) == 1
100
+
101
+ # Interlaced PNGs are larger
102
+ savings = (@result.file_size * 0.15).to_i
103
+
104
+ @suggestions << {
105
+ type: :remove_interlacing,
106
+ priority: :low,
107
+ savings_bytes: savings,
108
+ description: "Remove interlacing for smaller file size " \
109
+ "(~15% reduction, but slower initial display)",
110
+ current: "Adam7 interlaced",
111
+ recommended: "Non-interlaced",
112
+ }
113
+ end
114
+
115
+ def check_text_chunks
116
+ text_chunks = @result.chunks.select { |c| TEXT_CHUNKS.include?(c.type) }
117
+ return if text_chunks.empty?
118
+
119
+ total_text_size = text_chunks.sum { |c| c.length + 12 }
120
+ return if total_text_size < 500 # Ignore small metadata
121
+
122
+ @suggestions << {
123
+ type: :reduce_metadata,
124
+ priority: :low,
125
+ savings_bytes: total_text_size,
126
+ description: "#{text_chunks.count} text chunks using #{total_text_size} bytes " \
127
+ "(consider removing non-essential metadata)",
128
+ chunks: text_chunks.map(&:type),
129
+ }
130
+ end
131
+
132
+ def check_metadata_size
133
+ metadata_chunks = @result.chunks.select do |c|
134
+ METADATA_CHUNKS.include?(c.type)
135
+ end
136
+
137
+ total_metadata = metadata_chunks.sum { |c| c.length + 12 }
138
+ file_size = @result.file_size
139
+
140
+ # If metadata is more than 10% of file size
141
+ return unless total_metadata > file_size * 0.1
142
+
143
+ @suggestions << {
144
+ type: :excessive_metadata,
145
+ priority: :medium,
146
+ savings_bytes: total_metadata,
147
+ description: "Metadata comprises #{(total_metadata.to_f / file_size * 100).round(1)}% " \
148
+ "of file size (#{total_metadata} bytes)",
149
+ recommendation: "Review if all metadata is necessary",
150
+ }
151
+ end
152
+
153
+ # Helper methods to extract IHDR data
154
+ def get_bit_depth(ihdr_chunk)
155
+ # IHDR data: width(4) + height(4) + bit_depth(1) + ...
156
+ return nil unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 9
157
+
158
+ ihdr_chunk.data.bytes[8]
159
+ end
160
+
161
+ def get_color_type(ihdr_chunk)
162
+ return nil unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 10
163
+
164
+ ihdr_chunk.data.bytes[9]
165
+ end
166
+
167
+ def get_interlace_method(ihdr_chunk)
168
+ return nil unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 13
169
+
170
+ ihdr_chunk.data.bytes[12]
171
+ end
172
+
173
+ def could_use_8_bit?
174
+ # Conservative heuristic: suggest 8-bit for smaller files
175
+ # Without pixel analysis, we're conservative
176
+ @result.file_size < 100_000
177
+ end
178
+
179
+ def calculate_total_savings
180
+ @suggestions.sum { |s| s[:savings_bytes] || 0 }
181
+ end
182
+
183
+ def calculate_savings_percentage
184
+ return 0 if @result.file_size.zero?
185
+
186
+ (calculate_total_savings.to_f / @result.file_size * 100).round(1)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Analyzers
5
+ # Analyzes PNG resolution and DPI for various use cases
6
+ class ResolutionAnalyzer
7
+ # Standard DPI values
8
+ SCREEN_DPI = 72
9
+ PRINT_DPI_LOW = 150
10
+ PRINT_DPI_STANDARD = 300
11
+ PRINT_DPI_HIGH = 600
12
+
13
+ # Retina display densities
14
+ RETINA_1X = 1.0
15
+ RETINA_2X = 2.0
16
+ RETINA_3X = 3.0
17
+
18
+ def initialize(result)
19
+ @result = result
20
+ ihdr = result.ihdr_chunk
21
+ @width = ihdr ? get_width(ihdr) : 0
22
+ @height = ihdr ? get_height(ihdr) : 0
23
+ @dpi = extract_dpi
24
+ end
25
+
26
+ def analyze
27
+ {
28
+ resolution: resolution_info,
29
+ retina: retina_analysis,
30
+ print: print_analysis,
31
+ web: web_analysis,
32
+ recommendations: generate_recommendations,
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ # Extract data from IHDR chunk
39
+ def get_width(ihdr_chunk)
40
+ return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 4
41
+
42
+ ihdr_chunk.data.bytes[0..3].pack("C*").unpack1("N")
43
+ end
44
+
45
+ def get_height(ihdr_chunk)
46
+ return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 8
47
+
48
+ ihdr_chunk.data.bytes[4..7].pack("C*").unpack1("N")
49
+ end
50
+
51
+ def resolution_info
52
+ {
53
+ width_px: @width,
54
+ height_px: @height,
55
+ dimensions: "#{@width}x#{@height}",
56
+ total_pixels: @width * @height,
57
+ megapixels: (@width * @height / 1_000_000.0).round(2),
58
+ dpi: @dpi,
59
+ has_dpi_metadata: !@dpi.nil?,
60
+ }
61
+ end
62
+
63
+ def retina_analysis
64
+ analysis = {
65
+ is_retina_ready: check_retina_ready,
66
+ at_1x: calculate_physical_size(RETINA_1X),
67
+ at_2x: calculate_physical_size(RETINA_2X),
68
+ at_3x: calculate_physical_size(RETINA_3X),
69
+ }
70
+
71
+ analysis[:recommended_density] = recommend_density
72
+ analysis[:ios_asset_catalog] = ios_asset_suggestions
73
+ analysis[:android_density] = android_density_bucket
74
+
75
+ analysis
76
+ end
77
+
78
+ def print_analysis
79
+ return { capable: false, reason: "No DPI metadata" } unless @dpi
80
+
81
+ width_inches = @width.to_f / @dpi
82
+ height_inches = @height.to_f / @dpi
83
+
84
+ {
85
+ capable: @dpi >= PRINT_DPI_LOW,
86
+ dpi: @dpi,
87
+ physical_size: {
88
+ width_inches: width_inches.round(2),
89
+ height_inches: height_inches.round(2),
90
+ width_cm: (width_inches * 2.54).round(2),
91
+ height_cm: (height_inches * 2.54).round(2),
92
+ },
93
+ quality: print_quality_assessment,
94
+ suitable_for: suitable_print_sizes,
95
+ }
96
+ end
97
+
98
+ def web_analysis
99
+ {
100
+ suitable_for_web: @width <= 4096 && @height <= 4096,
101
+ typical_screen_size: calculate_screen_coverage,
102
+ mobile_friendly: @width <= 1920 && @height <= 1920,
103
+ retina_optimized: @width >= 1000 && @height >= 1000,
104
+ load_time_estimate: estimate_load_time,
105
+ }
106
+ end
107
+
108
+ def extract_dpi
109
+ # Look for pHYs chunk
110
+ phys_chunk = @result.chunks_by_type("pHYs").first
111
+ return nil unless phys_chunk&.data && phys_chunk.data.bytesize >= 9
112
+
113
+ # pHYs: pixels_per_unit_x(4) + pixels_per_unit_y(4) + unit_specifier(1)
114
+ unit = phys_chunk.data.bytes[8]
115
+ return nil unless unit == 1 # 1 = meters
116
+
117
+ pixels_per_meter = phys_chunk.data.bytes[0..3].pack("C*").unpack1("N")
118
+ (pixels_per_meter * 0.0254).round # Convert to DPI
119
+ end
120
+
121
+ def check_retina_ready
122
+ @width >= 88 && @height >= 88
123
+ end
124
+
125
+ def calculate_physical_size(density)
126
+ css_reference_dpi = 163
127
+ effective_dpi = css_reference_dpi * density
128
+
129
+ width_points = (@width.to_f / effective_dpi * 72).round(1)
130
+ height_points = (@height.to_f / effective_dpi * 72).round(1)
131
+
132
+ {
133
+ width_points: width_points,
134
+ height_points: height_points,
135
+ dimensions_pt: "#{width_points}x#{height_points}pt",
136
+ suitable_for: suitable_element_sizes(width_points, height_points),
137
+ }
138
+ end
139
+
140
+ def recommend_density
141
+ pixels = @width * @height
142
+ case pixels
143
+ when 0..10_000 then "@1x (too small for higher densities)"
144
+ when 10_001..100_000 then "@1x or @2x"
145
+ when 100_001..500_000 then "@2x"
146
+ when 500_001..2_000_000 then "@2x or @3x"
147
+ else "@3x"
148
+ end
149
+ end
150
+
151
+ def ios_asset_suggestions
152
+ suggestions = []
153
+
154
+ if @width == @height
155
+ case @width
156
+ when 20 then suggestions << "Icon 20pt @1x (Settings)"
157
+ when 40 then suggestions << "Icon 20pt @2x (Settings)"
158
+ when 60 then suggestions << "Icon 20pt @3x or 60pt @1x"
159
+ when 29 then suggestions << "Icon 29pt @1x"
160
+ when 58 then suggestions << "Icon 29pt @2x"
161
+ when 87 then suggestions << "Icon 29pt @3x"
162
+ when 1024 then suggestions << "App Store Icon (1024x1024)"
163
+ end
164
+ end
165
+
166
+ suggestions.empty? ? ["Custom size"] : suggestions
167
+ end
168
+
169
+ def android_density_bucket
170
+ case @width
171
+ when 0..120 then "ldpi or mdpi"
172
+ when 121..240 then "mdpi or hdpi"
173
+ when 241..480 then "hdpi or xhdpi"
174
+ when 481..720 then "xhdpi or xxhdpi"
175
+ when 721..960 then "xxhdpi or xxxhdpi"
176
+ else "xxxhdpi or custom"
177
+ end
178
+ end
179
+
180
+ def print_quality_assessment
181
+ return "Unknown" unless @dpi
182
+
183
+ case @dpi
184
+ when 0...PRINT_DPI_LOW then "Not suitable"
185
+ when PRINT_DPI_LOW...PRINT_DPI_STANDARD then "Acceptable"
186
+ when PRINT_DPI_STANDARD...PRINT_DPI_HIGH then "Good"
187
+ else "Excellent"
188
+ end
189
+ end
190
+
191
+ def suitable_print_sizes
192
+ return [] unless @dpi && @dpi >= PRINT_DPI_LOW
193
+
194
+ width_in = @width.to_f / @dpi
195
+ height_in = @height.to_f / @dpi
196
+
197
+ sizes = []
198
+ sizes << "4x6\"" if width_in >= 4 && height_in >= 6
199
+ sizes << "5x7\"" if width_in >= 5 && height_in >= 7
200
+ sizes << "8x10\"" if width_in >= 8 && height_in >= 10
201
+
202
+ sizes.empty? ? ["Small prints only"] : sizes
203
+ end
204
+
205
+ def calculate_screen_coverage
206
+ screens = {
207
+ "Mobile (375x667)" => { w: 375, h: 667 },
208
+ "Desktop (1920x1080)" => { w: 1920, h: 1080 },
209
+ }
210
+
211
+ screens.transform_values do |screen|
212
+ w_pct = (@width.to_f / screen[:w] * 100).round(1)
213
+ h_pct = (@height.to_f / screen[:h] * 100).round(1)
214
+ "#{w_pct}% x #{h_pct}%"
215
+ end
216
+ end
217
+
218
+ def suitable_element_sizes(width_pt, height_pt)
219
+ elements = []
220
+ elements << "Small icon" if width_pt < 32 && height_pt < 32
221
+ elements << "Standard icon" if width_pt.between?(32, 64)
222
+ elements << "Large icon" if width_pt.between?(64, 128)
223
+ elements << "Banner" if width_pt > 300
224
+
225
+ elements.empty? ? ["Custom"] : elements
226
+ end
227
+
228
+ def estimate_load_time
229
+ file_size = @result.file_size
230
+ mbps = 5
231
+ bytes_per_second = (mbps * 1_000_000 / 8).to_i
232
+ seconds = file_size.to_f / bytes_per_second
233
+
234
+ if seconds < 0.1
235
+ "< 0.1s"
236
+ elsif seconds < 1
237
+ "#{(seconds * 1000).round}ms"
238
+ else
239
+ "#{seconds.round(1)}s"
240
+ end
241
+ end
242
+
243
+ def generate_recommendations
244
+ recs = []
245
+
246
+ if @width < 100 && @height < 100
247
+ recs << {
248
+ category: :retina,
249
+ priority: :high,
250
+ message: "Image is too small for Retina displays - consider @2x/@3x versions",
251
+ }
252
+ end
253
+
254
+ unless @dpi
255
+ recs << {
256
+ category: :metadata,
257
+ priority: :medium,
258
+ message: "Add pHYs chunk with DPI information for print compatibility",
259
+ }
260
+ end
261
+
262
+ if @width > 3000 || @height > 3000
263
+ recs << {
264
+ category: :web,
265
+ priority: :high,
266
+ message: "Image is very large for web - consider reducing dimensions",
267
+ }
268
+ end
269
+
270
+ recs
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module PngConform
6
+ module BinData
7
+ # Base structure for PNG chunks
8
+ #
9
+ # PNG chunk format (from PNG spec):
10
+ # Length: 4 bytes (big-endian unsigned integer)
11
+ # Type: 4 bytes (4 ASCII characters)
12
+ # Data: variable length (specified by Length field)
13
+ # CRC: 4 bytes (CRC-32 of Type and Data)
14
+ #
15
+ # @example Reading a chunk
16
+ # chunk = ChunkStructure.read(io)
17
+ # puts chunk.type # => "IHDR"
18
+ # puts chunk.length # => 13
19
+ # puts chunk.data.length # => 13
20
+ # puts chunk.crc # => 0x9a76a1ae
21
+ #
22
+ class ChunkStructure < ::BinData::Record
23
+ endian :big
24
+
25
+ # Length of the data field (4 bytes, big-endian)
26
+ uint32 :data_length
27
+
28
+ # Chunk type code (4 bytes, ASCII)
29
+ string :chunk_type, length: 4
30
+
31
+ # Chunk data (variable length, specified by data_length field)
32
+ string :chunk_data, read_length: :data_length
33
+
34
+ # CRC-32 checksum (4 bytes, big-endian)
35
+ # CRC is calculated over the Type and Data fields
36
+ uint32 :crc
37
+
38
+ # Convenience accessor for chunk type
39
+ #
40
+ # @return [String] chunk type code
41
+ def type
42
+ chunk_type
43
+ end
44
+
45
+ # Convenience accessor for chunk data
46
+ #
47
+ # @return [String] chunk data
48
+ def data
49
+ chunk_data
50
+ end
51
+
52
+ # Convenience accessor for data length
53
+ #
54
+ # @return [Integer] data length
55
+ def length
56
+ data_length
57
+ end
58
+
59
+ # Calculate the expected CRC-32 value
60
+ #
61
+ # The CRC is calculated over the chunk type and data fields.
62
+ # This uses the standard CRC-32 algorithm as specified in PNG spec.
63
+ #
64
+ # @return [Integer] the calculated CRC-32 value
65
+ def calculated_crc
66
+ require "zlib"
67
+ ::Zlib.crc32(chunk_type + chunk_data)
68
+ end
69
+
70
+ # Check if the stored CRC matches the calculated CRC
71
+ #
72
+ # @return [Boolean] true if CRC is valid, false otherwise
73
+ def crc_valid?
74
+ crc == calculated_crc
75
+ end
76
+
77
+ # Get chunk type as a symbol for easier matching
78
+ #
79
+ # @return [Symbol] chunk type as symbol (e.g., :IHDR)
80
+ def type_symbol
81
+ chunk_type.to_sym
82
+ end
83
+
84
+ # Check if this is a critical chunk
85
+ #
86
+ # Critical chunks have uppercase first letter in type code.
87
+ # From PNG spec: bit 5 of first byte is 0 for critical chunks.
88
+ #
89
+ # @return [Boolean] true if critical chunk
90
+ def critical?
91
+ (chunk_type[0].ord & 0x20).zero?
92
+ end
93
+
94
+ # Check if this is an ancillary chunk
95
+ #
96
+ # Ancillary chunks have lowercase first letter in type code.
97
+ #
98
+ # @return [Boolean] true if ancillary chunk
99
+ def ancillary?
100
+ !critical?
101
+ end
102
+
103
+ # Check if this chunk is safe to copy
104
+ #
105
+ # Safe-to-copy chunks have lowercase fourth letter.
106
+ # From PNG spec: bit 5 of fourth byte is 1 for safe-to-copy.
107
+ #
108
+ # @return [Boolean] true if safe to copy
109
+ def safe_to_copy?
110
+ chunk_type[3].ord & 0x20 != 0
111
+ end
112
+
113
+ # Check if this is a private chunk
114
+ #
115
+ # Private chunks have lowercase second letter.
116
+ # From PNG spec: bit 5 of second byte is 1 for private chunks.
117
+ #
118
+ # @return [Boolean] true if private chunk
119
+ def private?
120
+ chunk_type[1].ord & 0x20 != 0
121
+ end
122
+
123
+ # Get human-readable chunk information
124
+ #
125
+ # @return [String] formatted chunk information
126
+ def to_s
127
+ format(
128
+ "%s: %d bytes (CRC: 0x%08x %s)",
129
+ chunk_type,
130
+ data_length,
131
+ crc,
132
+ crc_valid? ? "OK" : "INVALID",
133
+ )
134
+ end
135
+
136
+ # Get detailed chunk information for debugging
137
+ #
138
+ # @return [Hash] chunk details
139
+ def inspect_details
140
+ {
141
+ type: chunk_type,
142
+ length: data_length,
143
+ data_size: chunk_data.length,
144
+ crc: format("0x%08x", crc),
145
+ crc_valid: crc_valid?,
146
+ critical: critical?,
147
+ private: private?,
148
+ safe_to_copy: safe_to_copy?,
149
+ }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module BinData
5
+ # JNG (JPEG Network Graphics) file structure
6
+ # A JNG file consists of:
7
+ # - 8-byte signature: 139 74 78 71 13 10 26 10
8
+ # - JHDR chunk (must be first)
9
+ # - JDAT chunks (JPEG data)
10
+ # - Optional IDAT chunks (PNG alpha channel)
11
+ # - IEND chunk (must be last)
12
+ class JngFile < ::BinData::Record
13
+ # JNG signature (magic number)
14
+ JNG_SIGNATURE = [139, 74, 78, 71, 13, 10, 26, 10].pack("C*").freeze
15
+
16
+ string :signature, length: 8
17
+ array :chunks, type: :chunk_structure, read_until: :eof
18
+
19
+ # Validate JNG signature
20
+ def valid_signature?
21
+ signature == JNG_SIGNATURE
22
+ end
23
+
24
+ # Get signature as hex string for display
25
+ def signature_hex
26
+ signature.unpack1("H*")
27
+ end
28
+
29
+ # Find chunks by type
30
+ def chunks_by_type(type)
31
+ chunks.select { |chunk| chunk.type == type }
32
+ end
33
+
34
+ # Get JHDR chunk (must be first chunk)
35
+ def jhdr_chunk
36
+ chunks.first if chunks.first&.type == "JHDR"
37
+ end
38
+
39
+ # Get IEND chunk (must be last chunk)
40
+ def iend_chunk
41
+ chunks.last if chunks.last&.type == "IEND"
42
+ end
43
+
44
+ # Get all JDAT chunks (JPEG image data)
45
+ def jdat_chunks
46
+ chunks_by_type("JDAT")
47
+ end
48
+
49
+ # Get all IDAT chunks (PNG alpha channel data)
50
+ def idat_chunks
51
+ chunks_by_type("IDAT")
52
+ end
53
+
54
+ # Get JSEP chunk (8/12-bit separator, if present)
55
+ def jsep_chunk
56
+ chunks_by_type("JSEP").first
57
+ end
58
+
59
+ # Check if JNG has alpha channel
60
+ def has_alpha?
61
+ !idat_chunks.empty?
62
+ end
63
+
64
+ # Check if file has proper chunk ordering
65
+ def proper_chunk_order?
66
+ return false unless jhdr_chunk
67
+ return false unless iend_chunk
68
+ return false if jdat_chunks.empty?
69
+
70
+ true
71
+ end
72
+
73
+ # Get total file size
74
+ def total_size
75
+ 8 + chunks.sum(&:total_size)
76
+ end
77
+ end
78
+ end
79
+ end