diff --git a/src/SVGPathData.ts b/src/SVGPathData.ts index 0bf8727..497d827 100644 --- a/src/SVGPathData.ts +++ b/src/SVGPathData.ts @@ -62,6 +62,15 @@ export class SVGPathData extends TransformableSVG { return this; } + /** + * Reorients the subpaths of a path filled with the `evenodd` rule so that + * the `nonzero` rule fills the same area + */ + evenoddToNonzero() { + this.commands = SVGPathDataTransformer.EVENODD_TO_NONZERO(this.commands); + return this; + } + static encode(commands: SVGCommand[]) { return encodeSVGPath(commands); } diff --git a/src/SVGPathDataTransformer.ts b/src/SVGPathDataTransformer.ts index c320f93..cb0d7bb 100644 --- a/src/SVGPathDataTransformer.ts +++ b/src/SVGPathDataTransformer.ts @@ -15,6 +15,7 @@ import { type Point, } from './mathUtils.js'; import { SVGPathData } from './SVGPathData.js'; +import { EVENODD_TO_NONZERO } from './transformers/evenodd_to_nonzero.js'; import { REMOVE_COLLINEAR } from './transformers/remove_collinear.js'; import { REVERSE_PATH } from './transformers/reverse_path.js'; import type { SVGCommand, TransformFunction } from './types.js'; @@ -818,4 +819,5 @@ export const SVGPathDataTransformer = { CALCULATE_BOUNDS, REVERSE_PATH, REMOVE_COLLINEAR, + EVENODD_TO_NONZERO, }; diff --git a/src/tests/evenodd_to_nonzero.test.ts b/src/tests/evenodd_to_nonzero.test.ts new file mode 100644 index 0000000..0726dc2 --- /dev/null +++ b/src/tests/evenodd_to_nonzero.test.ts @@ -0,0 +1,157 @@ +import { describe, test, expect } from '@jest/globals'; +import { SVGPathData, SVGPathDataTransformer } from '../index.js'; + +function testEvenoddToNonzero(input: string): string { + return new SVGPathData(input).evenoddToNonzero().encode(); +} + +describe('Reorienting evenodd subpaths for the nonzero rule', () => { + test('empty path', () => { + expect(testEvenoddToNonzero('')).toEqual(''); + }); + + test('single subpath', () => { + const input = 'M0 0H10V10H0Z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('hole drawn in the same direction as its outline', () => { + expect(testEvenoddToNonzero('M0 0H20V20H0Z M5 5H15V15H5Z')).toEqual( + 'M0 0H20V20H0zM5 15H15V5H5z', + ); + }); + + test('hole already drawn against its outline', () => { + const input = 'M0 0H20V20H0Z M5 5V15H15V5Z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('island inside a hole winds with the outline', () => { + expect( + testEvenoddToNonzero('M0 0H30V30H0Z M5 5H25V25H5Z M10 10H20V20H10Z'), + ).toEqual('M0 0H30V30H0zM5 25H25V5H5zM10 10H20V20H10z'); + }); + + test('nested subpaths listed from the inside out', () => { + expect( + testEvenoddToNonzero('M10 10H20V20H10Z M5 5H25V25H5Z M0 0H30V30H0Z'), + ).toEqual('M10 10H20V20H10zM5 25H25V5H5zM0 0H30V30H0z'); + }); + + test('separate outlines keep their own directions', () => { + const input = 'M0 0H10V10H0Z M20 0V10H30V0Z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('curved hole is reversed along the same curve', () => { + expect( + testEvenoddToNonzero( + 'M0 0H40V40H0Z M10 20C10 14.477 14.477 10 20 10C25.523 10 30 14.477 30 20C30 25.523 25.523 30 20 30C14.477 30 10 25.523 10 20Z', + ), + ).toEqual( + 'M0 0H40V40H0zM10 20C10 25.523 14.477 30 20 30C25.523 30 30 25.523 30 20C30 14.477 25.523 10 20 10C14.477 10 10 14.477 10 20z', + ); + }); + + test('quadratic hole is reversed as a cubic curve', () => { + expect(testEvenoddToNonzero('M0 0H30V30H0Z M6 6Q15 6 15 15L6 15Z')).toEqual( + 'M0 0H30V30H0zM6 15L15 15C15 9 12 6 6 6z', + ); + }); + + test('relative hole is reversed in absolute commands', () => { + expect(testEvenoddToNonzero('m0 0h20v20h-20z m5 5h10v10h-10z')).toEqual( + 'M0 0H20V20H0zM5 15H15V5H5z', + ); + }); + + test('relative and curved subpaths that need no reversal are kept as written', () => { + const input = 'm0 0h30v30h-30z m6 6v9h9q0 -9 -9 -9z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('open subpaths are reoriented like closed ones', () => { + expect(testEvenoddToNonzero('M0 0H20V20H0 M5 5H15V15H5')).toEqual( + 'M0 0H20V20H0M5 15H15V5H5', + ); + }); + + test('subpath drawn after a closepath without a moveto', () => { + expect( + testEvenoddToNonzero('M10 10H20V20H10Z L0 0H30V30H0L0 0L10 10Z'), + ).toEqual('M10 20H20V10H10zM10 10L0 0H30V30H0L0 0L10 10z'); + }); + + test('subpath drawn twice cancels out', () => { + expect(testEvenoddToNonzero('M0 0H10V10H0Z M0 0H10V10H0Z')).toEqual( + 'M0 0H10V10H0zM0 10H10V0H0z', + ); + }); + + test('subpath drawn twice in opposite directions is kept as written', () => { + const input = 'M0 0H10V10H0Z M0 0V10H10V0Z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('subpath drawn three times alternates in drawing order', () => { + expect( + testEvenoddToNonzero('M0 0H10V10H0Z M0 0H10V10H0Z M0 0H10V10H0Z'), + ).toEqual('M0 0H10V10H0zM0 10H10V0H0zM0 0H10V10H0z'); + }); + + test('holes touching their outline on every side wind against it', () => { + expect( + testEvenoddToNonzero( + 'M0 0H30V30H0Z M12 0H18V8H12Z M22 12H30V18H22Z M12 22H18V30H12Z M0 12H8V18H0Z', + ), + ).toEqual( + 'M0 0H30V30H0zM12 8H18V0H12zM22 18H30V12H22zM12 30H18V22H12zM0 18H8V12H0z', + ); + }); + + test('subpath touching another from outside is not nested in it', () => { + expect( + testEvenoddToNonzero('M0 0H30V30H0Z M15 5H25V25H15Z M15 8V22L5 15Z'), + ).toEqual('M0 0H30V30H0zM15 25H25V5H15zM5 15L15 22V8z'); + }); + + test('crossing subpath mostly outside the other keeps its direction', () => { + const input = 'M0 0H20V20H0Z M10 10H40V40H10Z'; + expect(testEvenoddToNonzero(input)).toEqual( + new SVGPathData(input).encode(), + ); + }); + + test('crossing subpath mostly inside the other winds against it', () => { + expect(testEvenoddToNonzero('M0 0H20V20H0Z M5 5L15 5L15 15L5 25Z')).toEqual( + 'M0 0H20V20H0zM5 25L15 15L15 5L5 5z', + ); + }); + + test('applying it again changes nothing', () => { + const once = testEvenoddToNonzero( + 'M0 0H30V30H0Z M5 5H25V25H5Z M10 10H20V20H10Z M40 0H70V30H40Z M46 6Q55 6 55 15L46 15Z', + ); + expect(testEvenoddToNonzero(once)).toEqual(once); + }); + + test('does not modify the commands it is given', () => { + const commands = new SVGPathData('m0 0h30v30h-30z m6 6q9 0 9 9l-9 0z') + .commands; + const before = SVGPathData.encode(commands); + + SVGPathDataTransformer.EVENODD_TO_NONZERO(commands); + + expect(SVGPathData.encode(commands)).toEqual(before); + }); +}); diff --git a/src/transformers/evenodd_to_nonzero.ts b/src/transformers/evenodd_to_nonzero.ts new file mode 100644 index 0000000..bcd03ec --- /dev/null +++ b/src/transformers/evenodd_to_nonzero.ts @@ -0,0 +1,254 @@ +import { SVGPathData } from '../SVGPathData.js'; +import { SVGPathDataTransformer } from '../SVGPathDataTransformer.js'; +import { bezierAt, type Point } from '../mathUtils.js'; +import { REVERSE_PATH } from './reverse_path.js'; +import type { SVGCommand } from '../types.js'; + +const CURVE_STEPS = 8; +const CONTAINMENT_PROBES = 16; + +interface Bounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} + +type PointLocation = 'inside' | 'outside' | 'outline'; + +/** + * Reorients the subpaths of a path filled with the `evenodd` rule so that the + * `nonzero` rule fills the same area: each subpath nested in another winds + * against the subpath enclosing it, and each outermost subpath keeps its + * direction. Subpaths of equal area nest in the order they are drawn, so a + * subpath repeated point for point cancels out; the same shape drawn with other + * commands is not recognised as a repeat. This holds when no subpath crosses + * itself or another; a subpath crossing another counts as nested in it when + * more of its points lie inside it than outside. + * @param commands SVG path commands of a path filled with the `evenodd` rule + * @returns The given commands when no subpath needs reversing, otherwise new + * absolute commands with the curves converted to cubic bezier curves + */ +export function EVENODD_TO_NONZERO(commands: SVGCommand[]): SVGCommand[] { + const subpaths = splitSubpaths( + new SVGPathData(commands.map(SVGPathDataTransformer.CLONE())) + .toAbs() + .normalizeST() + .qtToC() + .aToC().commands, + ); + + if (subpaths.length < 2) return commands; + + const outlines = subpaths.map(flattenSubpath); + const signedAreas = outlines.map(signedArea); + const areas = signedAreas.map(Math.abs); + const ascendingByArea = areas + .map((_area, index) => index) + .sort((first, second) => areas[first] - areas[second] || second - first); + const enclosures = findEnclosures(outlines, areas, ascendingByArea); + const windings: number[] = []; + + for (const index of ascendingByArea.toReversed()) { + windings[index] = + -1 === enclosures[index] + ? Math.sign(signedAreas[index]) + : -windings[enclosures[index]]; + } + + const reversed = signedAreas.map( + (area, index) => 0 !== area && Math.sign(area) !== windings[index], + ); + + if (!reversed.includes(true)) return commands; + + return subpaths.flatMap((subpath, index) => + reversed[index] ? REVERSE_PATH(subpath) : subpath, + ); +} + +function splitSubpaths(commands: readonly SVGCommand[]): SVGCommand[][] { + const subpaths: SVGCommand[][] = []; + let subpath: SVGCommand[] = []; + let start: Point = [0, 0]; + let closed = false; + + for (const command of commands) { + if (command.type === SVGPathData.MOVE_TO) { + if (subpath.length) subpaths.push(subpath); + subpath = [command]; + start = [command.x, command.y]; + closed = false; + continue; + } + if (closed) { + // A command after a closepath starts a subpath at the closed one's start + subpaths.push(subpath); + subpath = [ + { + type: SVGPathData.MOVE_TO, + relative: false, + x: start[0], + y: start[1], + }, + ]; + } + subpath.push(command); + closed = command.type === SVGPathData.CLOSE_PATH; + } + if (subpath.length) subpaths.push(subpath); + return subpaths; +} + +function flattenSubpath(subpath: readonly SVGCommand[]): Point[] { + const points: Point[] = []; + let x = 0; + let y = 0; + + for (const command of subpath) { + if ( + command.type === SVGPathData.MOVE_TO || + command.type === SVGPathData.LINE_TO + ) { + x = command.x; + y = command.y; + points.push([x, y]); + } else if (command.type === SVGPathData.HORIZ_LINE_TO) { + x = command.x; + points.push([x, y]); + } else if (command.type === SVGPathData.VERT_LINE_TO) { + y = command.y; + points.push([x, y]); + } else if (command.type === SVGPathData.CURVE_TO) { + for (let step = 1; step <= CURVE_STEPS; step++) { + const t = step / CURVE_STEPS; + + points.push([ + bezierAt(x, command.x1, command.x2, command.x, t), + bezierAt(y, command.y1, command.y2, command.y, t), + ]); + } + x = command.x; + y = command.y; + } + } + return points; +} + +function signedArea(outline: readonly Point[]): number { + let area = 0; + + for (let index = 0; index < outline.length; index++) { + const [x, y] = outline[index]; + const [nextX, nextY] = outline[(index + 1) % outline.length]; + + area += x * nextY - nextX * y; + } + return area / 2; +} + +function boundsOf(points: readonly Point[]): Bounds { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const [x, y] of points) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + return { minX, minY, maxX, maxY }; +} + +function overlap(first: Bounds, second: Bounds): boolean { + return ( + first.minX <= second.maxX && + second.minX <= first.maxX && + first.minY <= second.maxY && + second.minY <= first.maxY + ); +} + +function locatePoint(outline: readonly Point[], [x, y]: Point): PointLocation { + let inside = false; + + for ( + let index = 0, previous = outline.length - 1; + index < outline.length; + previous = index++ + ) { + const [currentX, currentY] = outline[index]; + const [priorX, priorY] = outline[previous]; + + if ( + (currentX - priorX) * (y - priorY) === + (currentY - priorY) * (x - priorX) && + Math.min(priorX, currentX) <= x && + x <= Math.max(priorX, currentX) && + Math.min(priorY, currentY) <= y && + y <= Math.max(priorY, currentY) + ) { + return 'outline'; + } + if ( + currentY > y !== priorY > y && + x < + ((priorX - currentX) * (y - currentY)) / (priorY - currentY) + currentX + ) { + inside = !inside; + } + } + return inside ? 'inside' : 'outside'; +} + +// A point on the outline tells neither way +function holdsMostOf( + outline: readonly Point[], + probes: readonly Point[], +): boolean { + const locations = probes.map((probe) => locatePoint(outline, probe)); + const inside = locations.filter((location) => 'inside' === location); + const outside = locations.filter((location) => 'outside' === location); + + return inside.length > outside.length || 0 === outside.length; +} + +// Subpaths of equal area nest in drawing order, so a subpath repeated point for point cancels out +function canEnclose( + areas: readonly number[], + candidate: number, + index: number, +): boolean { + return ( + areas[candidate] > areas[index] || + (areas[candidate] === areas[index] && candidate < index) + ); +} + +function findEnclosures( + outlines: readonly (readonly Point[])[], + areas: readonly number[], + ascendingByArea: readonly number[], +): number[] { + const bounds = outlines.map(boundsOf); + + return outlines.map((outline, index) => { + if (0 === areas[index]) return -1; + + const stride = Math.max(1, Math.floor(outline.length / CONTAINMENT_PROBES)); + const probes = outline.filter( + (_point, pointIndex) => 0 === pointIndex % stride, + ); + const probeBounds = boundsOf(probes); + const tightestEnclosure = ascendingByArea.find( + (candidate) => + canEnclose(areas, candidate, index) && + overlap(bounds[candidate], probeBounds) && + holdsMostOf(outlines[candidate], probes), + ); + + return tightestEnclosure ?? -1; + }); +}