Skip to content

Commit

Permalink
Add cleanupTextElements plugin (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnkenny54 authored Nov 1, 2024
1 parent a5b3fd3 commit 827538b
Show file tree
Hide file tree
Showing 11 changed files with 358 additions and 24 deletions.
4 changes: 3 additions & 1 deletion lib/builtin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as addClassesToSVGElement from '../plugins/addClassesToSVGElement.js';
import * as cleanupIds from '../plugins/cleanupIds.js';
import * as cleanupNumericValues from '../plugins/cleanupNumericValues.js';
import * as cleanupStyleAttributes from '../plugins/cleanupStyleAttributes.js';
import * as cleanupTextElements from '../plugins/cleanupTextElements.js';
import * as cleanupXlink from '../plugins/cleanupXlink.js';
import * as collapseGroups from '../plugins/collapseGroups.js';
import * as combinePaths from '../plugins/combinePaths.js';
Expand Down Expand Up @@ -70,10 +71,11 @@ export const builtin = Object.freeze([
cleanupIds,
cleanupNumericValues,
cleanupStyleAttributes,
combineStyleElements,
cleanupTextElements,
cleanupXlink,
collapseGroups,
combinePaths,
combineStyleElements,
convertEllipseToCircle,
convertPathData,
convertShapeToPath,
Expand Down
267 changes: 267 additions & 0 deletions plugins/cleanupTextElements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
export const name = 'cleanupTextElements';
export const description = 'simplify <text> elements and content';

/**
* @type {import('./plugins-types.js').Plugin<'cleanupTextElements'>}
*/
export const fn = (root, params, info) => {
const styleData = info.docData.getStyles();
if (
info.docData.hasScripts() ||
styleData === null ||
styleData.hasStyles()
) {
return;
}

/** @type {Map<import('./inlineStyles.js').XastParent,Set<import('./collapseGroups.js').XastElement>>} */
const textElsToHoist = new Map();

return {
element: {
exit: (element) => {
if (element.name !== 'text') {
return;
}

// Remove xml:space="preserve" if possible.
if (element.attributes['xml:space'] === 'preserve') {
if (canRemovePreserve(element)) {
delete element.attributes['xml:space'];
}
}

// Remove any pure whitespace children.
const childrenToDelete = new Set();
for (const child of element.children) {
switch (child.type) {
case 'cdata':
case 'text':
if (isOnlyWhiteSpace(child.value)) {
childrenToDelete.add(child);
}
break;
}
}
if (childrenToDelete.size > 0) {
element.children = element.children.filter(
(c) => !childrenToDelete.has(c),
);
}

// If there is a single child whose content can be hoisted, do so.
const hoistableChild = getHoistableChild(element);
if (hoistableChild) {
element.children = hoistableChild.children;
for (const child of element.children) {
child.parentNode = element;
}
for (const attributeName of ['x', 'y']) {
if (hoistableChild.attributes[attributeName] !== undefined) {
element.attributes[attributeName] =
hoistableChild.attributes[attributeName];
}
}
}

// If the <text> element has x/y, and so do all children, remove x/y from <text>.
if (
element.attributes.x !== undefined &&
element.attributes.y !== undefined &&
childrenAllHaveXY(element)
) {
delete element.attributes.x;
delete element.attributes.y;
}

// If the <text> has no attributes, and all of the children can be hoisted, add this element to the list to be updated
// at the end.
if (Object.keys(element.attributes).length === 0) {
if (
element.parentNode.type === 'element' &&
element.parentNode.name !== 'switch'
) {
if (
element.children.every(
(child) => isHoistable(child) !== undefined,
)
) {
let textEls = textElsToHoist.get(element.parentNode);
if (!textEls) {
textEls = new Set();
textElsToHoist.set(element.parentNode, textEls);
}
textEls.add(element);
}
}
}
},
},
root: {
exit: () => {
for (const [parent, textEls] of textElsToHoist.entries()) {
/** @type {import('../lib/types.js').XastChild[]} */
const newChildren = [];
for (const child of parent.children) {
if (child.type !== 'element' || !textEls.has(child)) {
newChildren.push(child);
continue;
}
// Promote all children to <text> elements.
for (const textChild of child.children) {
if (textChild.type !== 'element') {
throw new Error();
}
textChild.parentNode = parent;
textChild.name = 'text';
newChildren.push(textChild);
}
}
parent.children = newChildren;
}
},
},
};
};

/**
* @param {import('../lib/types.js').XastElement} element
* @returns {boolean}
*/
function canRemovePreserve(element) {
for (const child of element.children) {
switch (child.type) {
case 'cdata':
case 'text':
if (hasSignificantWhiteSpace(child.value)) {
return false;
}
break;
case 'element':
switch (child.name) {
case 'tspan':
if (!canRemovePreserve(child)) {
return false;
}
break;
default:
return false;
}
break;
}
}
return true;
}

/**
* @param {import('../lib/types.js').XastElement} element
* @returns {boolean}
*/
function childrenAllHaveXY(element) {
for (const child of element.children) {
if (child.type !== 'element') {
return false;
}
if (child.name !== 'tspan') {
return false;
}
if (child.attributes.x === undefined || child.attributes.y === undefined) {
return false;
}
}
return true;
}

/**
* @param {import('../lib/types.js').XastElement} element
* @returns {import('../lib/types.js').XastElement|undefined}
*/
function getHoistableChild(element) {
if (element.children.length !== 1) {
return;
}
const child = element.children[0];
return isHoistable(child);
}

/**
* @param {string} str
* @returns {boolean}
*/
export function hasSignificantWhiteSpace(str) {
let isStart = true;
let lastIsSpace = false;
for (const char of str) {
switch (char) {
case ' ':
case '\n':
case '\t':
if (!isStart) {
// Consective space within text is significant.
if (lastIsSpace) {
return true;
}
}
lastIsSpace = true;
break;
default:
if (isStart) {
if (lastIsSpace) {
// There is space at beginning of string.
return true;
}
isStart = false;
}
lastIsSpace = false;
break;
}
}
if (isStart) {
return false;
}
return lastIsSpace;
}

/**
* @param {import('../lib/types.js').XastChild} child
* @returns {import('./collapseGroups.js').XastElement|undefined}
*/
function isHoistable(child) {
if (child.type !== 'element') {
return;
}
if (child.children.length !== 1) {
return;
}
if (child.children[0].type !== 'text') {
return;
}
for (const attributeName of Object.keys(child.attributes)) {
switch (attributeName) {
case 'x':
case 'y':
break;
default:
return;
}
}
return child;
}

/**
* @param {string} str
* @returns {boolean}
*/
function isOnlyWhiteSpace(str) {
for (const char of str) {
switch (char) {
case ' ':
case '\n':
case '\t':
continue;
default:
return false;
}
}
return true;
}
1 change: 1 addition & 0 deletions plugins/plugins-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type DefaultPlugins = {
};
cleanupStyleAttributes: void;
cleanupStyleElements: void;
cleanupTextElements: void;
collapseGroups: void;
combinePaths: void;
convertEllipseToCircle: void;
Expand Down
2 changes: 2 additions & 0 deletions plugins/preset-default.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createPreset } from '../lib/svgo/plugins.js';
import * as cleanupIds from './cleanupIds.js';
import * as cleanupStyleAttributes from './cleanupStyleAttributes.js';
import * as cleanupTextElements from './cleanupTextElements.js';
import * as cleanupXlink from './cleanupXlink.js';
import * as collapseGroups from './collapseGroups.js';
import * as combineStyleElements from './combineStyleElements.js';
Expand Down Expand Up @@ -59,6 +60,7 @@ const presetDefault = createPreset({
removeEmptyContainers,
removeUnusedNS,
createGroups,
cleanupTextElements,
],
});

Expand Down
2 changes: 2 additions & 0 deletions plugins/preset-next.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createPreset } from '../lib/svgo/plugins.js';
import * as cleanupIds from './cleanupIds.js';
import * as cleanupStyleAttributes from './cleanupStyleAttributes.js';
import * as cleanupTextElements from './cleanupTextElements.js';
import * as cleanupXlink from './cleanupXlink.js';
import * as collapseGroups from './collapseGroups.js';
import * as combineStyleElements from './combineStyleElements.js';
Expand Down Expand Up @@ -59,6 +60,7 @@ const presetNext = createPreset({
removeEmptyContainers,
removeUnusedNS,
createGroups,
cleanupTextElements,
],
});

Expand Down
20 changes: 20 additions & 0 deletions test/plugins/_cleanupTextElements.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { hasSignificantWhiteSpace } from '../../plugins/cleanupTextElements.js';

describe('test for significant whitespace', () => {
/** @type {{in:string,out:boolean}[]} */
const testCases = [
{ in: '', out: false },
{ in: ' \n\n\t', out: false },
{ in: ' x', out: true },
{ in: 'xxxx', out: false },
{ in: 'xx xx', out: false },
{ in: 'xx xx', out: true },
{ in: 'xxxx\n', out: true },
];

for (const testCase of testCases) {
it(`${JSON.stringify(testCase.in)}`, () => {
expect(hasSignificantWhiteSpace(testCase.in)).toBe(testCase.out);
});
}
});
20 changes: 20 additions & 0 deletions test/plugins/cleanupTextElements.01.svg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Remove xml:space when not needed.

===

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300">
<text xml:space="preserve" x="35" y="45">
<tspan x="35" y="45"> Here is some text</tspan>
<tspan x="35" y="55">And Here is some more</tspan>
</text>
<text xml:space="preserve" x="105" y="185">
<tspan x="105" y="185">Also down here</tspan>
</text>
</svg>

@@@

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300">
<text xml:space="preserve"><tspan x="35" y="45"> Here is some text</tspan><tspan x="35" y="55">And Here is some more</tspan></text>
<text x="105" y="185">Also down here</text>
</svg>
17 changes: 17 additions & 0 deletions test/plugins/cleanupTextElements.02.svg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Hoist multiple <tspan>s if possible.

===

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300">
<text>
<tspan x="35" y="45">Here is some text</tspan>
<tspan x="35" y="55">And Here is some more</tspan>
</text>
</svg>

@@@

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300">
<text x="35" y="45">Here is some text</text>
<text x="35" y="55">And Here is some more</text>
</svg>
Loading

0 comments on commit 827538b

Please sign in to comment.