diff --git a/.eslintrc.yml b/.eslintrc.yml index e9dfa10fd..82661d2ea 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -132,6 +132,14 @@ globals: MSExportFormat: false NSThread: false MSTheme: false + MSJSONDataArchiver: false + MSJSONDictionaryUnarchiver: false + MSArchiveHeader: false + NSUTF8StringEncoding: false + MSExportRequest: false + MSExportManager: false + NSFileManager: false + NSDataWritingWithoutOverwriting: false rules: ########### diff --git a/CHANGELOG.json b/CHANGELOG.json index 52db6043a..9db4db4bc 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -10,6 +10,7 @@ "[New] Add some methods to store a session variable", "[Improved] Allow using setting methods even from the Run Script panel", "[New] Add method to get the theme of Sketch", + "[New] `export` can now export to the JSON file format" "[New] Add `background` property on Artboard", "[New] Add `transform` property on Layer" ], diff --git a/Source/dom/__tests__/export.test.js b/Source/dom/__tests__/export.test.js new file mode 100644 index 000000000..5df6f0820 --- /dev/null +++ b/Source/dom/__tests__/export.test.js @@ -0,0 +1,71 @@ +/* globals expect, test */ + +import { exportObject, objectFromJSON } from '../export' +import { Shape } from '../layers/Shape' + +test('should return exported json data', () => { + const object = new Shape() + const archive = exportObject(object, { + formats: 'json', + output: false, + }) + expect(archive.do_objectID).toEqual(String(object.id)) + expect(archive._class).toEqual('shapeGroup') +}) + +test('should return array of exported json data', () => { + const objects = [new Shape(), new Shape()] + const archive = exportObject(objects, { + formats: 'json', + output: false, + }) + expect(archive.length).toBe(2) + expect(archive[0].do_objectID).toEqual(String(objects[0].id)) + expect(archive[1].do_objectID).toEqual(String(objects[1].id)) +}) + +test('should restore object from json data', () => { + const object = new Shape() + const archive = exportObject(object, { + formats: ['json'], + output: false, + }) + const restored = objectFromJSON(archive) + expect(restored.id).toEqual(String(object.id)) +}) + +test('Should fail with no object provided', () => { + try { + exportObject([], { + output: false, + }) + } catch (err) { + expect(err.message).toMatch('No objects provided to export') + } +}) + +test('Should fail to return image data', () => { + try { + const object = new Shape() + exportObject(object, { + formats: 'png', + output: false, + }) + } catch (err) { + expect(err.message).toMatch( + 'Return output is only support for the json format' + ) + } +}) + +test('should fail with to return with mulitple formats', () => { + try { + const object = new Shape() + exportObject(object, { + formats: ['png', 'json'], + output: false, + }) + } catch (err) { + expect(err.message).toMatch('Can only return 1 format with no output type') + } +}) diff --git a/Source/dom/export.js b/Source/dom/export.js index 4630a9ea6..94358635a 100644 --- a/Source/dom/export.js +++ b/Source/dom/export.js @@ -1,5 +1,5 @@ import { isWrappedObject } from './utils' -import { Types } from './enums' +import { wrapNativeObject } from './wrapNativeObject' export const DEFAULT_EXPORT_OPTIONS = { compact: false, @@ -14,6 +14,83 @@ export const DEFAULT_EXPORT_OPTIONS = { output: '~/Documents/Sketch Exports', } +function getJSONData(nativeObject) { + const archiver = MSJSONDataArchiver.new() + archiver.archiveObjectIDs = true + const aPtr = MOPointer.alloc().init() + const obj = nativeObject.immutableModelObject + ? nativeObject.immutableModelObject() + : nativeObject + archiver.archivedDataWithRootObject_error(obj, aPtr) + if (aPtr.value()) { + throw Error(`Couldn’t create the JSON string: ${aPtr.value()}`) + } + return archiver.archivedData() +} + +function getJSONString(nativeObject) { + const data = getJSONData(nativeObject) + return String( + NSString.alloc().initWithData_encoding(data, NSUTF8StringEncoding) + ) +} + +function exportToJSONFile(nativeObjects, options) { + const fm = NSFileManager.defaultManager() + const directory = NSString.stringWithString( + options.output + ).stringByExpandingTildeInPath() + const comps = String(directory).split('/') + fm.createDirectoryAtPath_withIntermediateDirectories_attributes_error( + directory, + true, + null, + null + ) + + nativeObjects.forEach(o => { + const name = + options['use-id-for-name'] === true || !o.name ? o.objectID() : o.name() + const pathComps = comps.slice() + pathComps.push(`${name}.json`) + const url = NSURL.fileURLWithPath(pathComps.join('/')) + const data = getJSONData(o) + const writeOptions = options.overwriting + ? 0 + : NSDataWritingWithoutOverwriting + const ptr = MOPointer.new() + data.writeToURL_options_error(url, writeOptions, ptr) + if (ptr.value()) { + throw new Error(`Error writing json file ${ptr.value()}`) + } + }) +} + +function exportToImageFile(nativeObjects, options) { + // we need to class the objects by types as we need to do different things depending on it + const pages = [] + const layers = [] + nativeObjects.forEach(o => { + if (o.isKindOfClass(MSPage)) { + pages.push(o) + } else { + layers.push(o) + } + }) + + const exporter = MSSelfContainedHighLevelExporter.alloc().initWithOptions( + options + ) + + // export the pages + pages.forEach(exporter.exportPage) + + // export the layers + if (layers.length) { + exporter.exportLayers(layers) + } +} + /** * Export an object, using the options supplied. * @@ -26,11 +103,11 @@ export const DEFAULT_EXPORT_OPTIONS = { * ### General Options * * - use-id-for-name : normally the exported files are given the same names as the layers they represent, but if this options is true, then the layer ids are used instead; defaults to false. - * - output : this is the path of the folder where all exported files are placed; defaults to "~/Documents/Sketch Exports" + * - output : this is the path of the folder where all exported files are placed; defaults to "~/Documents/Sketch Exports". If falsey the data is returned immediately (only supported for json). * - overwriting : if true, the exporter will overwrite any existing files with new ones; defaults to false. * - trimmed: if true, any transparent space around the exported image will be trimmed; defaults to false. * - scales: this should be a list of numbers; it will determine the sizes at which the layers are exported; defaults to "1" - * - formats: this should be a list of one or more of "png", "jpg", "svg", and "pdf"; defaults to "png" (see discussion below) + * - formats: this should be a list of one or more of "png", "jpg", "svg", "json", and "pdf"; defaults to "png" (see discussion below) * * ### SVG options * - compact : if exporting as SVG, this option makes the output more compact; defaults to false. @@ -46,58 +123,92 @@ export const DEFAULT_EXPORT_OPTIONS = { * * * @param {dictionary} options Options indicating which sizes and formats to use, etc. + * + * @returns If an output path is not set, the data is returned */ export function exportObject(object, options) { - const merged = { ...DEFAULT_EXPORT_OPTIONS, ...options } - const exporter = MSSelfContainedHighLevelExporter.alloc().initWithOptions( - merged - ) + // Validate the provided objects + const objectsToExport = (Array.isArray(object) ? object : [object]) + .map(o => (isWrappedObject(o) ? o.sketchObject : o)) + .filter(o => o) - function exportNativeLayers(layers) { - exporter.exportLayers(layers) + if (!objectsToExport.length) { + throw new Error('No objects provided to export') } - function exportNativePage(page) { - exporter.exportPage(page) + // Validate export formats + let formats = (options || {}).formats || [] + if (typeof formats === 'string') { + formats = formats.split(',') } - if (Array.isArray(object)) { - const isArrayOfPages = isWrappedObject(object[0]) - ? object[0].type === Types.Page - : String(object[0].class()) === 'MSPage' - - if (isArrayOfPages) { - // support an array of pages - object.forEach(o => { - if (isWrappedObject(o)) { - exportNativePage(o.sketchObject) - } else { - exportNativePage(o) - } - }) - } else { - // support an array of layers - exportNativeLayers( - object.map(o => { - if (isWrappedObject(o)) { - return o.sketchObject - } - return o - }) - ) - } - } else if (isWrappedObject(object)) { - // support a wrapped object - if (object.type === Types.Page) { - exportNativePage(object.sketchObject) - } else { - exportNativeLayers([object.sketchObject]) + formats = formats.map(format => format.trim()) + + // if we don't have any format, we default to png + if (formats.length === 0) { + formats.push('png') + } + + const shouldExportToJSON = formats.indexOf('json') !== -1 + const exportImagesFormat = formats.filter(format => format !== 'json') + + const optionsWithDefaults = { + ...DEFAULT_EXPORT_OPTIONS, + ...options, + ...{ formats: exportImagesFormat.join(',') }, + } + + // Return data if no output directory specified + if (!optionsWithDefaults.output) { + if (formats.length > 1) { + throw new Error('Can only return 1 format with no output type') } - } else if (String(object.class()) === 'MSPage') { - // support a native page - exportNativePage(object) - } else { - // support a native layer - exportNativeLayers([object]) + const format = formats[0] + const exported = objectsToExport.map(nativeObject => { + if (format === 'json') { + const str = getJSONString(nativeObject) + return JSON.parse(str) + } + // Insert code for returning image data here... + throw new Error('Return output is only support for the json format') + }) + // Return the same format that was provided + return Array.isArray(object) ? exported : exported[0] + } + + if (shouldExportToJSON) { + exportToJSONFile(objectsToExport, optionsWithDefaults) + } + + if (exportImagesFormat.length) { + exportToImageFile(objectsToExport, optionsWithDefaults) + } + return true +} + +/** + * Create an object from the exported Sketch JSON. + * + * @param {dictionary} sketchJSON The exported Sketch JSON data + * @param {number} version The file version that the Sketch JSON + * was exported from. Defaults to the current version + * @returns {WrappedObject} A javascript object (subclass of WrappedObject), + * which represents the restored Sketch object. + */ +export function objectFromJSON(sketchJSON, version) { + const v = version || MSArchiveHeader.metadataForNewHeader().version + const ptr = MOPointer.new() + let object = MSJSONDictionaryUnarchiver.unarchiveObjectFromDictionary_asVersion_corruptionDetected_error( + sketchJSON, + v, + null, + ptr + ) + if (ptr.value()) { + throw new Error(`Failed to create object from sketch JSON: ${ptr.value()}`) + } + if (object.newMutableCounterpart) { + object = object.newMutableCounterpart() } + return wrapNativeObject(object) } diff --git a/Source/dom/index.js b/Source/dom/index.js index 44cf60342..af862acab 100755 --- a/Source/dom/index.js +++ b/Source/dom/index.js @@ -1,7 +1,10 @@ import { AnimationType, BackTarget } from './models/Flow' import './models/DataOverride' -export { exportObject as export } from './export' +export { + exportObject as export, + objectFromJSON as fromSketchJSON, +} from './export' export { Document, getDocuments, getSelectedDocument } from './models/Document' export { Library, getLibraries } from './models/Library' diff --git a/docs/api/export.md b/docs/api/export.md index 73dd7f738..068efe653 100644 --- a/docs/api/export.md +++ b/docs/api/export.md @@ -24,21 +24,26 @@ sketch.export(page, options) sketch.export(document.pages) ``` +```javascript +const options = { formats: 'json', output: false } +const sketchJSON = sketch.export(layer, options) +``` + Export an object, using the options supplied. -| Parameters | | -| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| objectToExport[Layer](#layer) / [Layer](#layer)[] / [Page](#page) / [Page](#page)[] | The object to export. | -| optionsobject | Options indicating which sizes and formats to use, etc.. | -| options.outputstring | this is the path of the folder where all exported files are placed (defaults to `"~/Documents/Sketch Exports"`). | -| options.formatsstring | Comma separated list of formats to export to (`png`, `jpg`, `svg` or `pdf`) (default to `"png"`). | -| options.scalesstring | Comma separated list of scales which determine the sizes at which the layers are exported (defaults to `"1"`). | -| options['use-id-for-name']boolean | Name exported images using their id rather than their name (defaults to `false`). | -| options['group-contents-only']boolean | Export only layers that are contained within the group (default to `false`). | -| options.overwritingboolean | Overwrite existing files (if any) with newly generated ones (defaults to `false`). | -| options.trimmedboolean | Trim any transparent space around the exported image (defaults to `false`). | -| options['save-for-web']boolean | If exporting a PNG, remove metadata such as the colour profile from the exported file (defaults to `false`). | -| options.compactboolean | If exporting a SVG, make the output more compact (defaults to `false`). | -| options['include-namespaces']boolean | If exporting a SVG, include extra attributes (defaults to `false`). | -| options.progressiveboolean | If exporting a JPG, export a progressive JPEG (only used when exporting to `jpeg`) (defaults to `false`). | -| options.compressionnumber | If exporting a JPG, the compression level to use fo `jpeg` (with `0` being the completely compressed, `1.0` no compression) (defaults to `1.0`). | +| Parameters | | +|-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| objectToExport[Layer](#layer) / [Layer](#layer)[] / [Page](#page) / [Page](#page)[] | The object to export. | +| optionsobject | Options indicating which sizes and formats to use, etc.. | +| options.outputstring | this is the path of the folder where all exported files are placed (defaults to `"~/Documents/Sketch Exports"`). If falsey, the data for the first object/format is returned immmediately (currently only supported for json). | +| options.formatsstring | Comma separated list of formats to export to (`png`, `jpg`, `svg`, `json` or `pdf`) (default to `"png"`). | +| options.scalesstring | Comma separated list of scales which determine the sizes at which the layers are exported (defaults to `"1"`). | +| options['use-id-for-name']boolean | Name exported images using their id rather than their name (defaults to `false`). | +| options['group-contents-only']boolean | Export only layers that are contained within the group (default to `false`). | +| options.overwritingboolean | Overwrite existing files (if any) with newly generated ones (defaults to `false`). | +| options.trimmedboolean | Trim any transparent space around the exported image (defaults to `false`). | +| options['save-for-web']boolean | If exporting a PNG, remove metadata such as the colour profile from the exported file (defaults to `false`). | +| options.compactboolean | If exporting a SVG, make the output more compact (defaults to `false`). | +| options['include-namespaces']boolean | If exporting a SVG, include extra attributes (defaults to `false`). | +| options.progressiveboolean | If exporting a JPG, export a progressive JPEG (only used when exporting to `jpeg`) (defaults to `false`). | +| options.compressionnumber | If exporting a JPG, the compression level to use fo `jpeg` (with `0` being the completely compressed, `1.0` no compression) (defaults to `1.0`). |