diff --git a/CHANGELOG.md b/CHANGELOG.md index cbedf33f..039e87e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). Every release, along with the migration instructions, is documented on the Github [Releases](https://github.com/airbnb/react-sketchapp/releases) page. +## Version 3.0.0-beta.3 + +- Fix setting overrides (#409) +- Fix images on NodeJS +- Fix Border-radius clipping incorrectly calculated (#279) + ## Version 3.0.0-beta.1 - Fix ShapeGroup on nodejs (#387) diff --git a/README.md b/README.md index 2cbc40ac..81ecdce8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Quick-start 🏃‍ -First, make sure you have installed [Sketch](http://sketchapp.com) version 43+, & a recent [npm](https://nodejs.org/en/download/). +First, make sure you have installed [Sketch](http://sketchapp.com) version 50+, & a recent [npm](https://nodejs.org/en/download/). Open a new Sketch file, then in a terminal: diff --git a/__tests__/jest/index.js b/__tests__/jest/index.js index ca3694f9..3dec7c54 100644 --- a/__tests__/jest/index.js +++ b/__tests__/jest/index.js @@ -4,7 +4,10 @@ let ReactSketch; describe('public API', () => { beforeEach(() => { // jest.resetModules(); - jest.mock('../../src/jsonUtils/hacksForJSONImpl'); + jest.mock('../../src/jsonUtils/sketchImpl/createStringMeasurer'); + jest.mock('../../src/jsonUtils/sketchImpl/findFontName'); + jest.mock('../../src/jsonUtils/sketchImpl/makeImageDataFromUrl'); + jest.mock('../../src/jsonUtils/sketchImpl/makeSvgLayer'); ReactSketch = require('../../src'); }); diff --git a/__tests__/jest/jsonUtils/hacksForJSONImpl.js b/__tests__/jest/jsonUtils/hacksForJSONImpl.js deleted file mode 100644 index 2003921b..00000000 --- a/__tests__/jest/jsonUtils/hacksForJSONImpl.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as hacks from '../../../src/jsonUtils/hacksForJSONImpl'; - -describe('API', () => { - it('exports makeImageDataFromUrl', () => { - expect(hacks.makeImageDataFromUrl).toBeInstanceOf(Function); - }); - - it('exports makeImageDataFromUrl', () => { - expect(hacks.createAttributedString).toBeInstanceOf(Function); - }); - - it('exports makeEncodedAttributedString', () => { - expect(hacks.makeEncodedAttributedString).toBeInstanceOf(Function); - }); -}); diff --git a/__tests__/jest/sharedStyles/TextStyles.js b/__tests__/jest/sharedStyles/TextStyles.js index d7d02791..03f26860 100644 --- a/__tests__/jest/sharedStyles/TextStyles.js +++ b/__tests__/jest/sharedStyles/TextStyles.js @@ -12,7 +12,7 @@ beforeEach(() => { })); jest.mock('../../../src/utils/getSketchVersion.js', () => ({ - default: jest.fn(() => 47), + default: jest.fn(() => 51), })); TextStyles = require('../../../src/sharedStyles/TextStyles'); @@ -21,7 +21,10 @@ beforeEach(() => { jest.mock('../../../src/wrappers/sharedTextStyles'); - jest.mock('../../../src/jsonUtils/hacksForJSONImpl'); + jest.mock('../../../src/jsonUtils/sketchImpl/createStringMeasurer'); + jest.mock('../../../src/jsonUtils/sketchImpl/findFontName'); + jest.mock('../../../src/jsonUtils/sketchImpl/makeImageDataFromUrl'); + jest.mock('../../../src/jsonUtils/sketchImpl/makeSvgLayer'); TextStyles = TextStyles.default; sharedTextStyles = sharedTextStyles.default; diff --git a/package.json b/package.json index bc211ed4..12886b27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-sketchapp", - "version": "3.0.0-beta.2", + "version": "3.0.0-beta.3", "description": "A React renderer for Sketch.app", "main": "lib/index.js", "license": "MIT", @@ -44,6 +44,7 @@ "airbnb-prop-types": "^2.10.0", "error-stack-parser": "^2.0.0", "invariant": "^2.2.2", + "js-sha1": "^0.6.0", "murmur2js": "^1.0.0", "normalize-css-color": "^1.0.1", "pegjs": "^0.10.0", @@ -118,7 +119,7 @@ "react": "^16.3.2", "react-test-renderer": "^16.3.2", "rimraf": "^2.5.4", - "sketchapp-json-flow-types": "^0.3.4" + "sketchapp-json-flow-types": "^0.3.5" }, "skpm": { "test": { diff --git a/src/jsonUtils/borders.js b/src/jsonUtils/borders.js index 9eab77fb..300e97a3 100644 --- a/src/jsonUtils/borders.js +++ b/src/jsonUtils/borders.js @@ -59,10 +59,21 @@ export const createBorders = ( isEnabled: true, color: makeColorFromCSS(borderTopColor), fillType: FillType.Solid, - position: BorderPosition.Inside, + position: BorderPosition.Outside, thickness: borderTopWidth, }, ]; + const backingLayer = content.layers ? content.layers[0] : undefined; + if (backingLayer) { + // $FlowFixMe + backingLayer.frame.x += borderTopWidth; + // $FlowFixMe + backingLayer.frame.y += borderTopWidth; + // $FlowFixMe + backingLayer.frame.width -= borderTopWidth * 2; + // $FlowFixMe + backingLayer.frame.height -= borderTopWidth * 2; + } } return [content]; diff --git a/src/jsonUtils/hacksForJSONImpl.js b/src/jsonUtils/hacksForJSONImpl.js deleted file mode 100644 index d38a2755..00000000 --- a/src/jsonUtils/hacksForJSONImpl.js +++ /dev/null @@ -1,179 +0,0 @@ -// @flow -// We need native macOS fonts and colors for these hacks so import the old utils -import { toSJSON } from '@skpm/sketchapp-json-plugin'; -import { - TEXT_ALIGN, - TEXT_DECORATION_UNDERLINE, - TEXT_DECORATION_LINETHROUGH, - TEXT_TRANSFORM, -} from '../utils/constants'; -import findFont from '../utils/findFont'; -import getSketchVersion from '../utils/getSketchVersion'; -import type { TextNodes, TextNode, TextStyle, LayoutInfo } from '../types'; -import { makeColorFromCSS } from './models'; - -// NOTE(gold): toSJSON doesn't recursively parse JS objects -// https://github.com/airbnb/react-sketchapp/pull/73#discussion_r108529703 -function encodeSketchJSON(sketchObj): Object { - const encoded = toSJSON(sketchObj); - return encoded ? JSON.parse(encoded) : {}; -} - -function makeParagraphStyle(textStyle) { - const pStyle = NSMutableParagraphStyle.alloc().init(); - if (textStyle.lineHeight !== undefined) { - pStyle.minimumLineHeight = textStyle.lineHeight; - pStyle.lineHeightMultiple = 1.0; - pStyle.maximumLineHeight = textStyle.lineHeight; - } - - if (textStyle.textAlign) { - pStyle.alignment = TEXT_ALIGN[textStyle.textAlign]; - } - - // TODO: check against only positive spacing values? - if (textStyle.paragraphSpacing !== undefined) { - pStyle.paragraphSpacing = textStyle.paragraphSpacing; - } - - return pStyle; -} - -export const makeImageDataFromUrl = (url: string): MSImageData => { - let fetchedData = NSData.dataWithContentsOfURL(NSURL.URLWithString(url)); - - if (fetchedData) { - const firstByte = fetchedData.subdataWithRange(NSMakeRange(0, 1)).description(); - - // Check for first byte. Must use non-type-exact matching (!=). - // 0xFF = JPEG, 0x89 = PNG, 0x47 = GIF, 0x49 = TIFF, 0x4D = TIFF - if ( - /* eslint-disable eqeqeq */ - firstByte != '' && - firstByte != '<89>' && - firstByte != '<47>' && - firstByte != '<49>' && - firstByte != '<4d>' - /* eslint-enable eqeqeq */ - ) { - fetchedData = null; - } - } - - let image; - - if (!fetchedData) { - const errorUrl = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mM8w8DwHwAEOQHNmnaaOAAAAABJRU5ErkJggg=='; - image = NSImage.alloc().initWithContentsOfURL(NSURL.URLWithString(errorUrl)); - } else { - image = NSImage.alloc().initWithData(fetchedData); - } - - if (MSImageData.alloc().initWithImage_convertColorSpace !== undefined) { - return MSImageData.alloc().initWithImage_convertColorSpace(image, false); - } - return MSImageData.alloc().initWithImage(image); -}; - -// This shouldn't need to call into Sketch, but it does currently, which is bad for perf :( -function createStringAttributes(textStyles: TextStyle): Object { - const font = findFont(textStyles); - const { textDecoration } = textStyles; - - const underline = textDecoration && TEXT_DECORATION_UNDERLINE[textDecoration]; - const strikethrough = textDecoration && TEXT_DECORATION_LINETHROUGH[textDecoration]; - - const attribs: Object = { - MSAttributedStringFontAttribute: font.fontDescriptor(), - NSFont: font, - NSParagraphStyle: makeParagraphStyle(textStyles), - NSUnderline: underline || 0, - NSStrikethrough: strikethrough || 0, - }; - - const color = makeColorFromCSS(textStyles.color || 'black'); - const sketchVersion = getSketchVersion(); - if (sketchVersion === 'NodeJS' || sketchVersion >= 50) { - attribs.MSAttributedStringColorAttribute = color; - } else { - attribs.NSColor = NSColor.colorWithDeviceRed_green_blue_alpha( - color.red, - color.green, - color.blue, - color.alpha, - ); - } - - if (textStyles.letterSpacing !== undefined) { - attribs.NSKern = textStyles.letterSpacing; - } - - if (textStyles.textTransform !== undefined) { - attribs.MSAttributedStringTextTransformAttribute = TEXT_TRANSFORM[textStyles.textTransform] * 1; - } - - return attribs; -} - -export function createAttributedString(textNode: TextNode): NSAttributedString { - const { content, textStyles } = textNode; - - const attribs = createStringAttributes(textStyles); - - return NSAttributedString.attributedStringWithString_attributes_(content, attribs); -} - -export function makeEncodedAttributedString(textNodes: TextNodes) { - const fullStr = NSMutableAttributedString.alloc().init(); - - textNodes.forEach(textNode => { - const newString = createAttributedString(textNode); - fullStr.appendAttributedString(newString); - }); - - const encodedAttribStr = MSAttributedString.encodeAttributedString(fullStr); - - const msAttribStr = MSAttributedString.alloc().initWithEncodedAttributedString(encodedAttribStr); - return encodeSketchJSON(msAttribStr); -} - -export function makeEncodedTextStyleAttributes(textStyle: TextStyle) { - const pStyle = makeParagraphStyle(textStyle); - - const font = findFont(textStyle); - - const color = makeColorFromCSS(textStyle.color || 'black'); - - return { - MSAttributedStringFontAttribute: encodeSketchJSON(font.fontDescriptor()), - NSFont: font, - NSColor: encodeSketchJSON( - NSColor.colorWithDeviceRed_green_blue_alpha(color.red, color.green, color.blue, color.alpha), - ), - NSParagraphStyle: encodeSketchJSON(pStyle), - NSKern: textStyle.letterSpacing || 0, - MSAttributedStringTextTransformAttribute: - TEXT_TRANSFORM[textStyle.textTransform || 'initial'] * 1, - }; -} - -export function makeSvgLayer(layout: LayoutInfo, name: string, svg: string) { - const svgString = NSString.stringWithString(svg); - const svgData = svgString.dataUsingEncoding(NSUTF8StringEncoding); - const svgImporter = MSSVGImporter.svgImporter(); - svgImporter.prepareToImportFromData(svgData); - const svgLayer = svgImporter.importAsLayer(); - svgLayer.name = name; - svgLayer.rect = { - origin: { - x: 0, - y: 0, - }, - size: { - width: layout.width, - height: layout.height, - }, - }; - return encodeSketchJSON(svgLayer); -} diff --git a/src/jsonUtils/models.js b/src/jsonUtils/models.js index e5c1e822..f1495a7a 100644 --- a/src/jsonUtils/models.js +++ b/src/jsonUtils/models.js @@ -115,23 +115,33 @@ export const makeRect = (x: number, y: number, width: number, height: number): S height, }); -export const makeJSONDataReference = (image: MSImageData): SJImageDataReference => ({ +export const makeJSONDataReference = (image: { + data: string, + sha1: string, +}): SJImageDataReference => ({ _class: 'MSJSONOriginalDataReference', _ref: `images/${generateID()}`, _ref_class: 'MSImageData', data: { - _data: image - .data() - .base64EncodedStringWithOptions(NSDataBase64EncodingEndLineWithCarriageReturn), + _data: image.data, // TODO(gold): can I just declare this as a var instead of using Cocoa? }, sha1: { - _data: image - .sha1() - .base64EncodedStringWithOptions(NSDataBase64EncodingEndLineWithCarriageReturn), + _data: image.sha1, }, }); +export const makeOverride = ( + path: string, + type: 'symbolID' | 'stringValue' | 'layerStyle' | 'textStyle' | 'flowDestination' | 'image', + value: string | SJImageDataReference, +) => ({ + _class: 'overrideValue', + do_objectID: generateID(), + overrideName: `${path}_${type}`, + value, +}); + export const makeSymbolInstance = ( frame: SJRect, symbolID: string, diff --git a/src/jsonUtils/nodeImpl/createStringMeasurer.js b/src/jsonUtils/nodeImpl/createStringMeasurer.js new file mode 100644 index 00000000..5f45e1b1 --- /dev/null +++ b/src/jsonUtils/nodeImpl/createStringMeasurer.js @@ -0,0 +1,94 @@ +import requireNodobjC from './requireNodobjC'; + +import type { TextNodes, Size, TextNode, TextStyle } from '../../types'; +import { TEXT_DECORATION_UNDERLINE, TEXT_DECORATION_LINETHROUGH, TEXT_ALIGN } from '../textLayers'; +import { TEXT_TRANSFORM } from '../../utils/constants'; +import { findFont } from './findFontName'; + +// TODO(lmr): do something more sensible here +const FLOAT_MAX = 999999; + +function makeParagraphStyle(textStyle) { + const $ = requireNodobjC(); + const pStyle = $.NSMutableParagraphStyle('alloc')('init'); + if (textStyle.lineHeight !== undefined) { + pStyle('setMinimumLineHeight', textStyle.lineHeight); + pStyle('setLineHeightMultiple', 1.0); + pStyle('setMaximumLineHeight', textStyle.lineHeight); + } + + if (textStyle.textAlign && TEXT_ALIGN[textStyle.textAlign]) { + pStyle('setAlignment', TEXT_ALIGN[textStyle.textAlign]); + } + + return pStyle; +} + +function createStringAttributes(textStyles: TextStyle): Object { + const $ = requireNodobjC(); + const font = findFont(textStyles); + const { textDecoration } = textStyles; + + const attribs = $.NSMutableDictionary('alloc')('init'); + const fontDescriptor = font('valueForKey', $('fontDescriptor')); + + attribs('setValue', fontDescriptor, 'forKey', $('MSAttributedStringFontAttribute')); + attribs('setValue', font, 'forKey', $('NSFont')); + attribs('setValue', makeParagraphStyle(textStyles), 'forKey', $('NSParagraphStyle')); + attribs( + 'setValue', + textDecoration ? TEXT_DECORATION_UNDERLINE[textDecoration] || 0 : 0, + 'forKey', + $('NSUnderline'), + ); + attribs( + 'setValue', + textDecoration ? TEXT_DECORATION_LINETHROUGH[textDecoration] || 0 : 0, + 'forKey', + $('NSStrikethrough'), + ); + + if (textStyles.letterSpacing !== undefined) { + attribs('setValue', $(textStyles.letterSpacing), 'forKey', $('NSKern')); + } + + if (textStyles.textTransform !== undefined) { + attribs( + 'setValue', + TEXT_TRANSFORM[textStyles.textTransform] * 1, + 'forKey', + $('MSAttributedStringTextTransformAttribute'), + ); + } + + return attribs; +} + +function createAttributedString(textNode: TextNode): NSAttributedString { + const $ = requireNodobjC(); + const { content, textStyles } = textNode; + + const attribs = createStringAttributes(textStyles); + + return $.NSAttributedString('alloc')('initWithString', $(content), 'attributes', attribs); +} + +export default function createStringMeasurer(textNodes: TextNodes, width: number): Size { + const $ = requireNodobjC(); + const pool = $.NSAutoreleasePool('alloc')('init'); + const fullStr = $.NSMutableAttributedString('alloc')('init'); + textNodes.forEach(textNode => { + const newString = createAttributedString(textNode); + fullStr('appendAttributedString', newString); + }); + const { height: measureHeight, width: measureWidth } = fullStr( + 'boundingRectWithSize', + $.CGSizeMake(width, FLOAT_MAX), + 'options', + $.NSStringDrawingUsesLineFragmentOrigin, + 'context', + null, + ).size; + pool('drain'); + return { width: measureWidth, height: measureHeight }; +} diff --git a/src/jsonUtils/hacksForNodObjCImpl.js b/src/jsonUtils/nodeImpl/findFontName.js similarity index 61% rename from src/jsonUtils/hacksForNodObjCImpl.js rename to src/jsonUtils/nodeImpl/findFontName.js index abbb4f2e..6db376ce 100644 --- a/src/jsonUtils/hacksForNodObjCImpl.js +++ b/src/jsonUtils/nodeImpl/findFontName.js @@ -1,34 +1,10 @@ /* eslint-disable no-bitwise */ -// This is the worst code ever. Only there to measure a string when running on node - -import type { TextNodes, Size, TextNode, TextStyle } from '../types'; -import { - TEXT_DECORATION_UNDERLINE, - TEXT_DECORATION_LINETHROUGH, - TEXT_ALIGN, - FONT_STYLES, -} from './textLayers'; -import hashStyle from '../utils/hashStyle'; -import { APPLE_BROKEN_SYSTEM_FONT, TEXT_TRANSFORM } from '../utils/constants'; - -// This is the ugliest but it's kind of the only way to avoid bundling -// this module when using skpm (the other solution would be to add an `ignore` option -// in every client webpack config...) -let cached$; // cache nodobjc instance -function requireNodobjC() { - if (cached$) { - return cached$; - } - cached$ = eval("require('nodobjc')"); // eslint-disable-line - cached$.framework('Foundation'); - cached$.framework('AppKit'); - cached$.framework('CoreGraphics'); - return cached$; -} -// this borrows heavily from react-native's RCTFont class -// thanks y'all -// https://github.com/facebook/react-native/blob/master/React/Views/RCTFont.mm +import requireNodobjC from './requireNodobjC'; +import hashStyle from '../../utils/hashStyle'; +import type { TextStyle } from '../../types'; +import { FONT_STYLES } from '../textLayers'; +import { APPLE_BROKEN_SYSTEM_FONT } from '../../utils/constants'; /* eslint-disable quote-props */ const FONT_WEIGHTS = { @@ -46,9 +22,6 @@ const FONT_WEIGHTS = { }; /* eslint-enable quote-props */ -// TODO(lmr): do something more sensible here -const FLOAT_MAX = 999999; - const useCache = true; const _cache: Map = new Map(); @@ -115,7 +88,7 @@ const weightOfFont = (font: NSFont): number => { return weight; }; -function findFont(style: TextStyle): NSFont { +export function findFont(style: TextStyle): NSFont { const $ = requireNodobjC(); const cacheKey = hashStyle(style); @@ -228,94 +201,9 @@ function findFont(style: TextStyle): NSFont { return font; } -export function findFontName(style: TextStyle): String { +export default function findFontName(style: TextStyle): String { const $ = requireNodobjC(); const font = findFont(style); const fontDescriptor = font('valueForKey', $('fontDescriptor')); return fontDescriptor('valueForKey', $('postscriptName')); } - -function makeParagraphStyle(textStyle) { - const $ = requireNodobjC(); - const pStyle = $.NSMutableParagraphStyle('alloc')('init'); - if (textStyle.lineHeight !== undefined) { - pStyle('setMinimumLineHeight', textStyle.lineHeight); - pStyle('setLineHeightMultiple', 1.0); - pStyle('setMaximumLineHeight', textStyle.lineHeight); - } - - if (textStyle.textAlign && TEXT_ALIGN[textStyle.textAlign]) { - pStyle('setAlignment', TEXT_ALIGN[textStyle.textAlign]); - } - - return pStyle; -} - -function createStringAttributes(textStyles: TextStyle): Object { - const $ = requireNodobjC(); - const font = findFont(textStyles); - const { textDecoration } = textStyles; - - const attribs = $.NSMutableDictionary('alloc')('init'); - const fontDescriptor = font('valueForKey', $('fontDescriptor')); - - attribs('setValue', fontDescriptor, 'forKey', $('MSAttributedStringFontAttribute')); - attribs('setValue', font, 'forKey', $('NSFont')); - attribs('setValue', makeParagraphStyle(textStyles), 'forKey', $('NSParagraphStyle')); - attribs( - 'setValue', - textDecoration ? TEXT_DECORATION_UNDERLINE[textDecoration] || 0 : 0, - 'forKey', - $('NSUnderline'), - ); - attribs( - 'setValue', - textDecoration ? TEXT_DECORATION_LINETHROUGH[textDecoration] || 0 : 0, - 'forKey', - $('NSStrikethrough'), - ); - - if (textStyles.letterSpacing !== undefined) { - attribs('setValue', $(textStyles.letterSpacing), 'forKey', $('NSKern')); - } - - if (textStyles.textTransform !== undefined) { - attribs( - 'setValue', - TEXT_TRANSFORM[textStyles.textTransform] * 1, - 'forKey', - $('MSAttributedStringTextTransformAttribute'), - ); - } - - return attribs; -} - -export function createAttributedString(textNode: TextNode): NSAttributedString { - const $ = requireNodobjC(); - const { content, textStyles } = textNode; - - const attribs = createStringAttributes(textStyles); - - return $.NSAttributedString('alloc')('initWithString', $(content), 'attributes', attribs); -} - -export const createNodeJSStringMeasurer = (textNodes: TextNodes, width: number): Size => { - const $ = requireNodobjC(); - const pool = $.NSAutoreleasePool('alloc')('init'); - const fullStr = $.NSMutableAttributedString('alloc')('init'); - textNodes.forEach(textNode => { - const newString = createAttributedString(textNode); - fullStr('appendAttributedString', newString); - }); - const { height: measureHeight, width: measureWidth } = fullStr( - 'boundingRectWithSize', - $.CGSizeMake(width, FLOAT_MAX), - 'options', - $.NSStringDrawingUsesLineFragmentOrigin, - 'context', - null, - ).size; - pool('drain'); - return { width: measureWidth, height: measureHeight }; -}; diff --git a/src/jsonUtils/nodeImpl/makeImageDataFromUrl.js b/src/jsonUtils/nodeImpl/makeImageDataFromUrl.js new file mode 100644 index 00000000..9a57b952 --- /dev/null +++ b/src/jsonUtils/nodeImpl/makeImageDataFromUrl.js @@ -0,0 +1,50 @@ +import sha1 from 'js-sha1'; +import requireNodobjC from './requireNodobjC'; + +// TODO could use nodejs APIs directly +export default function makeImageDataFromUrl(url: string): { data: string, sha1: string } { + const $ = requireNodobjC(); + const pool = $.NSAutoreleasePool('alloc')('init'); + let fetchedData = $.NSData('dataWithContentsOfURL', $.NSURL('URLWithString', url)); + + if (fetchedData) { + const firstByte = fetchedData('subdataWithRange', $.NSMakeRange(0, 1))('description'); + + // Check for first byte. Must use non-type-exact matching (!=). + // 0xFF = JPEG, 0x89 = PNG, 0x47 = GIF, 0x49 = TIFF, 0x4D = TIFF + if ( + /* eslint-disable eqeqeq */ + firstByte != '' && + firstByte != '<89>' && + firstByte != '<47>' && + firstByte != '<49>' && + firstByte != '<4d>' + /* eslint-enable eqeqeq */ + ) { + fetchedData = null; + } + } + + if (!fetchedData) { + fetchedData = $.NSData('alloc')( + 'initWithBase64EncodedString', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mM8w8DwHwAEOQHNmnaaOAAAAABJRU5ErkJggg==', + 'options', + $.NSDataBase64DecodingIgnoreUnknownCharacters, + ); + } + const image = $.NSImage('alloc')('initWithData', fetchedData); + + const base64 = image + .TIFFRepresentation() + .base64EncodedStringWithOptions(NSDataBase64EncodingEndLineWithCarriageReturn); + + const result = { + data: base64, + sha1: sha1(base64), + }; + + pool('drain'); + + return result; +} diff --git a/src/jsonUtils/nodeImpl/makeSvgLayer.js b/src/jsonUtils/nodeImpl/makeSvgLayer.js new file mode 100644 index 00000000..f6dbdcec --- /dev/null +++ b/src/jsonUtils/nodeImpl/makeSvgLayer.js @@ -0,0 +1,9 @@ +import type { LayoutInfo } from '../../types'; +import { makeRectShapeLayer } from '../shapeLayers'; + +// TODO: +export default function makeSvgLayer(layout: LayoutInfo, name: string /* , svg: string */) { + const shape = makeRectShapeLayer(0, 0, layout.width, layout.height); + shape.name = name; + return shape; +} diff --git a/src/jsonUtils/nodeImpl/requireNodobjC.js b/src/jsonUtils/nodeImpl/requireNodobjC.js new file mode 100644 index 00000000..722a98eb --- /dev/null +++ b/src/jsonUtils/nodeImpl/requireNodobjC.js @@ -0,0 +1,14 @@ +// This is the ugliest but it's kind of the only way to avoid bundling +// this module when using skpm (the other solution would be to add an `ignore` option +// in every client webpack config...) +let cached$; // cache nodobjc instance +export default function requireNodobjC() { + if (cached$) { + return cached$; + } + cached$ = eval("require('nodobjc')"); // eslint-disable-line + cached$.framework('Foundation'); + cached$.framework('AppKit'); + cached$.framework('CoreGraphics'); + return cached$; +} diff --git a/src/jsonUtils/sketchImpl/createStringMeasurer.js b/src/jsonUtils/sketchImpl/createStringMeasurer.js new file mode 100644 index 00000000..62be08cc --- /dev/null +++ b/src/jsonUtils/sketchImpl/createStringMeasurer.js @@ -0,0 +1,83 @@ +import type { TextNodes, Size, TextNode, TextStyle } from '../../types'; +import { TEXT_DECORATION_UNDERLINE, TEXT_DECORATION_LINETHROUGH, TEXT_ALIGN } from '../textLayers'; +import { TEXT_TRANSFORM } from '../../utils/constants'; +import { findFont } from './findFontName'; +import { makeColorFromCSS } from '../models'; + +// TODO(lmr): do something more sensible here +const FLOAT_MAX = 999999; + +function makeParagraphStyle(textStyle) { + const pStyle = NSMutableParagraphStyle.alloc().init(); + if (textStyle.lineHeight !== undefined) { + pStyle.minimumLineHeight = textStyle.lineHeight; + pStyle.lineHeightMultiple = 1.0; + pStyle.maximumLineHeight = textStyle.lineHeight; + } + + if (textStyle.textAlign) { + pStyle.alignment = TEXT_ALIGN[textStyle.textAlign]; + } + + // TODO: check against only positive spacing values? + if (textStyle.paragraphSpacing !== undefined) { + pStyle.paragraphSpacing = textStyle.paragraphSpacing; + } + + return pStyle; +} + +// This shouldn't need to call into Sketch, but it does currently, which is bad for perf :( +function createStringAttributes(textStyles: TextStyle): Object { + const font = findFont(textStyles); + const { textDecoration } = textStyles; + + const underline = textDecoration && TEXT_DECORATION_UNDERLINE[textDecoration]; + const strikethrough = textDecoration && TEXT_DECORATION_LINETHROUGH[textDecoration]; + + const attribs: Object = { + MSAttributedStringFontAttribute: font.fontDescriptor(), + NSFont: font, + NSParagraphStyle: makeParagraphStyle(textStyles), + NSUnderline: underline || 0, + NSStrikethrough: strikethrough || 0, + }; + + const color = makeColorFromCSS(textStyles.color || 'black'); + attribs.MSAttributedStringColorAttribute = color; + + if (textStyles.letterSpacing !== undefined) { + attribs.NSKern = textStyles.letterSpacing; + } + + if (textStyles.textTransform !== undefined) { + attribs.MSAttributedStringTextTransformAttribute = TEXT_TRANSFORM[textStyles.textTransform] * 1; + } + + return attribs; +} + +function createAttributedString(textNode: TextNode): NSAttributedString { + const { content, textStyles } = textNode; + + const attribs = createStringAttributes(textStyles); + + return NSAttributedString.attributedStringWithString_attributes_(content, attribs); +} + +export default function createStringMeasurer(textNodes: TextNodes, width: number): Size { + const fullStr = NSMutableAttributedString.alloc().init(); + textNodes.forEach(textNode => { + const newString = createAttributedString(textNode); + fullStr.appendAttributedString(newString); + }); + const { + height: measureHeight, + width: measureWidth, + } = fullStr.boundingRectWithSize_options_context( + CGSizeMake(width, FLOAT_MAX), + NSStringDrawingUsesLineFragmentOrigin, + null, + ).size; + return { width: measureWidth, height: measureHeight }; +} diff --git a/src/jsonUtils/sketchImpl/findFontName.js b/src/jsonUtils/sketchImpl/findFontName.js new file mode 100644 index 00000000..a4d4c94c --- /dev/null +++ b/src/jsonUtils/sketchImpl/findFontName.js @@ -0,0 +1,186 @@ +/* eslint-disable no-bitwise */ + +import hashStyle from '../../utils/hashStyle'; +import type { TextStyle } from '../../types'; +import { FONT_STYLES } from '../textLayers'; +import { APPLE_BROKEN_SYSTEM_FONT } from '../../utils/constants'; + +// this borrows heavily from react-native's RCTFont class +// thanks y'all +// https://github.com/facebook/react-native/blob/master/React/Views/RCTFont.mm + +const FONT_WEIGHTS = { + normal: 0, + bold: 0.4, + '100': -0.8, + '200': -0.6, + '300': -0.4, + '400': 0, + '500': 0.23, + '600': 0.3, + '700': 0.4, + '800': 0.56, + '900': 0.62, +}; + +const isItalicFont = (font: NSFont): boolean => { + const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); + const symbolicTraits = traits[NSFontSymbolicTrait].unsignedIntValue(); + + return (symbolicTraits & NSFontItalicTrait) !== 0; +}; + +const isCondensedFont = (font: NSFont): boolean => { + const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); + const symbolicTraits = traits[NSFontSymbolicTrait].unsignedIntValue(); + + return (symbolicTraits & NSFontCondensedTrait) !== 0; +}; + +const weightOfFont = (font: NSFont): number => { + const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); + + const weight = traits[NSFontWeightTrait].doubleValue(); + if (weight === 0.0) { + const weights = Object.keys(FONT_WEIGHTS); + const fontName = String(font.fontName()).toLowerCase(); + const matchingWeight = weights.find(w => fontName.endsWith(w)); + if (matchingWeight) { + return FONT_WEIGHTS[matchingWeight]; + } + } + + return weight; +}; + +const fontNamesForFamilyName = (familyName: string): Array => { + const manager = NSFontManager.sharedFontManager(); + const members = NSArray.arrayWithArray(manager.availableMembersOfFontFamily(familyName)); + + const results = []; + for (let i = 0; i < members.length; i += 1) { + results.push(members[i][0]); + } + + return results; +}; + +const useCache = true; +const _cache: Map = new Map(); + +const getCached = (key: string): NSFont => { + if (!useCache) return undefined; + return _cache.get(key); +}; + +export const findFont = (style: TextStyle): NSFont => { + const cacheKey = hashStyle(style); + + let font = getCached(cacheKey); + if (font) { + return font; + } + const defaultFontFamily = NSFont.systemFontOfSize(14).familyName(); + const defaultFontWeight = NSFontWeightRegular; + const defaultFontSize = 14; + + const fontSize = style.fontSize ? style.fontSize : defaultFontSize; + let fontWeight = style.fontWeight ? FONT_WEIGHTS[style.fontWeight] : defaultFontWeight; + // Default to Helvetica if fonts are missing + // Must use two equals (==) for compatibility with Cocoascript + // eslint-disable-next-line eqeqeq + let familyName = defaultFontFamily == APPLE_BROKEN_SYSTEM_FONT ? 'Helvetica' : defaultFontFamily; + let isItalic = false; + let isCondensed = false; + + if (style.fontFamily) { + familyName = style.fontFamily; + } + + if (style.fontStyle) { + isItalic = FONT_STYLES[style.fontStyle] || false; + } + + let didFindFont = false; + + // Handle system font as special case. This ensures that we preserve + // the specific metrics of the standard system font as closely as possible. + if (familyName === defaultFontFamily || familyName === 'System') { + font = NSFont.systemFontOfSize_weight(fontSize, fontWeight); + + if (font) { + didFindFont = true; + + if (isItalic || isCondensed) { + let fontDescriptor = font.fontDescriptor(); + let symbolicTraits = fontDescriptor.symbolicTraits(); + if (isItalic) { + symbolicTraits |= NSFontItalicTrait; + } + + if (isCondensed) { + symbolicTraits |= NSFontCondensedTrait; + } + + fontDescriptor = fontDescriptor.fontDescriptorWithSymbolicTraits(symbolicTraits); + font = NSFont.fontWithDescriptor_size(fontDescriptor, fontSize); + } + } + } + + const fontNames = fontNamesForFamilyName(familyName); + + // Gracefully handle being given a font name rather than font family, for + // example: "Helvetica Light Oblique" rather than just "Helvetica". + if (!didFindFont && fontNames.length === 0) { + font = NSFont.fontWithName_size(familyName, fontSize); + if (font) { + // It's actually a font name, not a font family name, + // but we'll do what was meant, not what was said. + familyName = font.familyName(); + fontWeight = style.fontWeight ? fontWeight : weightOfFont(font); + isItalic = style.fontStyle ? isItalic : isItalicFont(font); + isCondensed = isCondensedFont(font); + } else { + log(`Unrecognized font family '${familyName}'`); + font = NSFont.systemFontOfSize_weight(fontSize, fontWeight); + } + } + + // Get the closest font that matches the given weight for the fontFamily + let closestWeight = Infinity; + for (let i = 0; i < fontNames.length; i += 1) { + const match = NSFont.fontWithName_size(fontNames[i], fontSize); + + if (isItalic === isItalicFont(match) && isCondensed === isCondensedFont(match)) { + const testWeight = weightOfFont(match); + + if (Math.abs(testWeight - fontWeight) < Math.abs(closestWeight - fontWeight)) { + font = match; + + closestWeight = testWeight; + } + } + } + + // If we still don't have a match at least return the first font in the fontFamily + // This is to support built-in font Zapfino and other custom single font families like Impact + if (!font) { + if (fontNames.length > 0) { + font = NSFont.fontWithName_size(fontNames[0], fontSize); + } + } + + // TODO(gold): support opentype features: small-caps & number types + + if (font) { + _cache.set(cacheKey, font); + } + + return font; +}; + +export default function findFontName(style: TextStyle) { + const font = findFont(style); + return font.fontDescriptor().postscriptName(); +} diff --git a/src/jsonUtils/sketchImpl/makeImageDataFromUrl.js b/src/jsonUtils/sketchImpl/makeImageDataFromUrl.js new file mode 100644 index 00000000..0e99d156 --- /dev/null +++ b/src/jsonUtils/sketchImpl/makeImageDataFromUrl.js @@ -0,0 +1,48 @@ +export default function makeImageDataFromUrl(url: string): { data: string, sha1: string } { + let fetchedData = NSData.dataWithContentsOfURL(NSURL.URLWithString(url)); + + if (fetchedData) { + const firstByte = fetchedData.subdataWithRange(NSMakeRange(0, 1)).description(); + + // Check for first byte. Must use non-type-exact matching (!=). + // 0xFF = JPEG, 0x89 = PNG, 0x47 = GIF, 0x49 = TIFF, 0x4D = TIFF + if ( + /* eslint-disable eqeqeq */ + firstByte != '' && + firstByte != '<89>' && + firstByte != '<47>' && + firstByte != '<49>' && + firstByte != '<4d>' + /* eslint-enable eqeqeq */ + ) { + fetchedData = null; + } + } + + let image; + + if (!fetchedData) { + const errorUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mM8w8DwHwAEOQHNmnaaOAAAAABJRU5ErkJggg=='; + image = NSImage.alloc().initWithContentsOfURL(NSURL.URLWithString(errorUrl)); + } else { + image = NSImage.alloc().initWithData(fetchedData); + } + + let imageData: MSImageData; + + if (MSImageData.alloc().initWithImage_convertColorSpace !== undefined) { + imageData = MSImageData.alloc().initWithImage_convertColorSpace(image, false); + } else { + imageData = MSImageData.alloc().initWithImage(image); + } + + return { + data: imageData + .data() + .base64EncodedStringWithOptions(NSDataBase64EncodingEndLineWithCarriageReturn), + sha1: imageData + .sha1() + .base64EncodedStringWithOptions(NSDataBase64EncodingEndLineWithCarriageReturn), + }; +} diff --git a/src/jsonUtils/sketchImpl/makeSvgLayer.js b/src/jsonUtils/sketchImpl/makeSvgLayer.js new file mode 100644 index 00000000..aa476fd6 --- /dev/null +++ b/src/jsonUtils/sketchImpl/makeSvgLayer.js @@ -0,0 +1,30 @@ +import { toSJSON } from '@skpm/sketchapp-json-plugin'; + +import type { LayoutInfo } from '../../types'; + +// NOTE(gold): toSJSON doesn't recursively parse JS objects +// https://github.com/airbnb/react-sketchapp/pull/73#discussion_r108529703 +function encodeSketchJSON(sketchObj): Object { + const encoded = toSJSON(sketchObj); + return encoded ? JSON.parse(encoded) : {}; +} + +export default function makeSvgLayer(layout: LayoutInfo, name: string, svg: string) { + const svgString = NSString.stringWithString(svg); + const svgData = svgString.dataUsingEncoding(NSUTF8StringEncoding); + const svgImporter = MSSVGImporter.svgImporter(); + svgImporter.prepareToImportFromData(svgData); + const svgLayer = svgImporter.importAsLayer(); + svgLayer.name = name; + svgLayer.rect = { + origin: { + x: 0, + y: 0, + }, + size: { + width: layout.width, + height: layout.height, + }, + }; + return encodeSketchJSON(svgLayer); +} diff --git a/src/jsonUtils/svgLayer.js b/src/jsonUtils/svgLayer.js new file mode 100644 index 00000000..9ace2920 --- /dev/null +++ b/src/jsonUtils/svgLayer.js @@ -0,0 +1,15 @@ +// @flow +import type { SJShapeGroupLayer } from 'sketchapp-json-flow-types'; +import type { LayoutInfo } from '../types'; +import getSketchVersion from '../utils/getSketchVersion'; +import sketchMethod from './sketchImpl/makeSvgLayer'; +import nodeMethod from './nodeImpl/makeSvgLayer'; + +const makeSvgLayer = (layout: LayoutInfo, name: string, svg: string): SJShapeGroupLayer => { + if (getSketchVersion() === 'NodeJS') { + return nodeMethod(layout, name, svg); + } + return sketchMethod(layout, name, svg); +}; + +export default makeSvgLayer; diff --git a/src/jsonUtils/textLayers.js b/src/jsonUtils/textLayers.js index 3d9e25ce..388491c1 100644 --- a/src/jsonUtils/textLayers.js +++ b/src/jsonUtils/textLayers.js @@ -1,16 +1,13 @@ // @flow import type { SJRect, SJTextLayer } from 'sketchapp-json-flow-types'; import { TextAlignment } from 'sketch-constants'; -import getSketchVersion from '../utils/getSketchVersion'; import makeResizeConstraint from './resizeConstraint'; -import { makeEncodedAttributedString, makeEncodedTextStyleAttributes } from './hacksForJSONImpl'; import type { TextNode, TextNodes, ResizeConstraints, TextStyle, ViewStyle } from '../types'; import { generateID, makeColorFromCSS } from './models'; import { TEXT_TRANSFORM } from '../utils/constants'; import { makeStyle } from './style'; -import findFontInSketch from '../utils/findFont'; -import { findFontName as findFontInNode } from './hacksForNodObjCImpl'; +import findFontName from '../utils/findFont'; export const TEXT_DECORATION_UNDERLINE = { none: 0, @@ -67,22 +64,10 @@ export const FONT_WEIGHTS = { }; /* eslint-enable */ -const sketchVersion = getSketchVersion(); - -export const getFontName = (style: TextStyle) => { - // if we are running in node - if (sketchVersion === 'NodeJS') { - return findFontInNode(style); - } - - const font = findFontInSketch(style); - return font.fontDescriptor().postscriptName(); -}; - const makeFontDescriptor = (style: TextStyle) => ({ _class: 'fontDescriptor', attributes: { - name: String(getFontName(style)), // will default to the system font + name: String(findFontName(style)), // will default to the system font size: style.fontSize || 14, }, }); @@ -152,10 +137,7 @@ export const makeTextStyle = (style: TextStyle, shadows?: Array) => { const json = makeStyle(style, undefined, shadows); json.textStyle = { _class: 'textStyle', - encodedAttributes: - sketchVersion === 'NodeJS' || sketchVersion >= 49 - ? makeTextStyleAttributes(style) - : makeEncodedTextStyleAttributes(style), + encodedAttributes: makeTextStyleAttributes(style), }; return json; }; @@ -182,14 +164,8 @@ const makeTextLayer = ( resizingType: 0, rotation: 0, shouldBreakMaskChain: false, - attributedString: - sketchVersion === 'NodeJS' || sketchVersion >= 49 - ? makeAttributedString(textNodes) - : makeEncodedAttributedString(textNodes), - style: - sketchVersion === 'NodeJS' || sketchVersion >= 49 - ? makeTextStyle((textNodes[0] || { textStyles: {} }).textStyles, shadows) - : undefined, + attributedString: makeAttributedString(textNodes), + style: makeTextStyle((textNodes[0] || { textStyles: {} }).textStyles, shadows), automaticallyDrawOnUnderlyingPath: false, dontSynchroniseWithSymbol: false, // NOTE(akp): I haven't fully figured out the meaning of glyphBounds diff --git a/src/renderers/ImageRenderer.js b/src/renderers/ImageRenderer.js index ac0b4d94..d453664b 100644 --- a/src/renderers/ImageRenderer.js +++ b/src/renderers/ImageRenderer.js @@ -2,7 +2,7 @@ import type { SJShapeGroupLayer } from 'sketchapp-json-flow-types'; import { PatternFillType } from '../utils/constants'; import SketchRenderer from './SketchRenderer'; -import { makeImageDataFromUrl } from '../jsonUtils/hacksForJSONImpl'; +import getImageDataFromURL from '../utils/getImageDataFromURL'; // import processTransform from './processTransform'; import { makeRect, makeImageFill, makeJSONDataReference } from '../jsonUtils/models'; import { makeRectShapeLayer, makeShapeGroup } from '../jsonUtils/shapeLayers'; @@ -32,7 +32,7 @@ export default class ImageRenderer extends SketchRenderer { borderBottomLeftRadius = 0, } = style; - const image = makeImageDataFromUrl(extractURLFromSource(props.source)); + const image = getImageDataFromURL(extractURLFromSource(props.source)); const fillImage = makeJSONDataReference(image); diff --git a/src/renderers/SvgRenderer.js b/src/renderers/SvgRenderer.js index 9687ed5a..61b43d88 100644 --- a/src/renderers/SvgRenderer.js +++ b/src/renderers/SvgRenderer.js @@ -1,7 +1,7 @@ // @flow import ViewRenderer from './ViewRenderer'; import type { SketchLayer, ViewStyle, LayoutInfo, TextStyle, TreeNode } from '../types'; -import { makeSvgLayer } from '../jsonUtils/hacksForJSONImpl'; +import makeSvgLayer from '../jsonUtils/svgLayer'; const snakeExceptions = [ 'gradientUnits', diff --git a/src/renderers/SymbolInstanceRenderer.js b/src/renderers/SymbolInstanceRenderer.js index f477ef97..ba71cece 100644 --- a/src/renderers/SymbolInstanceRenderer.js +++ b/src/renderers/SymbolInstanceRenderer.js @@ -1,17 +1,20 @@ // @flow import SketchRenderer from './SketchRenderer'; -import { makeSymbolInstance, makeRect, makeJSONDataReference } from '../jsonUtils/models'; +import { + makeSymbolInstance, + makeRect, + makeJSONDataReference, + makeOverride, +} from '../jsonUtils/models'; import type { ViewStyle, LayoutInfo, SketchLayer, TextStyle } from '../types'; import { getSymbolMasterById } from '../symbol'; -import { makeImageDataFromUrl } from '../jsonUtils/hacksForJSONImpl'; +import getImageDataFromURL from '../utils/getImageDataFromURL'; type Override = { - type: string, - objectId: string, + type: 'symbolID' | 'stringValue' | 'layerStyle' | 'textStyle' | 'flowDestination' | 'image', + path: string, name: string, symbolID?: string, - width?: number, - height?: number, }; const findInGroup = (layer: ?SketchLayer, type: string): ?SketchLayer => @@ -20,29 +23,27 @@ const findInGroup = (layer: ?SketchLayer, type: string): ?SketchLayer => const hasImageFill = (layer: SketchLayer): boolean => !!(layer.style && layer.style.fills && layer.style.fills.some(f => f.image)); -const overrideProps = (layer: SketchLayer): Override => ({ - type: layer._class, - objectId: layer.do_objectID, - name: layer.name, -}); - const removeDuplicateOverrides = (overrides: Array): Array => { const seen = {}; - return overrides.filter(({ objectId }) => { - const isDuplicate = typeof seen[objectId] !== 'undefined'; - seen[objectId] = true; + return overrides.filter(({ path }) => { + const isDuplicate = typeof seen[path] !== 'undefined'; + seen[path] = true; return !isDuplicate; }); }; -const extractOverridesReducer = ( +const extractOverridesReducer = (path: string) => ( overrides: Array, layer: SketchLayer, ): Array => { if (layer._class === 'text') { - return overrides.concat(overrideProps(layer)); + return overrides.concat({ + type: 'stringValue', + path: `${path}${layer.do_objectID}`, + name: layer.name, + }); } if (layer._class === 'group') { @@ -52,7 +53,11 @@ const extractOverridesReducer = ( const subGroup = findInGroup(layer, 'group'); const textLayer = findInGroup(subGroup, 'text'); if (textLayer) { - return overrides.concat(overrideProps(textLayer)); + return overrides.concat({ + type: 'stringValue', + path: `${path}${textLayer.do_objectID}`, + name: textLayer.name, + }); } // here we're doing look-ahead to see if this group contains a shapeGroup @@ -61,8 +66,8 @@ const extractOverridesReducer = ( const shapeGroup = findInGroup(layer, 'shapeGroup'); if (shapeGroup && hasImageFill(shapeGroup)) { return overrides.concat({ - ...overrideProps(shapeGroup), type: 'image', + path: `${path}${shapeGroup.do_objectID}`, name: layer.name, }); } @@ -70,10 +75,10 @@ const extractOverridesReducer = ( if (layer._class === 'symbolInstance') { return overrides.concat({ - ...overrideProps(layer), + type: 'symbolID', + path: `${path}${layer.do_objectID}`, + name: layer.name, symbolID: layer.symbolID, - width: layer.frame.width, - height: layer.frame.height, }); } @@ -81,14 +86,14 @@ const extractOverridesReducer = ( (layer._class === 'shapeGroup' || layer._class === 'artboard' || layer._class === 'group') && layer.layers ) { - return layer.layers.reduce(extractOverridesReducer, overrides); + return layer.layers.reduce(extractOverridesReducer(path), overrides); } return overrides; }; -const extractOverrides = (layers: Array = []): Array => { - const overrides = layers.reduce(extractOverridesReducer, []); +const extractOverrides = (layers: Array = [], path?: string): Array => { + const overrides = layers.reduce(extractOverridesReducer(path || ''), []); return removeDuplicateOverrides(overrides); }; @@ -109,8 +114,14 @@ export default class SymbolInstanceRenderer extends SketchRenderer { const overridableLayers = extractOverrides(masterTree.layers); - const overrides = overridableLayers.reduce(function inject(memo, reference) { - if (reference.type === 'symbolInstance') { + const overrides = overridableLayers.reduce(function inject( + memo: Array, + reference: Override, + ) { + if (reference.type === 'symbolID') { + const newPath = `${reference.path}/`; + const originalMaster = getSymbolMasterById(reference.symbolID); + // eslint-disable-next-line if (props.overrides.hasOwnProperty(reference.name)) { const overrideValue = props.overrides[reference.name]; @@ -120,7 +131,6 @@ export default class SymbolInstanceRenderer extends SketchRenderer { ); } - const originalMaster = getSymbolMasterById(reference.symbolID); const replacementMaster = getSymbolMasterById(overrideValue.symbolID); if ( @@ -132,27 +142,16 @@ export default class SymbolInstanceRenderer extends SketchRenderer { ); } - const nestedOverrides = extractOverrides( - getSymbolMasterById(overrideValue.symbolID).layers, - ).reduce(inject, {}); - - return { - ...memo, - [reference.objectId]: { - symbolID: replacementMaster.symbolID, - ...nestedOverrides, - }, - }; + memo.push(makeOverride(reference.path, reference.type, replacementMaster.symbolID)); + + extractOverrides(replacementMaster.layers, newPath).reduce(inject, memo); + + return memo; } - const nestedOverrides = extractOverrides( - getSymbolMasterById(reference.symbolID).layers, - ).reduce(inject, {}); + extractOverrides(originalMaster.layers, newPath).reduce(inject, memo); - return { - ...memo, - [reference.objectId]: nestedOverrides, - }; + return memo; } // eslint-disable-next-line @@ -162,27 +161,31 @@ export default class SymbolInstanceRenderer extends SketchRenderer { const overrideValue = props.overrides[reference.name]; - if (reference.type === 'text') { + if (reference.type === 'stringValue') { if (typeof overrideValue !== 'string') { throw new Error('##FIXME## TEXT OVERRIDE VALUES MUST BE STRINGS'); } - return { ...memo, [reference.objectId]: overrideValue }; + memo.push(makeOverride(reference.path, reference.type, overrideValue)); } if (reference.type === 'image') { if (typeof overrideValue !== 'string') { throw new Error('##FIXME"" IMAGE OVERRIDE VALUES MUST BE VALID IMAGE HREFS'); } - return { - ...memo, - [reference.objectId]: makeJSONDataReference(makeImageDataFromUrl(overrideValue)), - }; + memo.push( + makeOverride( + reference.path, + reference.type, + makeJSONDataReference(getImageDataFromURL(overrideValue)), + ), + ); } return memo; - }, {}); + }, + []); - symbolInstance.overrides = overrides; + symbolInstance.overrideValues = overrides; return symbolInstance; } diff --git a/src/sharedStyles/TextStyles.js b/src/sharedStyles/TextStyles.js index bdef6b17..900d12a7 100644 --- a/src/sharedStyles/TextStyles.js +++ b/src/sharedStyles/TextStyles.js @@ -60,8 +60,8 @@ type Options = { const create = (options: Options, styles: { [key: string]: TextStyle }): StyleHash => { const { clearExistingStyles, context, idMap } = options; - if (sketchVersion !== 'NodeJS' && sketchVersion < 43) { - context.document.showMessage('💎 Requires Sketch 43+ 💎'); + if (sketchVersion !== 'NodeJS' && sketchVersion < 50) { + context.document.showMessage('💎 Requires Sketch 50+ 💎'); return {}; } diff --git a/src/utils/createStringMeasurer.js b/src/utils/createStringMeasurer.js index e0f0832d..7c0ebbf2 100644 --- a/src/utils/createStringMeasurer.js +++ b/src/utils/createStringMeasurer.js @@ -1,43 +1,23 @@ // @flow import type { TextNodes, Size } from '../types'; import getSketchVersion from './getSketchVersion'; -import { createAttributedString } from '../jsonUtils/hacksForJSONImpl'; -import { createNodeJSStringMeasurer } from '../jsonUtils/hacksForNodObjCImpl'; - -// TODO(lmr): do something more sensible here -const FLOAT_MAX = 999999; +import sketchMethod from '../jsonUtils/sketchImpl/createStringMeasurer'; +import nodeMethod from '../jsonUtils/nodeImpl/createStringMeasurer'; const createStringMeasurer = (textNodes: TextNodes) => (width: number = 0): Size => { // width would be obj-c NaN and the only way to check for it is by comparing // width to itself (because NaN !== NaN) // eslint-disable-next-line no-self-compare const _width = width !== width ? 0 : width; - let newHeight = 0; - let newWidth = _width; if (textNodes.length > 0) { - // if we are running in node if (getSketchVersion() === 'NodeJS') { - return createNodeJSStringMeasurer(textNodes, _width); + return nodeMethod(textNodes, _width); } - const fullStr = NSMutableAttributedString.alloc().init(); - textNodes.forEach(textNode => { - const newString = createAttributedString(textNode); - fullStr.appendAttributedString(newString); - }); - const { - height: measureHeight, - width: measureWidth, - } = fullStr.boundingRectWithSize_options_context( - CGSizeMake(_width, FLOAT_MAX), - NSStringDrawingUsesLineFragmentOrigin, - null, - ).size; - newHeight = measureHeight; - newWidth = measureWidth; + return sketchMethod(textNodes, _width); } - return { width: newWidth, height: newHeight }; + return { width: _width, height: 0 }; }; export default createStringMeasurer; diff --git a/src/utils/findFont.js b/src/utils/findFont.js index cb3d5571..44660c09 100644 --- a/src/utils/findFont.js +++ b/src/utils/findFont.js @@ -1,189 +1,15 @@ // @flow -/* eslint-disable no-bitwise, quote-props */ import type { TextStyle } from '../types'; -import hashStyle from './hashStyle'; -import { APPLE_BROKEN_SYSTEM_FONT } from './constants'; +import getSketchVersion from './getSketchVersion'; +import sketchMethod from '../jsonUtils/sketchImpl/findFontName'; +import nodeMethod from '../jsonUtils/nodeImpl/findFontName'; -// this borrows heavily from react-native's RCTFont class -// thanks y'all -// https://github.com/facebook/react-native/blob/master/React/Views/RCTFont.mm - -const FONT_STYLES = { - normal: false, - italic: true, - oblique: true, -}; - -const FONT_WEIGHTS = { - normal: 0, - bold: 0.4, - '100': -0.8, - '200': -0.6, - '300': -0.4, - '400': 0, - '500': 0.23, - '600': 0.3, - '700': 0.4, - '800': 0.56, - '900': 0.62, -}; - -const isItalicFont = (font: NSFont): boolean => { - const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); - const symbolicTraits = traits[NSFontSymbolicTrait].unsignedIntValue(); - - return (symbolicTraits & NSFontItalicTrait) !== 0; -}; - -const isCondensedFont = (font: NSFont): boolean => { - const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); - const symbolicTraits = traits[NSFontSymbolicTrait].unsignedIntValue(); - - return (symbolicTraits & NSFontCondensedTrait) !== 0; -}; - -const weightOfFont = (font: NSFont): number => { - const traits = font.fontDescriptor().objectForKey(NSFontTraitsAttribute); - - const weight = traits[NSFontWeightTrait].doubleValue(); - if (weight === 0.0) { - const weights = Object.keys(FONT_WEIGHTS); - const fontName = String(font.fontName()).toLowerCase(); - const matchingWeight = weights.find(w => fontName.endsWith(w)); - if (matchingWeight) { - return FONT_WEIGHTS[matchingWeight]; - } - } - - return weight; -}; - -const fontNamesForFamilyName = (familyName: string): Array => { - const manager = NSFontManager.sharedFontManager(); - const members = NSArray.arrayWithArray(manager.availableMembersOfFontFamily(familyName)); - - const results = []; - for (let i = 0; i < members.length; i += 1) { - results.push(members[i][0]); +const findFontName = (style: TextStyle) => { + if (getSketchVersion() === 'NodeJS') { + return nodeMethod(style); } - - return results; -}; - -const useCache = true; -const _cache: Map = new Map(); - -const getCached = (key: string): NSFont => { - if (!useCache) return undefined; - return _cache.get(key); -}; - -const findFont = (style: TextStyle): NSFont => { - const cacheKey = hashStyle(style); - - let font = getCached(cacheKey); - if (font) { - return font; - } - const defaultFontFamily = NSFont.systemFontOfSize(14).familyName(); - const defaultFontWeight = NSFontWeightRegular; - const defaultFontSize = 14; - - const fontSize = style.fontSize ? style.fontSize : defaultFontSize; - let fontWeight = style.fontWeight ? FONT_WEIGHTS[style.fontWeight] : defaultFontWeight; - // Default to Helvetica if fonts are missing - // Must use two equals (==) for compatibility with Cocoascript - // eslint-disable-next-line eqeqeq - let familyName = defaultFontFamily == APPLE_BROKEN_SYSTEM_FONT ? 'Helvetica' : defaultFontFamily; - let isItalic = false; - let isCondensed = false; - - if (style.fontFamily) { - familyName = style.fontFamily; - } - - if (style.fontStyle) { - isItalic = FONT_STYLES[style.fontStyle] || false; - } - - let didFindFont = false; - - // Handle system font as special case. This ensures that we preserve - // the specific metrics of the standard system font as closely as possible. - if (familyName === defaultFontFamily || familyName === 'System') { - font = NSFont.systemFontOfSize_weight(fontSize, fontWeight); - - if (font) { - didFindFont = true; - - if (isItalic || isCondensed) { - let fontDescriptor = font.fontDescriptor(); - let symbolicTraits = fontDescriptor.symbolicTraits(); - if (isItalic) { - symbolicTraits |= NSFontItalicTrait; - } - - if (isCondensed) { - symbolicTraits |= NSFontCondensedTrait; - } - - fontDescriptor = fontDescriptor.fontDescriptorWithSymbolicTraits(symbolicTraits); - font = NSFont.fontWithDescriptor_size(fontDescriptor, fontSize); - } - } - } - - const fontNames = fontNamesForFamilyName(familyName); - - // Gracefully handle being given a font name rather than font family, for - // example: "Helvetica Light Oblique" rather than just "Helvetica". - if (!didFindFont && fontNames.length === 0) { - font = NSFont.fontWithName_size(familyName, fontSize); - if (font) { - // It's actually a font name, not a font family name, - // but we'll do what was meant, not what was said. - familyName = font.familyName(); - fontWeight = style.fontWeight ? fontWeight : weightOfFont(font); - isItalic = style.fontStyle ? isItalic : isItalicFont(font); - isCondensed = isCondensedFont(font); - } else { - log(`Unrecognized font family '${familyName}'`); - font = NSFont.systemFontOfSize_weight(fontSize, fontWeight); - } - } - - // Get the closest font that matches the given weight for the fontFamily - let closestWeight = Infinity; - for (let i = 0; i < fontNames.length; i += 1) { - const match = NSFont.fontWithName_size(fontNames[i], fontSize); - - if (isItalic === isItalicFont(match) && isCondensed === isCondensedFont(match)) { - const testWeight = weightOfFont(match); - - if (Math.abs(testWeight - fontWeight) < Math.abs(closestWeight - fontWeight)) { - font = match; - - closestWeight = testWeight; - } - } - } - - // If we still don't have a match at least return the first font in the fontFamily - // This is to support built-in font Zapfino and other custom single font families like Impact - if (!font) { - if (fontNames.length > 0) { - font = NSFont.fontWithName_size(fontNames[0], fontSize); - } - } - - // TODO(gold): support opentype features: small-caps & number types - - if (font) { - _cache.set(cacheKey, font); - } - - return font; + return sketchMethod(style); }; -export default findFont; +export default findFontName; diff --git a/src/utils/getImageDataFromURL.js b/src/utils/getImageDataFromURL.js new file mode 100644 index 00000000..994861ba --- /dev/null +++ b/src/utils/getImageDataFromURL.js @@ -0,0 +1,13 @@ +// @flow +import getSketchVersion from './getSketchVersion'; +import sketchMethod from '../jsonUtils/sketchImpl/makeImageDataFromUrl'; +import nodeMethod from '../jsonUtils/nodeImpl/makeImageDataFromUrl'; + +const makeImageDataFromUrl = (url: string): { data: string, sha1: string } => { + if (getSketchVersion() === 'NodeJS') { + return nodeMethod(url); + } + return sketchMethod(url); +}; + +export default makeImageDataFromUrl;