1
Fork 0
This repository has been archived on 2025-04-12. You can view files and clone it, but cannot push or open issues or pull requests.
silica-viewer/SilicaViewer/Document.swift

717 lines
29 KiB
Swift

import Cocoa
import ZIPFoundation
import CoreFoundation
import Accelerate
import CoreMedia
func objectRefGetValue2(_ objectRef: CFTypeRef) -> UInt32 {
let val = unsafeBitCast(objectRef, to: UInt64.self)
return valueForKeyedArchiverUID(val)
}
/// NSDocument for reading Silica Document files
class Document: NSDocument {
// TODO: don't keep the entire file in memory, especially since we also store the image data raw
var data: Data?
var dict: NSDictionary?
var info = SilicaDocument()
struct SilicaParsingError: Error, LocalizedError {
enum Kind {
case invalid
}
let kind: Kind
let filename: URL?
public var errorDescription: String? {
switch self.kind {
case .invalid:
return filename!.lastPathComponent + " is an invalid Silica Document."
}
}
}
func throwError(_ error: SilicaParsingError.Kind) {
DispatchQueue.main.sync {
let _ = presentError(SilicaParsingError(kind: error, filename: fileURL))
}
}
override init() {
super.init()
}
override func makeWindowControllers() {
let storyboard = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: nil)
let windowController = storyboard.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier("Document Window Controller")) as! NSWindowController
self.addWindowController(windowController)
}
override class func canConcurrentlyReadDocuments(ofType: String) -> Bool {
return ofType == "com.procreate"
}
/// Figures out the best filename for the document, depending on what information we know.
/// It prefers the actual name defined in the document, unless it's missing - where we'll fall back to the filename.
/// - Returns: The best filename.
func getIdealFilename() -> String {
if (!(info.name.isEmpty)) {
return info.name
} else {
return fileURL!.deletingPathExtension().lastPathComponent
}
}
/// Pass in an object from the `$object` array, which always contains a `$class` key.
/// - Parameter dict: The NSDictionary to parse.
/// - Returns: The class name.
func getDocumentClassName(dict: NSDictionary) -> String? {
let objectsArray = self.dict?["$objects"] as! NSArray
if let value = dict["$class"] {
let classObjectId = objectRefGetValue2(value as CFTypeRef)
let classObject = objectsArray[Int(classObjectId)] as! NSDictionary
return classObject["$classname"] as? String
}
return nil
}
/// Calculates the correct tile size, taking into account the remainder between tile size and image size.
/// - Parameters:
/// - x: The X position of the tile.
/// - y: The Y position of the tile.
/// - Returns: A tuple containing the correct tile size.
func getTileSize(_ x: Int, _ y: Int) -> (Int, Int) {
var width: Int = info.tileSize
var height: Int = info.tileSize
if((x + 1) == info.columns) {
width = info.tileSize - info.remainderWidth
}
if(y == info.rows) {
height = info.tileSize - info.remainderHeight
}
return (width, height)
}
/// Converts a `CFKeyedArchiveUID` from a `NSKeyedArchive` to a Int for indexing an array or `NSDictionary`.
/// - Parameter id: The `CFKeyedArchiveUID` to parse.
/// - Returns: An integer representation fo the `CFKeyedArchiveUID`.
func getClassID(id: Any?) -> Int {
return Int(objectRefGetValue2(id! as CFTypeRef))
}
/// Parses a chunk filename to integer coordinates.
/// "1~1.chunk" -> (1, 2).
/// - Parameter filename: The chunk filename.
/// - Returns: A tuple containing the parsed coordinates.
func parseChunkFilename(_ filename: String) -> (Int, Int)? {
let pathURL = URL(fileURLWithPath: filename)
let pathComponents = pathURL.lastPathComponent.replacingOccurrences(of: ".chunk", with: "").components(separatedBy: "~")
if let x = Int(pathComponents[0]), let y = Int(pathComponents[1]) {
return (x, y + 1)
} else {
return nil
}
}
/// Calculates a `NSRect` for a `SilicaChunk`.
/// - Parameter chunk: The `SilicaChunk` to return a `NSRect` for.
/// - Returns: A `NSRect` containg the rectangle for the chunk.
func getChunkRect(_ chunk: SilicaChunk) -> NSRect {
let x = chunk.x
var y = chunk.y
let (width, height) = getTileSize(x, y)
if y == info.rows {
y = 0
}
return NSRect(x: info.tileSize * x, y: info.height - (info.tileSize * y), width: width, height: height)
}
/// Parses a raw integer blend mode from the Silica Document, into a `BlendMode` enumeration.
/// - Parameters:
/// - blendMode: The integer value for the `blendMode` property.
/// - extendedBlend: The integer value for the `extendedBlend` property.
/// - Returns: If the provided integer representations are correct, a `BlendMode` enumeration.
func parseRawBlendMode(blendMode: Int, extendedBlend: Int) -> BlendMode? {
if blendMode == 0 && blendMode != extendedBlend {
return BlendMode(rawValue: extendedBlend)
} else {
return BlendMode(rawValue: blendMode)
}
}
/// Figures out the correct `CIBlendKernel` corresponding to the `SilicaLayer`'s blending mode.
/// - Parameter layer: The `SilicaLayer` to fetch the blend kernel for.
/// - Returns: If a blend kernel is found, returns a `CIBlendKernel`.
func getBlendKernel(_ layer: SilicaLayer) -> CIBlendKernel? {
switch(layer.data.blendMode) {
case .Normal:
return .sourceOver
case .Multiply:
return .multiply
case .Screen:
return .screen
case .Add:
return .componentAdd
case .Lighten:
return .lighten
case .Exclusion:
return .exclusion
case .Difference:
return .difference
case .Subtract:
return .subtract
case .LinearBurn:
return .linearBurn
case .ColorDodge:
return .colorDodge
case .ColorBurn:
return .colorBurn
case .Overlay:
return .overlay
case .HardLight:
return .hardLight
case .Color:
return .color
case .Luminosity:
return .luminosity
case .Hue:
return .hue
case .Saturation:
return .saturation
case .SoftLight:
return .softLight
case .Darken:
return .darken
case .HardMix:
return .hardMix
case .VividLight:
return .vividLight
case .LinearLight:
return .linearLight
case .PinLight:
return .pinLight
case .LighterColor:
return .lighterColor
case .DarkerColor:
return .darkerColor
case .Divide:
return .divide
}
}
func parseSilicaLayer(archive: Archive, dict: NSDictionary, isMask: Bool) -> SilicaLayer? {
let objectsArray = self.dict?["$objects"] as! NSArray
if getDocumentClassName(dict: dict) == LayerClassName {
var layer = SilicaLayer()
if let val = dict["name"] {
let NameClassID = getClassID(id: val)
let NameClass = objectsArray[NameClassID] as! NSString
layer.name = NameClass as String
}
let UUIDKey = dict["UUID"]
let UUIDClassID = getClassID(id: UUIDKey)
let UUIDClass = objectsArray[UUIDClassID] as! NSString
let maskKey = dict["mask"]
let maskClassID = getClassID(id: maskKey)
let maskClass = objectsArray[maskClassID]
layer.data.blendMode = parseRawBlendMode(blendMode: (dict["blend"] as? NSNumber)!.intValue, extendedBlend: (dict["extendedBlend"] as? NSNumber)!.intValue)!
layer.data.opacity = (dict["opacity"] as? NSNumber)!.doubleValue
layer.data.hidden = (dict["hidden"] as? Bool)!
layer.clipped = (dict["clipped"] as? Bool)!
if maskClassID != 0 {
layer.mask = parseSilicaLayer(archive: archive, dict: maskClass as! NSDictionary, isMask: true)?.data
}
var chunkPaths: [String] = []
archive.forEach { (entry: Entry) in
if entry.path.contains(String(UUIDClass)) {
chunkPaths.append(entry.path)
}
}
layer.data.chunks = Array(repeating: SilicaChunk(), count: chunkPaths.count)
let dispatchGroup = DispatchGroup()
let queue = DispatchQueue(label: "imageWork")
DispatchQueue.concurrentPerform(iterations: chunkPaths.count) { (i: Int) in
guard let threadArchive = Archive(data: self.data!, accessMode: .read) else {
return
}
let threadEntry = threadArchive[chunkPaths[i]]
guard let (x, y) = parseChunkFilename(threadEntry!.path) else {
return
}
let (width, height) = getTileSize(x, y)
let numChannels = isMask ? 1 : 4
let byteSize = width * height * numChannels
let uncompressedMemory = UnsafeMutablePointer<UInt8>.allocate(capacity: byteSize)
guard let lzoData = readData(archive: threadArchive, entry: threadEntry!) else {
return
}
lzoData.withUnsafeBytes({ (bytes: UnsafeRawBufferPointer) -> Void in
var len = lzo_uint(byteSize)
lzo1x_decompress_safe(bytes.baseAddress!.assumingMemoryBound(to: uint8.self), lzo_uint(lzoData.count), uncompressedMemory, &len, nil)
})
let imageData = Data(bytes: uncompressedMemory, count: byteSize)
let render: CGColorRenderingIntent = .defaultIntent
let rgbColorSpace = isMask ? CGColorSpaceCreateDeviceGray() : info.colorSpace
let bitmapInfo = CGBitmapInfo(rawValue: (isMask ? CGImageAlphaInfo.none : CGImageAlphaInfo.premultipliedLast).rawValue).union(.byteOrder32Big)
let providerRef: CGDataProvider? = CGDataProvider(data: imageData as CFData)
guard let cgimage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 8 * numChannels, bytesPerRow: width * numChannels, space: rgbColorSpace, bitmapInfo: bitmapInfo, provider: providerRef!, decode: nil, shouldInterpolate: false, intent: render) else {
return
}
queue.async(group: dispatchGroup) {
layer.data.chunks[i].image = cgimage
layer.data.chunks[i].x = x
layer.data.chunks[i].y = y
}
}
dispatchGroup.wait()
return layer
}
return nil
}
/// Parses a pair string into a proper typed tuple.
/// For example, {0, 0} -> (0, 0).
/// - Parameter str: The pair string.
/// - Returns: If it can be parsed correctly, a tuple containing the integer values found in the string.
func parsePairString(_ str: String) -> (Int, Int)? {
let sizeComponents = str.replacingOccurrences(of: "{", with: "").replacingOccurrences(of: "}", with: "").components(separatedBy: ", ")
if sizeComponents.count == 2 {
let width = Int(sizeComponents[0])
let height = Int(sizeComponents[1])
return (width!, height!)
} else {
return nil
}
}
func parseSilicaDocument(archive: Archive, dict: NSDictionary) {
let objectsArray = self.dict?["$objects"] as! NSArray
if getDocumentClassName(dict: dict) == DocumentClassName {
info.trackedTime = (dict[TrackedTimeKey] as! NSNumber).intValue
info.tileSize = (dict[TileSizeKey] as! NSNumber).intValue
info.orientation = (dict[OrientationKey] as! NSNumber).intValue
info.flippedHorizontally = (dict[FlippedHorizontallyKey] as! NSNumber).boolValue
info.flippedVertically = (dict[FlippedVerticallyKey] as! NSNumber).boolValue
let videoResolutionClassKey = dict["SilicaDocumentVideoSegmentInfoKey"]
let videoResolutionClassID = getClassID(id: videoResolutionClassKey)
let videoResolution = objectsArray[videoResolutionClassID] as! NSDictionary
let frameSizeClassKey = videoResolution["frameSize"]
let frameSizeClassID = getClassID(id: frameSizeClassKey)
let frameSize = objectsArray[frameSizeClassID] as! String
info.videoFrame = parsePairString(frameSize)!
let colorProfileClassKey = dict["colorProfile"]
if colorProfileClassKey != nil {
let colorProfileClassID = getClassID(id: colorProfileClassKey)
let colorProfile = objectsArray[colorProfileClassID] as! NSDictionary
let colorProfileNameClassKey = colorProfile["SiColorProfileArchiveICCNameKey"]
let colorProfileNameClassID = getClassID(id: colorProfileNameClassKey)
let colorProfileName = objectsArray[colorProfileNameClassID] as! NSString
// we only support the basic "Display P3" color space... does Procreate actually store the ICC data??
if colorProfileName == "Display P3" {
info.colorSpace = CGColorSpace(name: CGColorSpace.displayP3)!
}
} else {
info.colorSpace = CGColorSpace(name: CGColorSpace.displayP3)!
}
let backgroundClassKey = dict["backgroundColor"]
let backgroundClassID = getClassID(id: backgroundClassKey)
let background = objectsArray[backgroundClassID] as! NSData
var backgroundArray: [Float] = [0.0, 0.0, 0.0, 0.0]
background.getBytes(&backgroundArray, length: 16)
let backgroundCgArray: [CGFloat] = [CGFloat(backgroundArray[0]), CGFloat(backgroundArray[1]), CGFloat(backgroundArray[2]), CGFloat(backgroundArray[3])]
info.backgroundColor = CGColor(colorSpace: info.colorSpace, components: backgroundCgArray)!
let strokeClassKey = dict[StrokeCountKey]
let strokeClassID = getClassID(id: strokeClassKey)
let strokeCount = objectsArray[strokeClassID] as! NSNumber
info.strokeCount = Int(truncating: strokeCount)
let nameClassKey = dict[NameKey]
let nameClassID = getClassID(id: nameClassKey)
let nameString = objectsArray[nameClassID] as! NSString
if nameString != "$null" {
info.name = nameString as String
}
let authorClassKey = dict[AuthorNameKey]
if authorClassKey != nil {
let authorClassID = getClassID(id: authorClassKey)
let authorString = objectsArray[authorClassID] as! String
if authorString != "$null" {
info.authorName = authorString
}
}
let sizeClassKey = dict[SizeKey]
let sizeClassID = getClassID(id: sizeClassKey)
let sizeString = objectsArray[sizeClassID] as! String
let (width, height) = parsePairString(sizeString)!
info.width = width
info.height = height
info.columns = Int(ceil(Float(info.width) / Float(info.tileSize)))
info.rows = Int(ceil(Float(info.height) / Float(info.tileSize))) + 1 // TODO: lol why
if info.width % info.tileSize != 0 {
info.remainderWidth = (info.columns * info.tileSize) - info.width
}
if info.height % info.tileSize != 0 {
info.remainderHeight = (info.rows * info.tileSize) - info.height
}
let layersClassKey = dict[LayersKey]
let layersClassID = getClassID(id: layersClassKey)
let layersClass = objectsArray[layersClassID] as! NSDictionary
let array = layersClass["NS.objects"] as! NSArray
for object in array {
let layerClassID = getClassID(id: object)
let layerClass = objectsArray[layerClassID] as! NSDictionary
guard let layer = parseSilicaLayer(archive: archive, dict: layerClass, isMask: false) else { return }
info.layers.append(layer)
}
}
}
func parseDocument(archive: Archive, dict: NSDictionary) {
// double check if this archive is really correct
if let value = dict["$version"] {
if (value as! Int) != NSKeyedArchiveVersion {
throwError(.invalid)
return
}
self.dict = dict
let objectsArray = dict["$objects"] as! NSArray
// let's read the $top class, which is always going to be SilicaDocument type.
let topObject = dict["$top"] as! NSDictionary
let topClassID = objectRefGetValue2(topObject["root"] as CFTypeRef)
let topObjectClass = objectsArray[Int(topClassID)] as! NSDictionary
parseSilicaDocument(archive: archive, dict: topObjectClass)
}
}
override func read(from data: Data, ofType typeName: String) throws {
self.data = data
guard let archive = Archive(data: data, accessMode: Archive.AccessMode.read) else {
throwError(.invalid)
return
}
guard let documentEntry = archive[DocumentArchivePath] else {
throwError(.invalid)
return
}
guard let documentData = readData(archive: archive, entry: documentEntry) else {
throwError(.invalid)
return
}
var plistFormat = PropertyListSerialization.PropertyListFormat.binary
guard let propertyList = try? PropertyListSerialization.propertyList(from: documentData, options: [], format: &plistFormat) else {
throwError(.invalid)
return
}
parseDocument(archive: archive, dict: propertyList as! NSDictionary)
}
func makeBlendImage(_ layer: SilicaLayer) -> CGImage {
var maskContext: CGContext?
if layer.mask != nil {
let grayColorSpace = CGColorSpaceCreateDeviceGray()
let maskBitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue).union(.byteOrder16Big)
maskContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 16, bytesPerRow: 0, space: grayColorSpace, bitmapInfo: maskBitmapInfo.rawValue)
maskContext?.setFillColor(.white)
maskContext?.fill(info.cgRect)
for chunk in layer.mask!.chunks {
maskContext?.draw(chunk.image!, in: getChunkRect(chunk))
}
}
return (maskContext?.makeImage())!
}
/// Draws the layer data into a CGImage, without taking into account the opacity or the blending of the layer.
/// - Parameter layer: The layer data to draw.
/// - Returns: If no errors occured during drawing, a `CGImage` of the layer data.
func simpleDrawLayer(_ layer : SilicaLayerData) -> CGImage? {
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big)
// start by creating a new layer composite image, needed for image masking
let layerContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 8, bytesPerRow: info.width * 4, space: info.colorSpace, bitmapInfo: bitmapInfo.rawValue)
layerContext?.clear(info.cgRect)
for chunk in layer.chunks {
layerContext?.setAlpha(1.0)
layerContext?.setBlendMode(.normal)
if !layer.hidden {
layerContext?.draw(chunk.image!, in: getChunkRect(chunk))
}
}
return layerContext?.makeImage()
}
/// Draws the layer into a CIImage, taking into account the layer opacity, blending and previous image - if any.
/// - Parameters:
/// - layer: The `SilicaLayer` to draw.
/// - previousImage: Previous `CGImage` to draw on top of, can be null.
/// - Returns: A `CIImage` of the new image.
func blendLayer(_ layer : SilicaLayer, previousImage : inout CGImage?) -> CIImage {
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big)
// start by creating a new layer composite image, needed for image masking
let layerContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 8, bytesPerRow: info.width * 4, space: info.colorSpace, bitmapInfo: bitmapInfo.rawValue)
layerContext?.clear(info.cgRect)
let kernel = getBlendKernel(layer)
for chunk in layer.data.chunks {
layerContext?.setAlpha(CGFloat(layer.data.opacity))
layerContext?.setBlendMode(.normal)
if !layer.data.hidden {
layerContext?.draw(chunk.image!, in: getChunkRect(chunk))
}
}
let layerImage = layerContext?.makeImage()
// apply image
return kernel!.apply(foreground: CIImage(cgImage: layerImage!), background: previousImage == nil ? CIImage(color: .clear) : CIImage(cgImage: previousImage!), colorSpace: info.colorSpace)!
}
/// Calculates all of the layers that are clipping onto this one
/// - Parameter layer: The layer to figure out the clipping layers of.
/// - Returns: An array of clipping layers.
func getAllClippingLayers(layer: SilicaLayer) -> [SilicaLayer] {
var clippingLayers : [SilicaLayer] = []
let layers : [SilicaLayer] = info.layers.reversed()
let index = layers.firstIndex(of: layer)! + 1
if index >= layers.count {
return clippingLayers
}
for layerIndex in index...layers.count - 1 {
//Swift.print("checking " + layers[layerIndex].name + " is clipping = " + layers[layerIndex].clipped.description)
if(layers[layerIndex].clipped) {
clippingLayers.append(layers[layerIndex])
} else {
break
}
}
return clippingLayers
}
/// Renders a full, composite image of the Silica Document - emulating the Procreate drawing engine.
/// - Returns: If the image suceeds in drawing, then a `NSImage` of the canvas.
func makeComposite() -> NSImage? {
// create the final composite output image
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big)
let ccgContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 8, bytesPerRow: info.width * 4, space: info.colorSpace, bitmapInfo: bitmapInfo.rawValue)
ccgContext?.setFillColor(info.backgroundColor)
ccgContext?.fill(info.cgRect)
let context = CIContext()
guard let cgImage = ccgContext?.makeImage() else {
return nil
}
var masterImage = CIImage(cgImage: cgImage)
var previousImage: CGImage? = nil
for layer in info.layers.reversed() {
if !layer.data.hidden && !layer.clipped {
// start by creating a new layer composite image, needed for image masking
let layerContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 8, bytesPerRow: info.width * 4, space: info.colorSpace, bitmapInfo: bitmapInfo.rawValue)
layerContext?.clear(info.cgRect)
var maskContext: CGContext?
let kernel = getBlendKernel(layer)
if layer.mask != nil {
let grayColorSpace = CGColorSpaceCreateDeviceGray()
let maskBitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue).union(.byteOrder16Big)
maskContext = CGContext(data: nil, width: info.width, height: info.height, bitsPerComponent: 16, bytesPerRow: 0, space: grayColorSpace, bitmapInfo: maskBitmapInfo.rawValue)
maskContext?.setFillColor(.white)
maskContext?.fill(info.cgRect)
for chunk in layer.mask!.chunks {
maskContext?.draw(chunk.image!, in: getChunkRect(chunk))
}
}
for chunk in layer.data.chunks {
layerContext?.setAlpha(CGFloat(layer.data.opacity))
layerContext?.setBlendMode(.normal)
if !layer.data.hidden {
layerContext?.draw(chunk.image!, in: getChunkRect(chunk))
}
}
let clippingLayers = getAllClippingLayers(layer: layer)
if !clippingLayers.isEmpty {
let layerImage = layerContext?.makeImage()
var clippedMaster: CGImage? = layerImage
for layer in clippingLayers {
// so we if we want to clip, we want to gather all of the clipping layers in order first...
let temporaryClippedMaster = blendLayer(layer, previousImage: &clippedMaster)
clippedMaster = context.createCGImage(temporaryClippedMaster, from: info.cgRect, format: .RGBA8, colorSpace: info.colorSpace)
}
layerContext?.setAlpha(1.0)
layerContext?.setBlendMode(.sourceAtop)
layerContext?.draw(clippedMaster!, in: info.cgRect)
}
let layerImage = layerContext?.makeImage()
if layer.mask != nil && maskContext != nil {
let maskImage = (maskContext?.makeImage())!
let newImage = layerImage!.masking(maskImage)!
previousImage = newImage
} else {
previousImage = layerImage
}
// apply image
masterImage = kernel!.apply(foreground: CIImage(cgImage: previousImage!), background: masterImage, colorSpace: info.colorSpace)!
}
}
guard let finalCgImage = context.createCGImage(masterImage, from: info.cgRect, format: .RGBA8, colorSpace: info.colorSpace) else {
return nil
}
var image = NSImage(cgImage: finalCgImage, size: info.nsSize)
if info.orientation == 3 {
image = image.imageRotatedByDegreess(degrees: 90)
} else if info.orientation == 4 {
image = image.imageRotatedByDegreess(degrees: -90)
}
if info.flippedHorizontally && (info.orientation == 1 || info.orientation == 2) {
image = image.flipHorizontally()
} else if info.flippedHorizontally && (info.orientation == 3 || info.orientation == 4) {
image = image.flipVertically()
} else if info.flippedVertically && (info.orientation == 1 || info.orientation == 2) {
image = image.flipVertically()
} else if !info.flippedVertically && (info.orientation == 3 || info.orientation == 4) {
image = image.flipHorizontally()
}
return image
}
/// Extracts the thumbnail image generated by Procreate itself.
/// - Returns: If it's able to find the thumbnail, an `NSImage`.
func makeThumbnail() -> NSImage? {
guard let archive = Archive(data: data!, accessMode: Archive.AccessMode.read) else {
return nil
}
guard let entry = archive[ThumbnailPath] else {
return nil
}
guard let thumbnailData = readData(archive: archive, entry: entry) else {
return nil
}
return NSImage(data: thumbnailData)
}
}