diff --git a/src/platformBridges/NodeMacOSBridge.ts b/src/platformBridges/NodeMacOSBridge.ts new file mode 100644 index 00000000..1d84d5a5 --- /dev/null +++ b/src/platformBridges/NodeMacOSBridge.ts @@ -0,0 +1,19 @@ +import { PlatformBridge } from '../types'; +import weakRequire from '../utils/weakRequire'; + +const { createStringMeasurer, findFontName } = weakRequire(module, 'node-sketch-bridge'); +const fetch = weakRequire(module, 'node-fetch'); +const { readFile: nodeReadFile } = weakRequire(module, 'fs'); + +const NodeMacOSBridge: PlatformBridge = { + createStringMeasurer, + findFontName, + fetch, + async readFile(path: string): Promise { + return new Promise((resolve, reject) => { + nodeReadFile(path, (err, data) => (err ? reject(err) : resolve(data))); + }); + }, +}; + +export default NodeMacOSBridge; diff --git a/src/platformBridges/SketchBridge/createStringMeasurer.ts b/src/platformBridges/SketchBridge/createStringMeasurer.ts new file mode 100644 index 00000000..f3aacfaf --- /dev/null +++ b/src/platformBridges/SketchBridge/createStringMeasurer.ts @@ -0,0 +1,89 @@ +import { Size, TextNode, TextStyle } from '../../types'; +import { + TEXT_DECORATION_UNDERLINE, + TEXT_DECORATION_LINETHROUGH, + TEXT_ALIGN, + TEXT_TRANSFORM, +} from '../../jsonUtils/textLayers'; +import { findFont } from './findFontName'; +import { makeColorFromCSS } from '../../jsonUtils/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: any = { + 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; +} + +type NSAttributedString = any; + +function createAttributedString(textNode: TextNode): NSAttributedString { + const { content, textStyles } = textNode; + + const attribs = createStringAttributes(textStyles); + + return NSAttributedString.attributedStringWithString_attributes_(content, attribs); +} + +export default function createStringMeasurer(textNodes: TextNode[], 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/platformBridges/SketchBridge/findFontName.ts b/src/platformBridges/SketchBridge/findFontName.ts new file mode 100644 index 00000000..2453fbb9 --- /dev/null +++ b/src/platformBridges/SketchBridge/findFontName.ts @@ -0,0 +1,200 @@ +/* eslint-disable no-bitwise */ + +import hashStyle from '../../utils/hashStyle'; +import { TextStyle } from '../../types'; +import { FONT_STYLES } from '../../jsonUtils/textLayers'; + +// 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 = { + ultralight: -0.8, + '100': -0.8, + thin: -0.6, + '200': -0.6, + light: -0.4, + '300': -0.4, + normal: 0, + regular: 0, + '400': 0, + semibold: 0.23, + demibold: 0.23, + '500': 0.23, + '600': 0.3, + bold: 0.4, + '700': 0.4, + extrabold: 0.56, + ultrabold: 0.56, + heavy: 0.56, + '800': 0.56, + black: 0.62, + '900': 0.62, +}; + +type NSFont = any; + +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.toLowerCase()] + : defaultFontWeight; + + let familyName = 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 { + font = NSFont.systemFontOfSize_weight(fontSize, fontWeight); + } + + didFindFont = true; + } + + if (!didFindFont) { + // 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): string { + const font = findFont(style); + return font.fontDescriptor().postscriptName(); +} diff --git a/src/platformBridges/SketchBridge/index.ts b/src/platformBridges/SketchBridge/index.ts new file mode 100644 index 00000000..bc15e5db --- /dev/null +++ b/src/platformBridges/SketchBridge/index.ts @@ -0,0 +1,13 @@ +import { PlatformBridge } from '../../types'; +import createStringMeasurer from './createStringMeasurer'; +import findFontName from './findFontName'; +import readFile from './readFile'; + +const SketchBridge: PlatformBridge = { + createStringMeasurer, + findFontName, + fetch, + readFile, +}; + +export default SketchBridge; diff --git a/src/platformBridges/SketchBridge/readFile.ts b/src/platformBridges/SketchBridge/readFile.ts new file mode 100644 index 00000000..3f6bc55b --- /dev/null +++ b/src/platformBridges/SketchBridge/readFile.ts @@ -0,0 +1,5 @@ +import { readFileSync } from '@skpm/fs'; + +export default async function readFile(path: string): Promise { + return Promise.resolve(readFileSync(path)); +}