2
0
mirror of https://github.com/iconify/iconify.git synced 2025-02-06 14:08:40 +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 { 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)
);
},
};

View File

@ -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 <svg> 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 =
'<g transform="' +
transformations.join(' ') +
'">' +
body +
'</g>';
}
});
// 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 =
'<g transform="' + transformations.join(' ') + '">' + body + '</g>';
}
// Result
const result: IconifyIconBuildResult = {
attributes: {

View File

@ -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<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 { 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: '<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', () => {
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:
'<g transform="translate(20 0) scale(-1 1)"><path d="..." /></g>',
body: '<g transform="translate(20 0) scale(-1 1)"><path d="..." /></g>',
};
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:
'<g transform="rotate(90 8 8) translate(0 16) scale(1 -1)"><path d="..." /></g>',
body: '<g transform="rotate(90 8 8) translate(20 0) 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);
@ -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 =
'<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({
body: iconBody,
width: 128,