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,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Models
|
|
7
|
+
# Domain model representing PNG image metadata
|
|
8
|
+
class ImageInfo < Lutaml::Model::Serializable
|
|
9
|
+
attribute :width, :integer
|
|
10
|
+
attribute :height, :integer
|
|
11
|
+
attribute :bit_depth, :integer
|
|
12
|
+
attribute :color_type, :string
|
|
13
|
+
attribute :interlaced, :boolean
|
|
14
|
+
attribute :animated, :boolean
|
|
15
|
+
|
|
16
|
+
# Color type constants for validation
|
|
17
|
+
COLOR_TYPES = {
|
|
18
|
+
0 => "grayscale",
|
|
19
|
+
2 => "truecolor",
|
|
20
|
+
3 => "palette",
|
|
21
|
+
4 => "grayscale+alpha",
|
|
22
|
+
6 => "truecolor+alpha",
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Convert color type code to human-readable string
|
|
26
|
+
def self.color_type_name(code)
|
|
27
|
+
COLOR_TYPES[code] || "unknown"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Format as pngcheck summary (e.g., "32x32, 1-bit grayscale")
|
|
31
|
+
def summary
|
|
32
|
+
parts = []
|
|
33
|
+
parts << "#{width}x#{height}"
|
|
34
|
+
parts << "#{bit_depth}-bit #{color_type}"
|
|
35
|
+
parts << (interlaced ? "interlaced" : "non-interlaced")
|
|
36
|
+
parts << (animated ? "animated" : "static")
|
|
37
|
+
parts.join(", ")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Convert to hash for serialization
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
"width" => width,
|
|
44
|
+
"height" => height,
|
|
45
|
+
"bit_depth" => bit_depth,
|
|
46
|
+
"color_type" => color_type,
|
|
47
|
+
"interlaced" => interlaced,
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Models
|
|
5
|
+
# Domain model representing a validation error or warning
|
|
6
|
+
class ValidationError < Lutaml::Model::Serializable
|
|
7
|
+
attribute :severity, :string
|
|
8
|
+
attribute :message, :string
|
|
9
|
+
attribute :chunk_type, :string
|
|
10
|
+
attribute :chunk_offset, :integer
|
|
11
|
+
attribute :error_type, :string
|
|
12
|
+
attribute :expected, :string
|
|
13
|
+
attribute :actual, :string
|
|
14
|
+
|
|
15
|
+
# Severity levels
|
|
16
|
+
SEVERITY_ERROR = "error"
|
|
17
|
+
SEVERITY_WARNING = "warning"
|
|
18
|
+
SEVERITY_INFO = "info"
|
|
19
|
+
|
|
20
|
+
# Error types
|
|
21
|
+
ERROR_TYPE_SIGNATURE = "signature"
|
|
22
|
+
ERROR_TYPE_CRC = "crc"
|
|
23
|
+
ERROR_TYPE_CHUNK_ORDER = "chunk_order"
|
|
24
|
+
ERROR_TYPE_CHUNK_DATA = "chunk_data"
|
|
25
|
+
ERROR_TYPE_ZLIB = "zlib"
|
|
26
|
+
ERROR_TYPE_MISSING_CHUNK = "missing_chunk"
|
|
27
|
+
ERROR_TYPE_INVALID_VALUE = "invalid_value"
|
|
28
|
+
ERROR_TYPE_PROFILE = "profile"
|
|
29
|
+
|
|
30
|
+
# Create error-level validation error
|
|
31
|
+
def self.error(message, options = {})
|
|
32
|
+
new(
|
|
33
|
+
severity: SEVERITY_ERROR,
|
|
34
|
+
message: message,
|
|
35
|
+
chunk_type: options[:chunk_type],
|
|
36
|
+
chunk_offset: options[:chunk_offset],
|
|
37
|
+
error_type: options[:error_type],
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create warning-level validation error
|
|
42
|
+
def self.warning(message, options = {})
|
|
43
|
+
new(
|
|
44
|
+
severity: SEVERITY_WARNING,
|
|
45
|
+
message: message,
|
|
46
|
+
chunk_type: options[:chunk_type],
|
|
47
|
+
chunk_offset: options[:chunk_offset],
|
|
48
|
+
error_type: options[:error_type],
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create info-level validation error
|
|
53
|
+
def self.info(message, options = {})
|
|
54
|
+
new(
|
|
55
|
+
severity: SEVERITY_INFO,
|
|
56
|
+
message: message,
|
|
57
|
+
chunk_type: options[:chunk_type],
|
|
58
|
+
chunk_offset: options[:chunk_offset],
|
|
59
|
+
error_type: options[:error_type],
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if this is an error
|
|
64
|
+
def error?
|
|
65
|
+
severity == SEVERITY_ERROR
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if this is a warning
|
|
69
|
+
def warning?
|
|
70
|
+
severity == SEVERITY_WARNING
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if this is info
|
|
74
|
+
def info?
|
|
75
|
+
severity == SEVERITY_INFO
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Format for display
|
|
79
|
+
def to_s
|
|
80
|
+
parts = []
|
|
81
|
+
parts << "[#{severity.upcase}]"
|
|
82
|
+
parts << chunk_type if chunk_type
|
|
83
|
+
parts << format("at 0x%05x", chunk_offset) if chunk_offset
|
|
84
|
+
parts << message
|
|
85
|
+
parts.join(" ")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Models
|
|
5
|
+
# Domain model representing validation results for a file
|
|
6
|
+
class ValidationResult < Lutaml::Model::Serializable
|
|
7
|
+
attribute :filename, :string
|
|
8
|
+
attribute :file_type, :string
|
|
9
|
+
attribute :file_size, :integer
|
|
10
|
+
attribute :valid, :boolean, default: -> { true }
|
|
11
|
+
attribute :chunks, Chunk, collection: true, default: -> { [] }
|
|
12
|
+
attribute :errors, ValidationError, collection: true, default: -> { [] }
|
|
13
|
+
attribute :compression_ratio, :float
|
|
14
|
+
attribute :crc_errors_count, :integer, default: -> { 0 }
|
|
15
|
+
|
|
16
|
+
# File types
|
|
17
|
+
FILE_TYPE_PNG = "PNG"
|
|
18
|
+
FILE_TYPE_MNG = "MNG"
|
|
19
|
+
FILE_TYPE_JNG = "JNG"
|
|
20
|
+
FILE_TYPE_UNKNOWN = "UNKNOWN"
|
|
21
|
+
|
|
22
|
+
# Add a chunk to the result
|
|
23
|
+
def add_chunk(chunk)
|
|
24
|
+
chunks << chunk
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add an error to the result
|
|
28
|
+
def add_error(error)
|
|
29
|
+
errors << error
|
|
30
|
+
self.valid = false if error.error?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create error and add to result
|
|
34
|
+
def error(message, options = {})
|
|
35
|
+
add_error(ValidationError.error(message, options))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create warning and add to result
|
|
39
|
+
def warning(message, options = {})
|
|
40
|
+
add_error(ValidationError.warning(message, options))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create info and add to result
|
|
44
|
+
def info(message, options = {})
|
|
45
|
+
add_error(ValidationError.info(message, options))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if validation passed
|
|
49
|
+
def valid?
|
|
50
|
+
valid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Alias for filename (compatibility with reporters)
|
|
54
|
+
def file_path
|
|
55
|
+
filename
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get only errors (not warnings or info)
|
|
59
|
+
def error_messages
|
|
60
|
+
errors.select(&:error?)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get only warnings
|
|
64
|
+
def warning_messages
|
|
65
|
+
errors.select(&:warning?)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get only info messages
|
|
69
|
+
def info_messages
|
|
70
|
+
errors.select(&:info?)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get error count
|
|
74
|
+
def error_count
|
|
75
|
+
error_messages.count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get warning count
|
|
79
|
+
def warning_count
|
|
80
|
+
warning_messages.count
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get info count
|
|
84
|
+
def info_count
|
|
85
|
+
info_messages.count
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get chunk count
|
|
89
|
+
def chunk_count
|
|
90
|
+
chunks.count
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Find chunks by type
|
|
94
|
+
def chunks_by_type(type)
|
|
95
|
+
chunks.select { |chunk| chunk.type == type }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if file has specific chunk type
|
|
99
|
+
def has_chunk?(type)
|
|
100
|
+
chunks.any? { |chunk| chunk.type == type }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get IHDR chunk (PNG/JNG)
|
|
104
|
+
def ihdr_chunk
|
|
105
|
+
chunks_by_type("IHDR").first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get MHDR chunk (MNG)
|
|
109
|
+
def mhdr_chunk
|
|
110
|
+
chunks_by_type("MHDR").first
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get JHDR chunk (JNG)
|
|
114
|
+
def jhdr_chunk
|
|
115
|
+
chunks_by_type("JHDR").first
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Summary for display
|
|
119
|
+
def summary
|
|
120
|
+
status = valid? ? "OK" : "ERRORS"
|
|
121
|
+
"#{status}: #{filename} (#{file_type}, #{file_size} bytes, " \
|
|
122
|
+
"#{chunk_count} chunks, #{error_count} errors, " \
|
|
123
|
+
"#{warning_count} warnings)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Error summary for output (pngcheck format)
|
|
127
|
+
def error_summary
|
|
128
|
+
parts = []
|
|
129
|
+
parts << "ERROR: #{filename}"
|
|
130
|
+
errors.each do |error|
|
|
131
|
+
parts << " #{error.message}"
|
|
132
|
+
end
|
|
133
|
+
parts.join("\n")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../bindata/png_file"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Readers
|
|
7
|
+
# Full-file PNG reader
|
|
8
|
+
#
|
|
9
|
+
# This reader loads the entire PNG file into memory at once, providing
|
|
10
|
+
# fast random access to chunks but using more memory. Suitable for
|
|
11
|
+
# small to medium files or when you need to access chunks non-sequentially.
|
|
12
|
+
#
|
|
13
|
+
# @example Reading an entire PNG file
|
|
14
|
+
# File.open("image.png", "rb") do |f|
|
|
15
|
+
# reader = FullLoadReader.new(f)
|
|
16
|
+
# png = reader.read
|
|
17
|
+
#
|
|
18
|
+
# puts "Signature valid: #{png.valid_signature?}"
|
|
19
|
+
# puts "Chunks: #{png.chunk_sequence.join(', ')}"
|
|
20
|
+
# puts "Structurally valid: #{png.structurally_valid?}"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class FullLoadReader
|
|
24
|
+
attr_reader :io, :png
|
|
25
|
+
|
|
26
|
+
# Initialize a new full-load reader
|
|
27
|
+
#
|
|
28
|
+
# @param filepath_or_io [String, IO] File path or IO object to read from
|
|
29
|
+
def initialize(filepath_or_io)
|
|
30
|
+
if filepath_or_io.is_a?(String)
|
|
31
|
+
# File path provided
|
|
32
|
+
@filepath = filepath_or_io
|
|
33
|
+
@io = File.open(filepath_or_io, "rb")
|
|
34
|
+
@owns_io = true
|
|
35
|
+
else
|
|
36
|
+
# IO object provided
|
|
37
|
+
@io = filepath_or_io
|
|
38
|
+
@owns_io = false
|
|
39
|
+
end
|
|
40
|
+
@png = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Read the entire PNG file structure
|
|
44
|
+
#
|
|
45
|
+
# @return [BinData::PngFile] the complete PNG file structure
|
|
46
|
+
def read
|
|
47
|
+
return @png if @png
|
|
48
|
+
|
|
49
|
+
@io.rewind
|
|
50
|
+
@png = BinData::PngFile.read(@io)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get PNG signature
|
|
54
|
+
#
|
|
55
|
+
# @return [String] 8-byte PNG signature
|
|
56
|
+
def signature
|
|
57
|
+
read unless @png
|
|
58
|
+
@png.signature.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Iterate over each chunk
|
|
62
|
+
#
|
|
63
|
+
# @yield [chunk] Each chunk in the file
|
|
64
|
+
def each_chunk(&block)
|
|
65
|
+
read unless @png
|
|
66
|
+
@png.chunks.each(&block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get file size
|
|
70
|
+
#
|
|
71
|
+
# @return [Integer] File size in bytes
|
|
72
|
+
def file_size
|
|
73
|
+
if @filepath
|
|
74
|
+
File.size(@filepath)
|
|
75
|
+
elsif @io.respond_to?(:stat)
|
|
76
|
+
@io.stat.size
|
|
77
|
+
else
|
|
78
|
+
# Fallback: calculate from PNG structure
|
|
79
|
+
read unless @png
|
|
80
|
+
8 + @png.chunks.sum { |c| 12 + c.length }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Close the IO if we own it
|
|
85
|
+
def close
|
|
86
|
+
@io.close if @owns_io && @io && !@io.closed?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Read file from path and return PNG structure
|
|
90
|
+
#
|
|
91
|
+
# @param path [String] path to PNG file
|
|
92
|
+
# @return [BinData::PngFile] the PNG file structure
|
|
93
|
+
def self.read_file(path)
|
|
94
|
+
File.open(path, "rb") do |f|
|
|
95
|
+
new(f).read
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Read file from path and yield PNG structure to block
|
|
100
|
+
#
|
|
101
|
+
# @param path [String] path to PNG file
|
|
102
|
+
# @yield [png] The PNG file structure
|
|
103
|
+
# @yieldparam png [BinData::PngFile] the PNG structure
|
|
104
|
+
# @return [Object] result of the block
|
|
105
|
+
def self.open(path)
|
|
106
|
+
File.open(path, "rb") do |f|
|
|
107
|
+
png = new(f).read
|
|
108
|
+
yield png
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../bindata/chunk_structure"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Readers
|
|
7
|
+
# Memory-efficient chunk-by-chunk PNG reader
|
|
8
|
+
#
|
|
9
|
+
# This reader processes PNG files one chunk at a time, making it suitable
|
|
10
|
+
# for large files where loading the entire file into memory would be
|
|
11
|
+
# impractical.
|
|
12
|
+
#
|
|
13
|
+
# @example Reading chunks one at a time
|
|
14
|
+
# File.open("large.png", "rb") do |f|
|
|
15
|
+
# reader = StreamingReader.new(f)
|
|
16
|
+
#
|
|
17
|
+
# if reader.valid_signature?
|
|
18
|
+
# reader.each_chunk do |chunk|
|
|
19
|
+
# puts "#{chunk.type}: #{chunk.length} bytes"
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
class StreamingReader
|
|
25
|
+
# PNG signature (8 bytes)
|
|
26
|
+
PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
|
|
27
|
+
.pack("C*")
|
|
28
|
+
.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :io, :signature
|
|
31
|
+
|
|
32
|
+
# Initialize a new streaming reader
|
|
33
|
+
#
|
|
34
|
+
# @param io [IO] IO object to read from (must be opened in binary mode)
|
|
35
|
+
def initialize(io)
|
|
36
|
+
@io = io
|
|
37
|
+
@signature = nil
|
|
38
|
+
@chunks_read = 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Read and validate the PNG signature
|
|
42
|
+
#
|
|
43
|
+
# This must be called before reading chunks.
|
|
44
|
+
#
|
|
45
|
+
# @return [Boolean] true if signature is valid
|
|
46
|
+
def read_signature
|
|
47
|
+
@signature = @io.read(8)
|
|
48
|
+
valid_signature?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if the signature is valid
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean] true if signature matches PNG specification
|
|
54
|
+
def valid_signature?
|
|
55
|
+
@signature == PNG_SIGNATURE
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get signature as hex string for debugging
|
|
59
|
+
#
|
|
60
|
+
# @return [String, nil] signature in hex format, or nil if not read
|
|
61
|
+
def signature_hex
|
|
62
|
+
return nil unless @signature
|
|
63
|
+
|
|
64
|
+
@signature.bytes.map { |b| format("%02x", b) }.join(" ")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Read the next chunk from the stream
|
|
68
|
+
#
|
|
69
|
+
# @return [BinData::ChunkStructure, nil] the next chunk, or nil if EOF
|
|
70
|
+
def read_chunk
|
|
71
|
+
return nil if @io.eof?
|
|
72
|
+
|
|
73
|
+
chunk = BinData::ChunkStructure.read(@io)
|
|
74
|
+
@chunks_read += 1
|
|
75
|
+
chunk
|
|
76
|
+
rescue EOFError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Iterate over all chunks in the file
|
|
81
|
+
#
|
|
82
|
+
# @yield [chunk] Each chunk in the file
|
|
83
|
+
# @yieldparam chunk [BinData::ChunkStructure] the current chunk
|
|
84
|
+
# @return [Integer] number of chunks read
|
|
85
|
+
def each_chunk
|
|
86
|
+
return enum_for(:each_chunk) unless block_given?
|
|
87
|
+
|
|
88
|
+
while (chunk = read_chunk)
|
|
89
|
+
yield chunk
|
|
90
|
+
break if chunk.type == "IEND"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@chunks_read
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Read all chunks into an array
|
|
97
|
+
#
|
|
98
|
+
# Note: This defeats the purpose of streaming for large files.
|
|
99
|
+
# Use only when you need random access to chunks.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<BinData::ChunkStructure>] all chunks
|
|
102
|
+
def read_all_chunks
|
|
103
|
+
chunks = []
|
|
104
|
+
each_chunk { |chunk| chunks << chunk }
|
|
105
|
+
chunks
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Find the first chunk of a specific type
|
|
109
|
+
#
|
|
110
|
+
# @param type [String] chunk type to find
|
|
111
|
+
# @return [BinData::ChunkStructure, nil] the first matching chunk
|
|
112
|
+
def find_chunk(type)
|
|
113
|
+
each_chunk do |chunk|
|
|
114
|
+
return chunk if chunk.type == type
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Collect all chunks of a specific type
|
|
120
|
+
#
|
|
121
|
+
# @param type [String] chunk type to find
|
|
122
|
+
# @return [Array<BinData::ChunkStructure>] all matching chunks
|
|
123
|
+
def find_chunks(type)
|
|
124
|
+
chunks = []
|
|
125
|
+
each_chunk do |chunk|
|
|
126
|
+
chunks << chunk if chunk.type == type
|
|
127
|
+
end
|
|
128
|
+
chunks
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Reset the reader to the beginning of the file
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def rewind
|
|
135
|
+
@io.rewind
|
|
136
|
+
@signature = nil
|
|
137
|
+
@chunks_read = 0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get the current position in the file
|
|
141
|
+
#
|
|
142
|
+
# @return [Integer] current byte position
|
|
143
|
+
def position
|
|
144
|
+
@io.pos
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check if we've reached end of file
|
|
148
|
+
#
|
|
149
|
+
# @return [Boolean] true if at EOF
|
|
150
|
+
def eof?
|
|
151
|
+
@io.eof?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Get statistics about the reading process
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash] reading statistics
|
|
157
|
+
def stats
|
|
158
|
+
{
|
|
159
|
+
chunks_read: @chunks_read,
|
|
160
|
+
current_position: position,
|
|
161
|
+
eof: eof?,
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Read file from path using streaming reader
|
|
166
|
+
#
|
|
167
|
+
# @param path [String] path to PNG file
|
|
168
|
+
# @yield [reader] The streaming reader
|
|
169
|
+
# @yieldparam reader [StreamingReader] the reader instance
|
|
170
|
+
# @return [Object] result of the block
|
|
171
|
+
def self.open(path)
|
|
172
|
+
File.open(path, "rb") do |f|
|
|
173
|
+
reader = new(f)
|
|
174
|
+
reader.read_signature
|
|
175
|
+
yield reader
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Reporters
|
|
5
|
+
# Base class for all reporters
|
|
6
|
+
# Reporters consume FileAnalysis models and produce formatted output
|
|
7
|
+
class BaseReporter
|
|
8
|
+
attr_reader :output
|
|
9
|
+
|
|
10
|
+
def initialize(output = $stdout, colorize: true)
|
|
11
|
+
@output = output
|
|
12
|
+
@colorize = colorize
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Report a single file analysis
|
|
16
|
+
# Must be implemented by subclasses
|
|
17
|
+
def report(file_analysis)
|
|
18
|
+
raise NotImplementedError, "Subclasses must implement #report"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Report multiple file analyses
|
|
22
|
+
def report_all(file_analyses)
|
|
23
|
+
file_analyses.each do |analysis|
|
|
24
|
+
report(analysis)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
# Write a line to output
|
|
31
|
+
def write_line(text = "")
|
|
32
|
+
output.puts(text)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Write text without newline
|
|
36
|
+
def write(text)
|
|
37
|
+
output.print(text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Format with color (if supported)
|
|
41
|
+
def colorize(text, _color)
|
|
42
|
+
# Base implementation only colorizes if @colorize is true
|
|
43
|
+
# Override in ColorReporter for actual color support
|
|
44
|
+
text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if colorization is enabled
|
|
48
|
+
def colorize?
|
|
49
|
+
@colorize
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_reporter"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Reporters
|
|
7
|
+
# Color reporter - wraps another reporter to add ANSI color support (-c flag)
|
|
8
|
+
# Decorator pattern to add color to any reporter
|
|
9
|
+
class ColorReporter < BaseReporter
|
|
10
|
+
# ANSI color codes
|
|
11
|
+
COLORS = {
|
|
12
|
+
red: "\e[31m",
|
|
13
|
+
green: "\e[32m",
|
|
14
|
+
yellow: "\e[33m",
|
|
15
|
+
blue: "\e[34m",
|
|
16
|
+
magenta: "\e[35m",
|
|
17
|
+
cyan: "\e[36m",
|
|
18
|
+
reset: "\e[0m",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :wrapped_reporter
|
|
22
|
+
|
|
23
|
+
# @param wrapped_reporter [BaseReporter] The reporter to wrap with colors
|
|
24
|
+
def initialize(wrapped_reporter)
|
|
25
|
+
super(wrapped_reporter.output, colorize: true)
|
|
26
|
+
@wrapped_reporter = wrapped_reporter
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def report(file_analysis)
|
|
30
|
+
# Temporarily enable colorization
|
|
31
|
+
@colorize_enabled = true
|
|
32
|
+
wrapped_reporter.report(file_analysis)
|
|
33
|
+
@colorize_enabled = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def report_all(file_analyses)
|
|
37
|
+
@colorize_enabled = true
|
|
38
|
+
wrapped_reporter.report_all(file_analyses)
|
|
39
|
+
@colorize_enabled = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
# Override colorize to actually apply colors
|
|
45
|
+
def colorize(text, color)
|
|
46
|
+
return text unless @colorize_enabled
|
|
47
|
+
return text unless COLORS.key?(color)
|
|
48
|
+
|
|
49
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Determine color based on validation status
|
|
53
|
+
def status_color(file_analysis)
|
|
54
|
+
file_analysis.valid? ? :green : :red
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Determine color based on chunk type
|
|
58
|
+
def chunk_color(chunk_type)
|
|
59
|
+
# Critical chunks in one color, ancillary in another
|
|
60
|
+
critical_chunks = %w[IHDR PLTE IDAT IEND]
|
|
61
|
+
critical_chunks.include?(chunk_type) ? :cyan : :blue
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_reporter"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module PngConform
|
|
7
|
+
module Reporters
|
|
8
|
+
# JSON reporter - outputs complete file analysis in JSON format
|
|
9
|
+
# Proper Model → Formatter pattern
|
|
10
|
+
# Receives FileAnalysis model and formats it (no analysis done here)
|
|
11
|
+
class JsonReporter < BaseReporter
|
|
12
|
+
def report(file_analysis)
|
|
13
|
+
# Simple formatting - model knows how to serialize itself
|
|
14
|
+
write_line(JSON.pretty_generate(file_analysis.to_h))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|