diff --git a/packages/core/src/builder/functions.ts b/packages/core/src/builder/functions.ts index 42d75e8..adcbd2b 100644 --- a/packages/core/src/builder/functions.ts +++ b/packages/core/src/builder/functions.ts @@ -1,7 +1,7 @@ import { replaceIDs } from './ids'; import { calculateSize } from './calc-size'; import { fullIcon, IconifyIcon } from '../icon'; -import { fullCustomisations } from '../customisations'; +import { defaults, mergeCustomisations } from '../customisations'; import type { IconifyIconCustomisations } from '../customisations'; import { iconToSVG } from '.'; import type { IconifyIconBuildResult } from '.'; @@ -29,6 +29,9 @@ export const builderFunctions: IconifyBuilderFunctions = { replaceIDs, calculateSize, buildIcon: (icon, customisations) => { - return iconToSVG(fullIcon(icon), fullCustomisations(customisations)); + return iconToSVG( + fullIcon(icon), + mergeCustomisations(defaults, customisations) + ); }, }; diff --git a/packages/core/src/builder/index.ts b/packages/core/src/builder/index.ts index d59bae1..3261fae 100644 --- a/packages/core/src/builder/index.ts +++ b/packages/core/src/builder/index.ts @@ -66,7 +66,7 @@ interface ViewBox { * Get SVG attributes and content from icon + customisations * * Does not generate style to make it compatible with frameworks that use objects for style, such as React. - * Instead, it generates verticalAlign value that should be added to style. + * Instead, it generates 'inline' value. If true, rendering engine should add verticalAlign: -0.125em to icon. * * Customisations should be normalised by platform specific parser. * Result should be converted to by platform specific parser. @@ -84,80 +84,102 @@ export function iconToSVG( height: icon.height, }; - // Apply transformations - const transformations: string[] = []; - const hFlip = customisations.hFlip !== icon.hFlip; - const vFlip = customisations.vFlip !== icon.vFlip; - let rotation = customisations.rotate + icon.rotate; + // Body + let body = icon.body; - if (hFlip) { - if (vFlip) { - rotation += 2; - } else { - // Horizontal flip + // Apply transformations + [icon, customisations].forEach((props) => { + const transformations: string[] = []; + const hFlip = props.hFlip; + const vFlip = props.vFlip; + let rotation = props.rotate; + + // Icon is flipped first, then rotated + if (hFlip) { + if (vFlip) { + rotation += 2; + } else { + // Horizontal flip + transformations.push( + 'translate(' + + (box.width + box.left) + + ' ' + + (0 - box.top) + + ')' + ); + transformations.push('scale(-1 1)'); + box.top = box.left = 0; + } + } else if (vFlip) { + // Vertical flip transformations.push( 'translate(' + - (box.width + box.left) + + (0 - box.left) + ' ' + - (0 - box.top) + + (box.height + box.top) + ')' ); - transformations.push('scale(-1 1)'); + transformations.push('scale(1 -1)'); box.top = box.left = 0; } - } else if (vFlip) { - // Vertical flip - transformations.push( - 'translate(' + (0 - box.left) + ' ' + (box.height + box.top) + ')' - ); - transformations.push('scale(1 -1)'); - box.top = box.left = 0; - } - let tempValue: number; - rotation = rotation % 4; - switch (rotation) { - case 1: - // 90deg - tempValue = box.height / 2 + box.top; - transformations.unshift( - 'rotate(90 ' + tempValue + ' ' + tempValue + ')' - ); - break; - - case 2: - // 180deg - transformations.unshift( - 'rotate(180 ' + - (box.width / 2 + box.left) + - ' ' + - (box.height / 2 + box.top) + - ')' - ); - break; - - case 3: - // 270deg - tempValue = box.width / 2 + box.left; - transformations.unshift( - 'rotate(-90 ' + tempValue + ' ' + tempValue + ')' - ); - break; - } - - if (rotation % 2 === 1) { - // Swap width/height and x/y for 90deg or 270deg rotation - if (box.left !== 0 || box.top !== 0) { - tempValue = box.left; - box.left = box.top; - box.top = tempValue; + let tempValue: number; + if (rotation < 0) { + rotation -= Math.floor(rotation / 4) * 4; } - if (box.width !== box.height) { - tempValue = box.width; - box.width = box.height; - box.height = tempValue; + rotation = rotation % 4; + switch (rotation) { + case 1: + // 90deg + tempValue = box.height / 2 + box.top; + transformations.unshift( + 'rotate(90 ' + tempValue + ' ' + tempValue + ')' + ); + break; + + case 2: + // 180deg + transformations.unshift( + 'rotate(180 ' + + (box.width / 2 + box.left) + + ' ' + + (box.height / 2 + box.top) + + ')' + ); + break; + + case 3: + // 270deg + tempValue = box.width / 2 + box.left; + transformations.unshift( + 'rotate(-90 ' + tempValue + ' ' + tempValue + ')' + ); + break; } - } + + if (rotation % 2 === 1) { + // Swap width/height and x/y for 90deg or 270deg rotation + if (box.left !== 0 || box.top !== 0) { + tempValue = box.left; + box.left = box.top; + box.top = tempValue; + } + if (box.width !== box.height) { + tempValue = box.width; + box.width = box.height; + box.height = tempValue; + } + } + + if (transformations.length) { + body = + '' + + body + + ''; + } + }); // Calculate dimensions let width, height; @@ -195,13 +217,6 @@ export function iconToSVG( width = typeof width === 'string' ? width : width + ''; height = typeof height === 'string' ? height : height + ''; - // Generate body - let body = icon.body; - if (transformations.length) { - body = - '' + body + ''; - } - // Result const result: IconifyIconBuildResult = { attributes: { diff --git a/packages/core/src/customisations/index.ts b/packages/core/src/customisations/index.ts index 8d372e4..f3fe4bc 100644 --- a/packages/core/src/customisations/index.ts +++ b/packages/core/src/customisations/index.ts @@ -1,5 +1,3 @@ -import { merge } from '../misc/merge'; - /** * Icon alignment */ @@ -57,11 +55,81 @@ export const defaults: FullIconCustomisations = Object.freeze({ rotate: 0, }); +/** + * TypeScript + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental, @typescript-eslint/no-unused-vars +function assertNever(v: never) { + // +} + /** * Convert IconifyIconCustomisations to FullIconCustomisations */ -export function fullCustomisations( +export function mergeCustomisations( + defaults: FullIconCustomisations, item: IconifyIconCustomisations ): FullIconCustomisations { - return merge(defaults, item) as FullIconCustomisations; + const result: FullIconCustomisations = {} as FullIconCustomisations; + for (const key in defaults) { + const attr = key as keyof FullIconCustomisations; + + // Copy old value + (result as Record)[attr] = defaults[attr]; + + if (item[attr] === void 0) { + continue; + } + + // Validate new value + const value = item[attr]; + switch (attr) { + // Boolean attributes that override old value + case 'inline': + case 'slice': + if (typeof value === 'boolean') { + result[attr] = value; + } + break; + + // Boolean attributes that are merged + case 'hFlip': + case 'vFlip': + if (value === true) { + result[attr] = !result[attr]; + } + break; + + // Non-empty string + case 'hAlign': + case 'vAlign': + if (typeof value === 'string' && value !== '') { + (result as Record)[attr] = value; + } + break; + + // Non-empty string / non-zero number / null + case 'width': + case 'height': + if ( + (typeof value === 'string' && value !== '') || + (typeof value === 'number' && value) || + value === null + ) { + result[attr] = value; + } + break; + + // Rotation + case 'rotate': + if (typeof value === 'number') { + result[attr] += value; + } + break; + + default: + assertNever(attr); + } + } + return result; } diff --git a/packages/core/tests/20-builder/icon-to-svg-test.ts b/packages/core/tests/20-builder/icon-to-svg-test.ts index 53a8d38..1e001bc 100644 --- a/packages/core/tests/20-builder/icon-to-svg-test.ts +++ b/packages/core/tests/20-builder/icon-to-svg-test.ts @@ -5,7 +5,7 @@ import { iconToSVG } from '../../lib/builder'; import type { FullIconifyIcon } from '../../lib/icon'; import { iconDefaults, fullIcon } from '../../lib/icon'; import type { FullIconCustomisations } from '../../lib/customisations'; -import { defaults, fullCustomisations } from '../../lib/customisations'; +import { defaults, mergeCustomisations } from '../../lib/customisations'; describe('Testing iconToSVG', () => { it('Empty icon', () => { @@ -26,7 +26,7 @@ describe('Testing iconToSVG', () => { }); it('Auto size, inline, body', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { inline: true, height: 'auto', }); @@ -49,7 +49,7 @@ describe('Testing iconToSVG', () => { }); it('Auto size, inline, body', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { inline: true, height: 'auto', }); @@ -72,7 +72,7 @@ describe('Testing iconToSVG', () => { }); it('Custom size, alignment', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { height: 'auto', hAlign: 'left', slice: true, @@ -97,7 +97,7 @@ describe('Testing iconToSVG', () => { }); it('Rotation, alignment', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { height: '40px', vAlign: 'bottom', rotate: 1, @@ -121,8 +121,32 @@ describe('Testing iconToSVG', () => { expect(result).to.be.eql(expected); }); + it('Negative rotation', () => { + const custom: FullIconCustomisations = mergeCustomisations(defaults, { + height: '40px', + rotate: -1, + }); + const icon: FullIconifyIcon = fullIcon({ + width: 20, + height: 16, + body: '', + }); + const expected: IconifyIconBuildResult = { + attributes: { + width: '32px', + height: '40px', + preserveAspectRatio: 'xMidYMid meet', + viewBox: '0 0 16 20', + }, + body: '', + }; + + const result = iconToSVG(icon, custom); + expect(result).to.be.eql(expected); + }); + it('Flip, alignment', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { height: '32', vAlign: 'top', hAlign: 'right', @@ -140,8 +164,7 @@ describe('Testing iconToSVG', () => { preserveAspectRatio: 'xMaxYMin meet', viewBox: '0 0 20 16', }, - body: - '', + body: '', }; const result = iconToSVG(icon, custom); @@ -149,8 +172,8 @@ describe('Testing iconToSVG', () => { }); it('Flip, rotation', () => { - const custom: FullIconCustomisations = fullCustomisations({ - vFlip: true, + const custom: FullIconCustomisations = mergeCustomisations(defaults, { + hFlip: true, rotate: 1, }); const icon: FullIconifyIcon = fullIcon({ @@ -165,8 +188,34 @@ describe('Testing iconToSVG', () => { preserveAspectRatio: 'xMidYMid meet', viewBox: '0 0 16 20', }, - body: - '', + body: '', + }; + + const result = iconToSVG(icon, custom); + expect(result).to.be.eql(expected); + }); + + it('Flip icon that is rotated by default', () => { + const custom: FullIconCustomisations = mergeCustomisations(defaults, { + hFlip: true, + }); + const icon: FullIconifyIcon = fullIcon({ + width: 20, + height: 16, + body: '', + rotate: 1, + }); + + // Horizontally flipping icon that has 90 or 270 degrees rotation will result in vertical flip. + // Therefore result should be rotation + vertical flip to visually match horizontal flip on normal icon. + const expected: IconifyIconBuildResult = { + attributes: { + width: '0.8em', + height: '1em', + preserveAspectRatio: 'xMidYMid meet', + viewBox: '0 0 16 20', + }, + body: '', }; const result = iconToSVG(icon, custom); @@ -174,7 +223,7 @@ describe('Testing iconToSVG', () => { }); it('Flip and rotation canceling eachother', () => { - const custom: FullIconCustomisations = fullCustomisations({ + const custom: FullIconCustomisations = mergeCustomisations(defaults, { width: '1em', height: 'auto', hFlip: true, @@ -204,7 +253,10 @@ describe('Testing iconToSVG', () => { const iconBody = ''; - const custom: FullIconCustomisations = fullCustomisations({}); + const custom: FullIconCustomisations = mergeCustomisations( + defaults, + {} + ); const icon: FullIconifyIcon = fullIcon({ body: iconBody, width: 128,