2
0
mirror of https://github.com/iconify/iconify.git synced 2025-02-11 16:19:05 +00:00

Fix incorrect transformations for negative rotation, for merging icon and customisation rotations, better code for merging customisations

This commit is contained in:
Vjacheslav Trushkin 2021-05-11 21:03:10 +03:00
parent 8da393c816
commit 46ae993b95
4 changed files with 229 additions and 91 deletions

View File

@ -1,7 +1,7 @@
import { replaceIDs } from './ids'; import { replaceIDs } from './ids';
import { calculateSize } from './calc-size'; import { calculateSize } from './calc-size';
import { fullIcon, IconifyIcon } from '../icon'; import { fullIcon, IconifyIcon } from '../icon';
import { fullCustomisations } from '../customisations'; import { defaults, mergeCustomisations } from '../customisations';
import type { IconifyIconCustomisations } from '../customisations'; import type { IconifyIconCustomisations } from '../customisations';
import { iconToSVG } from '.'; import { iconToSVG } from '.';
import type { IconifyIconBuildResult } from '.'; import type { IconifyIconBuildResult } from '.';
@ -29,6 +29,9 @@ export const builderFunctions: IconifyBuilderFunctions = {
replaceIDs, replaceIDs,
calculateSize, calculateSize,
buildIcon: (icon, customisations) => { buildIcon: (icon, customisations) => {
return iconToSVG(fullIcon(icon), fullCustomisations(customisations)); return iconToSVG(
fullIcon(icon),
mergeCustomisations(defaults, customisations)
);
}, },
}; };

View File

@ -66,7 +66,7 @@ interface ViewBox {
* Get SVG attributes and content from icon + customisations * 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. * 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. * Customisations should be normalised by platform specific parser.
* Result should be converted to <svg> by platform specific parser. * Result should be converted to <svg> by platform specific parser.
@ -84,80 +84,102 @@ export function iconToSVG(
height: icon.height, height: icon.height,
}; };
// Apply transformations // Body
const transformations: string[] = []; let body = icon.body;
const hFlip = customisations.hFlip !== icon.hFlip;
const vFlip = customisations.vFlip !== icon.vFlip;
let rotation = customisations.rotate + icon.rotate;
if (hFlip) { // Apply transformations
if (vFlip) { [icon, customisations].forEach((props) => {
rotation += 2; const transformations: string[] = [];
} else { const hFlip = props.hFlip;
// Horizontal flip 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( transformations.push(
'translate(' + '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; 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; let tempValue: number;
rotation = rotation % 4; if (rotation < 0) {
switch (rotation) { rotation -= Math.floor(rotation / 4) * 4;
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) { rotation = rotation % 4;
tempValue = box.width; switch (rotation) {
box.width = box.height; case 1:
box.height = tempValue; // 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 =
'<g transform="' +
transformations.join(' ') +
'">' +
body +
'</g>';
}
});
// Calculate dimensions // Calculate dimensions
let width, height; let width, height;
@ -195,13 +217,6 @@ export function iconToSVG(
width = typeof width === 'string' ? width : width + ''; width = typeof width === 'string' ? width : width + '';
height = typeof height === 'string' ? height : height + ''; height = typeof height === 'string' ? height : height + '';
// Generate body
let body = icon.body;
if (transformations.length) {
body =
'<g transform="' + transformations.join(' ') + '">' + body + '</g>';
}
// Result // Result
const result: IconifyIconBuildResult = { const result: IconifyIconBuildResult = {
attributes: { attributes: {

View File

@ -1,5 +1,3 @@
import { merge } from '../misc/merge';
/** /**
* Icon alignment * Icon alignment
*/ */
@ -57,11 +55,81 @@ export const defaults: FullIconCustomisations = Object.freeze({
rotate: 0, 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 * Convert IconifyIconCustomisations to FullIconCustomisations
*/ */
export function fullCustomisations( export function mergeCustomisations(
defaults: FullIconCustomisations,
item: IconifyIconCustomisations item: IconifyIconCustomisations
): FullIconCustomisations { ): 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<string, unknown>)[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<string, unknown>)[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;
} }

View File

@ -5,7 +5,7 @@ import { iconToSVG } from '../../lib/builder';
import type { FullIconifyIcon } from '../../lib/icon'; import type { FullIconifyIcon } from '../../lib/icon';
import { iconDefaults, fullIcon } from '../../lib/icon'; import { iconDefaults, fullIcon } from '../../lib/icon';
import type { FullIconCustomisations } from '../../lib/customisations'; import type { FullIconCustomisations } from '../../lib/customisations';
import { defaults, fullCustomisations } from '../../lib/customisations'; import { defaults, mergeCustomisations } from '../../lib/customisations';
describe('Testing iconToSVG', () => { describe('Testing iconToSVG', () => {
it('Empty icon', () => { it('Empty icon', () => {
@ -26,7 +26,7 @@ describe('Testing iconToSVG', () => {
}); });
it('Auto size, inline, body', () => { it('Auto size, inline, body', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
inline: true, inline: true,
height: 'auto', height: 'auto',
}); });
@ -49,7 +49,7 @@ describe('Testing iconToSVG', () => {
}); });
it('Auto size, inline, body', () => { it('Auto size, inline, body', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
inline: true, inline: true,
height: 'auto', height: 'auto',
}); });
@ -72,7 +72,7 @@ describe('Testing iconToSVG', () => {
}); });
it('Custom size, alignment', () => { it('Custom size, alignment', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
height: 'auto', height: 'auto',
hAlign: 'left', hAlign: 'left',
slice: true, slice: true,
@ -97,7 +97,7 @@ describe('Testing iconToSVG', () => {
}); });
it('Rotation, alignment', () => { it('Rotation, alignment', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
height: '40px', height: '40px',
vAlign: 'bottom', vAlign: 'bottom',
rotate: 1, rotate: 1,
@ -121,8 +121,32 @@ describe('Testing iconToSVG', () => {
expect(result).to.be.eql(expected); 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: '<path d="..." />',
});
const expected: IconifyIconBuildResult = {
attributes: {
width: '32px',
height: '40px',
preserveAspectRatio: 'xMidYMid meet',
viewBox: '0 0 16 20',
},
body: '<g transform="rotate(-90 10 10)"><path d="..." /></g>',
};
const result = iconToSVG(icon, custom);
expect(result).to.be.eql(expected);
});
it('Flip, alignment', () => { it('Flip, alignment', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
height: '32', height: '32',
vAlign: 'top', vAlign: 'top',
hAlign: 'right', hAlign: 'right',
@ -140,8 +164,7 @@ describe('Testing iconToSVG', () => {
preserveAspectRatio: 'xMaxYMin meet', preserveAspectRatio: 'xMaxYMin meet',
viewBox: '0 0 20 16', viewBox: '0 0 20 16',
}, },
body: body: '<g transform="translate(20 0) scale(-1 1)"><path d="..." /></g>',
'<g transform="translate(20 0) scale(-1 1)"><path d="..." /></g>',
}; };
const result = iconToSVG(icon, custom); const result = iconToSVG(icon, custom);
@ -149,8 +172,8 @@ describe('Testing iconToSVG', () => {
}); });
it('Flip, rotation', () => { it('Flip, rotation', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
vFlip: true, hFlip: true,
rotate: 1, rotate: 1,
}); });
const icon: FullIconifyIcon = fullIcon({ const icon: FullIconifyIcon = fullIcon({
@ -165,8 +188,34 @@ describe('Testing iconToSVG', () => {
preserveAspectRatio: 'xMidYMid meet', preserveAspectRatio: 'xMidYMid meet',
viewBox: '0 0 16 20', viewBox: '0 0 16 20',
}, },
body: body: '<g transform="rotate(90 8 8) translate(20 0) scale(-1 1)"><path d="..." /></g>',
'<g transform="rotate(90 8 8) translate(0 16) scale(1 -1)"><path d="..." /></g>', };
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: '<path d="..." />',
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: '<g transform="translate(16 0) scale(-1 1)"><g transform="rotate(90 8 8)"><path d="..." /></g></g>',
}; };
const result = iconToSVG(icon, custom); const result = iconToSVG(icon, custom);
@ -174,7 +223,7 @@ describe('Testing iconToSVG', () => {
}); });
it('Flip and rotation canceling eachother', () => { it('Flip and rotation canceling eachother', () => {
const custom: FullIconCustomisations = fullCustomisations({ const custom: FullIconCustomisations = mergeCustomisations(defaults, {
width: '1em', width: '1em',
height: 'auto', height: 'auto',
hFlip: true, hFlip: true,
@ -204,7 +253,10 @@ describe('Testing iconToSVG', () => {
const iconBody = const iconBody =
'<g stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" fill="none" fill-rule="evenodd"><path d="M40 64l48-48" class="animation-delay-0 animation-duration-10 animate-stroke stroke-length-102"/><path d="M40 64l48 48" class="animation-delay-0 animation-duration-10 animate-stroke stroke-length-102"/></g>'; '<g stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" fill="none" fill-rule="evenodd"><path d="M40 64l48-48" class="animation-delay-0 animation-duration-10 animate-stroke stroke-length-102"/><path d="M40 64l48 48" class="animation-delay-0 animation-duration-10 animate-stroke stroke-length-102"/></g>';
const custom: FullIconCustomisations = fullCustomisations({}); const custom: FullIconCustomisations = mergeCustomisations(
defaults,
{}
);
const icon: FullIconifyIcon = fullIcon({ const icon: FullIconifyIcon = fullIcon({
body: iconBody, body: iconBody,
width: 128, width: 128,