diff --git a/ACS/Private Headers/CoreUI.h b/ACS/Private Headers/CoreUI.h index d360167..60a49c3 100755 --- a/ACS/Private Headers/CoreUI.h +++ b/ACS/Private Headers/CoreUI.h @@ -88,6 +88,7 @@ struct _renditionkeytoken { @property (nonatomic, readonly) BOOL isVectorBased; @property (nonatomic, readonly) struct CGSVGDocument *svgDocument; @property (nonatomic, readonly) long long vectorGlyphRenderingMode; +@property (nonatomic, readonly) CGColorRef cgColor; @end diff --git a/ACS/Source/AssetCatalogReader.h b/ACS/Source/AssetCatalogReader.h index 189ef71..79e74ea 100644 --- a/ACS/Source/AssetCatalogReader.h +++ b/ACS/Source/AssetCatalogReader.h @@ -20,6 +20,10 @@ extern NSString *__nonnull const kACSFilenameKey; extern NSString *__nonnull const kACSContentsDataKey; /// An NSBitmapImageRep containing a bitmap representation of the asset extern NSString *__nonnull const kACSImageRepKey; +/// An NSString indicating the type of asset ("image" or "color") +extern NSString *__nonnull const kACSTypeKey; +/// An NSColor representing the color asset +extern NSString *__nonnull const kACSColorKey; @interface AssetCatalogReader : NSObject diff --git a/ACS/Source/AssetCatalogReader.m b/ACS/Source/AssetCatalogReader.m index 5b12f06..e9031ae 100644 --- a/ACS/Source/AssetCatalogReader.m +++ b/ACS/Source/AssetCatalogReader.m @@ -17,6 +17,8 @@ NSString * const kACSFilenameKey = @"filename"; NSString * const kACSContentsDataKey = @"data"; NSString * const kACSImageRepKey = @"imagerep"; +NSString * const kACSTypeKey = @"type"; +NSString * const kACSColorKey = @"color"; NSString * const kAssetCatalogReaderErrorDomain = @"br.com.guilhermerambo.AssetCatalogReader"; @@ -215,10 +217,10 @@ - (void)readWithCompletionHandler:(void (^__nonnull)(void))callback progressHand } } - // we've got no images for some reason (the console will usually contain some information from CoreUI as to why) + // we've got no assets for some reason (the console will usually contain some information from CoreUI as to why) if (!self.images.count) { dispatch_async(dispatch_get_main_queue(), ^{ - self.error = [NSError errorWithDomain:kAssetCatalogReaderErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: @"Failed to load images"}]; + self.error = [NSError errorWithDomain:kAssetCatalogReaderErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: @"Failed to load assets"}]; callback(); }); @@ -297,6 +299,19 @@ - (void)readThemeStoreWithCompletionHandler:(void (^__nonnull)(void))callback pr if (self.cancelled) return; + [self.mutableImages addObject:desc]; + } else if (rendition.cgColor) { + // This is a color rendition - cast to the color type + CGColorRef cgColor = rendition.cgColor; + NSColor *nsColor = [NSColor colorWithCGColor:cgColor]; + NSString *filename = [NSString stringWithFormat:@"%@.json", [self cleanupRenditionName:rendition.name]]; + + NSDictionary *desc = [self colorDescriptionWithName:rendition.name filename:filename color:nsColor]; + + if (!desc) { + return; + } + if (self.cancelled) return; [self.mutableImages addObject:desc]; } else { NSLog(@"The rendition %@ doesn't have an image, It is probably an effect or material.", rendition.name); @@ -340,7 +355,7 @@ - (NSImage *)constrainImage:(NSImage *)image toSize:(NSSize)size - (BOOL)isProThemeStoreAtPath:(NSString *)path { - #define proThemeTokenLength 18 + static const int proThemeTokenLength = 18; static const char proThemeToken[proThemeTokenLength] = { 0x50,0x72,0x6F,0x54,0x68,0x65,0x6D,0x65,0x44,0x65,0x66,0x69,0x6E,0x69,0x74,0x69,0x6F,0x6E }; @try { @@ -378,7 +393,8 @@ - (NSDictionary *)imageDescriptionWithName:(NSString *)name filename:(NSString * return @{ kACSNameKey : name, kACSFilenameKey: filename, - kACSImageRepKey: imageRep + kACSImageRepKey: imageRep, + kACSTypeKey: @"image" }; } else { NSData *pngData = contentsData(); @@ -395,7 +411,111 @@ - (NSDictionary *)imageDescriptionWithName:(NSString *)name filename:(NSString * kACSImageKey : originalImage, kACSThumbnailKey: thumbnail, kACSFilenameKey: filename, - kACSContentsDataKey: pngData + kACSContentsDataKey: pngData, + kACSTypeKey: @"image" + }; + } +} + +- (NSDictionary *)colorDescriptionWithName:(NSString *)name filename:(NSString *)filename color:(NSColor *)color +{ + // Convert color to RGB color space for consistent representation + NSColor *rgbColor = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (!rgbColor) { + rgbColor = color; + } + + // Create a visual representation of the color (a solid color bitmap) + NSSize colorSize = _resourceConstrained ? NSMakeSize(64, 64) : NSMakeSize(128, 128); + + // Create full-size bitmap directly to avoid graphics context issues + NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:colorSize.width + pixelsHigh:colorSize.height + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSCalibratedRGBColorSpace + bytesPerRow:0 + bitsPerPixel:0]; + + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmapRep]; + [NSGraphicsContext setCurrentContext:context]; + + [rgbColor setFill]; + NSRectFill(NSMakeRect(0, 0, colorSize.width, colorSize.height)); + + [NSGraphicsContext restoreGraphicsState]; + + // Create full-size image from bitmap + NSImage *colorImage = [[NSImage alloc] initWithSize:colorSize]; + [colorImage addRepresentation:bitmapRep]; + + // Create thumbnail bitmap directly at the right size (avoid using constrainImage which has drawing handlers) + NSBitmapImageRep *thumbnailRep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:self.thumbnailSize.width + pixelsHigh:self.thumbnailSize.height + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSCalibratedRGBColorSpace + bytesPerRow:0 + bitsPerPixel:0]; + + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *thumbContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:thumbnailRep]; + [NSGraphicsContext setCurrentContext:thumbContext]; + + [rgbColor setFill]; + NSRectFill(NSMakeRect(0, 0, self.thumbnailSize.width, self.thumbnailSize.height)); + + [NSGraphicsContext restoreGraphicsState]; + + NSImage *thumbnail = [[NSImage alloc] initWithSize:self.thumbnailSize]; + [thumbnail addRepresentation:thumbnailRep]; + + // Create JSON representation of the color + CGFloat red = 0, green = 0, blue = 0, alpha = 0; + [rgbColor getRed:&red green:&green blue:&blue alpha:&alpha]; + + NSDictionary *colorInfo = @{ + @"red": @(red), + @"green": @(green), + @"blue": @(blue), + @"alpha": @(alpha), + @"hex": [NSString stringWithFormat:@"#%02X%02X%02X", (int)(red * 255), (int)(green * 255), (int)(blue * 255)] + }; + + NSError *jsonError = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:colorInfo options:NSJSONWritingPrettyPrinted error:&jsonError]; + + if (jsonError) { + NSLog(@"Unable to create JSON data for color named %@: %@", name, jsonError); + return nil; + } + + if (_resourceConstrained) { + return @{ + kACSNameKey: name, + kACSFilenameKey: filename, + kACSColorKey: rgbColor, + kACSThumbnailKey: thumbnail, // Include thumbnail for pasteboard + kACSTypeKey: @"color" + }; + } else { + return @{ + kACSNameKey: name, + kACSImageKey: colorImage, + kACSThumbnailKey: thumbnail, + kACSFilenameKey: filename, + kACSContentsDataKey: jsonData, + kACSColorKey: rgbColor, + kACSTypeKey: @"color" }; } } @@ -403,8 +523,12 @@ - (NSDictionary *)imageDescriptionWithName:(NSString *)name filename:(NSString * - (NSString *)cleanupRenditionName:(NSString *)name { NSArray *components = [name.stringByDeletingPathExtension componentsSeparatedByString:@"@"]; + NSString *cleanedName = components.firstObject; + + // Replace slashes with underscores to avoid directory issues + cleanedName = [cleanedName stringByReplacingOccurrencesOfString:@"/" withString:@"_"]; - return components.firstObject; + return cleanedName; } - (NSString *)filenameForAssetNamed:(NSString *)name scale:(CGFloat)scale presentationState:(NSInteger)presentationState diff --git a/ACS/Source/ImageExporter.swift b/ACS/Source/ImageExporter.swift index 2d18285..d60d816 100644 --- a/ACS/Source/ImageExporter.swift +++ b/ACS/Source/ImageExporter.swift @@ -25,11 +25,11 @@ public struct ImageExporter { pathComponents.append(filename) - guard let pngData = image[kACSContentsDataKey] as? Data else { return } + guard let contentsData = image[kACSContentsDataKey] as? Data else { return } let path = self.nextAvailablePath(filePath: NSString.path(withComponents: pathComponents) as String) do { - try pngData.write(to: URL(fileURLWithPath: path), options: .atomic) + try contentsData.write(to: URL(fileURLWithPath: path), options: .atomic) } catch { NSLog("ERROR: Unable to write \(filename) to \(path); \(error)") } diff --git a/Asset Catalog Tinkerer/Assets.xcassets/AccentColor.colorset/Contents.json b/Asset Catalog Tinkerer/Assets.xcassets/AccentColor.colorset/Contents.json index 6112869..81359ca 100644 --- a/Asset Catalog Tinkerer/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Asset Catalog Tinkerer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -2,8 +2,13 @@ "colors" : [ { "color" : { - "platform" : "universal", - "reference" : "systemBlueColor" + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.281", + "green" : "0.322", + "red" : "0.904" + } }, "idiom" : "universal" } diff --git a/Asset Catalog Tinkerer/ImagesCollectionViewDataProvider.swift b/Asset Catalog Tinkerer/ImagesCollectionViewDataProvider.swift index a8a3830..021965d 100644 --- a/Asset Catalog Tinkerer/ImagesCollectionViewDataProvider.swift +++ b/Asset Catalog Tinkerer/ImagesCollectionViewDataProvider.swift @@ -14,6 +14,99 @@ extension NSUserInterfaceItemIdentifier { static let imageItemIdentifier = NSUserInterfaceItemIdentifier("ImageItemIdentifier") } +// Custom pasteboard writer for colors that provides multiple representations +class ColorPasteboardWriter: NSObject, NSPasteboardWriting { + let color: NSColor + let image: NSImage + let colorName: String + + init(color: NSColor, image: NSImage, name: String) { + self.color = color + self.image = image + self.colorName = name + super.init() + } + + func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { + return [.fileURL, .color, .string, .tiff] + } + + func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { + switch type { + case .fileURL: + // Write image to temp file for QuickLook + return temporaryFileURL()?.absoluteString + + case .color: + // Write the actual NSColor object + return try? NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false) + + case .string: + // Write text representation (hex for sRGB, CSS color() for P3) + return textRepresentation(for: color) + + case .tiff: + // Write image representation for inline paste + return image.tiffRepresentation + + default: + return nil + } + } + + private func temporaryFileURL() -> URL? { + // Create a safe filename + let safeFilename = colorName + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("\(safeFilename).png") + + // Write PNG to temp file + guard let tiffData = image.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData), + let pngData = bitmapRep.representation(using: .png, properties: [:]) else { + return nil + } + + try? pngData.write(to: tempURL, options: .atomic) + return tempURL + } + + private func textRepresentation(for color: NSColor) -> String { + // Try to convert to sRGB first + if let srgbColor = color.usingColorSpace(.sRGB) { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + srgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + // If it's in sRGB and not transparent, use hex + if alpha >= 0.999 { + return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) + } else { + return String(format: "#%02X%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255), Int(alpha * 255)) + } + } + + // Try Display P3 + if let p3Color = color.usingColorSpace(NSColorSpace.displayP3) { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + p3Color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + if alpha >= 0.999 { + return String(format: "color(display-p3 %.3f %.3f %.3f)", red, green, blue) + } else { + return String(format: "color(display-p3 %.3f %.3f %.3f / %.3f)", red, green, blue, alpha) + } + } + + // Fallback to generic RGB + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return String(format: "rgb(%.0f, %.0f, %.0f, %.2f)", red * 255, green * 255, blue * 255, alpha) + } +} + class ImagesCollectionViewDataProvider: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate { fileprivate struct Constants { @@ -111,7 +204,17 @@ class ImagesCollectionViewDataProvider: NSObject, NSCollectionViewDataSource, NS guard canPerformPasteboardOperation(at: indexPath) else { return nil } let image = filteredImages[indexPath.item] + + // Check if this is a color asset + if let assetType = image[kACSTypeKey] as? String, assetType == "color", + let color = image[kACSColorKey] as? NSColor, + let thumbnail = image[kACSThumbnailKey] as? NSImage, + let name = image[kACSNameKey] as? String { + // For colors, use custom pasteboard writer with multiple representations + return ColorPasteboardWriter(color: color, image: thumbnail, name: name) + } + // For images, write as file guard let filename = image[kACSFilenameKey] as? String else { return nil } guard let data = image[kACSContentsDataKey] as? Data else { return nil } diff --git a/Asset Catalog Tinkerer/ImagesViewController.swift b/Asset Catalog Tinkerer/ImagesViewController.swift index 6f68cc6..70c8eeb 100644 --- a/Asset Catalog Tinkerer/ImagesViewController.swift +++ b/Asset Catalog Tinkerer/ImagesViewController.swift @@ -48,7 +48,7 @@ class ImagesViewController: NSViewController, NSMenuItemValidation { view.layer?.backgroundColor = NSColor.white.cgColor buildUI() - showStatus("Extracting Images...") + showStatus("Extracting Assets...") } // MARK: - UI @@ -237,7 +237,7 @@ class ImagesViewController: NSViewController, NSMenuItemValidation { dataProvider.searchTerm = sender.stringValue if dataProvider.filteredImages.count == 0 { - showStatus("No images found for \"\(dataProvider.searchTerm)\"") + showStatus("No assets found for \"\(dataProvider.searchTerm)\"") } else { hideStatus() } diff --git a/README.md b/README.md index 0d87e7f..a44a7e4 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Asset Catalog Tinkerer -Asset Catalog Tinkerer lets you open asset catalog files (`.car`) and view the images that they contain. You can also copy and export individual images out or export all images from an asset catalog. +Asset Catalog Tinkerer lets you open asset catalog files (`.car`) and view the images and colors that they contain. You can also copy and export individual assets out or export all assets from an asset catalog. [⬇ Download and contribute with any amount on Gumroad](https://insidegui.gumroad.com/l/AssetCatalogTinkerer) @@ -10,9 +10,11 @@ You can also install it with [Homebrew](https://brew.sh) by running `brew instal ![screenshot](https://raw.github.com/insidegui/AssetCatalogTinkerer/master/screenshot.png) -### Unsupported Asset Types +### Supported Asset Types -Asset Catalog Tinkerer was designed with images in mind, so it doesn't support some of the more modern asset catalog features, such as colors. It may or may not be updated in the future to support those. +Asset Catalog Tinkerer supports: +- **Images**: PNG, SVG, and other image formats including @2x/@3x retina variants +- **Colors**: Named colors are displayed as color swatches and can be exported as JSON files containing RGB/hex values ### QuickLook PlugIn