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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../services/profile_manager"
4
+
5
+ module PngConform
6
+ module Commands
7
+ # Command to list available validation profiles.
8
+ #
9
+ # Displays information about all available profiles including
10
+ # their requirements and restrictions.
11
+ class ListCommand
12
+ attr_reader :options
13
+
14
+ # @param options [Hash] Command-line options
15
+ def initialize(options = {})
16
+ @options = options
17
+ end
18
+
19
+ # Execute the list command.
20
+ #
21
+ # @return [Integer] Exit code (always 0)
22
+ def run
23
+ profiles = Services::ProfileManager.available_profiles
24
+
25
+ puts "Available Validation Profiles:"
26
+ puts
27
+
28
+ profiles.each do |profile_name|
29
+ display_profile(profile_name)
30
+ puts
31
+ end
32
+
33
+ 0
34
+ end
35
+
36
+ private
37
+
38
+ # Display information about a single profile.
39
+ #
40
+ # @param profile_name [String] Name of the profile to display
41
+ def display_profile(profile_name)
42
+ profile = Services::ProfileManager.get_profile(profile_name)
43
+
44
+ puts " #{profile_name.upcase}"
45
+ puts " #{'-' * (profile_name.length + 2)}"
46
+
47
+ puts " Description: #{profile[:description]}" if profile[:description]
48
+
49
+ if profile[:required_chunks] && !profile[:required_chunks].empty?
50
+ puts " Required chunks: #{profile[:required_chunks].join(', ')}"
51
+ end
52
+
53
+ if profile[:optional_chunks] && !profile[:optional_chunks].empty?
54
+ puts " Optional chunks: #{profile[:optional_chunks].join(', ')}"
55
+ end
56
+
57
+ if profile[:prohibited_chunks] && !profile[:prohibited_chunks].empty?
58
+ puts " Prohibited chunks: #{profile[:prohibited_chunks].join(', ')}"
59
+ end
60
+
61
+ return unless profile[:strict_mode]
62
+
63
+ puts " Strict mode: enabled"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Models
5
+ # Domain model representing a PNG/MNG/JNG chunk
6
+ # This is the business logic representation, separate from binary parsing
7
+ class Chunk < Lutaml::Model::Serializable
8
+ attribute :type, :string
9
+ attribute :length, :integer
10
+ attribute :data, :string
11
+ attribute :crc, :integer
12
+ attribute :offset, :integer
13
+ attribute :valid_crc, :boolean, default: -> { false }
14
+ attribute :crc_expected, :string
15
+ attribute :crc_actual, :string
16
+
17
+ # Create from BinData chunk structure
18
+ def self.from_bindata(bindata_chunk, offset = 0)
19
+ new(
20
+ type: bindata_chunk.type,
21
+ length: bindata_chunk.length,
22
+ data: bindata_chunk.data,
23
+ crc: bindata_chunk.crc,
24
+ offset: offset,
25
+ # valid_crc will be set by ValidationService
26
+ )
27
+ end
28
+
29
+ # Check if chunk is critical (uppercase first letter)
30
+ def critical?
31
+ type[0] == type[0].upcase
32
+ end
33
+
34
+ # Check if chunk is ancillary (lowercase first letter)
35
+ def ancillary?
36
+ !critical?
37
+ end
38
+
39
+ # Check if chunk is public (uppercase second letter)
40
+ def public?
41
+ type[1] == type[1].upcase
42
+ end
43
+
44
+ # Check if chunk is private (lowercase second letter)
45
+ def private?
46
+ !public?
47
+ end
48
+
49
+ # Check if chunk is reserved (uppercase third letter)
50
+ def reserved?
51
+ type[2] == type[2].upcase
52
+ end
53
+
54
+ # Check if chunk is safe to copy (lowercase fourth letter)
55
+ def safe_to_copy?
56
+ type[3] == type[3].downcase
57
+ end
58
+
59
+ # Get chunk type as symbol
60
+ def type_symbol
61
+ type.to_sym
62
+ end
63
+
64
+ # Get total size including all fields
65
+ def total_size
66
+ 4 + 4 + length + 4
67
+ end
68
+
69
+ # Format offset as hex
70
+ def offset_hex
71
+ format("0x%05x", offset)
72
+ end
73
+
74
+ # Alias methods for CRC validation (compatibility with validators and specs)
75
+ def crc_valid?
76
+ valid_crc
77
+ end
78
+
79
+ def valid_crc?
80
+ valid_crc
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module PngConform
6
+ module Models
7
+ # Domain model representing PNG chunk metadata
8
+ class ChunkInfo < Lutaml::Model::Serializable
9
+ attribute :type, :string
10
+ attribute :offset, :integer
11
+ attribute :length, :integer
12
+ attribute :crc_valid, :boolean
13
+ attribute :critical, :boolean
14
+ attribute :private, :boolean
15
+ attribute :safe_to_copy, :boolean
16
+ attribute :decoded_data, DecodedChunkData
17
+
18
+ # Binary data - use attr_accessor to avoid Lutaml serialization corruption
19
+ attr_accessor :data, :crc
20
+
21
+ # Custom initializer to handle binary data fields
22
+ def initialize(attributes = {})
23
+ @data = attributes.delete(:data)
24
+ @crc = attributes.delete(:crc)
25
+ super(attributes)
26
+ end
27
+
28
+ # Format offset as hex for display
29
+ def offset_hex
30
+ format("0x%05x", offset)
31
+ end
32
+
33
+ # Chunk property summary
34
+ def properties
35
+ props = []
36
+ props << "critical" if critical
37
+ props << "ancillary" unless critical
38
+ props << "private" if private
39
+ props << "safe-to-copy" if safe_to_copy
40
+ props
41
+ end
42
+
43
+ # Format chunk for verbose output
44
+ # Example: "chunk IHDR at offset 0x0000c, length 13"
45
+ def summary
46
+ "chunk #{type} at offset #{offset_hex}, length #{length}"
47
+ end
48
+
49
+ # Format with decoded data
50
+ def detailed_summary
51
+ parts = [summary]
52
+ parts << decoded_data.summary if decoded_data
53
+ parts.join("\n ")
54
+ end
55
+
56
+ # Aliases for compatibility with spec expectations
57
+ alias chunk_type type
58
+ alias abs_offset offset
59
+
60
+ # Force binary encoding for chunk data
61
+ def chunk_data
62
+ data&.b
63
+ end
64
+
65
+ # CRC validation method alias
66
+ def crc_valid?
67
+ crc_valid
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module PngConform
6
+ module Models
7
+ # Domain model representing PNG compression metadata
8
+ class CompressionInfo < Lutaml::Model::Serializable
9
+ attribute :uncompressed_size, :integer
10
+ attribute :compressed_size, :integer
11
+ attribute :compression_ratio, :float
12
+ attribute :window_bits, :integer
13
+ attribute :compression_level, :string
14
+
15
+ # Compression level constants
16
+ COMPRESSION_LEVELS = {
17
+ 1 => "fastest",
18
+ 6 => "default",
19
+ 9 => "maximum",
20
+ }.freeze
21
+
22
+ # Calculate compression ratio from sizes
23
+ def self.calculate_ratio(uncompressed, compressed)
24
+ return 0.0 if uncompressed.zero?
25
+
26
+ ((compressed.to_f / uncompressed) - 1.0) * 100.0
27
+ end
28
+
29
+ # Get compression level name
30
+ def level_name
31
+ COMPRESSION_LEVELS[compression_level] || "custom"
32
+ end
33
+
34
+ # Format compression info for display
35
+ def summary
36
+ format("%.1f%%", compression_ratio)
37
+ end
38
+
39
+ # Format full details (for verbose mode)
40
+ def details
41
+ parts = []
42
+ parts << "deflated"
43
+ parts << "#{window_bits}K window" if window_bits
44
+ parts << "#{level_name} compression"
45
+ parts.join(", ")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module PngConform
6
+ module Models
7
+ # Base class for decoded chunk data
8
+ class DecodedChunkData < Lutaml::Model::Serializable
9
+ # Override in subclasses to provide formatted summary
10
+ def summary
11
+ ""
12
+ end
13
+ end
14
+
15
+ # IHDR chunk decoded data
16
+ class IhdrData < DecodedChunkData
17
+ attribute :width, :integer
18
+ attribute :height, :integer
19
+ attribute :bit_depth, :integer
20
+ attribute :color_type, :integer
21
+ attribute :compression_method, :integer
22
+ attribute :filter_method, :integer
23
+ attribute :interlace_method, :integer
24
+
25
+ COLOR_TYPE_NAMES = {
26
+ 0 => "grayscale",
27
+ 2 => "truecolor",
28
+ 3 => "palette",
29
+ 4 => "grayscale+alpha",
30
+ 6 => "truecolor+alpha",
31
+ }.freeze
32
+
33
+ def color_type_name
34
+ COLOR_TYPE_NAMES[color_type] || "unknown"
35
+ end
36
+
37
+ def interlaced?
38
+ interlace_method == 1
39
+ end
40
+
41
+ def summary
42
+ parts = []
43
+ parts << "#{width} x #{height} image"
44
+ parts << "#{bit_depth}-bit #{color_type_name}"
45
+ parts << (interlaced? ? "interlaced" : "non-interlaced")
46
+ parts.join(", ")
47
+ end
48
+ end
49
+
50
+ # gAMA chunk decoded data
51
+ class GamaData < DecodedChunkData
52
+ attribute :gamma, :float
53
+
54
+ def summary
55
+ format("%.4f", gamma)
56
+ end
57
+ end
58
+
59
+ # IDAT chunk decoded data
60
+ class IdatData < DecodedChunkData
61
+ attribute :compression_method, :integer
62
+ attribute :window_bits, :integer
63
+ attribute :compression_level, :string
64
+ attribute :row_filters, :integer, collection: true
65
+
66
+ def summary
67
+ parts = []
68
+ parts << "zlib: deflated"
69
+ parts << "#{window_bits}K window" if window_bits
70
+ parts << "#{compression_level} compression" if compression_level
71
+ parts.join(", ")
72
+ end
73
+
74
+ # Format row filters for very verbose mode
75
+ def filter_summary
76
+ return nil if row_filters.nil? || row_filters.empty?
77
+
78
+ filter_names = {
79
+ 0 => "none",
80
+ 1 => "sub",
81
+ 2 => "up",
82
+ 3 => "avg",
83
+ 4 => "paeth",
84
+ }
85
+ "row filters (#{filter_names.map do |k, v|
86
+ "#{k} #{v}"
87
+ end.join(', ')}):\n #{row_filters.join(' ')}"
88
+ end
89
+ end
90
+
91
+ # Palette entry
92
+ class PaletteEntry < Lutaml::Model::Serializable
93
+ attribute :red, :integer
94
+ attribute :green, :integer
95
+ attribute :blue, :integer
96
+ end
97
+
98
+ # PLTE chunk decoded data
99
+ class PlteData < DecodedChunkData
100
+ attribute :entries, PaletteEntry, collection: true
101
+
102
+ def summary
103
+ "#{entries&.size || 0} palette entries"
104
+ end
105
+
106
+ # Format for palette printing mode
107
+ def detailed_entries
108
+ return [] unless entries
109
+
110
+ entries.each_with_index.map do |entry, index|
111
+ format("%3d: (%3d,%3d,%3d) = (0x%02x,0x%02x,0x%02x)",
112
+ index, entry.red, entry.green, entry.blue,
113
+ entry.red, entry.green, entry.blue)
114
+ end
115
+ end
116
+ end
117
+
118
+ # tEXt chunk decoded data
119
+ class TextData < DecodedChunkData
120
+ attribute :keyword, :string
121
+ attribute :text, :string
122
+
123
+ def summary
124
+ "#{keyword}: #{text}"
125
+ end
126
+ end
127
+
128
+ # tIME chunk decoded data
129
+ class TimeData < DecodedChunkData
130
+ attribute :year, :integer
131
+ attribute :month, :integer
132
+ attribute :day, :integer
133
+ attribute :hour, :integer
134
+ attribute :minute, :integer
135
+ attribute :second, :integer
136
+
137
+ def summary
138
+ format("%04d-%02d-%02d %02d:%02d:%02d",
139
+ year, month, day, hour, minute, second)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module PngConform
6
+ module Models
7
+ # Top-level domain model for PNG file analysis
8
+ class FileAnalysis < Lutaml::Model::Serializable
9
+ attribute :file_path, :string
10
+ attribute :file_size, :integer
11
+ attribute :file_type, :string # 'PNG', 'MNG', 'JNG'
12
+ attribute :validation_result, ValidationResult
13
+ attribute :image_info, ImageInfo
14
+ attribute :compression_info, CompressionInfo
15
+
16
+ # Analyzer results (proper Model → Formatter pattern)
17
+ attribute :resolution_analysis, :hash
18
+ attribute :optimization_analysis, :hash
19
+ attribute :metrics, :hash
20
+
21
+ # Total chunk count
22
+ def chunk_count
23
+ chunks.size
24
+ end
25
+
26
+ # Get chunks (either from direct attribute or validation_result)
27
+ def chunks
28
+ validation_result&.chunks || []
29
+ end
30
+
31
+ # Check if file is valid
32
+ def valid?
33
+ validation_result&.valid? || false
34
+ end
35
+
36
+ # Get validation status text
37
+ def status
38
+ valid? ? "OK" : "ERROR"
39
+ end
40
+
41
+ # Format file header line (for verbose mode)
42
+ # Example: "File: file.png (164 bytes)"
43
+ def file_header
44
+ "File: #{file_path} (#{file_size} bytes)"
45
+ end
46
+
47
+ # Format summary line (default output mode)
48
+ # Example: "OK: file.png (32x32, 1-bit grayscale, non-interlaced, -28.1%)."
49
+ def summary_line
50
+ parts = []
51
+ parts << "#{status}:"
52
+ parts << file_path
53
+ if image_info
54
+ info_parts = []
55
+ info_parts << image_info.summary
56
+ info_parts << compression_info.summary if compression_info
57
+ parts << "(#{info_parts.join(', ')})"
58
+ end
59
+ "#{parts.join(' ')}."
60
+ end
61
+
62
+ # Format validation summary (for verbose mode)
63
+ # Example: "No errors detected in file.png (4 chunks, -28.1% compression)."
64
+ def validation_summary
65
+ return validation_result.error_summary if validation_result && !valid?
66
+
67
+ parts = []
68
+ parts << "No errors detected in #{file_path}"
69
+ detail_parts = []
70
+ detail_parts << "#{chunk_count} chunks" if chunk_count.positive?
71
+ if compression_info
72
+ detail_parts << "#{compression_info.summary} compression"
73
+ end
74
+ parts << "(#{detail_parts.join(', ')})" unless detail_parts.empty?
75
+ "#{parts.join(' ')}."
76
+ end
77
+
78
+ # Alias for compatibility with reporters
79
+ alias filename file_path
80
+
81
+ # Delegate to validation_result for API compatibility
82
+ def errors
83
+ validation_result&.errors || []
84
+ end
85
+
86
+ def error_messages
87
+ validation_result&.error_messages || []
88
+ end
89
+
90
+ def error_count
91
+ validation_result&.error_count || 0
92
+ end
93
+
94
+ def warning_count
95
+ validation_result&.warning_count || 0
96
+ end
97
+
98
+ def info_count
99
+ validation_result&.info_count || 0
100
+ end
101
+
102
+ # Delegate to compression_info
103
+ def compression_ratio
104
+ compression_info&.compression_ratio
105
+ end
106
+
107
+ # Convert to complete hash for serialization
108
+ # This provides a single source of truth for all output formats
109
+ def to_h
110
+ hash = {
111
+ "filename" => file_path,
112
+ "file_type" => file_type,
113
+ "file_size" => file_size,
114
+ "compression_ratio" => compression_ratio,
115
+ "crc_errors_count" => validation_result&.crc_errors_count || 0,
116
+ "valid" => valid?,
117
+ }
118
+
119
+ # Add image info if available
120
+ hash["image"] = image_info.to_h if image_info
121
+
122
+ # Add chunks info
123
+ if chunks.any?
124
+ hash["chunks"] = {
125
+ "total" => chunk_count,
126
+ "types" => chunks.map(&:type).uniq.sort,
127
+ }
128
+ end
129
+
130
+ # Add errors if any
131
+ if validation_result&.errors&.any?
132
+ hash["errors"] = validation_result.errors.map do |error|
133
+ error_hash = {
134
+ "severity" => error.severity,
135
+ "message" => error.message,
136
+ }
137
+ error_hash["chunk_type"] = error.chunk_type if error.chunk_type
138
+ error_hash["expected"] = error.expected if error.expected
139
+ error_hash["actual"] = error.actual if error.actual
140
+ error_hash
141
+ end
142
+ end
143
+
144
+ # Add resolution analysis if available
145
+ if resolution_analysis && !resolution_analysis.empty?
146
+ hash["resolution"] =
147
+ resolution_analysis
148
+ end
149
+
150
+ # Add optimization if available
151
+ if optimization_analysis && optimization_analysis[:suggestions]&.any?
152
+ hash["optimization"] = {
153
+ "suggestions" => optimization_analysis[:suggestions],
154
+ "total_savings_bytes" => optimization_analysis[:potential_savings_bytes],
155
+ "total_savings_percent" => optimization_analysis[:potential_savings_percent],
156
+ }
157
+ end
158
+
159
+ # Add recommendations
160
+ recs = extract_recommendations
161
+ hash["recommendations"] = recs if recs&.any?
162
+
163
+ hash
164
+ end
165
+
166
+ # Extract recommendations from analyzers
167
+ def extract_recommendations
168
+ recs = []
169
+
170
+ # From resolution analysis
171
+ if resolution_analysis && resolution_analysis[:recommendations]
172
+ recs.concat(resolution_analysis[:recommendations].map do |r|
173
+ r[:message]
174
+ end)
175
+ end
176
+
177
+ recs.empty? ? nil : recs
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Models
5
+ # Domain model representing file metadata
6
+ class FileInfo < Lutaml::Model::Serializable
7
+ attribute :filename, :string
8
+ attribute :file_size, :integer
9
+ attribute :file_type, :string
10
+ attribute :signature, :string
11
+ attribute :width, :integer
12
+ attribute :height, :integer
13
+ attribute :bit_depth, :integer
14
+ attribute :color_type, :integer
15
+ attribute :compression_method, :integer
16
+ attribute :filter_method, :integer
17
+ attribute :interlace_method, :integer
18
+
19
+ # File types
20
+ FILE_TYPE_PNG = "PNG"
21
+ FILE_TYPE_MNG = "MNG"
22
+ FILE_TYPE_JNG = "JNG"
23
+ FILE_TYPE_UNKNOWN = "UNKNOWN"
24
+
25
+ # Color type constants (PNG)
26
+ COLOR_TYPE_GRAYSCALE = 0
27
+ COLOR_TYPE_RGB = 2
28
+ COLOR_TYPE_INDEXED = 3
29
+ COLOR_TYPE_GRAYSCALE_ALPHA = 4
30
+ COLOR_TYPE_RGB_ALPHA = 6
31
+
32
+ # Color type names
33
+ COLOR_TYPE_NAMES = {
34
+ COLOR_TYPE_GRAYSCALE => "grayscale",
35
+ COLOR_TYPE_RGB => "RGB",
36
+ COLOR_TYPE_INDEXED => "indexed",
37
+ COLOR_TYPE_GRAYSCALE_ALPHA => "grayscale+alpha",
38
+ COLOR_TYPE_RGB_ALPHA => "RGB+alpha",
39
+ }.freeze
40
+
41
+ # Get color type name
42
+ def color_type_name
43
+ COLOR_TYPE_NAMES[color_type] || "unknown"
44
+ end
45
+
46
+ # Get interlace method name
47
+ def interlace_method_name
48
+ case interlace_method
49
+ when 0 then "non-interlaced"
50
+ when 1 then "Adam7 interlaced"
51
+ else "unknown"
52
+ end
53
+ end
54
+
55
+ # Check if image is interlaced
56
+ def interlaced?
57
+ interlace_method == 1
58
+ end
59
+
60
+ # Get image dimensions as string
61
+ def dimensions
62
+ "#{width}x#{height}"
63
+ end
64
+
65
+ # Get bit depth description
66
+ def bit_depth_description
67
+ "#{bit_depth}-bit #{color_type_name}"
68
+ end
69
+
70
+ # Format signature as hex
71
+ def signature_hex
72
+ signature&.unpack1("H*")
73
+ end
74
+
75
+ # Check if file is PNG
76
+ def png?
77
+ file_type == FILE_TYPE_PNG
78
+ end
79
+
80
+ # Check if file is MNG
81
+ def mng?
82
+ file_type == FILE_TYPE_MNG
83
+ end
84
+
85
+ # Check if file is JNG
86
+ def jng?
87
+ file_type == FILE_TYPE_JNG
88
+ end
89
+ end
90
+ end
91
+ end