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 } /// 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 } } /// 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) } } 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 (tileX, tileY) = parseChunkFilename(threadEntry!.path) else { return } let (tileWidth, tileHeight) = info.getTileSize(tileX, tileY) let numChannels = isMask ? 1 : 4 let byteSize = tileWidth * tileHeight * numChannels let uncompressedMemory = UnsafeMutablePointer.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: tileWidth, height: tileHeight, bitsPerComponent: 8, bitsPerPixel: 8 * numChannels, bytesPerRow: tileWidth * 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 = tileX layer.data.chunks[i].y = tileY } } 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 guard let (parsedWidth, parsedHeight) = parsePairString(sizeString) else { return } info.width = parsedWidth info.height = parsedHeight 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) } /// 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? { let engine = SilicaEngine() var image = NSImage(cgImage: engine.draw(document: info)!, 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) } }