Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ACS/Private Headers/CoreUI.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions ACS/Source/AssetCatalogReader.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
136 changes: 130 additions & 6 deletions ACS/Source/AssetCatalogReader.m
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -395,16 +411,124 @@ - (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)]
};

Comment on lines +482 to +493

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this is a bit out of sync with what goes on the pasteboard. (I used claude 4.5 for this and it went a bit haywire).
How do we think colors should be saved?

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"
};
}
}

- (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
Expand Down
4 changes: 2 additions & 2 deletions ACS/Source/ImageExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
103 changes: 103 additions & 0 deletions Asset Catalog Tinkerer/ImagesCollectionViewDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }

Expand Down
4 changes: 2 additions & 2 deletions Asset Catalog Tinkerer/ImagesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ImagesViewController: NSViewController, NSMenuItemValidation {

view.layer?.backgroundColor = NSColor.white.cgColor
buildUI()
showStatus("Extracting Images...")
showStatus("Extracting Assets...")
}

// MARK: - UI
Expand Down Expand Up @@ -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()
}
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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

Expand Down