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,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
|