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

Class Method Details

.application?(mime) ⇒ Boolean

Check if a MIME type is an application type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for application/* MIME types



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for audio/* MIME types



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

Parameters:

  • available (Array<String>)

    available MIME types

  • accept_header (String)

    Accept header value

Returns:

  • (String, nil)

    best matching MIME type or nil



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for non-text MIME types



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.

Parameters:

  • mime (String)

Returns:

  • (String)


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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Symbol, nil)

    category (:text, :image, :audio, :video, :application, :font, :message)



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (String, nil)

    “utf-8” for text types, nil for binary types



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Array<String>)

    list of extensions (empty if unknown)



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for font/* MIME types



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

Parameters:

  • bytes (String)

    binary content (first 300+ bytes recommended)

Returns:

  • (String, nil)

    MIME type or nil if unrecognized



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

Parameters:

  • ext (String)

    file extension (with or without leading dot)

Returns:

  • (String, nil)

    MIME type or nil if unknown



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

Parameters:

  • name (String)

    filename with extension

Returns:

  • (String, nil)

    MIME type or nil if unknown



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for image/* MIME types



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for message/* MIME types



229
230
231
# File 'lib/philiprehberger/mime_type.rb', line 229

def self.message?(mime)
  category_prefix?(mime, 'message')
end

.multipart?(mime) ⇒ Boolean

Check if a MIME type is a multipart type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for multipart/* MIME types



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

Parameters:

  • header (String)

    Accept header value

Returns:

  • (Array<Hash>)

    array of { type: String, q: Float } sorted by quality descending



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

Parameters:

  • ext (String)

    file extension (with or without leading dot)

  • mime_type (String)

    MIME type string



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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for text/* MIME types



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

Parameters:

  • ext (String)

    file extension (with or without leading dot)



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

Parameters:

  • mime (String)

    MIME type to validate

Returns:

  • (Boolean)

    true if the format 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

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for video/* MIME types



197
198
199
# File 'lib/philiprehberger/mime_type.rb', line 197

def self.video?(mime)
  category_prefix?(mime, 'video')
end