Document application modes (development/debug/production) Add global file drop handler, order column normalization, SPA hash fix Serve CDN assets via /_vendor/ URLs instead of merging into bundles Add production minification with license preservation Improve JSON formatting for debugging and production optimization Add CDN asset caching with CSS URL inlining for production builds Add three-mode system (development, debug, production) Update Manifest CLAUDE.md to reflect helper class architecture Refactor Manifest.php into helper classes for better organization Pre-manifest-refactor checkpoint: Add app_mode documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1347 lines
38 KiB
JavaScript
Executable File
1347 lines
38 KiB
JavaScript
Executable File
import { js2path, path2js } from './_path.js';
|
||
import { pathElems } from './_collections.js';
|
||
import { applyTransforms } from './applyTransforms.js';
|
||
import { collectStylesheet, computeStyle } from '../lib/style.js';
|
||
import { visit } from '../lib/util/visit.js';
|
||
import { cleanupOutData, toFixed } from '../lib/svgo/tools.js';
|
||
|
||
/**
|
||
* @typedef {[number, number]} Point
|
||
*
|
||
* @typedef Circle
|
||
* @property {Point} center
|
||
* @property {number} radius
|
||
*
|
||
* @typedef MakeArcs
|
||
* @property {number} threshold
|
||
* @property {number} tolerance
|
||
*
|
||
* @typedef ConvertPathDataParams
|
||
* @property {boolean=} applyTransforms
|
||
* @property {boolean=} applyTransformsStroked
|
||
* @property {MakeArcs=} makeArcs
|
||
* @property {boolean=} straightCurves
|
||
* @property {boolean=} convertToQ
|
||
* @property {boolean=} lineShorthands
|
||
* @property {boolean=} convertToZ
|
||
* @property {boolean=} curveSmoothShorthands
|
||
* @property {number | false=} floatPrecision
|
||
* @property {number=} transformPrecision
|
||
* @property {boolean=} smartArcRounding
|
||
* @property {boolean=} removeUseless
|
||
* @property {boolean=} collapseRepeated
|
||
* @property {boolean=} utilizeAbsolute
|
||
* @property {boolean=} leadingZero
|
||
* @property {boolean=} negativeExtraSpace
|
||
* @property {boolean=} noSpaceAfterFlags
|
||
* @property {boolean=} forceAbsolutePath
|
||
*
|
||
* @typedef {Required<ConvertPathDataParams>} InternalParams
|
||
*/
|
||
|
||
export const name = 'convertPathData';
|
||
export const description =
|
||
'optimizes path data: writes in shorter form, applies transformations';
|
||
|
||
/** @type {(data: number[]) => number[]} */
|
||
let roundData;
|
||
/** @type {number | false} */
|
||
let precision;
|
||
/** @type {number} */
|
||
let error;
|
||
/** @type {number} */
|
||
let arcThreshold;
|
||
/** @type {number} */
|
||
let arcTolerance;
|
||
|
||
/**
|
||
* Convert absolute Path to relative,
|
||
* collapse repeated instructions,
|
||
* detect and convert Lineto shorthands,
|
||
* remove useless instructions like "l0,0",
|
||
* trim useless delimiters and leading zeros,
|
||
* decrease accuracy of floating-point numbers.
|
||
*
|
||
* @see https://www.w3.org/TR/SVG11/paths.html#PathData
|
||
*
|
||
* @author Kir Belevich
|
||
*
|
||
* @type {import('../lib/types.js').Plugin<ConvertPathDataParams>}
|
||
*/
|
||
export const fn = (root, params) => {
|
||
const {
|
||
// TODO convert to separate plugin in v3
|
||
applyTransforms: _applyTransforms = true,
|
||
applyTransformsStroked = true,
|
||
makeArcs = {
|
||
threshold: 2.5, // coefficient of rounding error
|
||
tolerance: 0.5, // percentage of radius
|
||
},
|
||
straightCurves = true,
|
||
convertToQ = true,
|
||
lineShorthands = true,
|
||
convertToZ = true,
|
||
curveSmoothShorthands = true,
|
||
floatPrecision = 3,
|
||
transformPrecision = 5,
|
||
smartArcRounding = true,
|
||
removeUseless = true,
|
||
collapseRepeated = true,
|
||
utilizeAbsolute = true,
|
||
leadingZero = true,
|
||
negativeExtraSpace = true,
|
||
noSpaceAfterFlags = false, // a20 60 45 0 1 30 20 → a20 60 45 0130 20
|
||
forceAbsolutePath = false,
|
||
} = params;
|
||
|
||
/** @type {InternalParams} */
|
||
const newParams = {
|
||
applyTransforms: _applyTransforms,
|
||
applyTransformsStroked,
|
||
makeArcs,
|
||
straightCurves,
|
||
convertToQ,
|
||
lineShorthands,
|
||
convertToZ,
|
||
curveSmoothShorthands,
|
||
floatPrecision,
|
||
transformPrecision,
|
||
smartArcRounding,
|
||
removeUseless,
|
||
collapseRepeated,
|
||
utilizeAbsolute,
|
||
leadingZero,
|
||
negativeExtraSpace,
|
||
noSpaceAfterFlags,
|
||
forceAbsolutePath,
|
||
};
|
||
|
||
// invoke applyTransforms plugin
|
||
if (_applyTransforms) {
|
||
visit(
|
||
root,
|
||
// @ts-expect-error
|
||
applyTransforms(root, {
|
||
transformPrecision,
|
||
applyTransformsStroked,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const stylesheet = collectStylesheet(root);
|
||
return {
|
||
element: {
|
||
enter: (node) => {
|
||
if (pathElems.has(node.name) && node.attributes.d != null) {
|
||
const computedStyle = computeStyle(stylesheet, node);
|
||
precision = floatPrecision;
|
||
error =
|
||
precision !== false
|
||
? +Math.pow(0.1, precision).toFixed(precision)
|
||
: 1e-2;
|
||
roundData =
|
||
precision && precision > 0 && precision < 20 ? strongRound : round;
|
||
if (makeArcs) {
|
||
arcThreshold = makeArcs.threshold;
|
||
arcTolerance = makeArcs.tolerance;
|
||
}
|
||
const hasMarkerMid = computedStyle['marker-mid'] != null;
|
||
|
||
const maybeHasStroke =
|
||
computedStyle.stroke &&
|
||
(computedStyle.stroke.type === 'dynamic' ||
|
||
computedStyle.stroke.value !== 'none');
|
||
const maybeHasLinecap =
|
||
computedStyle['stroke-linecap'] &&
|
||
(computedStyle['stroke-linecap'].type === 'dynamic' ||
|
||
computedStyle['stroke-linecap'].value !== 'butt');
|
||
const maybeHasStrokeAndLinecap = maybeHasStroke && maybeHasLinecap;
|
||
const isSafeToUseZ = maybeHasStroke
|
||
? computedStyle['stroke-linecap']?.type === 'static' &&
|
||
computedStyle['stroke-linecap'].value === 'round' &&
|
||
computedStyle['stroke-linejoin']?.type === 'static' &&
|
||
computedStyle['stroke-linejoin'].value === 'round'
|
||
: true;
|
||
|
||
let data = path2js(node);
|
||
|
||
// TODO: get rid of functions returns
|
||
if (data.length) {
|
||
const includesVertices = data.some(
|
||
(item) => item.command !== 'm' && item.command !== 'M',
|
||
);
|
||
convertToRelative(data);
|
||
|
||
data = filters(data, newParams, {
|
||
isSafeToUseZ,
|
||
maybeHasStrokeAndLinecap,
|
||
hasMarkerMid,
|
||
});
|
||
|
||
if (utilizeAbsolute) {
|
||
data = convertToMixed(data, newParams);
|
||
}
|
||
|
||
const hasMarker =
|
||
node.attributes['marker-start'] != null ||
|
||
node.attributes['marker-end'] != null;
|
||
const isMarkersOnlyPath =
|
||
hasMarker &&
|
||
includesVertices &&
|
||
data.every(
|
||
(item) => item.command === 'm' || item.command === 'M',
|
||
);
|
||
|
||
if (isMarkersOnlyPath) {
|
||
data.push({
|
||
command: 'z',
|
||
args: [],
|
||
});
|
||
}
|
||
|
||
// @ts-expect-error
|
||
js2path(node, data, newParams);
|
||
}
|
||
}
|
||
},
|
||
},
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Convert absolute path data coordinates to relative.
|
||
*
|
||
* @param {import('../lib/types.js').PathDataItem[]} pathData
|
||
* @returns {import('../lib/types.js').PathDataItem[]}
|
||
*/
|
||
const convertToRelative = (pathData) => {
|
||
const start = [0, 0];
|
||
const cursor = [0, 0];
|
||
let prevCoords = [0, 0];
|
||
|
||
for (let i = 0; i < pathData.length; i += 1) {
|
||
const pathItem = pathData[i];
|
||
let { command, args } = pathItem;
|
||
|
||
// moveto (x y)
|
||
if (command === 'm') {
|
||
// update start and cursor
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
start[0] = cursor[0];
|
||
start[1] = cursor[1];
|
||
}
|
||
if (command === 'M') {
|
||
// M → m
|
||
// skip first moveto
|
||
if (i !== 0) {
|
||
command = 'm';
|
||
}
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
// update start and cursor
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
start[0] = cursor[0];
|
||
start[1] = cursor[1];
|
||
}
|
||
|
||
// lineto (x y)
|
||
if (command === 'l') {
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
}
|
||
if (command === 'L') {
|
||
// L → l
|
||
command = 'l';
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
}
|
||
|
||
// horizontal lineto (x)
|
||
if (command === 'h') {
|
||
cursor[0] += args[0];
|
||
}
|
||
if (command === 'H') {
|
||
// H → h
|
||
command = 'h';
|
||
args[0] -= cursor[0];
|
||
cursor[0] += args[0];
|
||
}
|
||
|
||
// vertical lineto (y)
|
||
if (command === 'v') {
|
||
cursor[1] += args[0];
|
||
}
|
||
if (command === 'V') {
|
||
// V → v
|
||
command = 'v';
|
||
args[0] -= cursor[1];
|
||
cursor[1] += args[0];
|
||
}
|
||
|
||
// curveto (x1 y1 x2 y2 x y)
|
||
if (command === 'c') {
|
||
cursor[0] += args[4];
|
||
cursor[1] += args[5];
|
||
}
|
||
if (command === 'C') {
|
||
// C → c
|
||
command = 'c';
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
args[2] -= cursor[0];
|
||
args[3] -= cursor[1];
|
||
args[4] -= cursor[0];
|
||
args[5] -= cursor[1];
|
||
cursor[0] += args[4];
|
||
cursor[1] += args[5];
|
||
}
|
||
|
||
// smooth curveto (x2 y2 x y)
|
||
if (command === 's') {
|
||
cursor[0] += args[2];
|
||
cursor[1] += args[3];
|
||
}
|
||
if (command === 'S') {
|
||
// S → s
|
||
command = 's';
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
args[2] -= cursor[0];
|
||
args[3] -= cursor[1];
|
||
cursor[0] += args[2];
|
||
cursor[1] += args[3];
|
||
}
|
||
|
||
// quadratic Bézier curveto (x1 y1 x y)
|
||
if (command === 'q') {
|
||
cursor[0] += args[2];
|
||
cursor[1] += args[3];
|
||
}
|
||
if (command === 'Q') {
|
||
// Q → q
|
||
command = 'q';
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
args[2] -= cursor[0];
|
||
args[3] -= cursor[1];
|
||
cursor[0] += args[2];
|
||
cursor[1] += args[3];
|
||
}
|
||
|
||
// smooth quadratic Bézier curveto (x y)
|
||
if (command === 't') {
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
}
|
||
if (command === 'T') {
|
||
// T → t
|
||
command = 't';
|
||
args[0] -= cursor[0];
|
||
args[1] -= cursor[1];
|
||
cursor[0] += args[0];
|
||
cursor[1] += args[1];
|
||
}
|
||
|
||
// elliptical arc (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
|
||
if (command === 'a') {
|
||
cursor[0] += args[5];
|
||
cursor[1] += args[6];
|
||
}
|
||
if (command === 'A') {
|
||
// A → a
|
||
command = 'a';
|
||
args[5] -= cursor[0];
|
||
args[6] -= cursor[1];
|
||
cursor[0] += args[5];
|
||
cursor[1] += args[6];
|
||
}
|
||
|
||
// closepath
|
||
if (command === 'Z' || command === 'z') {
|
||
// reset cursor
|
||
cursor[0] = start[0];
|
||
cursor[1] = start[1];
|
||
}
|
||
|
||
pathItem.command = command;
|
||
pathItem.args = args;
|
||
// store absolute coordinates for later use
|
||
// base should preserve reference from other element
|
||
// @ts-expect-error
|
||
pathItem.base = prevCoords;
|
||
// @ts-expect-error
|
||
pathItem.coords = [cursor[0], cursor[1]];
|
||
// @ts-expect-error
|
||
prevCoords = pathItem.coords;
|
||
}
|
||
|
||
return pathData;
|
||
};
|
||
|
||
/**
|
||
* Main filters loop.
|
||
*
|
||
* @param {import('../lib/types.js').PathDataItem[]} path
|
||
* @param {InternalParams} params
|
||
* @param {{ isSafeToUseZ: boolean, maybeHasStrokeAndLinecap: boolean, hasMarkerMid: boolean }} param2
|
||
* @returns {import('../lib/types.js').PathDataItem[]}
|
||
*/
|
||
function filters(
|
||
path,
|
||
params,
|
||
{ isSafeToUseZ, maybeHasStrokeAndLinecap, hasMarkerMid },
|
||
) {
|
||
const stringify = data2Path.bind(null, params);
|
||
const relSubpoint = [0, 0];
|
||
const pathBase = [0, 0];
|
||
/** @type {any} */
|
||
let prev = {};
|
||
/** @type {Point | undefined} */
|
||
let prevQControlPoint;
|
||
|
||
path = path.filter(function (item, index, path) {
|
||
const qControlPoint = prevQControlPoint;
|
||
|
||
let command = item.command;
|
||
let data = item.args;
|
||
let next = path[index + 1];
|
||
|
||
if (command !== 'Z' && command !== 'z') {
|
||
let sdata = data;
|
||
let circle;
|
||
|
||
if (command === 's') {
|
||
sdata = [0, 0].concat(data);
|
||
|
||
const pdata = prev.args;
|
||
const n = pdata.length;
|
||
|
||
// (-x, -y) of the prev tangent point relative to the current point
|
||
sdata[0] = pdata[n - 2] - pdata[n - 4];
|
||
sdata[1] = pdata[n - 1] - pdata[n - 3];
|
||
}
|
||
|
||
// convert curves to arcs if possible
|
||
if (
|
||
params.makeArcs &&
|
||
(command == 'c' || command == 's') &&
|
||
isConvex(sdata) &&
|
||
(circle = findCircle(sdata))
|
||
) {
|
||
const r = roundData([circle.radius])[0];
|
||
let angle = findArcAngle(sdata, circle);
|
||
const sweep = sdata[5] * sdata[0] - sdata[4] * sdata[1] > 0 ? 1 : 0;
|
||
/** @type {import('../lib/types.js').PathDataItem} */
|
||
let arc = {
|
||
command: 'a',
|
||
args: [r, r, 0, 0, sweep, sdata[4], sdata[5]],
|
||
// @ts-expect-error
|
||
coords: item.coords.slice(),
|
||
// @ts-expect-error
|
||
base: item.base,
|
||
};
|
||
/** @type {import('../lib/types.js').PathDataItem[]} */
|
||
const output = [arc];
|
||
// relative coordinates to adjust the found circle
|
||
/** @type {Point} */
|
||
const relCenter = [
|
||
circle.center[0] - sdata[4],
|
||
circle.center[1] - sdata[5],
|
||
];
|
||
const relCircle = { center: relCenter, radius: circle.radius };
|
||
const arcCurves = [item];
|
||
let hasPrev = 0;
|
||
let suffix = '';
|
||
let nextLonghand;
|
||
|
||
if (
|
||
(prev.command == 'c' &&
|
||
isConvex(prev.args) &&
|
||
isArcPrev(prev.args, circle)) ||
|
||
(prev.command == 'a' && prev.sdata && isArcPrev(prev.sdata, circle))
|
||
) {
|
||
arcCurves.unshift(prev);
|
||
// @ts-expect-error
|
||
arc.base = prev.base;
|
||
// @ts-expect-error
|
||
arc.args[5] = arc.coords[0] - arc.base[0];
|
||
// @ts-expect-error
|
||
arc.args[6] = arc.coords[1] - arc.base[1];
|
||
const prevData = prev.command == 'a' ? prev.sdata : prev.args;
|
||
const prevAngle = findArcAngle(prevData, {
|
||
center: [
|
||
prevData[4] + circle.center[0],
|
||
prevData[5] + circle.center[1],
|
||
],
|
||
radius: circle.radius,
|
||
});
|
||
angle += prevAngle;
|
||
if (angle > Math.PI) {
|
||
arc.args[3] = 1;
|
||
}
|
||
hasPrev = 1;
|
||
}
|
||
|
||
// check if next curves are fitting the arc
|
||
for (
|
||
var j = index;
|
||
(next = path[++j]) && (next.command === 'c' || next.command === 's');
|
||
|
||
) {
|
||
let nextData = next.args;
|
||
if (next.command == 's') {
|
||
nextLonghand = makeLonghand(
|
||
{ command: 's', args: next.args.slice() },
|
||
path[j - 1].args,
|
||
);
|
||
nextData = nextLonghand.args;
|
||
nextLonghand.args = nextData.slice(0, 2);
|
||
suffix = stringify([nextLonghand]);
|
||
}
|
||
if (isConvex(nextData) && isArc(nextData, relCircle)) {
|
||
angle += findArcAngle(nextData, relCircle);
|
||
if (angle - 2 * Math.PI > 1e-3) {
|
||
break;
|
||
} // more than 360°
|
||
if (angle > Math.PI) {
|
||
arc.args[3] = 1;
|
||
}
|
||
arcCurves.push(next);
|
||
if (2 * Math.PI - angle > 1e-3) {
|
||
// less than 360°
|
||
// @ts-expect-error
|
||
arc.coords = next.coords;
|
||
// @ts-expect-error
|
||
arc.args[5] = arc.coords[0] - arc.base[0];
|
||
// @ts-expect-error
|
||
arc.args[6] = arc.coords[1] - arc.base[1];
|
||
} else {
|
||
// full circle, make a half-circle arc and add a second one
|
||
arc.args[5] = 2 * (relCircle.center[0] - nextData[4]);
|
||
arc.args[6] = 2 * (relCircle.center[1] - nextData[5]);
|
||
// @ts-expect-error
|
||
arc.coords = [
|
||
// @ts-expect-error
|
||
arc.base[0] + arc.args[5],
|
||
// @ts-expect-error
|
||
arc.base[1] + arc.args[6],
|
||
];
|
||
arc = {
|
||
command: 'a',
|
||
args: [
|
||
r,
|
||
r,
|
||
0,
|
||
0,
|
||
sweep,
|
||
// @ts-expect-error
|
||
next.coords[0] - arc.coords[0],
|
||
// @ts-expect-error
|
||
next.coords[1] - arc.coords[1],
|
||
],
|
||
// @ts-expect-error
|
||
coords: next.coords,
|
||
// @ts-expect-error
|
||
base: arc.coords,
|
||
};
|
||
output.push(arc);
|
||
j++;
|
||
break;
|
||
}
|
||
relCenter[0] -= nextData[4];
|
||
relCenter[1] -= nextData[5];
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ((stringify(output) + suffix).length < stringify(arcCurves).length) {
|
||
if (path[j] && path[j].command == 's') {
|
||
makeLonghand(path[j], path[j - 1].args);
|
||
}
|
||
if (hasPrev) {
|
||
const prevArc = output.shift();
|
||
// @ts-expect-error
|
||
roundData(prevArc.args);
|
||
// @ts-expect-error
|
||
relSubpoint[0] += prevArc.args[5] - prev.args[prev.args.length - 2];
|
||
// @ts-expect-error
|
||
relSubpoint[1] += prevArc.args[6] - prev.args[prev.args.length - 1];
|
||
prev.command = 'a';
|
||
// @ts-expect-error
|
||
prev.args = prevArc.args;
|
||
// @ts-expect-error
|
||
item.base = prev.coords = prevArc.coords;
|
||
}
|
||
// @ts-expect-error
|
||
arc = output.shift();
|
||
if (arcCurves.length == 1) {
|
||
// @ts-expect-error
|
||
item.sdata = sdata.slice(); // preserve curve data for future checks
|
||
} else if (arcCurves.length - 1 - hasPrev > 0) {
|
||
// filter out consumed next items
|
||
path.splice(index + 1, arcCurves.length - 1 - hasPrev, ...output);
|
||
}
|
||
if (!arc) {
|
||
return false;
|
||
}
|
||
command = 'a';
|
||
data = arc.args;
|
||
// @ts-expect-error
|
||
item.coords = arc.coords;
|
||
}
|
||
}
|
||
|
||
// Rounding relative coordinates, taking in account accumulating error
|
||
// to get closer to absolute coordinates. Sum of rounded value remains same:
|
||
// l .25 3 .25 2 .25 3 .25 2 -> l .3 3 .2 2 .3 3 .2 2
|
||
if (precision !== false) {
|
||
if (
|
||
command === 'm' ||
|
||
command === 'l' ||
|
||
command === 't' ||
|
||
command === 'q' ||
|
||
command === 's' ||
|
||
command === 'c'
|
||
) {
|
||
for (let i = data.length; i--; ) {
|
||
// @ts-expect-error
|
||
data[i] += item.base[i % 2] - relSubpoint[i % 2];
|
||
}
|
||
} else if (command == 'h') {
|
||
// @ts-expect-error
|
||
data[0] += item.base[0] - relSubpoint[0];
|
||
} else if (command == 'v') {
|
||
// @ts-expect-error
|
||
data[0] += item.base[1] - relSubpoint[1];
|
||
} else if (command == 'a') {
|
||
// @ts-expect-error
|
||
data[5] += item.base[0] - relSubpoint[0];
|
||
// @ts-expect-error
|
||
data[6] += item.base[1] - relSubpoint[1];
|
||
}
|
||
roundData(data);
|
||
|
||
if (command == 'h') {
|
||
relSubpoint[0] += data[0];
|
||
} else if (command == 'v') {
|
||
relSubpoint[1] += data[0];
|
||
} else {
|
||
relSubpoint[0] += data[data.length - 2];
|
||
relSubpoint[1] += data[data.length - 1];
|
||
}
|
||
roundData(relSubpoint);
|
||
|
||
if (command === 'M' || command === 'm') {
|
||
pathBase[0] = relSubpoint[0];
|
||
pathBase[1] = relSubpoint[1];
|
||
}
|
||
}
|
||
|
||
// round arc radius more accurately
|
||
// eg m 0 0 a 1234.567 1234.567 0 0 1 10 0 -> m 0 0 a 1235 1235 0 0 1 10 0
|
||
const sagitta = command === 'a' ? calculateSagitta(data) : undefined;
|
||
if (params.smartArcRounding && sagitta !== undefined && precision) {
|
||
for (let precisionNew = precision; precisionNew >= 0; precisionNew--) {
|
||
const radius = toFixed(data[0], precisionNew);
|
||
const sagittaNew = /** @type {number} */ (
|
||
calculateSagitta([radius, radius, ...data.slice(2)])
|
||
);
|
||
if (Math.abs(sagitta - sagittaNew) < error) {
|
||
data[0] = radius;
|
||
data[1] = radius;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// convert straight curves into lines segments
|
||
if (params.straightCurves) {
|
||
if (
|
||
(command === 'c' && isCurveStraightLine(data)) ||
|
||
(command === 's' && isCurveStraightLine(sdata))
|
||
) {
|
||
if (next && next.command == 's') {
|
||
makeLonghand(next, data);
|
||
} // fix up next curve
|
||
command = 'l';
|
||
data = data.slice(-2);
|
||
} else if (command === 'q' && isCurveStraightLine(data)) {
|
||
if (next && next.command == 't') {
|
||
makeLonghand(next, data);
|
||
} // fix up next curve
|
||
command = 'l';
|
||
data = data.slice(-2);
|
||
} else if (
|
||
command === 't' &&
|
||
prev.command !== 'q' &&
|
||
prev.command !== 't'
|
||
) {
|
||
command = 'l';
|
||
data = data.slice(-2);
|
||
} else if (
|
||
command === 'a' &&
|
||
(data[0] === 0 ||
|
||
data[1] === 0 ||
|
||
(sagitta !== undefined && sagitta < error))
|
||
) {
|
||
command = 'l';
|
||
data = data.slice(-2);
|
||
}
|
||
}
|
||
|
||
// degree-lower c to q when possible
|
||
// m 0 12 C 4 4 8 4 12 12 → M 0 12 Q 6 0 12 12
|
||
if (params.convertToQ && command == 'c') {
|
||
const x1 =
|
||
// @ts-expect-error
|
||
0.75 * (item.base[0] + data[0]) - 0.25 * item.base[0];
|
||
const x2 =
|
||
// @ts-expect-error
|
||
0.75 * (item.base[0] + data[2]) - 0.25 * (item.base[0] + data[4]);
|
||
if (Math.abs(x1 - x2) < error * 2) {
|
||
const y1 =
|
||
// @ts-expect-error
|
||
0.75 * (item.base[1] + data[1]) - 0.25 * item.base[1];
|
||
const y2 =
|
||
// @ts-expect-error
|
||
0.75 * (item.base[1] + data[3]) - 0.25 * (item.base[1] + data[5]);
|
||
if (Math.abs(y1 - y2) < error * 2) {
|
||
const newData = data.slice();
|
||
newData.splice(
|
||
0,
|
||
4,
|
||
// @ts-expect-error
|
||
x1 + x2 - item.base[0],
|
||
// @ts-expect-error
|
||
y1 + y2 - item.base[1],
|
||
);
|
||
roundData(newData);
|
||
const originalLength = cleanupOutData(data, params).length;
|
||
const newLength = cleanupOutData(newData, params).length;
|
||
if (newLength < originalLength) {
|
||
command = 'q';
|
||
data = newData;
|
||
if (next && next.command == 's') {
|
||
makeLonghand(next, data);
|
||
} // fix up next curve
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// horizontal and vertical line shorthands
|
||
// l 50 0 → h 50
|
||
// l 0 50 → v 50
|
||
if (params.lineShorthands && command === 'l') {
|
||
if (data[1] === 0) {
|
||
command = 'h';
|
||
data.pop();
|
||
} else if (data[0] === 0) {
|
||
command = 'v';
|
||
data.shift();
|
||
}
|
||
}
|
||
|
||
// collapse repeated commands
|
||
// h 20 h 30 -> h 50
|
||
if (
|
||
params.collapseRepeated &&
|
||
hasMarkerMid === false &&
|
||
(command === 'm' || command === 'h' || command === 'v') &&
|
||
prev.command &&
|
||
command == prev.command.toLowerCase() &&
|
||
((command != 'h' && command != 'v') ||
|
||
prev.args[0] >= 0 == data[0] >= 0)
|
||
) {
|
||
prev.args[0] += data[0];
|
||
if (command != 'h' && command != 'v') {
|
||
prev.args[1] += data[1];
|
||
}
|
||
// @ts-expect-error
|
||
prev.coords = item.coords;
|
||
path[index] = prev;
|
||
return false;
|
||
}
|
||
|
||
// convert curves into smooth shorthands
|
||
if (params.curveSmoothShorthands && prev.command) {
|
||
// curveto
|
||
if (command === 'c') {
|
||
// c + c → c + s
|
||
if (
|
||
prev.command === 'c' &&
|
||
Math.abs(data[0] - -(prev.args[2] - prev.args[4])) < error &&
|
||
Math.abs(data[1] - -(prev.args[3] - prev.args[5])) < error
|
||
) {
|
||
command = 's';
|
||
data = data.slice(2);
|
||
}
|
||
|
||
// s + c → s + s
|
||
else if (
|
||
prev.command === 's' &&
|
||
Math.abs(data[0] - -(prev.args[0] - prev.args[2])) < error &&
|
||
Math.abs(data[1] - -(prev.args[1] - prev.args[3])) < error
|
||
) {
|
||
command = 's';
|
||
data = data.slice(2);
|
||
}
|
||
|
||
// [^cs] + c → [^cs] + s
|
||
else if (
|
||
prev.command !== 'c' &&
|
||
prev.command !== 's' &&
|
||
Math.abs(data[0]) < error &&
|
||
Math.abs(data[1]) < error
|
||
) {
|
||
command = 's';
|
||
data = data.slice(2);
|
||
}
|
||
}
|
||
|
||
// quadratic Bézier curveto
|
||
else if (command === 'q') {
|
||
// q + q → q + t
|
||
if (
|
||
prev.command === 'q' &&
|
||
Math.abs(data[0] - (prev.args[2] - prev.args[0])) < error &&
|
||
Math.abs(data[1] - (prev.args[3] - prev.args[1])) < error
|
||
) {
|
||
command = 't';
|
||
data = data.slice(2);
|
||
}
|
||
|
||
// t + q → t + t
|
||
else if (prev.command === 't') {
|
||
const predictedControlPoint = reflectPoint(
|
||
// @ts-expect-error
|
||
qControlPoint,
|
||
// @ts-expect-error
|
||
item.base,
|
||
);
|
||
const realControlPoint = [
|
||
// @ts-expect-error
|
||
data[0] + item.base[0],
|
||
// @ts-expect-error
|
||
data[1] + item.base[1],
|
||
];
|
||
if (
|
||
Math.abs(predictedControlPoint[0] - realControlPoint[0]) <
|
||
error &&
|
||
Math.abs(predictedControlPoint[1] - realControlPoint[1]) < error
|
||
) {
|
||
command = 't';
|
||
data = data.slice(2);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// remove useless non-first path segments
|
||
if (params.removeUseless && !maybeHasStrokeAndLinecap) {
|
||
// l 0,0 / h 0 / v 0 / q 0,0 0,0 / t 0,0 / c 0,0 0,0 0,0 / s 0,0 0,0
|
||
if (
|
||
(command === 'l' ||
|
||
command === 'h' ||
|
||
command === 'v' ||
|
||
command === 'q' ||
|
||
command === 't' ||
|
||
command === 'c' ||
|
||
command === 's') &&
|
||
data.every(function (i) {
|
||
return i === 0;
|
||
})
|
||
) {
|
||
path[index] = prev;
|
||
return false;
|
||
}
|
||
|
||
// a 25,25 -30 0,1 0,0
|
||
if (command === 'a' && data[5] === 0 && data[6] === 0) {
|
||
path[index] = prev;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// convert going home to z
|
||
// m 0 0 h 5 v 5 l -5 -5 -> m 0 0 h 5 v 5 z
|
||
if (
|
||
params.convertToZ &&
|
||
(isSafeToUseZ || next?.command === 'Z' || next?.command === 'z') &&
|
||
(command === 'l' || command === 'h' || command === 'v')
|
||
) {
|
||
if (
|
||
// @ts-expect-error
|
||
Math.abs(pathBase[0] - item.coords[0]) < error &&
|
||
// @ts-expect-error
|
||
Math.abs(pathBase[1] - item.coords[1]) < error
|
||
) {
|
||
command = 'z';
|
||
data = [];
|
||
}
|
||
}
|
||
|
||
item.command = command;
|
||
item.args = data;
|
||
} else {
|
||
// z resets coordinates
|
||
relSubpoint[0] = pathBase[0];
|
||
relSubpoint[1] = pathBase[1];
|
||
if (prev.command === 'Z' || prev.command === 'z') {
|
||
return false;
|
||
}
|
||
}
|
||
if (
|
||
(command === 'Z' || command === 'z') &&
|
||
params.removeUseless &&
|
||
isSafeToUseZ &&
|
||
// @ts-expect-error
|
||
Math.abs(item.base[0] - item.coords[0]) < error / 10 &&
|
||
// @ts-expect-error
|
||
Math.abs(item.base[1] - item.coords[1]) < error / 10
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (command === 'q') {
|
||
// @ts-expect-error
|
||
prevQControlPoint = [data[0] + item.base[0], data[1] + item.base[1]];
|
||
} else if (command === 't') {
|
||
if (qControlPoint) {
|
||
// @ts-expect-error
|
||
prevQControlPoint = reflectPoint(qControlPoint, item.base);
|
||
} else {
|
||
// @ts-expect-error
|
||
prevQControlPoint = item.coords;
|
||
}
|
||
} else {
|
||
prevQControlPoint = undefined;
|
||
}
|
||
prev = item;
|
||
return true;
|
||
});
|
||
|
||
return path;
|
||
}
|
||
|
||
/**
|
||
* Writes data in shortest form using absolute or relative coordinates.
|
||
*
|
||
* @param {import('../lib/types.js').PathDataItem[]} path
|
||
* @param {InternalParams} params
|
||
* @returns {import('../lib/types.js').PathDataItem[]}
|
||
*/
|
||
function convertToMixed(path, params) {
|
||
let prev = path[0];
|
||
|
||
path = path.filter(function (item, index) {
|
||
if (index == 0) {
|
||
return true;
|
||
}
|
||
if (item.command === 'Z' || item.command === 'z') {
|
||
prev = item;
|
||
return true;
|
||
}
|
||
|
||
const command = item.command;
|
||
const data = item.args;
|
||
const adata = data.slice();
|
||
const rdata = data.slice();
|
||
|
||
if (
|
||
command === 'm' ||
|
||
command === 'l' ||
|
||
command === 't' ||
|
||
command === 'q' ||
|
||
command === 's' ||
|
||
command === 'c'
|
||
) {
|
||
for (let i = adata.length; i--; ) {
|
||
// @ts-expect-error
|
||
adata[i] += item.base[i % 2];
|
||
}
|
||
} else if (command == 'h') {
|
||
// @ts-expect-error
|
||
adata[0] += item.base[0];
|
||
} else if (command == 'v') {
|
||
// @ts-expect-error
|
||
adata[0] += item.base[1];
|
||
} else if (command == 'a') {
|
||
// @ts-expect-error
|
||
adata[5] += item.base[0];
|
||
// @ts-expect-error
|
||
adata[6] += item.base[1];
|
||
}
|
||
|
||
roundData(adata);
|
||
roundData(rdata);
|
||
|
||
const absoluteDataStr = cleanupOutData(adata, params);
|
||
const relativeDataStr = cleanupOutData(rdata, params);
|
||
|
||
// Convert to absolute coordinates if it's shorter or forceAbsolutePath is true.
|
||
// v-20 -> V0
|
||
// Don't convert if it fits following previous command.
|
||
// l20 30-10-50 instead of l20 30L20 30
|
||
if (
|
||
params.forceAbsolutePath ||
|
||
(absoluteDataStr.length < relativeDataStr.length &&
|
||
!(
|
||
params.negativeExtraSpace &&
|
||
command == prev.command &&
|
||
prev.command.charCodeAt(0) > 96 &&
|
||
absoluteDataStr.length == relativeDataStr.length - 1 &&
|
||
(data[0] < 0 ||
|
||
(Math.floor(data[0]) === 0 &&
|
||
!Number.isInteger(data[0]) &&
|
||
prev.args[prev.args.length - 1] % 1))
|
||
))
|
||
) {
|
||
// @ts-expect-error
|
||
item.command = command.toUpperCase();
|
||
item.args = adata;
|
||
}
|
||
|
||
prev = item;
|
||
return true;
|
||
});
|
||
|
||
return path;
|
||
}
|
||
|
||
/**
|
||
* Checks if curve is convex. Control points of such a curve must form a convex
|
||
* quadrilateral with diagonals crosspoint inside of it.
|
||
*
|
||
* @param {ReadonlyArray<number>} data
|
||
* @returns {boolean}
|
||
*/
|
||
function isConvex(data) {
|
||
const center = getIntersection([
|
||
0,
|
||
0,
|
||
data[2],
|
||
data[3],
|
||
data[0],
|
||
data[1],
|
||
data[4],
|
||
data[5],
|
||
]);
|
||
|
||
return (
|
||
center != null &&
|
||
data[2] < center[0] == center[0] < 0 &&
|
||
data[3] < center[1] == center[1] < 0 &&
|
||
data[4] < center[0] == center[0] < data[0] &&
|
||
data[5] < center[1] == center[1] < data[1]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Computes lines equations by two points and returns their intersection point.
|
||
*
|
||
* @param {ReadonlyArray<number>} coords
|
||
* @returns {Point | undefined}
|
||
*/
|
||
function getIntersection(coords) {
|
||
// Prev line equation parameters.
|
||
const a1 = coords[1] - coords[3]; // y1 - y2
|
||
const b1 = coords[2] - coords[0]; // x2 - x1
|
||
const c1 = coords[0] * coords[3] - coords[2] * coords[1]; // x1 * y2 - x2 * y1
|
||
// Next line equation parameters
|
||
const a2 = coords[5] - coords[7]; // y1 - y2
|
||
const b2 = coords[6] - coords[4]; // x2 - x1
|
||
const c2 = coords[4] * coords[7] - coords[5] * coords[6]; // x1 * y2 - x2 * y1
|
||
const denom = a1 * b2 - a2 * b1;
|
||
|
||
if (!denom) {
|
||
return;
|
||
} // parallel lines haven't an intersection
|
||
|
||
/** @type {Point} */
|
||
const cross = [(b1 * c2 - b2 * c1) / denom, (a1 * c2 - a2 * c1) / -denom];
|
||
if (
|
||
!isNaN(cross[0]) &&
|
||
!isNaN(cross[1]) &&
|
||
isFinite(cross[0]) &&
|
||
isFinite(cross[1])
|
||
) {
|
||
return cross;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Decrease accuracy of floating-point numbers in path data keeping a specified
|
||
* number of decimals. Smart rounds values like 2.3491 to 2.35 instead of 2.349.
|
||
* Doesn't apply "smartness" if the number precision fits already.
|
||
*
|
||
* @param {number[]} data
|
||
* @returns {number[]}
|
||
*/
|
||
function strongRound(data) {
|
||
const precisionNum = precision || 0;
|
||
for (let i = data.length; i-- > 0; ) {
|
||
const fixed = toFixed(data[i], precisionNum);
|
||
if (fixed !== data[i]) {
|
||
const rounded = toFixed(data[i], precisionNum - 1);
|
||
data[i] =
|
||
toFixed(Math.abs(rounded - data[i]), precisionNum + 1) >= error
|
||
? fixed
|
||
: rounded;
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Simple rounding function if precision is 0.
|
||
*
|
||
* @param {number[]} data
|
||
* @returns {number[]}
|
||
*/
|
||
function round(data) {
|
||
for (let i = data.length; i-- > 0; ) {
|
||
data[i] = Math.round(data[i]);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Checks if a curve is a straight line by measuring distance from middle points
|
||
* to the line formed by end points.
|
||
*
|
||
* @param {ReadonlyArray<number>} data
|
||
* @returns {boolean}
|
||
*/
|
||
function isCurveStraightLine(data) {
|
||
// Get line equation a·x + b·y + c = 0 coefficients a, b (c = 0) by start and end points.
|
||
let i = data.length - 2;
|
||
const a = -data[i + 1]; // y1 − y2 (y1 = 0)
|
||
const b = data[i]; // x2 − x1 (x1 = 0)
|
||
const d = 1 / (a * a + b * b); // same part for all points
|
||
|
||
if (i <= 1 || !isFinite(d)) {
|
||
return false;
|
||
} // curve that ends at start point isn't the case
|
||
|
||
// Distance from point (x0, y0) to the line is sqrt((c − a·x0 − b·y0)² / (a² + b²))
|
||
while ((i -= 2) >= 0) {
|
||
if (Math.sqrt(Math.pow(a * data[i] + b * data[i + 1], 2) * d) > error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Calculates the sagitta of an arc if possible.
|
||
*
|
||
* @see https://wikipedia.org/wiki/Sagitta_(geometry)#Formulas
|
||
* @param {ReadonlyArray<number>} data
|
||
* @returns {number | undefined}
|
||
*/
|
||
function calculateSagitta(data) {
|
||
if (data[3] === 1) {
|
||
return undefined;
|
||
}
|
||
const [rx, ry] = data;
|
||
if (Math.abs(rx - ry) > error) {
|
||
return undefined;
|
||
}
|
||
const chord = Math.hypot(data[5], data[6]);
|
||
if (chord > rx * 2) {
|
||
return undefined;
|
||
}
|
||
return rx - Math.sqrt(rx ** 2 - 0.25 * chord ** 2);
|
||
}
|
||
|
||
/**
|
||
* Converts next curve from shorthand to full form using the current curve data.
|
||
*
|
||
* @param {import('../lib/types.js').PathDataItem} item
|
||
* @param {ReadonlyArray<number>} data
|
||
* @returns {import('../lib/types.js').PathDataItem}
|
||
*/
|
||
function makeLonghand(item, data) {
|
||
switch (item.command) {
|
||
case 's':
|
||
item.command = 'c';
|
||
break;
|
||
case 't':
|
||
item.command = 'q';
|
||
break;
|
||
}
|
||
item.args.unshift(
|
||
data[data.length - 2] - data[data.length - 4],
|
||
data[data.length - 1] - data[data.length - 3],
|
||
);
|
||
return item;
|
||
}
|
||
|
||
/**
|
||
* Returns distance between two points
|
||
*
|
||
* @param {Point} point1
|
||
* @param {Point} point2
|
||
* @returns {number}
|
||
*/
|
||
function getDistance(point1, point2) {
|
||
return Math.hypot(point1[0] - point2[0], point1[1] - point2[1]);
|
||
}
|
||
|
||
/**
|
||
* Reflects point across another point.
|
||
*
|
||
* @param {Point} controlPoint
|
||
* @param {Point} base
|
||
* @returns {Point}
|
||
*/
|
||
function reflectPoint(controlPoint, base) {
|
||
return [2 * base[0] - controlPoint[0], 2 * base[1] - controlPoint[1]];
|
||
}
|
||
|
||
/**
|
||
* Returns coordinates of the curve point corresponding to the certain t
|
||
* a·(1 - t)³·p1 + b·(1 - t)²·t·p2 + c·(1 - t)·t²·p3 + d·t³·p4,
|
||
* where pN are control points and p1 is zero due to relative coordinates.
|
||
*
|
||
* @param {ReadonlyArray<number>} curve
|
||
* @param {number} t
|
||
* @returns {Point}
|
||
*/
|
||
function getCubicBezierPoint(curve, t) {
|
||
const sqrT = t * t;
|
||
const cubT = sqrT * t;
|
||
const mt = 1 - t;
|
||
const sqrMt = mt * mt;
|
||
|
||
return [
|
||
3 * sqrMt * t * curve[0] + 3 * mt * sqrT * curve[2] + cubT * curve[4],
|
||
3 * sqrMt * t * curve[1] + 3 * mt * sqrT * curve[3] + cubT * curve[5],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Finds circle by 3 points of the curve and checks if the curve fits the found circle.
|
||
*
|
||
* @param {ReadonlyArray<number>} curve
|
||
* @returns {Circle | undefined}
|
||
*/
|
||
function findCircle(curve) {
|
||
const midPoint = getCubicBezierPoint(curve, 1 / 2);
|
||
const m1 = [midPoint[0] / 2, midPoint[1] / 2];
|
||
const m2 = [(midPoint[0] + curve[4]) / 2, (midPoint[1] + curve[5]) / 2];
|
||
const center = getIntersection([
|
||
m1[0],
|
||
m1[1],
|
||
m1[0] + m1[1],
|
||
m1[1] - m1[0],
|
||
m2[0],
|
||
m2[1],
|
||
m2[0] + (m2[1] - midPoint[1]),
|
||
m2[1] - (m2[0] - midPoint[0]),
|
||
]);
|
||
const radius = center && getDistance([0, 0], center);
|
||
const tolerance = Math.min(
|
||
arcThreshold * error,
|
||
// @ts-expect-error
|
||
(arcTolerance * radius) / 100,
|
||
);
|
||
|
||
if (
|
||
center &&
|
||
// @ts-expect-error
|
||
radius < 1e15 &&
|
||
[1 / 4, 3 / 4].every(function (point) {
|
||
return (
|
||
Math.abs(
|
||
// @ts-expect-error
|
||
getDistance(getCubicBezierPoint(curve, point), center) - radius,
|
||
) <= tolerance
|
||
);
|
||
})
|
||
) {
|
||
// @ts-expect-error
|
||
return { center: center, radius: radius };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Checks if a curve fits the given circle.
|
||
*
|
||
* @param {ReadonlyArray<number>} curve
|
||
* @param {Circle} circle
|
||
* @returns {boolean}
|
||
*/
|
||
function isArc(curve, circle) {
|
||
const tolerance = Math.min(
|
||
arcThreshold * error,
|
||
(arcTolerance * circle.radius) / 100,
|
||
);
|
||
|
||
return [0, 1 / 4, 1 / 2, 3 / 4, 1].every(function (point) {
|
||
return (
|
||
Math.abs(
|
||
getDistance(getCubicBezierPoint(curve, point), circle.center) -
|
||
circle.radius,
|
||
) <= tolerance
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Checks if a previous curve fits the given circle.
|
||
*
|
||
* @param {ReadonlyArray<number>} curve
|
||
* @param {Circle} circle
|
||
* @returns {boolean}
|
||
*/
|
||
function isArcPrev(curve, circle) {
|
||
return isArc(curve, {
|
||
center: [circle.center[0] + curve[4], circle.center[1] + curve[5]],
|
||
radius: circle.radius,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Finds angle of a curve fitting the given arc.
|
||
*
|
||
* @param {ReadonlyArray<number>} curve
|
||
* @param {Circle} relCircle
|
||
* @returns {number}
|
||
*/
|
||
function findArcAngle(curve, relCircle) {
|
||
const x1 = -relCircle.center[0];
|
||
const y1 = -relCircle.center[1];
|
||
const x2 = curve[4] - relCircle.center[0];
|
||
const y2 = curve[5] - relCircle.center[1];
|
||
|
||
return Math.acos(
|
||
(x1 * x2 + y1 * y2) / Math.sqrt((x1 * x1 + y1 * y1) * (x2 * x2 + y2 * y2)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Converts given path data to string.
|
||
*
|
||
* @param {InternalParams} params
|
||
* @param {ReadonlyArray<import('../lib/types.js').PathDataItem>} pathData
|
||
* @returns {string}
|
||
*/
|
||
function data2Path(params, pathData) {
|
||
return pathData.reduce(function (pathString, item) {
|
||
let strData = '';
|
||
if (item.args) {
|
||
strData = cleanupOutData(roundData(item.args.slice()), params);
|
||
}
|
||
return pathString + item.command + strData;
|
||
}, '');
|
||
}
|