diff --git a/packages/tools/README.md b/packages/tools/README.md new file mode 100644 index 0000000..d6478f6 --- /dev/null +++ b/packages/tools/README.md @@ -0,0 +1,11 @@ +# `tools` + +> TODO: description + +## Usage + +``` +const tools = require('tools'); + +// TODO: DEMONSTRATE API +``` diff --git a/packages/tools/__tests__/hex.test.ts b/packages/tools/__tests__/hex.test.ts new file mode 100644 index 0000000..8874b5e --- /dev/null +++ b/packages/tools/__tests__/hex.test.ts @@ -0,0 +1,21 @@ +import Hex from '../lib/colors/hex' + +describe('Hex', () => { + let instance; + + beforeEach(() => { + instance = new Hex('rgba(255,25,2,0.5)') + }); + + it('toString()', () => { + expect(instance.toString()).toBe('ff190280') + }) + + it('toPrefixString() takes default prefix', () => { + expect(instance.toPrefixString()).toBe('#ff190280') + }) + + it('toPrefixString() takes prefix', () => { + expect(instance.toPrefixString('rgba:')).toBe('rgba:ff190280') + }) +}); \ No newline at end of file diff --git a/packages/tools/__tests__/rgbaConverter.test.ts b/packages/tools/__tests__/rgbaConverter.test.ts new file mode 100644 index 0000000..bf93dae --- /dev/null +++ b/packages/tools/__tests__/rgbaConverter.test.ts @@ -0,0 +1,57 @@ +import { RGBAToHexA } from '../lib/colors/rgbaConverter' + +describe('RGBAToHexA', () => { + it('converts from rgba(num, num, num, float)', () => { + expect(RGBAToHexA("rgba(255,25,2,0.5)")).toEqual({ + red: "ff", + green: "19", + blue: "02", + alpha: "80" + }) + }) + + it('converts from rgba(num num num / float)', () => { + expect(RGBAToHexA("rgba(255 25 2 / 0.5)")).toEqual({ + red: "ff", + green: "19", + blue: "02", + alpha: "80" + }) + }) + + it('converts from rgba(%, %, %, float)', () => { + expect(RGBAToHexA("rgba(50%,30%,10%,0.5)")).toEqual({ + red: "80", + green: "4d", + blue: "1a", + alpha: "80" + }) + }) + + it('converts from rgba(%, %, %, %)', () => { + expect(RGBAToHexA("rgba(50%,30%,10%,50%)")).toEqual({ + red: "80", + green: "4d", + blue: "1a", + alpha: "80" + }) + }) + + it('converts from rgba(% % % / float)', () => { + expect(RGBAToHexA("rgba(50% 30% 10% / 0.5)")).toEqual({ + red: "80", + green: "4d", + blue: "1a", + alpha: "80" + }) + }) + + it('converts from rgba(% % % / %)', () => { + expect(RGBAToHexA("rgba(50% 30% 10% / 50%)")).toEqual({ + red: "80", + green: "4d", + blue: "1a", + alpha: "80" + }) + }) +}); \ No newline at end of file diff --git a/packages/tools/__tests__/socialcard.test.ts b/packages/tools/__tests__/socialcard.test.ts new file mode 100644 index 0000000..3f0d21d --- /dev/null +++ b/packages/tools/__tests__/socialcard.test.ts @@ -0,0 +1,25 @@ +import { buildSocialCard} from '../lib/socialCard' + +describe('SocialCard', () => { + it('should return a card', () => { + expect(buildSocialCard({ + title: { + value: 'Deploy a Node.js App to DigitalOcean with SSL', + font:'futura', + }, + tagline: { + value: '#devops #nodejs #ssl', + font:'futura', + }, + image: { + publicId: 'lwj/blog-post-card', + }, + text: { + color: '232129', + width: 760, + } + }, { + cloudName: 'jlengstorf' + })).toBe('https://res.cloudinary.com/jlengstorf/image/upload/c_fill,w_1280,h_669,q_auto,f_auto/c_fit,w_760,x_480,y_254,g_south_west,l_text:futura_64:Deploy%20a%20Node.js%20App%20to%20DigitalOcean%20with%20SSL,co_rgb:232129/c_fit,w_760,x_480,y_445,g_north_west,l_text:futura_48:%2523devops%20%2523nodejs%20%2523ssl,co_rgb:232129/lwj/blog-post-card') + }); +}); \ No newline at end of file diff --git a/packages/tools/jest.config.json b/packages/tools/jest.config.json new file mode 100644 index 0000000..24880d2 --- /dev/null +++ b/packages/tools/jest.config.json @@ -0,0 +1,34 @@ +{ + "preset": "ts-jest", + "testEnvironment": "node", + "bail": true, + "collectCoverageFrom": [ + "/lib/**/*.ts", + "/lib/**/*.ts" + ], + "modulePaths": [ + "/lib" + ], + "coverageThreshold": { + "global": { + "branches": 95, + "functions": 95, + "lines": 95, + "statements": 95 + } + }, + "globals": { + "ts-jest": { + "diagnostics": false + } + }, + "reporters": [ + "default", + ["jest-html-reporters", { + "publicPath" : "./public/progress/", + "filename": "api-tools-report.html", + "pageTitle": "tools Report", + "expand": true + }] + ] +} \ No newline at end of file diff --git a/packages/tools/lib/colors/hex.ts b/packages/tools/lib/colors/hex.ts new file mode 100644 index 0000000..3f8da96 --- /dev/null +++ b/packages/tools/lib/colors/hex.ts @@ -0,0 +1,29 @@ + +import { RGBAToHexA } from './rgbaConverter' +import { RGBA } from '../types/RGBA' + +export class Hex implements RGBA { + red: string + green: string + blue: string + alpha?: string = '' + + constructor(rgba:string) { + const colors = RGBAToHexA(rgba) + + this.red = colors.red + this.green = colors.green + this.blue = colors.blue + this.alpha = colors.alpha || '' + } + + toString():string { + return `${this.red}${this.green}${this.blue}${this.alpha}` + } + + toPrefixString(prefix: string = '#'):string { + return `${prefix}${this.toString()}` + } +} + +export default Hex \ No newline at end of file diff --git a/packages/tools/lib/colors/hexConverter.ts b/packages/tools/lib/colors/hexConverter.ts new file mode 100644 index 0000000..73d95c1 --- /dev/null +++ b/packages/tools/lib/colors/hexConverter.ts @@ -0,0 +1,51 @@ +import type { RGBA} from '../types/RGBA' + +export const HEXToRGBA = (hex: string, isPct: boolean = false):RGBA => { + + let r = 0, g = 0, b = 0, a = 1; + + a = +(a / 255).toFixed(3); + if (h.length == 5) { + r = "0x" + h[1] + h[1]; + g = "0x" + h[2] + h[2]; + b = "0x" + h[3] + h[3]; + a = "0x" + h[4] + h[4]; + + } else if (h.length == 9) { + r = "0x" + h[1] + h[2]; + g = "0x" + h[3] + h[4]; + b = "0x" + h[5] + h[6]; + a = "0x" + h[7] + h[8]; + } + + if (isPct) { + r = +(r / 255 * 100).toFixed(1); + g = +(g / 255 * 100).toFixed(1); + b = +(b / 255 * 100).toFixed(1); + } + +} + +export const HEXToRGB = (hex: string) => { + if (hex.length < 4 ||) + + let r = 0, g = 0, b = 0; + isPct = isPct === true; + + if (h.length == 4) { + r = "0x" + h[1] + h[1]; + g = "0x" + h[2] + h[2]; + b = "0x" + h[3] + h[3]; + + } else if (h.length == 7) { + r = "0x" + h[1] + h[2]; + g = "0x" + h[3] + h[4]; + b = "0x" + h[5] + h[6]; + } + + if (isPct) { + r = +(r / 255 * 100).toFixed(1); + g = +(g / 255 * 100).toFixed(1); + b = +(b / 255 * 100).toFixed(1); + } +} \ No newline at end of file diff --git a/packages/tools/lib/colors/rgbaConverter.ts b/packages/tools/lib/colors/rgbaConverter.ts new file mode 100644 index 0000000..b86d15a --- /dev/null +++ b/packages/tools/lib/colors/rgbaConverter.ts @@ -0,0 +1,97 @@ +import { RGBA } from '../types/RGBA' + +const toHEXValue = (num):string => num.toString(16) +const SEPARATION = ',' + +export const parseRGBAColorStr = (rgba: string): number[] => { + const sep = rgba.includes(SEPARATION) ? SEPARATION : " "; + const indexToParse = rgba.indexOf('(') + const colors:string[] = rgba.substr(indexToParse + 1).split(")")[0].split(sep) + + // Strip the slash if using space-separated syntax + if (colors.includes("/")) { + colors.splice(3, 1) + } + + const mappedColors = colors.map((color, index) => { + if (!color.includes("%")) return +color + + // Convert %s to 0–255 + const pixels = +(color.substr(0, color.length - 1)) / 100 + + return index < 3 ? Math.round(pixels * 255) : pixels + }) + + return mappedColors +} + +export const RGBAToHexA = (rgba:string):RGBA => { + const colors:number[] = parseRGBAColorStr(rgba) + + const colorIndex:string[] = ["red", "green", "blue", "alpha"] + + const RGBAObj:RGBA = colors.reduce((obj:any, color:number , index: number) => { + const value = toHEXValue(index === 3 ? Math.round(color * 255) : color) + + obj[colorIndex[index]] = value.length === 1 ? `0${value}` : value + return obj + }, {}) + + return RGBAObj +} + +export const RGBAToHSL = (rgba : string) => { + const colors:number[] = parseRGBAColorStr(rgba) + + const mappedRGB = colors.map(color => color / 255) + + if (mappedRGB.length > 3) { mappedRGB.pop() } + + const channel = { + min: Math.min(...mappedRGB), + max: Math.max(...mappedRGB), + }; + + const hue = calculateHue(channel, { red: mappedRGB[0], green: mappedRGB[1], blue: mappedRGB[2] }) + const lightness = calculateLightness(channel) + const saturation = calculateSaturation(channel) + + return { + hue, + lightness, + saturation, + alpha: colors[3] + } +} + +const calculateHue = (channel, colors) => { + const delta = channel.max - channel.min + + if (delta === 0) return 0 + + let baseHue = (colors.red - colors.green) / delta + 4 + + if (channel.max === colors.red) { + baseHue = (colors.green - colors.blue) / delta % 6 + } else if (channel.max === colors.green) { + baseHue = (colors.blue - colors.red) / delta + 2 + } + + const roundHue = Math.round(baseHue * 60) + + return roundHue < 0 ? roundHue + 360 : roundHue +} + +const calculateLightness = (channel):number => +(((channel.max + channel.min) / 2) * 100).toFixed(1) + +const calculateSaturation = (channel):number => { + const delta = channel.max - channel.min + + if (delta === 0) return 0 + + const lightness = (channel.max + channel.min) / 2 + + const saturation = delta / (1 - Math.abs(2 * lightness - 1)) + + return +(saturation * 100).toFixed(1) +} diff --git a/packages/tools/lib/index.ts b/packages/tools/lib/index.ts new file mode 100644 index 0000000..7087e28 --- /dev/null +++ b/packages/tools/lib/index.ts @@ -0,0 +1,5 @@ +import Hex from './colors/hex' +import { RGBAToHexA } from './colors/rgbaConverter' +import type { RGBA } from './types/RGBA' + +export { Hex, RGBAToHexA, RGBA } diff --git a/packages/tools/lib/socialCard.ts b/packages/tools/lib/socialCard.ts new file mode 100644 index 0000000..f6a09dd --- /dev/null +++ b/packages/tools/lib/socialCard.ts @@ -0,0 +1,90 @@ + +import { cleanText, toString } from '../../url/lib/utils' +import { ResizeType, CloudConfig, TransformerOption, SocialCard, SocialImage, SocialText, TextArea } from '@cld-apis/types' +import { buildImageUrl, getConfig } from '../../url/lib' + +const DEFAULTS = { + TAGLINE: { + font: 'arial', + placementDirection: 'north_west', + size: 48, + position: { + y: 445 + }, + }, + TITLE: { + font: 'arial', + placementDirection: 'south_west', + size: 64, + position: { + y: 254 + }, + }, + + IMAGE: { + width: 1280, + height: 669, + cropMode: 'fill' + }, + TEXT: { + width: 760, + color: '000000', + cropMode: 'fit', + position: { + x: 480 + } + } +} + +const buildSocialTextTransformations = (options: SocialText, defaultOpts: TextArea = DEFAULTS.TEXT):TransformerOption => { + if (!options.value) return {} + + const textOverlay = toString([toString([options.font || defaultOpts.font, options.size || defaultOpts.size], '_'), options.extraConfig], '') + const overlay = toString(['text', textOverlay, cleanText(options.value)]) + const color = toString(['rgb', options.color || defaultOpts.color || DEFAULTS.TEXT.color]) + const position = options.position || defaultOpts.position || {} + + return { + resize: { + width: defaultOpts.width || DEFAULTS.TEXT.width, + type: DEFAULTS.TEXT.cropMode as ResizeType + }, + gravity: options.placementDirection || defaultOpts.placementDirection, + position: { + x: position.x || DEFAULTS.TEXT.position.x, + y: position.y, + }, + overlay, + color + } +} + +export const buildSocialCard = (options: SocialCard, cloud?: CloudConfig) => { + const image:SocialImage = options.image + const tagLineTransformations:TransformerOption = options.tagline ? buildSocialTextTransformations(options.tagline, { + ...(DEFAULTS.TAGLINE as any), + ...(options.text || {}), + }) : {} + const titleTransformations:TransformerOption = buildSocialTextTransformations(options.title, { + ...(DEFAULTS.TITLE as any), + ...(options.text || {}), + }) + + const transformations:TransformerOption = { + resize: { + width: image.width || DEFAULTS.IMAGE.width, + height: image.height || DEFAULTS.IMAGE.height, + type: image.cropMode || DEFAULTS.IMAGE.cropMode as ResizeType + }, + gravity: image.resizeFocus, + chaining: [titleTransformations, tagLineTransformations ] + } + + return buildImageUrl(image.publicId, { + cloud: { + ...getConfig(), + ...cloud + }, + transformations + }) +} diff --git a/packages/tools/lib/types/RGBA.ts b/packages/tools/lib/types/RGBA.ts new file mode 100644 index 0000000..5eedaae --- /dev/null +++ b/packages/tools/lib/types/RGBA.ts @@ -0,0 +1,6 @@ +export interface RGBA { + red: string, + green: string, + blue: string, + alpha?: string +} \ No newline at end of file diff --git a/packages/tools/package.json b/packages/tools/package.json new file mode 100644 index 0000000..1e28993 --- /dev/null +++ b/packages/tools/package.json @@ -0,0 +1,54 @@ +{ + "name": "tools", + "version": "0.0.1", + "description": "set of common tools for Cloudinary API", + "keywords": [ + "cloudinary", + "color", + "rgb", + "hex", + "rgba", + "color", + "conversion", + "js", + "javascript" + ], + "author": "Maya Shavin ", + "homepage": "https://github.com/mayashavin/cloudinary-api/tree/master/packages/tools#readme", + "license": "MIT", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/cjs/index.d.ts", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/mayashavin/cloudinary-api.git" + }, + "scripts": { + "build": "npm run clean && npm run compile", + "clean": "rm -rf ./dist", + "compile": "tsc -p tsconfig.build.json && tsc -p tsconfig.json", + "test:unit": "jest __tests__ --reporters default", + "tsc": "tsc -p tsconfig.build.json && tsc -p tsconfig.json", + "prepublishOnly": "agadoo dist/" + }, + "devDependencies": { + "@types/jest": "^26.0.15", + "@types/node": "^14.14.7", + "agadoo": "^2.0.0", + "husky": "^4.3.0", + "jest": "^26.6.3", + "jest-html-reporters": "^2.1.0", + "ts-jest": "^26.4.4", + "typescript": "^4.0.5" + }, + "bugs": { + "url": "https://github.com/mayashavin/cloudinary-api/issues" + } +} diff --git a/packages/tools/tsconfig.build.json b/packages/tools/tsconfig.build.json new file mode 100644 index 0000000..941b4a0 --- /dev/null +++ b/packages/tools/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "include": [ + "lib/**/*" + ], + "compilerOptions": { + "outDir": "./dist/cjs" + } +} \ No newline at end of file diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json new file mode 100644 index 0000000..b388ae0 --- /dev/null +++ b/packages/tools/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "ES2015" + } +} \ No newline at end of file