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:
parent
8da393c816
commit
46ae993b95
@ -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)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user