imsg-grep 0.1.2-darwin

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.
data/ext/img2png.swift ADDED
@@ -0,0 +1,325 @@
1
+ // Image to PNG conversion - both CLI and library versions
2
+ // Applies rotation metadata to actual pixels, strips orientation from output.
3
+ // Optional: resample to fit dimensions, place in centered box with black background.
4
+ //
5
+ // CLI Usage: ./img2png < input.jpg > output.png
6
+ // ./img2png --fit 800x600 < input.jpg > output.png
7
+ // ./img2png --fit 800x600 --box 1024x768 < input.jpg > output.png
8
+ // ./img2png --info < input.jpg # prints WxH to stdout
9
+ //
10
+ // Build CLI: swiftc -O -whole-module-optimization -lto=llvm-full -o bin/img2png ext/img2png.swift
11
+ // Build dylib: swiftc -O -whole-module-optimization -lto=llvm-full -emit-library -D LIBRARY -o lib/imsg-grep/images/img2png.dylib ext/img2png.swift
12
+
13
+ import Foundation
14
+ import ImageIO
15
+ import UniformTypeIdentifiers
16
+
17
+ let VERSION = "1.0.0"
18
+
19
+ // Shared image processing functions
20
+
21
+ func loadImage(from data: Data) -> (CGImage, Int, Int)? {
22
+ guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
23
+ let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
24
+ return nil
25
+ }
26
+
27
+ let orientation = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
28
+ .flatMap { $0 as? [String: Any] }
29
+ .flatMap { $0[kCGImagePropertyOrientation as String] as? UInt32 }
30
+ .flatMap { CGImagePropertyOrientation(rawValue: $0) } ?? .up
31
+
32
+ let rotatedWidth = orientation.rawValue > 4 ? image.height : image.width
33
+ let rotatedHeight = orientation.rawValue > 4 ? image.width : image.height
34
+
35
+ guard let rotateContext = CGContext(data: nil, width: rotatedWidth, height: rotatedHeight,
36
+ bitsPerComponent: 8, bytesPerRow: 0,
37
+ space: CGColorSpaceCreateDeviceRGB(),
38
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
39
+ return nil
40
+ }
41
+
42
+ rotateContext.interpolationQuality = .high
43
+
44
+ let w = CGFloat(rotatedWidth)
45
+ let h = CGFloat(rotatedHeight)
46
+
47
+ let transforms: [CGImagePropertyOrientation: (CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)] = [
48
+ .down: (w, h, 1, 1, .pi),
49
+ .downMirrored: (w, h, -1, 1, .pi),
50
+ .left: (w, 0, 1, 1, .pi / 2),
51
+ .leftMirrored: (w, 0, -1, 1, .pi / 2),
52
+ .right: (0, h, 1, 1, -.pi / 2),
53
+ .rightMirrored: (0, h, -1, 1, -.pi / 2),
54
+ .upMirrored: (w, 0, -1, 1, 0),
55
+ ]
56
+ let (tx, ty, sx, sy, rot) = transforms[orientation] ?? (0, 0, 1, 1, 0)
57
+
58
+ if tx != 0 || ty != 0 { rotateContext.translateBy(x: tx, y: ty) }
59
+ if sx != 1 || sy != 1 { rotateContext.scaleBy(x: sx, y: sy) }
60
+ if rot != 0 { rotateContext.rotate(by: rot) }
61
+
62
+ rotateContext.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
63
+
64
+ guard let rotatedImage = rotateContext.makeImage() else {
65
+ return nil
66
+ }
67
+
68
+ return (rotatedImage, rotatedWidth, rotatedHeight)
69
+ }
70
+
71
+ func emitPNG(image: CGImage, fitW: Int = 0, fitH: Int = 0, boxW: Int = 0, boxH: Int = 0) -> Data? {
72
+ var finalImage = image
73
+ var finalWidth = image.width
74
+ var finalHeight = image.height
75
+
76
+ // Fit if requested
77
+ if fitW > 0 && fitH > 0 {
78
+ let scale = min(Double(fitW) / Double(image.width), Double(fitH) / Double(image.height))
79
+ let scaledW = Int(Double(image.width) * scale)
80
+ let scaledH = Int(Double(image.height) * scale)
81
+
82
+ guard let fitContext = CGContext(data: nil, width: scaledW, height: scaledH,
83
+ bitsPerComponent: 8, bytesPerRow: 0,
84
+ space: CGColorSpaceCreateDeviceRGB(),
85
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
86
+ return nil
87
+ }
88
+
89
+ fitContext.interpolationQuality = .high
90
+ fitContext.draw(image, in: CGRect(x: 0, y: 0, width: scaledW, height: scaledH))
91
+
92
+ guard let fittedImage = fitContext.makeImage() else {
93
+ return nil
94
+ }
95
+
96
+ finalImage = fittedImage
97
+ finalWidth = scaledW
98
+ finalHeight = scaledH
99
+ }
100
+
101
+ // Box if requested
102
+ if boxW > 0 && boxH > 0 {
103
+ guard let boxContext = CGContext(data: nil, width: boxW, height: boxH,
104
+ bitsPerComponent: 8, bytesPerRow: 0,
105
+ space: CGColorSpaceCreateDeviceRGB(),
106
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
107
+ return nil
108
+ }
109
+
110
+ boxContext.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
111
+ boxContext.fill(CGRect(x: 0, y: 0, width: boxW, height: boxH))
112
+
113
+ let x = (boxW - finalWidth) / 2
114
+ let y = (boxH - finalHeight) / 2
115
+ boxContext.draw(finalImage, in: CGRect(x: x, y: y, width: finalWidth, height: finalHeight))
116
+
117
+ guard let boxedImage = boxContext.makeImage() else {
118
+ return nil
119
+ }
120
+
121
+ finalImage = boxedImage
122
+ }
123
+
124
+ let outputData = NSMutableData()
125
+ guard let destination = CGImageDestinationCreateWithData(outputData, UTType.png.identifier as CFString, 1, nil) else {
126
+ return nil
127
+ }
128
+
129
+ let options: [CFString: Any] = [kCGImagePropertyPNGCompressionFilter: 9]
130
+ CGImageDestinationAddImage(destination, finalImage, options as CFDictionary)
131
+
132
+ guard CGImageDestinationFinalize(destination) else {
133
+ return nil
134
+ }
135
+
136
+ return outputData as Data
137
+ }
138
+
139
+ // MARK: - Library API
140
+
141
+ // Opaque image handle
142
+ public class ImageHandle {
143
+ let image: CGImage
144
+ let width: Int
145
+ let height: Int
146
+
147
+ init(image: CGImage, width: Int, height: Int) {
148
+ self.image = image
149
+ self.width = width
150
+ self.height = height
151
+ }
152
+ }
153
+
154
+ // Load image from file path, apply rotation. Returns opaque handle or null on error.
155
+ @_cdecl("img2png_load_path")
156
+ public func img2png_load_path(path: UnsafePointer<CChar>,
157
+ outW: UnsafeMutablePointer<Int>,
158
+ outH: UnsafeMutablePointer<Int>) -> UnsafeMutableRawPointer? {
159
+ let pathStr = String(cString: path)
160
+ let url = URL(fileURLWithPath: pathStr)
161
+
162
+ guard let data = try? Data(contentsOf: url) else {
163
+ return nil
164
+ }
165
+
166
+ return img2png_load(inputData: data.withUnsafeBytes { $0.bindMemory(to: UInt8.self).baseAddress! },
167
+ inputLen: data.count, outW: outW, outH: outH)
168
+ }
169
+
170
+ // Load image from data, apply rotation. Returns opaque handle or null on error.
171
+ @_cdecl("img2png_load")
172
+ public func img2png_load(inputData: UnsafePointer<UInt8>, inputLen: Int,
173
+ outW: UnsafeMutablePointer<Int>,
174
+ outH: UnsafeMutablePointer<Int>) -> UnsafeMutableRawPointer? {
175
+ let data = Data(bytes: inputData, count: inputLen)
176
+
177
+ guard let (rotatedImage, rotatedWidth, rotatedHeight) = loadImage(from: data) else {
178
+ return nil
179
+ }
180
+
181
+ let handle = ImageHandle(image: rotatedImage, width: rotatedWidth, height: rotatedHeight)
182
+ outW.pointee = rotatedWidth
183
+ outH.pointee = rotatedHeight
184
+ return Unmanaged.passRetained(handle).toOpaque()
185
+ }
186
+
187
+ // Convert to PNG with optional fitting and boxing.
188
+ // fitW, fitH: scale to fit within these dimensions (0 = skip)
189
+ // boxW, boxH: place in centered box with black background (0 = skip)
190
+ // Returns PNG data and length via out parameters. Caller must free data.
191
+ @_cdecl("img2png_convert")
192
+ public func img2png_convert(
193
+ handle: UnsafeMutableRawPointer,
194
+ fitW: Int, fitH: Int,
195
+ boxW: Int, boxH: Int,
196
+ outData: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>,
197
+ outLen: UnsafeMutablePointer<Int>
198
+ ) -> Bool {
199
+ let img = Unmanaged<ImageHandle>.fromOpaque(handle).takeUnretainedValue()
200
+
201
+ guard let pngData = emitPNG(image: img.image, fitW: fitW, fitH: fitH, boxW: boxW, boxH: boxH) else {
202
+ return false
203
+ }
204
+
205
+ let bytes = malloc(pngData.count)!
206
+ _ = pngData.withUnsafeBytes { memcpy(bytes, $0.baseAddress!, pngData.count) }
207
+
208
+ outData.pointee = bytes.assumingMemoryBound(to: UInt8.self)
209
+ outLen.pointee = pngData.count
210
+
211
+ return true
212
+ }
213
+
214
+ // Release image handle
215
+ @_cdecl("img2png_release")
216
+ public func img2png_release(handle: UnsafeMutableRawPointer) {
217
+ Unmanaged<ImageHandle>.fromOpaque(handle).release()
218
+ }
219
+
220
+ // Free memory allocated by library
221
+ @_cdecl("img2png_free")
222
+ public func img2png_free(ptr: UnsafeMutableRawPointer?) {
223
+ free(ptr)
224
+ }
225
+
226
+ // MARK: - CLI Main
227
+
228
+ func parseDimensions(_ str: String) -> (Int, Int)? {
229
+ let parts = str.split(separator: "x")
230
+ guard parts.count == 2,
231
+ let w = Int(parts[0]),
232
+ let h = Int(parts[1]) else { return nil }
233
+ return (w, h)
234
+ }
235
+
236
+ #if !LIBRARY
237
+ @main
238
+ struct CLI {
239
+ static func main() {
240
+ // Parse arguments
241
+ var fit: (Int, Int)?
242
+ var box: (Int, Int)?
243
+ var infoMode = false
244
+ var i = 1
245
+
246
+ while i < CommandLine.arguments.count {
247
+ switch CommandLine.arguments[i] {
248
+ case "--version":
249
+ print(VERSION)
250
+ exit(0)
251
+ case "--fit":
252
+ guard i + 1 < CommandLine.arguments.count,
253
+ let dims = parseDimensions(CommandLine.arguments[i + 1]) else {
254
+ fputs("Invalid --fit WxH\n", stderr)
255
+ exit(1)
256
+ }
257
+ fit = dims
258
+ i += 2
259
+ case "--box":
260
+ guard i + 1 < CommandLine.arguments.count,
261
+ let dims = parseDimensions(CommandLine.arguments[i + 1]) else {
262
+ fputs("Invalid --box WxH\n", stderr)
263
+ exit(1)
264
+ }
265
+ box = dims
266
+ i += 2
267
+ case "--info":
268
+ infoMode = true
269
+ i += 1
270
+ default:
271
+ fputs("Unknown option: \(CommandLine.arguments[i])\n", stderr)
272
+ exit(1)
273
+ }
274
+ }
275
+
276
+ // Read entire stdin into memory
277
+ let inputData = FileHandle.standardInput.readDataToEndOfFile()
278
+
279
+ guard !inputData.isEmpty else {
280
+ if CommandLine.arguments.count == 1 {
281
+ print("Usage: img2png [OPTIONS] < input > output")
282
+ print("")
283
+ print("Options:")
284
+ print(" --fit WxH Scale to fit within dimensions")
285
+ print(" --box WxH Place in centered box with black background")
286
+ print(" --info Print dimensions only")
287
+ print(" --version Print version")
288
+ print("")
289
+ print("Examples:")
290
+ print(" img2png < input.jpg > output.png")
291
+ print(" img2png --fit 800x600 < input.jpg > output.png")
292
+ print(" img2png --fit 800x600 --box 1024x768 < input.jpg > output.png")
293
+ exit(0)
294
+ } else {
295
+ fputs("No input data\n", stderr)
296
+ exit(1)
297
+ }
298
+ }
299
+
300
+ guard let (rotatedImage, rotatedWidth, rotatedHeight) = loadImage(from: inputData) else {
301
+ fputs("Failed to load image\n", stderr)
302
+ exit(1)
303
+ }
304
+
305
+ // If info mode, print dimensions and exit
306
+ if infoMode {
307
+ print("\(rotatedWidth)x\(rotatedHeight)")
308
+ exit(0)
309
+ }
310
+
311
+ let fitW = fit?.0 ?? 0
312
+ let fitH = fit?.1 ?? 0
313
+ let boxW = box?.0 ?? 0
314
+ let boxH = box?.1 ?? 0
315
+
316
+ guard let pngData = emitPNG(image: rotatedImage, fitW: fitW, fitH: fitH, boxW: boxW, boxH: boxH) else {
317
+ fputs("Failed to convert to PNG\n", stderr)
318
+ exit(1)
319
+ }
320
+
321
+ // Write to stdout
322
+ FileHandle.standardOutput.write(pngData)
323
+ }
324
+ }
325
+ #endif
@@ -0,0 +1 @@
1
+ 0.1.2
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Extract NSString value from unkeyed-archived (typedstream) NSAttributedString
5
+
6
+ class AttributedStringExtractor
7
+ def self.extract(data) = (new(data).extract if data)
8
+
9
+ TAG_INTEGER_2 = -127 # Indicates 2-byte integer follows
10
+ TAG_INTEGER_4 = -126 # Indicates 4-byte integer follows
11
+
12
+ def initialize(data)
13
+ return if data.nil?
14
+ # Find NSString position first and fail fast if not found
15
+ @nsstring_pos = data.index("NSString") or raise "NSString not found in data"
16
+ @data = data.b
17
+ @pos = 0
18
+
19
+ # Read and validate header
20
+ version = read.ord
21
+ sig_length = read.ord
22
+ raise "Only version 4 supported, got #{version}" unless version == 4
23
+ raise "Invalid signature length #{sig_length}" unless sig_length == 11
24
+
25
+ signature = read(sig_length)
26
+ case signature
27
+ when "streamtyped" then @int16_format, @int32_format = "v", "V"
28
+ when "typedstream" then @int16_format, @int32_format = "n", "N"
29
+ else raise "Invalid signature: #{signature.inspect}"
30
+ end
31
+ end
32
+
33
+ def extract
34
+ return if @data.nil?
35
+
36
+ # Jump to after NSString and look for '+' string marker
37
+ marker_pos = @data.index(?+, @nsstring_pos + 8)
38
+ return unless marker_pos
39
+
40
+ @pos = marker_pos + 1 # Skip '+' marker
41
+
42
+ length_byte = read.ord # Read string length
43
+ length_byte = length_byte > 127 ? length_byte - 256 : length_byte # Convert to signed byte for tag comparison
44
+
45
+ length = case length_byte
46
+ when TAG_INTEGER_2 then read(2).unpack1(@int16_format) # 2-byte length
47
+ when TAG_INTEGER_4 then read(4).unpack1(@int32_format) # 4-byte length
48
+ else length_byte # Single byte length
49
+ end
50
+
51
+ return unless length && length > 0
52
+
53
+ read(length).force_encoding("UTF-8") # Extract and return string
54
+ end
55
+
56
+ private
57
+
58
+ def read(n = 1)
59
+ bytes = @data[@pos, n]
60
+ @pos += n
61
+ bytes
62
+ end
63
+ end
64
+
65
+ puts AttributedStringExtractor.extract(STDIN.read) if __FILE__ == $0 && STDIN.stat.size > 0
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Binary property list (bplist) parser for decoding Apple's binary plist format
4
+
5
+ require 'set'
6
+
7
+ module BPList
8
+ APPLE_EPOCH = 978307200 # Apple's epoch offset from Unix epoch
9
+
10
+ # Read big-endian integer from data at position
11
+ def self.read_int(data, pos, size)
12
+ raise "Position #{pos} + #{size} beyond data size" if pos + size > data.bytesize
13
+ case size
14
+ when 1 then data[pos].unpack1("C")
15
+ when 2 then data[pos, 2].unpack1("n")
16
+ when 4 then data[pos, 4].unpack1("N")
17
+ when 8 then data[pos, 8].unpack1("Q>")
18
+ else
19
+ # Fallback for other sizes
20
+ bytes = data[pos, size].unpack("C*")
21
+ bytes.reduce(0) { |a, b|
22
+ raise "nil value in read_int: a=#{a.inspect}, b=#{b.inspect}" if a.nil? || b.nil?
23
+ (a << 8) | b
24
+ }
25
+ end
26
+ end
27
+
28
+ # Get count/length (handles 0xF continuation)
29
+ def self.get_count(data, pos, low)
30
+ return [low, pos + 1] if low != 0x0F
31
+
32
+ raise "Position #{pos + 1} beyond data size" if pos + 1 >= data.bytesize
33
+ int_marker = data[pos + 1].ord
34
+ int_high = int_marker >> 4
35
+ raise "Invalid count marker" unless int_high == 0x1
36
+
37
+ byte_count = 1 << (int_marker & 0x0F)
38
+ count = read_int(data, pos + 2, byte_count)
39
+ [count, pos + 2 + byte_count]
40
+ end
41
+
42
+ def self.parse(data)
43
+ data = data.dup.force_encoding("BINARY")
44
+ raise "Invalid header" unless data.start_with?("bplist00")
45
+
46
+ # Parse trailer (last 32 bytes)
47
+ trailer_start = data.bytesize - 32
48
+ offset_int_size = data[trailer_start + 6].ord
49
+ objref_size = data[trailer_start + 7].ord
50
+ num_objects = data.unpack1("Q>", offset: trailer_start + 8)
51
+ root_object_index = data.unpack1("Q>", offset: trailer_start + 16)
52
+ offset_table_pos = data.unpack1("Q>", offset: trailer_start + 24)
53
+
54
+ raise "Invalid trailer" if offset_int_size < 1 || objref_size < 1
55
+ raise "Invalid object count" if num_objects < 1 || root_object_index >= num_objects
56
+
57
+ # Read offset table
58
+ offsets = Array.new(num_objects) do |i|
59
+ pos = offset_table_pos + i * offset_int_size
60
+ read_int(data, pos, offset_int_size)
61
+ end
62
+
63
+ # Parse objects recursively
64
+ objects = Array.new(num_objects)
65
+ object_cache = {}
66
+
67
+ parse_object = lambda do |index|
68
+ raise "Invalid object ref: #{index}" if index >= num_objects
69
+ return objects[index] if objects[index]
70
+
71
+ # Check cache first
72
+ offset = offsets[index]
73
+ return object_cache[offset] if object_cache.has_key?(offset)
74
+
75
+ # Set placeholder to detect circular refs
76
+ objects[index] = :parsing
77
+
78
+ pos = offsets[index]
79
+ raise "Position #{pos} beyond data size #{data.bytesize}" if pos >= data.bytesize
80
+ marker = data[pos].ord
81
+ high = marker >> 4
82
+ low = marker & 0x0F
83
+
84
+ result = case high
85
+ when 0x0 # Null, Bool, Fill
86
+ case marker
87
+ when 0x00 then nil
88
+ when 0x08 then false
89
+ when 0x09 then true
90
+ else raise "Unknown null type: 0x#{marker.to_s(16)}"
91
+ end
92
+
93
+ when 0x1 # Integer
94
+ byte_count = 1 << low
95
+ raise "Invalid int size" if byte_count > 16
96
+ raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
97
+
98
+ if byte_count == 16
99
+ # 128-bit integer - read as two 64-bit values (high, low)
100
+ high = data[pos + 1, 8].unpack1("Q>")
101
+ low = data[pos + 9, 8].unpack1("Q>")
102
+ # Convert to signed if high MSB is set
103
+ if high >= (1 << 63)
104
+ high = high - (1 << 64)
105
+ end
106
+ # Ruby handles big integers automatically
107
+ (high << 64) | low
108
+ else
109
+ value = read_int(data, pos + 1, byte_count)
110
+ # Per Apple spec: only 8+ byte integers are signed, 1/2/4 byte are unsigned
111
+ if byte_count >= 8 && value >= (1 << (byte_count * 8 - 1))
112
+ value - (1 << (byte_count * 8))
113
+ else
114
+ value
115
+ end
116
+ end
117
+
118
+ when 0x2 # Real
119
+ byte_count = 1 << low
120
+ raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
121
+ case byte_count
122
+ when 4 then data[pos + 1, 4].unpack1("g")
123
+ when 8 then data[pos + 1, 8].unpack1("G")
124
+ else raise "Invalid real size: #{byte_count}"
125
+ end
126
+
127
+ when 0x3 # Date
128
+ raise "Invalid date marker" unless marker == 0x33
129
+ raise "Position #{pos + 1} + 8 beyond data size" if pos + 1 + 8 > data.bytesize
130
+ seconds = data[pos + 1, 8].unpack1("G")
131
+ Time.at(APPLE_EPOCH + seconds)
132
+
133
+ when 0x4 # Data
134
+ count, start = get_count(data, pos, low)
135
+ raise "Position #{start} + #{count} beyond data size" if start + count > data.bytesize
136
+ data[start, count]
137
+
138
+ when 0x5 # ASCII string
139
+ count, start = get_count(data, pos, low)
140
+ raise "Position #{start} + #{count} beyond data size" if start + count > data.bytesize
141
+ ascii_data = data[start, count]
142
+ # Validate ASCII - check for non-ASCII bytes
143
+ ascii_data.force_encoding("US-ASCII")
144
+ if ascii_data.valid_encoding?
145
+ ascii_data.encode!("UTF-8")
146
+ else
147
+ # Invalid ASCII, keep as binary for later Base64 encoding
148
+ ascii_data.force_encoding("BINARY")
149
+ end
150
+
151
+ when 0x6 # UTF-16 string
152
+ count, start = get_count(data, pos, low)
153
+ raise "Position #{start} + #{count * 2} beyond data size" if start + count * 2 > data.bytesize
154
+ utf16_data = data[start, count * 2]
155
+ # Convert UTF-16BE to UTF-8
156
+ begin
157
+ utf16_data.force_encoding("UTF-16BE").encode!("UTF-8")
158
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
159
+ # Invalid UTF-16, keep as binary for later Base64 encoding
160
+ utf16_data.force_encoding("BINARY")
161
+ end
162
+
163
+ when 0x8 # UID
164
+ byte_count = low + 1
165
+ raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
166
+ { "CF$UID" => read_int(data, pos + 1, byte_count)}
167
+
168
+ when 0xA # Array
169
+ count, start = get_count(data, pos, low)
170
+ raise "Position #{start} + #{count * objref_size} beyond data size" if start + count * objref_size > data.bytesize
171
+ Array.new(count) { |i| parse_object.call(read_int(data, start + i * objref_size, objref_size)) }
172
+
173
+ when 0xC # Set
174
+ count, start = get_count(data, pos, low)
175
+ raise "Position #{start} + #{count * objref_size} beyond data size" if start + count * objref_size > data.bytesize
176
+ Set.new(Array.new(count) { |i| parse_object.call(read_int(data, start + i * objref_size, objref_size)) })
177
+
178
+ when 0xD # Dict
179
+ count, start = get_count(data, pos, low)
180
+ raise "Position #{start} + #{count * objref_size * 2} beyond data size" if start + count * objref_size * 2 > data.bytesize
181
+ Array.new(count) { |i|
182
+ [ parse_object.call(read_int(data, start + i * objref_size, objref_size)),
183
+ parse_object.call(read_int(data, start + (count + i) * objref_size, objref_size))]
184
+ }.to_h
185
+
186
+ else
187
+ raise "Unknown marker: 0x#{marker.to_s(16)}"
188
+ end
189
+
190
+ objects[index] = result
191
+ # Cache the parsed object by its offset for reuse
192
+ object_cache[offset] = result
193
+ end
194
+
195
+ parse_object.call(root_object_index)
196
+ end
197
+
198
+ end
199
+
200
+
201
+ __END__
202
+
203
+ Binary plist format specification (based on Apple CFBinaryPList.c):
204
+
205
+ HEADER
206
+ magic number ("bplist")
207
+ file format version (currently "0?")
208
+
209
+ OBJECT TABLE
210
+ variable-sized objects
211
+
212
+ Object Formats (marker byte followed by additional info in some cases)
213
+ null 0000 0000 // null object [v"1?"+ only]
214
+ bool 0000 1000 // false
215
+ bool 0000 1001 // true
216
+ url 0000 1100 string // URL with no base URL, recursive encoding of URL string [v"1?"+ only]
217
+ url 0000 1101 base string // URL with base URL, recursive encoding of base URL, then recursive encoding of URL string [v"1?"+ only]
218
+ uuid 0000 1110 // 16-byte UUID [v"1?"+ only]
219
+ fill 0000 1111 // fill byte
220
+ int 0001 0nnn ... // # of bytes is 2^nnn, big-endian bytes
221
+ real 0010 0nnn ... // # of bytes is 2^nnn, big-endian bytes
222
+ date 0011 0011 ... // 8 byte float follows, big-endian bytes
223
+ data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
224
+ string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
225
+ string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
226
+ string 0111 nnnn [int] ... // UTF8 string, nnnn is # of chars, else 1111 then int count, then bytes [v"1?"+ only]
227
+ uid 1000 nnnn ... // nnnn+1 is # of bytes
228
+ 1001 xxxx // unused
229
+ array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
230
+ ordset 1011 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows [v"1?"+ only]
231
+ set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows [v"1?"+ only]
232
+ dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
233
+ 1110 xxxx // unused
234
+ 1111 xxxx // unused
235
+
236
+ OFFSET TABLE
237
+ list of ints, byte size of which is given in trailer
238
+ -- these are the byte offsets into the file
239
+ -- number of these is in the trailer
240
+
241
+ TRAILER
242
+ byte size of offset ints in offset table
243
+ byte size of object refs in arrays and dicts
244
+ number of offsets in offset table (also is number of objects)
245
+ element # in offset table which is top level object
246
+ offset table offset
247
+
248
+ **Integer Signedness (per Apple spec):**
249
+ - 1, 2, 4-byte integers: Always unsigned
250
+ - 8+ byte integers: Signed when MSB is set (two's complement)
251
+ - 16-byte integers: 128-bit signed (high 64-bit + low 64-bit)
252
+
253
+ **Encoding:**
254
+ - Big-endian byte order throughout
255
+ - Length in low nibble (0-14 direct, 0xF = follow-on integer)
256
+ - Object references are indices into offset table
257
+ - Strings: ASCII (0x5X), UTF-16BE (0x6X), UTF-8 (0x7X, v1+ only)