cabriolet 0.1.2 → 0.2.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +700 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +154 -14
  6. data/lib/cabriolet/binary/bitstream_writer.rb +129 -17
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +35 -43
  13. data/lib/cabriolet/cab/decompressor.rb +14 -19
  14. data/lib/cabriolet/cab/extractor.rb +140 -31
  15. data/lib/cabriolet/chm/command_handler.rb +227 -0
  16. data/lib/cabriolet/chm/compressor.rb +7 -3
  17. data/lib/cabriolet/chm/decompressor.rb +39 -21
  18. data/lib/cabriolet/chm/parser.rb +5 -2
  19. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  20. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  21. data/lib/cabriolet/cli/command_registry.rb +83 -0
  22. data/lib/cabriolet/cli.rb +356 -607
  23. data/lib/cabriolet/compressors/base.rb +1 -1
  24. data/lib/cabriolet/compressors/lzx.rb +241 -54
  25. data/lib/cabriolet/compressors/mszip.rb +35 -3
  26. data/lib/cabriolet/compressors/quantum.rb +34 -45
  27. data/lib/cabriolet/decompressors/base.rb +1 -1
  28. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  29. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  30. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  31. data/lib/cabriolet/decompressors/quantum.rb +3 -2
  32. data/lib/cabriolet/errors.rb +3 -0
  33. data/lib/cabriolet/file_entry.rb +156 -0
  34. data/lib/cabriolet/file_manager.rb +144 -0
  35. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  36. data/lib/cabriolet/hlp/compressor.rb +28 -238
  37. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  38. data/lib/cabriolet/hlp/parser.rb +52 -101
  39. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  40. data/lib/cabriolet/hlp/quickhelp/compressor.rb +626 -0
  41. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  42. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  43. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  44. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  45. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  46. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  47. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  48. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  49. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  50. data/lib/cabriolet/huffman/tree.rb +85 -1
  51. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  52. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  53. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  54. data/lib/cabriolet/lit/command_handler.rb +221 -0
  55. data/lib/cabriolet/lit/compressor.rb +633 -38
  56. data/lib/cabriolet/lit/decompressor.rb +518 -152
  57. data/lib/cabriolet/lit/parser.rb +670 -0
  58. data/lib/cabriolet/models/hlp_file.rb +130 -29
  59. data/lib/cabriolet/models/hlp_header.rb +105 -17
  60. data/lib/cabriolet/models/lit_header.rb +212 -25
  61. data/lib/cabriolet/models/szdd_header.rb +10 -2
  62. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  63. data/lib/cabriolet/oab/command_handler.rb +257 -0
  64. data/lib/cabriolet/oab/compressor.rb +17 -8
  65. data/lib/cabriolet/oab/decompressor.rb +41 -10
  66. data/lib/cabriolet/offset_calculator.rb +81 -0
  67. data/lib/cabriolet/plugin.rb +233 -0
  68. data/lib/cabriolet/plugin_manager.rb +453 -0
  69. data/lib/cabriolet/plugin_validator.rb +422 -0
  70. data/lib/cabriolet/system/io_system.rb +3 -0
  71. data/lib/cabriolet/system/memory_handle.rb +17 -4
  72. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  73. data/lib/cabriolet/szdd/compressor.rb +15 -11
  74. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  75. data/lib/cabriolet/version.rb +1 -1
  76. data/lib/cabriolet.rb +67 -17
  77. metadata +33 -2
@@ -2,44 +2,145 @@
2
2
 
3
3
  module Cabriolet
4
4
  module Models
5
- # HLP internal file model
5
+ # QuickHelp topic model
6
6
  #
7
- # Represents a file within an HLP archive. HLP files contain an internal
8
- # file system where each file can be compressed using LZSS MODE_MSHELP.
9
- class HLPFile
10
- attr_accessor :filename, :offset, :length, :compressed_length,
11
- :compressed, :data
12
-
13
- # Initialize HLP file
14
- #
15
- # @param filename [String] File name within the HLP archive
16
- # @param offset [Integer] Offset in the HLP archive
17
- # @param length [Integer] Uncompressed file length
18
- # @param compressed_length [Integer] Compressed file length
19
- # @param compressed [Boolean] Whether the file is compressed
20
- def initialize(filename: nil, offset: 0, length: 0,
21
- compressed_length: 0, compressed: true)
22
- @filename = filename
7
+ # Represents a single topic in a QuickHelp help database.
8
+ # Each topic contains formatted text lines with styles and hyperlinks.
9
+ class HLPTopic
10
+ attr_accessor :index, :offset, :size, :lines, :source_data, :metadata
11
+
12
+ # Initialize a QuickHelp topic
13
+ #
14
+ # @param index [Integer] Topic index in the database
15
+ # @param offset [Integer] Offset of topic data in file
16
+ # @param size [Integer] Size of compressed topic data
17
+ def initialize(index: 0, offset: 0, size: 0)
18
+ @index = index
23
19
  @offset = offset
24
- @length = length
25
- @compressed_length = compressed_length
26
- @compressed = compressed
27
- @data = nil
20
+ @size = size
21
+ @lines = []
22
+ @source_data = nil
23
+ @metadata = {}
24
+ end
25
+
26
+ # Check if topic has any content
27
+ #
28
+ # @return [Boolean] true if topic has lines
29
+ def empty?
30
+ @lines.empty?
31
+ end
32
+
33
+ # Get plain text content (without formatting)
34
+ #
35
+ # @return [String] plain text of all lines
36
+ def plain_text
37
+ @lines.map(&:text).join("\n")
38
+ end
39
+
40
+ # Add a line to the topic
41
+ #
42
+ # @param line [HLPLine] line to add
43
+ # @return [void]
44
+ def add_line(line)
45
+ @lines << line
46
+ end
47
+ end
48
+
49
+ # QuickHelp topic line model
50
+ #
51
+ # Represents a single line within a topic, including text, styles, and links.
52
+ class HLPLine
53
+ attr_accessor :text, :attributes
54
+
55
+ # Initialize a topic line
56
+ #
57
+ # @param text [String] plain text content
58
+ def initialize(text = "")
59
+ @text = text
60
+ @attributes = Array.new(text.length) { TextAttribute.new }
61
+ end
62
+
63
+ # Get line length in characters
64
+ #
65
+ # @return [Integer] character count
66
+ def length
67
+ @text.length
28
68
  end
29
69
 
30
- # Check if file is compressed
70
+ # Apply style to a range of characters
31
71
  #
32
- # @return [Boolean] true if file is compressed
33
- def compressed?
34
- @compressed
72
+ # @param start_index [Integer] start position (0-based)
73
+ # @param end_index [Integer] end position (0-based, inclusive)
74
+ # @param style [Integer] style flags
75
+ # @return [void]
76
+ def apply_style(start_index, end_index, style)
77
+ (start_index..end_index).each do |i|
78
+ @attributes[i].style = style if i < @attributes.length
79
+ end
35
80
  end
36
81
 
37
- # Get the size to read from archive
82
+ # Apply link to a range of characters
38
83
  #
39
- # @return [Integer] Size to read (compressed or uncompressed)
40
- def read_size
41
- compressed? ? @compressed_length : @length
84
+ # @param start_index [Integer] start position (1-based, as per format)
85
+ # @param end_index [Integer] end position (1-based, inclusive)
86
+ # @param link [String] link target (context string or topic index)
87
+ # @return [void]
88
+ def apply_link(start_index, end_index, link)
89
+ # Convert from 1-based to 0-based indexing
90
+ start_idx = start_index - 1
91
+ end_idx = end_index - 1
92
+
93
+ (start_idx..end_idx).each do |i|
94
+ @attributes[i].link = link if i >= 0 && i < @attributes.length
95
+ end
96
+ end
97
+ end
98
+
99
+ # Text attribute model
100
+ #
101
+ # Represents style and link information for a single character.
102
+ class TextAttribute
103
+ attr_accessor :style, :link
104
+
105
+ # Initialize text attribute
106
+ #
107
+ # @param style [Integer] style flags (bold, italic, underline)
108
+ # @param link [String, nil] link target if any
109
+ def initialize(style = 0, link = nil)
110
+ @style = style
111
+ @link = link
112
+ end
113
+
114
+ # Check if character is bold
115
+ #
116
+ # @return [Boolean] true if bold
117
+ def bold?
118
+ @style.anybits?(Binary::HLPStructures::TextStyle::BOLD)
119
+ end
120
+
121
+ # Check if character is italic
122
+ #
123
+ # @return [Boolean] true if italic
124
+ def italic?
125
+ @style.anybits?(Binary::HLPStructures::TextStyle::ITALIC)
126
+ end
127
+
128
+ # Check if character is underlined
129
+ #
130
+ # @return [Boolean] true if underlined
131
+ def underline?
132
+ @style.anybits?(Binary::HLPStructures::TextStyle::UNDERLINE)
133
+ end
134
+
135
+ # Check if character has a link
136
+ #
137
+ # @return [Boolean] true if linked
138
+ def linked?
139
+ !@link.nil?
42
140
  end
43
141
  end
142
+
143
+ # Backward compatibility alias
144
+ HLPFile = HLPTopic
44
145
  end
45
146
  end
@@ -2,35 +2,123 @@
2
2
 
3
3
  module Cabriolet
4
4
  module Models
5
- # HLP file header model
5
+ # QuickHelp database header model
6
6
  #
7
- # NOTE: This implementation is based on the knowledge that HLP files use
8
- # LZSS compression with MODE_MSHELP, but cannot be fully validated due to
9
- # lack of test fixtures. Testing relies on round-trip
10
- # compression/decompression and comparison with libmspack tools if
11
- # available.
7
+ # Represents the metadata of a QuickHelp help database (.HLP file).
8
+ # HLP files contain topics, context strings, and optional compression
9
+ # (keyword dictionary and Huffman coding).
12
10
  class HLPHeader
13
- attr_accessor :magic, :version, :filename, :length, :files
11
+ attr_accessor :magic, :version, :attributes, :control_character,
12
+ :topic_count, :context_count, :display_width, :predefined_ctx_count, :database_name, :topic_index_offset, :context_strings_offset, :context_map_offset, :keywords_offset, :huffman_tree_offset, :topic_text_offset, :database_size, :filename, :keywords, :huffman_tree
14
13
 
15
- # Initialize HLP header
14
+ # Topics and context data
15
+ attr_accessor :topics, :contexts, :context_map
16
+
17
+ # Initialize QuickHelp database header
16
18
  #
17
- # @param magic [String] Magic number (should be specific to HLP)
18
- # @param version [Integer] Format version
19
- # @param filename [String] Original filename
20
- # @param length [Integer] Uncompressed file length
21
- def initialize(magic: nil, version: nil, filename: nil, length: 0)
22
- @magic = magic
19
+ # @param magic [String] Magic number (should be 0x4C 0x4E)
20
+ # @param version [Integer] Format version (should be 2)
21
+ # @param attributes [Integer] Attribute flags
22
+ # @param control_character [Integer] Control character (usually ':' or 0xFF)
23
+ # @param topic_count [Integer] Number of topics
24
+ # @param context_count [Integer] Number of context strings
25
+ # @param display_width [Integer] Display width in characters
26
+ # @param database_name [String] Database name for external links
27
+ def initialize(
28
+ magic: nil,
29
+ version: 2,
30
+ attributes: 0,
31
+ control_character: 0x3A,
32
+ topic_count: 0,
33
+ context_count: 0,
34
+ display_width: 80,
35
+ predefined_ctx_count: 0,
36
+ database_name: "",
37
+ topic_index_offset: 0,
38
+ context_strings_offset: 0,
39
+ context_map_offset: 0,
40
+ keywords_offset: 0,
41
+ huffman_tree_offset: 0,
42
+ topic_text_offset: 0,
43
+ database_size: 0,
44
+ filename: nil
45
+ )
46
+ @magic = magic || Binary::HLPStructures::SIGNATURE
23
47
  @version = version
48
+ @attributes = attributes
49
+ @control_character = control_character
50
+ @topic_count = topic_count
51
+ @context_count = context_count
52
+ @display_width = display_width
53
+ @predefined_ctx_count = predefined_ctx_count
54
+ @database_name = database_name
55
+ @topic_index_offset = topic_index_offset
56
+ @context_strings_offset = context_strings_offset
57
+ @context_map_offset = context_map_offset
58
+ @keywords_offset = keywords_offset
59
+ @huffman_tree_offset = huffman_tree_offset
60
+ @topic_text_offset = topic_text_offset
61
+ @database_size = database_size
24
62
  @filename = filename
25
- @length = length
26
- @files = []
63
+
64
+ # Collections
65
+ @topics = []
66
+ @contexts = []
67
+ @context_map = []
68
+ @keywords = []
69
+ @huffman_tree = nil
27
70
  end
28
71
 
29
72
  # Check if header is valid
30
73
  #
31
74
  # @return [Boolean] true if header appears valid
32
75
  def valid?
33
- !@magic.nil? && !@version.nil?
76
+ @magic == Binary::HLPStructures::SIGNATURE &&
77
+ @version == 2 &&
78
+ @topic_count >= 0 &&
79
+ @context_count >= 0
80
+ end
81
+
82
+ # Check if case-sensitive context strings
83
+ #
84
+ # @return [Boolean] true if case-sensitive
85
+ def case_sensitive?
86
+ @attributes.anybits?(Binary::HLPStructures::Attributes::CASE_SENSITIVE)
87
+ end
88
+
89
+ # Check if database is locked (cannot be decoded by HELPMAKE)
90
+ #
91
+ # @return [Boolean] true if locked
92
+ def locked?
93
+ @attributes.anybits?(Binary::HLPStructures::Attributes::LOCKED)
94
+ end
95
+
96
+ # Check if keyword compression is used
97
+ #
98
+ # @return [Boolean] true if keywords present
99
+ def has_keywords?
100
+ @keywords_offset.positive? && !@keywords.empty?
101
+ end
102
+
103
+ # Check if Huffman compression is used
104
+ #
105
+ # @return [Boolean] true if Huffman tree present
106
+ def has_huffman?
107
+ @huffman_tree_offset.positive? && !@huffman_tree.nil?
108
+ end
109
+
110
+ # Get control character as string
111
+ #
112
+ # @return [String] control character
113
+ def control_char
114
+ @control_character.chr(Encoding::ASCII)
115
+ end
116
+
117
+ # Get database name without null padding
118
+ #
119
+ # @return [String] trimmed database name
120
+ def db_name
121
+ @database_name.split("\x00").first || ""
34
122
  end
35
123
  end
36
124
  end
@@ -2,53 +2,240 @@
2
2
 
3
3
  module Cabriolet
4
4
  module Models
5
- # Represents the header of a Microsoft Reader LIT file
5
+ # Represents a Microsoft Reader LIT file structure
6
6
  #
7
- # LIT files are Microsoft Reader eBook files that use LZX compression
8
- # and may use DES encryption for DRM-protected content.
9
- class LITHeader
10
- attr_accessor :version, :filename, :length, :encrypted, :files
7
+ # LIT files have a complex structure with:
8
+ # - Primary and secondary headers
9
+ # - Piece table pointing to various data structures
10
+ # - Internal directory with IFCM/AOLL/AOLI chunks
11
+ # - DataSpace sections with transformation layers (compression/encryption)
12
+ # - Manifest mapping internal to original filenames
13
+ class LITFile
14
+ attr_accessor :version, :header_guid, :piece3_guid, :piece4_guid,
15
+ :content_offset, :timestamp, :language_id, :creator_id,
16
+ :entry_chunklen, :count_chunklen, :entry_unknown,
17
+ :count_unknown, :drm_level, :sections, :directory, :manifest
11
18
 
12
19
  def initialize
13
20
  @version = 0
14
- @filename = ""
15
- @length = 0
16
- @encrypted = false
17
- @files = []
21
+ @header_guid = ""
22
+ @piece3_guid = ""
23
+ @piece4_guid = ""
24
+ @content_offset = 0
25
+ @timestamp = 0
26
+ @language_id = 0
27
+ @creator_id = 0
28
+ @entry_chunklen = 0
29
+ @count_chunklen = 0
30
+ @entry_unknown = 0
31
+ @count_unknown = 0
32
+ @drm_level = 0
33
+ @sections = []
34
+ @directory = nil
35
+ @manifest = nil
18
36
  end
19
37
 
20
- # Check if the LIT file is encrypted
38
+ # Check if the LIT file has DRM encryption
21
39
  #
22
- # @return [Boolean] true if the file uses DES encryption
40
+ # @return [Boolean] true if DRM is present
23
41
  def encrypted?
24
- @encrypted
42
+ drm_level.positive?
43
+ end
44
+
45
+ # Get section by name
46
+ #
47
+ # @param name [String] Section name
48
+ # @return [LITSection, nil] The section or nil if not found
49
+ def section(name)
50
+ sections.find { |s| s.name == name }
25
51
  end
26
52
  end
27
53
 
28
- # Represents a file entry within a LIT archive
29
- class LITFile
30
- attr_accessor :filename, :offset, :length, :compressed, :encrypted
54
+ # Represents a section within the LIT file
55
+ #
56
+ # Sections contain compressed/encrypted data with transform layers
57
+ class LITSection
58
+ attr_accessor :name, :transforms, :compressed, :encrypted,
59
+ :uncompressed_length, :compressed_length,
60
+ :window_size, :reset_interval, :reset_table
31
61
 
32
62
  def initialize
33
- @filename = ""
34
- @offset = 0
35
- @length = 0
36
- @compressed = true
63
+ @name = ""
64
+ @transforms = []
65
+ @compressed = false
37
66
  @encrypted = false
67
+ @uncompressed_length = 0
68
+ @compressed_length = 0
69
+ @window_size = 0
70
+ @reset_interval = 0
71
+ @reset_table = []
38
72
  end
39
73
 
40
- # Check if the file is compressed
74
+ # Check if section is compressed
41
75
  #
42
- # @return [Boolean] true if the file uses LZX compression
76
+ # @return [Boolean] true if compressed
43
77
  def compressed?
44
- @compressed
78
+ compressed
45
79
  end
46
80
 
47
- # Check if the file is encrypted
81
+ # Check if section is encrypted
48
82
  #
49
- # @return [Boolean] true if the file uses DES encryption
83
+ # @return [Boolean] true if encrypted
50
84
  def encrypted?
51
- @encrypted
85
+ encrypted
86
+ end
87
+ end
88
+
89
+ # Represents the internal directory structure
90
+ #
91
+ # Directory contains file entries with encoded integers for efficiency
92
+ class LITDirectory
93
+ attr_accessor :entries, :num_chunks, :entry_chunklen, :count_chunklen
94
+
95
+ def initialize
96
+ @entries = []
97
+ @num_chunks = 0
98
+ @entry_chunklen = 0
99
+ @count_chunklen = 0
100
+ end
101
+
102
+ # Find entry by name
103
+ #
104
+ # @param name [String] Entry name
105
+ # @return [LITDirectoryEntry, nil] The entry or nil if not found
106
+ def find(name)
107
+ entries.find { |e| e.name == name }
108
+ end
109
+
110
+ # Get all entries in a section
111
+ #
112
+ # @param section_id [Integer] Section ID
113
+ # @return [Array<LITDirectoryEntry>] Entries in the section
114
+ def entries_in_section(section_id)
115
+ entries.select { |e| e.section == section_id }
116
+ end
117
+ end
118
+
119
+ # Represents a single directory entry
120
+ #
121
+ # Entries use variable-length encoded integers to save space
122
+ class LITDirectoryEntry
123
+ attr_accessor :name, :section, :offset, :size
124
+
125
+ def initialize
126
+ @name = ""
127
+ @section = 0
128
+ @offset = 0
129
+ @size = 0
130
+ end
131
+
132
+ # Check if this is a root entry
133
+ #
134
+ # @return [Boolean] true if root entry
135
+ def root?
136
+ ["/", ""].include?(name)
137
+ end
138
+
139
+ # Get the directory portion of the name
140
+ #
141
+ # @return [String] Directory path
142
+ def directory
143
+ return "/" if root?
144
+
145
+ parts = name.split("/")
146
+ parts[0..-2].join("/")
147
+ end
148
+
149
+ # Get the filename portion
150
+ #
151
+ # @return [String] Filename
152
+ def filename
153
+ return "" if root?
154
+
155
+ name.split("/").last
156
+ end
157
+ end
158
+
159
+ # Represents the manifest file
160
+ #
161
+ # Maps internal filenames to original filenames and content types
162
+ class LITManifest
163
+ attr_accessor :mappings
164
+
165
+ def initialize
166
+ @mappings = []
167
+ end
168
+
169
+ # Find mapping by internal name
170
+ #
171
+ # @param internal_name [String] Internal filename
172
+ # @return [LITManifestMapping, nil] The mapping or nil
173
+ def find_by_internal(internal_name)
174
+ mappings.find { |m| m.internal_name == internal_name }
175
+ end
176
+
177
+ # Find mapping by original name
178
+ #
179
+ # @param original_name [String] Original filename
180
+ # @return [LITManifestMapping, nil] The mapping or nil
181
+ def find_by_original(original_name)
182
+ mappings.find { |m| m.original_name == original_name }
183
+ end
184
+
185
+ # Get all HTML files
186
+ #
187
+ # @return [Array<LITManifestMapping>] HTML file mappings
188
+ def html_files
189
+ mappings.select { |m| m.content_type =~ /html/i }
190
+ end
191
+
192
+ # Get all CSS files
193
+ #
194
+ # @return [Array<LITManifestMapping>] CSS file mappings
195
+ def css_files
196
+ mappings.select { |m| m.content_type =~ /css/i }
197
+ end
198
+
199
+ # Get all image files
200
+ #
201
+ # @return [Array<LITManifestMapping>] Image file mappings
202
+ def image_files
203
+ mappings.select { |m| m.content_type =~ /image/i }
204
+ end
205
+ end
206
+
207
+ # Represents a single manifest mapping
208
+ class LITManifestMapping
209
+ attr_accessor :offset, :internal_name, :original_name, :content_type,
210
+ :group
211
+
212
+ def initialize
213
+ @offset = 0
214
+ @internal_name = ""
215
+ @original_name = ""
216
+ @content_type = ""
217
+ @group = 0
218
+ end
219
+
220
+ # Check if this is an HTML file
221
+ #
222
+ # @return [Boolean] true if HTML
223
+ def html?
224
+ content_type =~ /html/i
225
+ end
226
+
227
+ # Check if this is a CSS file
228
+ #
229
+ # @return [Boolean] true if CSS
230
+ def css?
231
+ content_type =~ /css/i
232
+ end
233
+
234
+ # Check if this is an image
235
+ #
236
+ # @return [Boolean] true if image
237
+ def image?
238
+ content_type =~ /image/i
52
239
  end
53
240
  end
54
241
  end
@@ -64,8 +64,16 @@ module Cabriolet
64
64
  return compressed_filename unless normal_format? && @missing_char
65
65
 
66
66
  # Replace trailing underscore with missing character
67
- # Pattern: ends with .XX_ where XX is any 2+ characters
68
- compressed_filename.sub(/\.(\w+)_$/, ".\\1#{@missing_char}")
67
+ # Uppercase unless all extension characters are lowercase
68
+ extension_match = compressed_filename.match(/\.(\w+)_$/)
69
+ if extension_match
70
+ extension = extension_match[1]
71
+ # Uppercase unless extension is entirely lowercase
72
+ missing_char = extension == extension.downcase ? @missing_char.downcase : @missing_char.upcase
73
+ compressed_filename.sub(/\.(\w+)_$/, ".\\1#{missing_char}")
74
+ else
75
+ compressed_filename
76
+ end
69
77
  end
70
78
  end
71
79
  end