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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module BinData
|
|
5
|
+
# MNG (Multiple-image Network Graphics) file structure
|
|
6
|
+
# An MNG file consists of:
|
|
7
|
+
# - 8-byte signature: 138 77 78 71 13 10 26 10
|
|
8
|
+
# - MHDR chunk (must be first)
|
|
9
|
+
# - Series of chunks (can include PNG chunks)
|
|
10
|
+
# - MEND chunk (must be last)
|
|
11
|
+
class MngFile < ::BinData::Record
|
|
12
|
+
# MNG signature (magic number)
|
|
13
|
+
MNG_SIGNATURE = [138, 77, 78, 71, 13, 10, 26, 10].pack("C*").freeze
|
|
14
|
+
|
|
15
|
+
string :signature, length: 8
|
|
16
|
+
array :chunks, type: :chunk_structure, read_until: :eof
|
|
17
|
+
|
|
18
|
+
# Validate MNG signature
|
|
19
|
+
def valid_signature?
|
|
20
|
+
signature == MNG_SIGNATURE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get signature as hex string for display
|
|
24
|
+
def signature_hex
|
|
25
|
+
signature.unpack1("H*")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Find chunks by type
|
|
29
|
+
def chunks_by_type(type)
|
|
30
|
+
chunks.select { |chunk| chunk.type == type }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get MHDR chunk (must be first chunk)
|
|
34
|
+
def mhdr_chunk
|
|
35
|
+
chunks.first if chunks.first&.type == "MHDR"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get MEND chunk (must be last chunk)
|
|
39
|
+
def mend_chunk
|
|
40
|
+
chunks.last if chunks.last&.type == "MEND"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get all PNG image chunks (IHDR-IEND sequences)
|
|
44
|
+
def png_images
|
|
45
|
+
images = []
|
|
46
|
+
current_image = []
|
|
47
|
+
|
|
48
|
+
chunks.each do |chunk|
|
|
49
|
+
if chunk.type == "IHDR"
|
|
50
|
+
current_image = [chunk]
|
|
51
|
+
elsif !current_image.empty?
|
|
52
|
+
current_image << chunk
|
|
53
|
+
if chunk.type == "IEND"
|
|
54
|
+
images << current_image
|
|
55
|
+
current_image = []
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
images
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get all JNG image chunks (JHDR-IEND sequences)
|
|
64
|
+
def jng_images
|
|
65
|
+
images = []
|
|
66
|
+
current_image = []
|
|
67
|
+
|
|
68
|
+
chunks.each do |chunk|
|
|
69
|
+
if chunk.type == "JHDR"
|
|
70
|
+
current_image = [chunk]
|
|
71
|
+
elsif !current_image.empty?
|
|
72
|
+
current_image << chunk
|
|
73
|
+
if chunk.type == "IEND"
|
|
74
|
+
images << current_image
|
|
75
|
+
current_image = []
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
images
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check if file has proper chunk ordering
|
|
84
|
+
def proper_chunk_order?
|
|
85
|
+
return false unless mhdr_chunk
|
|
86
|
+
return false unless mend_chunk
|
|
87
|
+
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get total file size
|
|
92
|
+
def total_size
|
|
93
|
+
8 + chunks.sum(&:total_size)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "chunk_structure"
|
|
5
|
+
|
|
6
|
+
module PngConform
|
|
7
|
+
module BinData
|
|
8
|
+
# Binary structure for PNG files
|
|
9
|
+
#
|
|
10
|
+
# PNG file format (from PNG spec):
|
|
11
|
+
# Signature: 8 bytes (0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A)
|
|
12
|
+
# Chunks: variable number of chunks until IEND
|
|
13
|
+
#
|
|
14
|
+
# The PNG signature is always:
|
|
15
|
+
# - 0x89: High bit set to detect transmission as text
|
|
16
|
+
# - "PNG": Format identifier
|
|
17
|
+
# - 0x0D 0x0A: DOS-style line ending (CRLF)
|
|
18
|
+
# - 0x1A: DOS end-of-file character
|
|
19
|
+
# - 0x0A: Unix-style line ending (LF)
|
|
20
|
+
#
|
|
21
|
+
# @example Reading a PNG file
|
|
22
|
+
# File.open("image.png", "rb") do |f|
|
|
23
|
+
# png = PngFile.read(f)
|
|
24
|
+
# puts "Valid PNG" if png.valid_signature?
|
|
25
|
+
# png.chunks.each { |chunk| puts chunk.type }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
class PngFile < ::BinData::Record
|
|
29
|
+
# PNG signature (8 bytes)
|
|
30
|
+
# Must be: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
|
|
31
|
+
string :signature, length: 8
|
|
32
|
+
|
|
33
|
+
# Array of chunks (read until IEND)
|
|
34
|
+
array :chunks, type: :chunk_structure, read_until: :eof
|
|
35
|
+
|
|
36
|
+
# Expected PNG signature bytes
|
|
37
|
+
PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
|
|
38
|
+
.pack("C*")
|
|
39
|
+
.freeze
|
|
40
|
+
|
|
41
|
+
# Check if the signature is valid
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] true if signature matches PNG specification
|
|
44
|
+
def valid_signature?
|
|
45
|
+
signature == PNG_SIGNATURE
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get signature as hex string for debugging
|
|
49
|
+
#
|
|
50
|
+
# @return [String] signature in hex format
|
|
51
|
+
def signature_hex
|
|
52
|
+
signature.bytes.map { |b| format("%02x", b) }.join(" ")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Find a specific chunk by type
|
|
56
|
+
#
|
|
57
|
+
# @param type [String, Symbol] chunk type (e.g., "IHDR" or :IHDR)
|
|
58
|
+
# @return [ChunkStructure, nil] the first matching chunk or nil
|
|
59
|
+
def find_chunk(type)
|
|
60
|
+
type_str = type.to_s
|
|
61
|
+
chunks.find { |chunk| chunk.type == type_str }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Find all chunks of a specific type
|
|
65
|
+
#
|
|
66
|
+
# @param type [String, Symbol] chunk type
|
|
67
|
+
# @return [Array<ChunkStructure>] all matching chunks
|
|
68
|
+
def find_chunks(type)
|
|
69
|
+
type_str = type.to_s
|
|
70
|
+
chunks.select { |chunk| chunk.type == type_str }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get the IHDR chunk (image header)
|
|
74
|
+
#
|
|
75
|
+
# @return [ChunkStructure, nil] the IHDR chunk
|
|
76
|
+
def ihdr
|
|
77
|
+
find_chunk("IHDR")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get the IEND chunk (image end)
|
|
81
|
+
#
|
|
82
|
+
# @return [ChunkStructure, nil] the IEND chunk
|
|
83
|
+
def iend
|
|
84
|
+
find_chunk("IEND")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get all IDAT chunks (image data)
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<ChunkStructure>] all IDAT chunks
|
|
90
|
+
def idats
|
|
91
|
+
find_chunks("IDAT")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get the PLTE chunk (palette)
|
|
95
|
+
#
|
|
96
|
+
# @return [ChunkStructure, nil] the PLTE chunk
|
|
97
|
+
def plte
|
|
98
|
+
find_chunk("PLTE")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if this appears to be a valid PNG structure
|
|
102
|
+
#
|
|
103
|
+
# Basic validation:
|
|
104
|
+
# - Valid signature
|
|
105
|
+
# - Has IHDR as first chunk
|
|
106
|
+
# - Has IEND as last chunk
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean] true if basic structure is valid
|
|
109
|
+
def structurally_valid?
|
|
110
|
+
return false unless valid_signature?
|
|
111
|
+
return false if chunks.empty?
|
|
112
|
+
return false unless chunks.first.type == "IHDR"
|
|
113
|
+
return false unless chunks.last.type == "IEND"
|
|
114
|
+
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get chunk sequence as array of types
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<String>] array of chunk type codes
|
|
121
|
+
def chunk_sequence
|
|
122
|
+
chunks.map(&:type)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Count chunks by type
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash<String, Integer>] chunk types and their counts
|
|
128
|
+
def chunk_counts
|
|
129
|
+
chunks.each_with_object(Hash.new(0)) do |chunk, counts|
|
|
130
|
+
counts[chunk.type] += 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if all chunks have valid CRCs
|
|
135
|
+
#
|
|
136
|
+
# @return [Boolean] true if all CRCs are valid
|
|
137
|
+
def all_crcs_valid?
|
|
138
|
+
chunks.all?(&:crc_valid?)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Get chunks with invalid CRCs
|
|
142
|
+
#
|
|
143
|
+
# @return [Array<ChunkStructure>] chunks with CRC errors
|
|
144
|
+
def invalid_crc_chunks
|
|
145
|
+
chunks.reject(&:crc_valid?)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get file summary information
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash] summary of file structure
|
|
151
|
+
def summary
|
|
152
|
+
{
|
|
153
|
+
signature_valid: valid_signature?,
|
|
154
|
+
chunk_count: chunks.length,
|
|
155
|
+
chunk_types: chunk_counts,
|
|
156
|
+
structurally_valid: structurally_valid?,
|
|
157
|
+
all_crcs_valid: all_crcs_valid?,
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "commands/check_command"
|
|
5
|
+
require_relative "commands/list_command"
|
|
6
|
+
|
|
7
|
+
module PngConform
|
|
8
|
+
# Main CLI application class using Thor framework.
|
|
9
|
+
#
|
|
10
|
+
# Provides the command-line interface for PNG validation and analysis.
|
|
11
|
+
# Delegates to command classes for implementation.
|
|
12
|
+
class Cli < Thor
|
|
13
|
+
class_option :verbose,
|
|
14
|
+
type: :boolean,
|
|
15
|
+
default: false,
|
|
16
|
+
desc: "Enable verbose output"
|
|
17
|
+
|
|
18
|
+
desc "check FILES", "Validate PNG files"
|
|
19
|
+
long_desc <<~DESC
|
|
20
|
+
Validate one or more PNG files and report any errors or warnings.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
-f, --format FORMAT Output format: text, yaml, json (default: text)
|
|
24
|
+
-v, --verbose Print detailed chunk information
|
|
25
|
+
-vv, --very-verbose Print very detailed information including \
|
|
26
|
+
line filters
|
|
27
|
+
-q, --quiet Only output errors (suppress success messages)
|
|
28
|
+
--no-color Disable colored output (colors enabled by default)
|
|
29
|
+
-p, --palette Print palette and histogram chunks
|
|
30
|
+
-t, --text Print text chunk contents
|
|
31
|
+
-7, --seven-bit Escape characters >= 128 for 7-bit terminals
|
|
32
|
+
--profile PROFILE Validate against a specific profile \
|
|
33
|
+
(minimal, web, print, archive, strict, default)
|
|
34
|
+
--strict Enable strict validation mode
|
|
35
|
+
--optimize Show optimization suggestions
|
|
36
|
+
--metrics Show detailed metrics (JSON/YAML)
|
|
37
|
+
--resolution Show resolution and Retina analysis
|
|
38
|
+
--mobile-ready Check mobile and Retina readiness
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
png_conform check image.png
|
|
42
|
+
png_conform check -v image.png
|
|
43
|
+
png_conform check --optimize image.png
|
|
44
|
+
png_conform check --resolution icon@2x.png
|
|
45
|
+
png_conform check --metrics --format json image.png
|
|
46
|
+
png_conform check --mobile-ready app-icon.png
|
|
47
|
+
png_conform check --profile web *.png
|
|
48
|
+
DESC
|
|
49
|
+
option :format, aliases: :f, type: :string, default: "text",
|
|
50
|
+
desc: "Output format (text, yaml, json)"
|
|
51
|
+
option :very_verbose, aliases: :vv, type: :boolean, default: false,
|
|
52
|
+
desc: "Print very detailed information"
|
|
53
|
+
option :quiet, aliases: :q, type: :boolean, default: false,
|
|
54
|
+
desc: "Only output errors"
|
|
55
|
+
option :no_color, type: :boolean, default: false,
|
|
56
|
+
desc: "Disable colored output"
|
|
57
|
+
option :palette, aliases: :p, type: :boolean, default: false,
|
|
58
|
+
desc: "Print palette and histogram chunks"
|
|
59
|
+
option :text, aliases: :t, type: :boolean, default: false,
|
|
60
|
+
desc: "Print text chunk contents"
|
|
61
|
+
option :seven_bit, aliases: :"7", type: :boolean, default: false,
|
|
62
|
+
desc: "Escape chars >= 128"
|
|
63
|
+
option :profile, type: :string, default: nil,
|
|
64
|
+
desc: "Validation profile"
|
|
65
|
+
option :strict, type: :boolean, default: false,
|
|
66
|
+
desc: "Strict validation mode"
|
|
67
|
+
option :optimize, type: :boolean, default: false,
|
|
68
|
+
desc: "Show file size optimization suggestions"
|
|
69
|
+
option :metrics, type: :boolean, default: false,
|
|
70
|
+
desc: "Show comprehensive metrics"
|
|
71
|
+
option :resolution, type: :boolean, default: false,
|
|
72
|
+
desc: "Show resolution and Retina/DPI analysis"
|
|
73
|
+
option :mobile_ready, type: :boolean, default: false,
|
|
74
|
+
desc: "Check mobile and Retina readiness"
|
|
75
|
+
def check(*files)
|
|
76
|
+
Commands::CheckCommand.new(files, options).run
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
desc "list", "List available validation profiles"
|
|
80
|
+
long_desc <<~DESC
|
|
81
|
+
Display all available validation profiles and their requirements.
|
|
82
|
+
|
|
83
|
+
Profiles define different validation rules for different use cases:
|
|
84
|
+
- minimal: Basic PNG structure validation
|
|
85
|
+
- web: Browser-optimized validation
|
|
86
|
+
- print: Print-ready validation with physical dimensions
|
|
87
|
+
- archive: Long-term preservation with full metadata
|
|
88
|
+
- strict: Strictest possible validation
|
|
89
|
+
- default: Balanced validation for general use
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
png_conform list
|
|
93
|
+
DESC
|
|
94
|
+
def list
|
|
95
|
+
Commands::ListCommand.new(options).run
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
desc "version", "Display version information"
|
|
99
|
+
def version
|
|
100
|
+
puts "png_conform version #{PngConform::VERSION}"
|
|
101
|
+
puts "Ruby version #{RUBY_VERSION}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Override default error handling to provide helpful messages
|
|
105
|
+
def self.exit_on_failure?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Provide helpful error message for unknown commands
|
|
110
|
+
def method_missing(method, *_args)
|
|
111
|
+
puts "Error: Unknown command '#{method}'"
|
|
112
|
+
puts "Run 'png_conform help' for usage information"
|
|
113
|
+
exit(1)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../services/validation_service"
|
|
4
|
+
require_relative "../services/profile_manager"
|
|
5
|
+
require_relative "../reporters/reporter_factory"
|
|
6
|
+
require_relative "../readers/streaming_reader"
|
|
7
|
+
|
|
8
|
+
module PngConform
|
|
9
|
+
module Commands
|
|
10
|
+
# Command to validate PNG files and report results.
|
|
11
|
+
#
|
|
12
|
+
# Coordinates between readers, validators, and reporters to analyze
|
|
13
|
+
# PNG files according to specified options and profiles.
|
|
14
|
+
class CheckCommand
|
|
15
|
+
attr_reader :files, :options
|
|
16
|
+
|
|
17
|
+
# @param files [Array<String>] List of file paths to validate
|
|
18
|
+
# @param options [Hash] Command-line options
|
|
19
|
+
def initialize(files, options = {})
|
|
20
|
+
@files = files
|
|
21
|
+
@options = options
|
|
22
|
+
@errors_found = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute the validation command.
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer] Exit code (0 for success, 1 for errors)
|
|
28
|
+
def run
|
|
29
|
+
validate_inputs
|
|
30
|
+
validate_files
|
|
31
|
+
exit_code
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Validate command inputs and options.
|
|
37
|
+
def validate_inputs
|
|
38
|
+
if files.empty?
|
|
39
|
+
puts "Error: No files specified"
|
|
40
|
+
puts "Usage: png_conform check [OPTIONS] FILES"
|
|
41
|
+
exit(1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if profile exists (if specified)
|
|
45
|
+
if options[:profile] && !Services::ProfileManager.profile_exists?(options[:profile])
|
|
46
|
+
puts "Error: Unknown profile '#{options[:profile]}'"
|
|
47
|
+
puts "Available profiles: #{Services::ProfileManager.available_profiles.join(', ')}"
|
|
48
|
+
exit(1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check for conflicting options
|
|
52
|
+
if options[:quiet] && options[:verbose]
|
|
53
|
+
puts "Warning: --quiet and --verbose are mutually exclusive, using --quiet"
|
|
54
|
+
options[:verbose] = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return unless options[:quiet] && options[:very_verbose]
|
|
58
|
+
|
|
59
|
+
puts "Warning: --quiet and --very-verbose are mutually exclusive, using --quiet"
|
|
60
|
+
options[:very_verbose] = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Validate all specified files.
|
|
64
|
+
def validate_files
|
|
65
|
+
reporter = create_reporter
|
|
66
|
+
|
|
67
|
+
files.each do |file_path|
|
|
68
|
+
validate_single_file(file_path, reporter)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validate a single PNG file.
|
|
73
|
+
#
|
|
74
|
+
# @param file_path [String] Path to the PNG file
|
|
75
|
+
# @param reporter [Reporters::BaseReporter] Reporter for output
|
|
76
|
+
def validate_single_file(file_path, reporter)
|
|
77
|
+
unless File.exist?(file_path)
|
|
78
|
+
puts "Error: File not found: #{file_path}"
|
|
79
|
+
@errors_found = true
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless File.file?(file_path)
|
|
84
|
+
puts "Error: Not a file: #{file_path}"
|
|
85
|
+
@errors_found = true
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Read and validate the file using streaming reader
|
|
90
|
+
Readers::StreamingReader.open(file_path) do |reader|
|
|
91
|
+
# Perform validation
|
|
92
|
+
validator = Services::ValidationService.new(reader, file_path)
|
|
93
|
+
file_analysis = validator.validate
|
|
94
|
+
|
|
95
|
+
# Track if any errors were found
|
|
96
|
+
@errors_found = true unless file_analysis.valid?
|
|
97
|
+
|
|
98
|
+
# Use reporter to output result
|
|
99
|
+
reporter.report(file_analysis)
|
|
100
|
+
|
|
101
|
+
# For text output (default), show additional analysis unless quiet
|
|
102
|
+
if (options[:format].nil? || options[:format] == "text") && !options[:quiet]
|
|
103
|
+
show_resolution_analysis(file_analysis)
|
|
104
|
+
show_optimization_suggestions(file_analysis)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Explicit flags always show
|
|
108
|
+
show_metrics(file_analysis) if options[:metrics]
|
|
109
|
+
show_mobile_readiness(file_analysis) if options[:mobile_ready]
|
|
110
|
+
end
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
puts "Error processing #{file_path}: #{e.message}"
|
|
113
|
+
puts e.backtrace.join("\n") if options[:verbose]
|
|
114
|
+
@errors_found = true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Create the appropriate reporter based on options.
|
|
118
|
+
#
|
|
119
|
+
# @return [Reporters::BaseReporter] Reporter instance
|
|
120
|
+
def create_reporter
|
|
121
|
+
Reporters::ReporterFactory.create(
|
|
122
|
+
format: options[:format] || "text",
|
|
123
|
+
verbose: options[:verbose] || options[:very_verbose],
|
|
124
|
+
quiet: options[:quiet],
|
|
125
|
+
colorize: !options[:no_color],
|
|
126
|
+
show_palette: options[:palette],
|
|
127
|
+
show_text: options[:text],
|
|
128
|
+
seven_bit: options[:seven_bit],
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Show optimization suggestions for the file
|
|
133
|
+
def show_optimization_suggestions(file_analysis)
|
|
134
|
+
analysis = file_analysis.optimization_analysis
|
|
135
|
+
return unless analysis && analysis[:suggestions]
|
|
136
|
+
return if analysis[:suggestions].empty?
|
|
137
|
+
|
|
138
|
+
puts "\n#{colorize('OPTIMIZATION SUGGESTIONS:', :bold)}"
|
|
139
|
+
analysis[:suggestions].each_with_index do |suggestion, index|
|
|
140
|
+
priority_color = priority_color(suggestion[:priority])
|
|
141
|
+
priority_label = suggestion[:priority].to_s.upcase
|
|
142
|
+
|
|
143
|
+
puts " #{index + 1}. [#{colorize(priority_label,
|
|
144
|
+
priority_color)}] #{suggestion[:description]}"
|
|
145
|
+
if suggestion[:savings_bytes]&.positive?
|
|
146
|
+
puts " Savings: #{format_bytes(suggestion[:savings_bytes])}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
total_savings = analysis[:potential_savings_bytes]
|
|
151
|
+
if total_savings.positive?
|
|
152
|
+
puts "\n #{colorize('Total Potential Savings:', :bold)} " \
|
|
153
|
+
"#{format_bytes(total_savings)} (#{analysis[:potential_savings_percent]}%)"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Show comprehensive metrics
|
|
158
|
+
def show_metrics(file_analysis)
|
|
159
|
+
metrics = file_analysis.metrics
|
|
160
|
+
return unless metrics
|
|
161
|
+
|
|
162
|
+
case options[:format]
|
|
163
|
+
when "json"
|
|
164
|
+
require "json"
|
|
165
|
+
puts JSON.pretty_generate(metrics)
|
|
166
|
+
when "yaml"
|
|
167
|
+
require "yaml"
|
|
168
|
+
puts metrics.to_yaml
|
|
169
|
+
else
|
|
170
|
+
# Text format with colored output
|
|
171
|
+
puts "\n#{colorize('METRICS:', :bold)}"
|
|
172
|
+
puts " File: #{metrics[:file][:filename]} (#{metrics[:file][:size_kb]} KB)"
|
|
173
|
+
puts " Image: #{metrics[:image][:dimensions]}, #{metrics[:image][:color_type_name]}, " \
|
|
174
|
+
"#{metrics[:image][:bit_depth]}-bit"
|
|
175
|
+
puts " Chunks: #{metrics[:chunks][:total_count]} (#{metrics[:chunks][:types].join(', ')})"
|
|
176
|
+
puts " Validation: #{metrics[:validation][:error_count]} errors, " \
|
|
177
|
+
"#{metrics[:validation][:warning_count]} warnings"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Show resolution and Retina analysis
|
|
182
|
+
def show_resolution_analysis(file_analysis)
|
|
183
|
+
analysis = file_analysis.resolution_analysis
|
|
184
|
+
return unless analysis
|
|
185
|
+
|
|
186
|
+
puts "\n#{colorize('RESOLUTION ANALYSIS:', :bold)}"
|
|
187
|
+
|
|
188
|
+
# Basic info
|
|
189
|
+
res = analysis[:resolution]
|
|
190
|
+
puts " Dimensions: #{res[:dimensions]} (#{res[:megapixels]} megapixels)"
|
|
191
|
+
puts " DPI: #{res[:dpi] || 'Not specified'}"
|
|
192
|
+
|
|
193
|
+
# Retina analysis
|
|
194
|
+
puts "\n #{colorize('Retina Analysis:', :bold)}"
|
|
195
|
+
retina = analysis[:retina]
|
|
196
|
+
puts " @1x: #{retina[:at_1x][:dimensions_pt]} (#{retina[:at_1x][:suitable_for].first})"
|
|
197
|
+
puts " @2x: #{retina[:at_2x][:dimensions_pt]} (#{retina[:at_2x][:suitable_for].first})"
|
|
198
|
+
puts " @3x: #{retina[:at_3x][:dimensions_pt]} (#{retina[:at_3x][:suitable_for].first})"
|
|
199
|
+
puts " Recommended: #{retina[:recommended_density]}"
|
|
200
|
+
|
|
201
|
+
# iOS suggestions
|
|
202
|
+
ios = retina[:ios_asset_catalog]
|
|
203
|
+
if ios && !ios.empty?
|
|
204
|
+
puts " iOS: #{ios.join(', ')}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Android
|
|
208
|
+
puts " Android: #{retina[:android_density]}"
|
|
209
|
+
|
|
210
|
+
# Print analysis if DPI available
|
|
211
|
+
if analysis[:print][:capable]
|
|
212
|
+
print_info = analysis[:print]
|
|
213
|
+
puts "\n #{colorize('Print Analysis:', :bold)}"
|
|
214
|
+
puts " Quality: #{print_info[:quality]} (#{print_info[:dpi]} DPI)"
|
|
215
|
+
phys = print_info[:physical_size]
|
|
216
|
+
puts " Physical Size: #{phys[:width_inches]}\" x #{phys[:height_inches]}\" " \
|
|
217
|
+
"(#{phys[:width_cm]} x #{phys[:height_cm]} cm)"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Recommendations
|
|
221
|
+
recommendations = analysis[:recommendations]
|
|
222
|
+
if recommendations && !recommendations.empty?
|
|
223
|
+
puts "\n #{colorize('Recommendations:', :bold)}"
|
|
224
|
+
recommendations.each do |rec|
|
|
225
|
+
priority_color = priority_color(rec[:priority])
|
|
226
|
+
puts " [#{colorize(rec[:priority].to_s.upcase,
|
|
227
|
+
priority_color)}] #{rec[:message]}"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Show mobile and Retina readiness
|
|
233
|
+
def show_mobile_readiness(file_analysis)
|
|
234
|
+
analysis = file_analysis.resolution_analysis
|
|
235
|
+
return unless analysis
|
|
236
|
+
|
|
237
|
+
puts "\n#{colorize('MOBILE & RETINA READINESS:', :bold)}"
|
|
238
|
+
|
|
239
|
+
retina = analysis[:retina]
|
|
240
|
+
web = analysis[:web]
|
|
241
|
+
|
|
242
|
+
# Overall readiness
|
|
243
|
+
is_ready = retina[:is_retina_ready] && web[:mobile_friendly]
|
|
244
|
+
status = if is_ready
|
|
245
|
+
colorize("✓ READY",
|
|
246
|
+
:green)
|
|
247
|
+
else
|
|
248
|
+
colorize("✗ NOT READY", :red)
|
|
249
|
+
end
|
|
250
|
+
puts " Status: #{status}"
|
|
251
|
+
|
|
252
|
+
# Specific checks
|
|
253
|
+
puts "\n Checks:"
|
|
254
|
+
puts " Retina Ready: #{format_check(retina[:is_retina_ready])}"
|
|
255
|
+
puts " Mobile Friendly: #{format_check(web[:mobile_friendly])}"
|
|
256
|
+
puts " Web Suitable: #{format_check(web[:suitable_for_web])}"
|
|
257
|
+
|
|
258
|
+
# Retina densities
|
|
259
|
+
puts "\n Retina Densities:"
|
|
260
|
+
puts " @1x: #{retina[:at_1x][:dimensions_pt]}"
|
|
261
|
+
puts " @2x: #{retina[:at_2x][:dimensions_pt]}"
|
|
262
|
+
puts " @3x: #{retina[:at_3x][:dimensions_pt]}"
|
|
263
|
+
puts " Recommended: #{retina[:recommended_density]}"
|
|
264
|
+
|
|
265
|
+
# Screen coverage
|
|
266
|
+
puts "\n Screen Coverage:"
|
|
267
|
+
web[:typical_screen_size].each do |screen, coverage|
|
|
268
|
+
puts " #{screen}: #{coverage}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Load time
|
|
272
|
+
puts "\n Load Time: #{web[:load_time_estimate]}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Helper methods for formatting
|
|
276
|
+
|
|
277
|
+
def format_check(passed)
|
|
278
|
+
passed ? colorize("✓", :green) : colorize("✗", :red)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def format_bytes(bytes)
|
|
282
|
+
if bytes < 1024
|
|
283
|
+
"#{bytes} bytes"
|
|
284
|
+
elsif bytes < 1024 * 1024
|
|
285
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
286
|
+
else
|
|
287
|
+
"#{(bytes / 1024.0 / 1024.0).round(2)} MB"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def priority_color(priority)
|
|
292
|
+
case priority
|
|
293
|
+
when :high then :red
|
|
294
|
+
when :medium then :yellow
|
|
295
|
+
when :low then :blue
|
|
296
|
+
else :default
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def colorize(text, color)
|
|
301
|
+
return text if options[:no_color]
|
|
302
|
+
|
|
303
|
+
codes = {
|
|
304
|
+
red: "\e[31m",
|
|
305
|
+
green: "\e[32m",
|
|
306
|
+
yellow: "\e[33m",
|
|
307
|
+
blue: "\e[34m",
|
|
308
|
+
bold: "\e[1m",
|
|
309
|
+
reset: "\e[0m",
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
"#{codes[color]}#{text}#{codes[:reset]}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Determine the exit code based on whether errors were found.
|
|
316
|
+
#
|
|
317
|
+
# @return [Integer] Exit code (0 for success, 1 for errors)
|
|
318
|
+
def exit_code
|
|
319
|
+
@errors_found ? 1 : 0
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|