Module: Philiprehberger::MimeType
- Defined in:
- lib/philiprehberger/mime_type.rb,
lib/philiprehberger/mime_type/magic.rb,
lib/philiprehberger/mime_type/version.rb,
lib/philiprehberger/mime_type/extensions.rb
Defined Under Namespace
Classes: Error
Constant Summary collapse
- ALIASES =
Maps legacy / non-standard MIME types to their canonical form.
{ 'image/jpg' => 'image/jpeg', 'image/pjpeg' => 'image/jpeg', 'image/x-png' => 'image/png', 'application/x-javascript' => 'text/javascript', 'application/javascript' => 'text/javascript', 'text/xml' => 'application/xml', 'application/x-yaml' => 'application/yaml', 'text/yaml' => 'application/yaml', 'audio/mp3' => 'audio/mpeg' }.freeze
- MAGIC_SIGNATURES =
Magic byte signatures for content-based detection Each entry: [offset, byte_pattern, mime_type]
[ # Images [0, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'image/png'], [0, [0xFF, 0xD8, 0xFF], 'image/jpeg'], [0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 'image/gif'], [0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 'image/gif'], [0, [0x42, 0x4D], 'image/bmp'], [0, [0x00, 0x00, 0x01, 0x00], 'image/x-icon'], [0, [0x00, 0x00, 0x02, 0x00], 'image/x-icon'], [8, [0x57, 0x45, 0x42, 0x50], 'image/webp'], [0, [0xFF, 0x0A], 'image/jxl'], [0, [0x49, 0x49, 0x2A, 0x00], 'image/tiff'], [0, [0x4D, 0x4D, 0x00, 0x2A], 'image/tiff'], # Documents [0, [0x25, 0x50, 0x44, 0x46], 'application/pdf'], [0, [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1], 'application/msword'], # Archives [0, [0x50, 0x4B, 0x03, 0x04], 'application/zip'], [0, [0x50, 0x4B, 0x05, 0x06], 'application/zip'], [0, [0x50, 0x4B, 0x07, 0x08], 'application/zip'], [0, [0x1F, 0x8B], 'application/gzip'], [0, [0x42, 0x5A, 0x68], 'application/x-bzip2'], [0, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed'], [0, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar'], [0, [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 'application/x-xz'], [257, [0x75, 0x73, 0x74, 0x61, 0x72], 'application/x-tar'], # Audio [0, [0x49, 0x44, 0x33], 'audio/mpeg'], [0, [0xFF, 0xFB], 'audio/mpeg'], [0, [0xFF, 0xF3], 'audio/mpeg'], [0, [0xFF, 0xF2], 'audio/mpeg'], [0, [0x52, 0x49, 0x46, 0x46], 'audio/wav'], [0, [0x4F, 0x67, 0x67, 0x53], 'audio/ogg'], [0, [0x66, 0x4C, 0x61, 0x43], 'audio/flac'], # Video [4, [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D], 'video/mp4'], [4, [0x66, 0x74, 0x79, 0x70, 0x4D, 0x53, 0x4E, 0x56], 'video/mp4'], [4, [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32], 'video/mp4'], [0, [0x1A, 0x45, 0xDF, 0xA3], 'video/webm'], [0, [0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70], 'video/mp4'], # Fonts [0, [0x77, 0x4F, 0x46, 0x46], 'font/woff'], [0, [0x77, 0x4F, 0x46, 0x32], 'font/woff2'], [0, [0x00, 0x01, 0x00, 0x00], 'font/ttf'], [0, [0x4F, 0x54, 0x54, 0x4F], 'font/otf'], # Binary [0, [0x4D, 0x5A], 'application/vnd.microsoft.portable-executable'], [0, [0x7F, 0x45, 0x4C, 0x46], 'application/x-elf'], [0, [0xCA, 0xFE, 0xBA, 0xBE], 'application/java-vm'], # WebAssembly [0, [0x00, 0x61, 0x73, 0x6D], 'application/wasm'], # SQLite [0, [0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6F, 0x72, 0x6D, 0x61, 0x74], 'application/vnd.sqlite3'] ].freeze
- ISOBMFF_BRANDS =
ISOBMFF ‘ftyp` brand → MIME type. The brand occupies bytes 8..11 of an ISO base media file format box; bytes 4..7 always contain “ftyp”.
{ 'heic' => 'image/heic', 'heix' => 'image/heic', 'heis' => 'image/heic', 'mif1' => 'image/heif', 'avif' => 'image/avif', 'jxl ' => 'image/jxl' }.freeze
- VERSION =
'0.4.0'- EXTENSION_MAP =
Extension to MIME type mappings
{ # Text '.txt' => 'text/plain', '.html' => 'text/html', '.htm' => 'text/html', '.css' => 'text/css', '.csv' => 'text/csv', '.tsv' => 'text/tab-separated-values', '.xml' => 'text/xml', '.rtf' => 'text/rtf', '.md' => 'text/markdown', '.yaml' => 'text/yaml', '.yml' => 'text/yaml', '.ics' => 'text/calendar', '.vcf' => 'text/vcard', '.vtt' => 'text/vtt', # JavaScript / JSON '.js' => 'text/javascript', '.mjs' => 'text/javascript', '.json' => 'application/json', '.jsonld' => 'application/ld+json', '.map' => 'application/json', # Images '.png' => 'image/png', '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg', '.gif' => 'image/gif', '.bmp' => 'image/bmp', '.ico' => 'image/x-icon', '.svg' => 'image/svg+xml', '.webp' => 'image/webp', '.avif' => 'image/avif', '.tiff' => 'image/tiff', '.tif' => 'image/tiff', '.heic' => 'image/heic', '.heif' => 'image/heif', '.jxl' => 'image/jxl', '.apng' => 'image/apng', '.psd' => 'image/vnd.adobe.photoshop', # Audio '.mp3' => 'audio/mpeg', '.wav' => 'audio/wav', '.ogg' => 'audio/ogg', '.oga' => 'audio/ogg', '.flac' => 'audio/flac', '.aac' => 'audio/aac', '.m4a' => 'audio/mp4', '.wma' => 'audio/x-ms-wma', '.opus' => 'audio/opus', '.mid' => 'audio/midi', '.midi' => 'audio/midi', '.weba' => 'audio/webm', '.aiff' => 'audio/aiff', # Video '.mp4' => 'video/mp4', '.m4v' => 'video/mp4', '.avi' => 'video/x-msvideo', '.mov' => 'video/quicktime', '.wmv' => 'video/x-ms-wmv', '.flv' => 'video/x-flv', '.webm' => 'video/webm', '.mkv' => 'video/x-matroska', '.ogv' => 'video/ogg', '.mpeg' => 'video/mpeg', '.mpg' => 'video/mpeg', '.3gp' => 'video/3gpp', '.3g2' => 'video/3gpp2', '.m2ts' => 'video/mp2t', '.m3u8' => 'application/vnd.apple.mpegurl', # Application / Documents '.pdf' => 'application/pdf', '.doc' => 'application/msword', '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls' => 'application/vnd.ms-excel', '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt' => 'application/vnd.ms-powerpoint', '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.odt' => 'application/vnd.oasis.opendocument.text', '.ods' => 'application/vnd.oasis.opendocument.spreadsheet', '.odp' => 'application/vnd.oasis.opendocument.presentation', '.epub' => 'application/epub+zip', # Archives '.zip' => 'application/zip', '.gz' => 'application/gzip', '.gzip' => 'application/gzip', '.tar' => 'application/x-tar', '.bz2' => 'application/x-bzip2', '.7z' => 'application/x-7z-compressed', '.rar' => 'application/vnd.rar', '.xz' => 'application/x-xz', '.zst' => 'application/zstd', '.lz' => 'application/x-lzip', '.lzma' => 'application/x-lzma', '.cab' => 'application/vnd.ms-cab-compressed', '.dmg' => 'application/x-apple-diskimage', '.iso' => 'application/x-iso9660-image', # Executables / Binary '.exe' => 'application/vnd.microsoft.portable-executable', '.dll' => 'application/vnd.microsoft.portable-executable', '.deb' => 'application/vnd.debian.binary-package', '.rpm' => 'application/x-rpm', '.msi' => 'application/x-msi', '.apk' => 'application/vnd.android.package-archive', '.jar' => 'application/java-archive', '.war' => 'application/java-archive', '.class' => 'application/java-vm', # Fonts '.woff' => 'font/woff', '.woff2' => 'font/woff2', '.ttf' => 'font/ttf', '.otf' => 'font/otf', '.eot' => 'application/vnd.ms-fontobject', # Data / Config '.sql' => 'application/sql', '.sqlite' => 'application/vnd.sqlite3', '.db' => 'application/octet-stream', '.wasm' => 'application/wasm', '.graphql' => 'application/graphql', '.toml' => 'application/toml', # Programming '.rb' => 'text/x-ruby', '.py' => 'text/x-python', '.java' => 'text/x-java-source', '.c' => 'text/x-c', '.cpp' => 'text/x-c++', '.h' => 'text/x-c', '.hpp' => 'text/x-c++', '.rs' => 'text/x-rust', '.go' => 'text/x-go', '.swift' => 'text/x-swift', '.kt' => 'text/x-kotlin', '.ts' => 'text/typescript', '.tsx' => 'text/tsx', '.jsx' => 'text/jsx', '.php' => 'text/x-php', '.sh' => 'application/x-sh', '.bash' => 'application/x-sh', '.bat' => 'application/x-msdos-program', '.ps1' => 'application/x-powershell', '.lua' => 'text/x-lua', '.r' => 'text/x-r', '.scala' => 'text/x-scala', '.pl' => 'text/x-perl', '.dart' => 'application/vnd.dart', '.ex' => 'text/x-elixir', '.erl' => 'text/x-erlang', '.hs' => 'text/x-haskell', '.clj' => 'text/x-clojure', '.vim' => 'text/x-vim', '.dockerfile' => 'text/x-dockerfile', # Misc '.swf' => 'application/x-shockwave-flash', '.atom' => 'application/atom+xml', '.rss' => 'application/rss+xml', '.m3u' => 'audio/x-mpegurl', '.pls' => 'audio/x-scpls', '.torrent' => 'application/x-bittorrent', '.bin' => 'application/octet-stream', '.dat' => 'application/octet-stream', '.crt' => 'application/x-x509-ca-cert', '.pem' => 'application/x-pem-file', '.p12' => 'application/x-pkcs12', '.key' => 'application/x-pem-file', '.asc' => 'application/pgp-signature', '.gpg' => 'application/pgp-encrypted', '.sig' => 'application/pgp-signature', '.eml' => 'message/rfc822', '.mbox' => 'application/mbox', '.xpi' => 'application/x-xpinstall', '.crx' => 'application/x-chrome-extension' }.freeze
- MIME_TO_EXTENSIONS =
Reverse mapping: MIME type to extensions
EXTENSION_MAP.each_with_object({}) do |(ext, mime), hash| (hash[mime] ||= []) << ext end.freeze
Class Method Summary collapse
-
.application?(mime) ⇒ Boolean
Check if a MIME type is an application type.
-
.audio?(mime) ⇒ Boolean
Check if a MIME type is an audio type.
-
.best_match(available, accept_header) ⇒ String?
Content negotiation: find the best matching MIME type.
-
.binary?(mime) ⇒ Boolean
Check if a MIME type is a binary (non-text) type.
-
.canonical(mime) ⇒ String
Returns the canonical MIME type for ‘mime`.
-
.category(mime) ⇒ Symbol?
Get the category of a MIME type.
-
.charset(mime) ⇒ String?
Return the default charset for a MIME type.
-
.extensions(mime) ⇒ Array<String>
Get file extensions for a MIME type.
-
.font?(mime) ⇒ Boolean
Check if a MIME type is a font type.
-
.for_content(bytes) ⇒ String?
Detect MIME type from file content using magic bytes.
-
.for_extension(ext) ⇒ String?
Detect MIME type from a file extension.
-
.for_filename(name) ⇒ String?
Detect MIME type from a filename.
-
.image?(mime) ⇒ Boolean
Check if a MIME type is an image type.
-
.message?(mime) ⇒ Boolean
Check if a MIME type is a message type.
-
.multipart?(mime) ⇒ Boolean
Check if a MIME type is a multipart type.
-
.parse_accept(header) ⇒ Array<Hash>
Parse an HTTP Accept header string.
-
.register(ext, mime_type) ⇒ void
Register a custom MIME type mapping for an extension.
-
.text?(mime) ⇒ Boolean
Check if a MIME type is a text type.
-
.unregister(ext) ⇒ void
Remove a custom registration for an extension.
-
.valid?(mime) ⇒ Boolean
Check if a MIME type string is valid.
-
.video?(mime) ⇒ Boolean
Check if a MIME type is a video type.
Class Method Details
.application?(mime) ⇒ Boolean
Check if a MIME type is an application type
205 206 207 |
# File 'lib/philiprehberger/mime_type.rb', line 205 def self.application?(mime) category_prefix?(mime, 'application') end |
.audio?(mime) ⇒ Boolean
Check if a MIME type is an audio type
189 190 191 |
# File 'lib/philiprehberger/mime_type.rb', line 189 def self.audio?(mime) category_prefix?(mime, 'audio') end |
.best_match(available, accept_header) ⇒ String?
Content negotiation: find the best matching MIME type
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
# File 'lib/philiprehberger/mime_type.rb', line 274 def self.best_match(available, accept_header) return nil if available.nil? || available.empty? parsed = parse_accept(accept_header) return nil if parsed.empty? parsed.each do |entry| accepted = entry[:type] if accepted == '*/*' return available.first end # Check for type/* wildcard if accepted.end_with?('/*') prefix = accepted.split('/').first match = available.find { |a| a.start_with?("#{prefix}/") } return match if match elsif available.include?(accepted) return accepted end end nil end |
.binary?(mime) ⇒ Boolean
Check if a MIME type is a binary (non-text) type
237 238 239 240 241 242 |
# File 'lib/philiprehberger/mime_type.rb', line 237 def self.binary?(mime) type = mime.to_s.strip.downcase return false if type.empty? !type.start_with?('text/') end |
.canonical(mime) ⇒ String
Returns the canonical MIME type for ‘mime`. Legacy or non-standard aliases (e.g. `image/jpg`) are mapped to their canonical form (`image/jpeg`). Any input that is not a known alias is returned unchanged after lowercasing.
32 33 34 35 36 37 |
# File 'lib/philiprehberger/mime_type.rb', line 32 def self.canonical(mime) return mime if mime.nil? normalized = mime.to_s.downcase ALIASES.fetch(normalized, normalized) end |
.category(mime) ⇒ Symbol?
Get the category of a MIME type
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/philiprehberger/mime_type.rb', line 128 def self.category(mime) type = mime.to_s.strip.downcase return nil if type.empty? major = type.split('/').first case major when 'text' then :text when 'image' then :image when 'audio' then :audio when 'video' then :video when 'application' then :application when 'font' then :font when 'message' then :message when 'multipart' then :multipart when 'model' then :model end end |
.charset(mime) ⇒ String?
Return the default charset for a MIME type
162 163 164 165 166 167 |
# File 'lib/philiprehberger/mime_type.rb', line 162 def self.charset(mime) type = mime.to_s.strip.downcase return nil if type.empty? type.start_with?('text/') ? 'utf-8' : nil end |
.extensions(mime) ⇒ Array<String>
Get file extensions for a MIME type
120 121 122 |
# File 'lib/philiprehberger/mime_type.rb', line 120 def self.extensions(mime) MIME_TO_EXTENSIONS[mime.to_s.strip.downcase] || [] end |
.font?(mime) ⇒ Boolean
Check if a MIME type is a font type
213 214 215 |
# File 'lib/philiprehberger/mime_type.rb', line 213 def self.font?(mime) category_prefix?(mime, 'font') end |
.for_content(bytes) ⇒ String?
Detect MIME type from file content using magic bytes
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/philiprehberger/mime_type.rb', line 85 def self.for_content(bytes) return nil if bytes.nil? || bytes.empty? raw = bytes.b MAGIC_SIGNATURES.each do |offset, pattern, mime| next if raw.length < offset + pattern.length match = pattern.each_with_index.all? do |byte, i| raw.getbyte(offset + i) == byte end return mime if match end isobmff_mime_for(raw) end |
.for_extension(ext) ⇒ String?
Detect MIME type from a file extension
64 65 66 67 68 |
# File 'lib/philiprehberger/mime_type.rb', line 64 def self.for_extension(ext) normalized = ext.to_s.strip.downcase normalized = ".#{normalized}" unless normalized.start_with?('.') @custom_extensions[normalized] || EXTENSION_MAP[normalized] end |
.for_filename(name) ⇒ String?
Detect MIME type from a filename
74 75 76 77 78 79 |
# File 'lib/philiprehberger/mime_type.rb', line 74 def self.for_filename(name) ext = File.extname(name.to_s.strip).downcase return nil if ext.empty? @custom_extensions[ext] || EXTENSION_MAP[ext] end |
.image?(mime) ⇒ Boolean
Check if a MIME type is an image type
181 182 183 |
# File 'lib/philiprehberger/mime_type.rb', line 181 def self.image?(mime) category_prefix?(mime, 'image') end |
.message?(mime) ⇒ Boolean
Check if a MIME type is a message type
229 230 231 |
# File 'lib/philiprehberger/mime_type.rb', line 229 def self.(mime) category_prefix?(mime, 'message') end |
.multipart?(mime) ⇒ Boolean
Check if a MIME type is a multipart type
221 222 223 |
# File 'lib/philiprehberger/mime_type.rb', line 221 def self.multipart?(mime) category_prefix?(mime, 'multipart') end |
.parse_accept(header) ⇒ Array<Hash>
Parse an HTTP Accept header string
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/philiprehberger/mime_type.rb', line 248 def self.parse_accept(header) return [] if header.nil? || header.strip.empty? entries = header.split(',').map do |part| segments = part.strip.split(';').map(&:strip) type = segments.first q = 1.0 segments[1..].each do |param| key, value = param.split('=', 2).map(&:strip) if key == 'q' q = value.to_f end end { type: type, q: q } end entries.sort_by { |e| -e[:q] } end |
.register(ext, mime_type) ⇒ void
This method returns an undefined value.
Register a custom MIME type mapping for an extension
44 45 46 47 48 |
# File 'lib/philiprehberger/mime_type.rb', line 44 def self.register(ext, mime_type) normalized = ext.to_s.strip.downcase normalized = ".#{normalized}" unless normalized.start_with?('.') @custom_extensions[normalized] = mime_type end |
.text?(mime) ⇒ Boolean
Check if a MIME type is a text type
173 174 175 |
# File 'lib/philiprehberger/mime_type.rb', line 173 def self.text?(mime) category_prefix?(mime, 'text') end |
.unregister(ext) ⇒ void
This method returns an undefined value.
Remove a custom registration for an extension
54 55 56 57 58 |
# File 'lib/philiprehberger/mime_type.rb', line 54 def self.unregister(ext) normalized = ext.to_s.strip.downcase normalized = ".#{normalized}" unless normalized.start_with?('.') @custom_extensions.delete(normalized) end |
.valid?(mime) ⇒ Boolean
Check if a MIME type string is valid
150 151 152 153 154 155 156 |
# File 'lib/philiprehberger/mime_type.rb', line 150 def self.valid?(mime) type = mime.to_s.strip return false if type.empty? canonical_type = canonical(type) canonical_type.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9!\#$\-^_.+]*/[a-zA-Z0-9][a-zA-Z0-9!\#$\-^_.+]*\z}) end |
.video?(mime) ⇒ Boolean
Check if a MIME type is a video type
197 198 199 |
# File 'lib/philiprehberger/mime_type.rb', line 197 def self.video?(mime) category_prefix?(mime, 'video') end |