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