From e3b0b441bae9b420493dd353a411c230b338b5f6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 2 Oct 2026 18:05:28 +0300 Subject: [PATCH 1/3] feat: add evenoddToNonzero to reorient subpaths for the nonzero rule A path filled with the evenodd rule leaves any region enclosed an even number of times empty, whatever the directions of its subpaths. Font outlines fill by the nonzero rule instead, where a hole stays empty only when its subpath winds against the subpath enclosing it. EVENODD_TO_NONZERO finds each subpath's tightest enclosing subpath and reverses every subpath that winds the same way as its enclosure, so the nonzero rule fills the same area. A path whose subpaths already alternate is returned as given. --- src/SVGPathData.ts | 9 + src/SVGPathDataTransformer.ts | 2 + src/tests/evenodd_to_nonzero.test.ts | 122 ++++++++++++++ src/transformers/evenodd_to_nonzero.ts | 224 +++++++++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 src/tests/evenodd_to_nonzero.test.ts create mode 100644 src/transformers/evenodd_to_nonzero.ts 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..7fffca0 --- /dev/null +++ b/src/tests/evenodd_to_nonzero.test.ts @@ -0,0 +1,122 @@ +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('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..11b1500 --- /dev/null +++ b/src/transformers/evenodd_to_nonzero.ts @@ -0,0 +1,224 @@ +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; +} + +/** + * 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. This holds when no subpath crosses itself or another; a subpath + * crossing another counts as nested in it when most of its points lie inside. + * @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]); + 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 containsPoint(outline: readonly Point[], [x, y]: Point): boolean { + 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 ( + currentY > y !== priorY > y && + x < + ((priorX - currentX) * (y - currentY)) / (priorY - currentY) + currentX + ) { + inside = !inside; + } + } + return inside; +} + +function holdsMostOf( + outline: readonly Point[], + probes: readonly Point[], +): boolean { + const inside = probes.filter((probe) => containsPoint(outline, probe)); + + return inside.length * 2 > probes.length; +} + +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) => + areas[candidate] > areas[index] && + overlap(bounds[candidate], probeBounds) && + holdsMostOf(outlines[candidate], probes), + ); + + return tightestEnclosure ?? -1; + }); +} From 00068dfb7ac5908b96d415128cd85ef7e6c38abd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 5 Oct 2026 13:30:25 +0300 Subject: [PATCH 2/3] fix: nest subpaths that coincide or share an outline evenoddToNonzero nested a subpath only in a strictly larger one holding most of its points by the crossing test, which counts a point on the left or top edge of an outline as inside it and one on the right or bottom edge as outside. A hole touching the right or bottom of its outline kept its direction, a subpath touching another from the left could land inside it, and a subpath drawn twice nested in neither copy. A point on the other subpath's outline now counts neither way, and subpaths of equal area nest in the order they are drawn, so the copies of a subpath drawn twice cancel out as evenodd paints them. --- src/tests/evenodd_to_nonzero.test.ts | 35 ++++++++++++++++++++ src/transformers/evenodd_to_nonzero.ts | 45 +++++++++++++++++++++----- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/tests/evenodd_to_nonzero.test.ts b/src/tests/evenodd_to_nonzero.test.ts index 7fffca0..0726dc2 100644 --- a/src/tests/evenodd_to_nonzero.test.ts +++ b/src/tests/evenodd_to_nonzero.test.ts @@ -90,6 +90,41 @@ describe('Reorienting evenodd subpaths for the nonzero rule', () => { ).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( diff --git a/src/transformers/evenodd_to_nonzero.ts b/src/transformers/evenodd_to_nonzero.ts index 11b1500..33b025a 100644 --- a/src/transformers/evenodd_to_nonzero.ts +++ b/src/transformers/evenodd_to_nonzero.ts @@ -14,12 +14,16 @@ interface Bounds { 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. This holds when no subpath crosses itself or another; a subpath - * crossing another counts as nested in it when most of its points lie inside. + * direction. Subpaths of equal area nest in the order they are drawn, so a + * subpath drawn twice cancels out. 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 @@ -40,7 +44,7 @@ export function EVENODD_TO_NONZERO(commands: SVGCommand[]): SVGCommand[] { const areas = signedAreas.map(Math.abs); const ascendingByArea = areas .map((_area, index) => index) - .sort((first, second) => areas[first] - areas[second]); + .sort((first, second) => areas[first] - areas[second] || second - first); const enclosures = findEnclosures(outlines, areas, ascendingByArea); const windings: number[] = []; @@ -166,7 +170,7 @@ function overlap(first: Bounds, second: Bounds): boolean { ); } -function containsPoint(outline: readonly Point[], [x, y]: Point): boolean { +function locatePoint(outline: readonly Point[], [x, y]: Point): PointLocation { let inside = false; for ( @@ -177,6 +181,16 @@ function containsPoint(outline: readonly Point[], [x, y]: Point): boolean { 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 < @@ -185,16 +199,31 @@ function containsPoint(outline: readonly Point[], [x, y]: Point): boolean { inside = !inside; } } - return inside; + return inside ? 'inside' : 'outside'; } +// A point on the outline tells neither way function holdsMostOf( outline: readonly Point[], probes: readonly Point[], ): boolean { - const inside = probes.filter((probe) => containsPoint(outline, probe)); + 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; +} - return inside.length * 2 > probes.length; +// Subpaths of equal area nest in drawing order, so a subpath drawn twice 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( @@ -214,7 +243,7 @@ function findEnclosures( const probeBounds = boundsOf(probes); const tightestEnclosure = ascendingByArea.find( (candidate) => - areas[candidate] > areas[index] && + canEnclose(areas, candidate, index) && overlap(bounds[candidate], probeBounds) && holdsMostOf(outlines[candidate], probes), ); From ea0e14832894da76c888c593b071c9c01e817c59 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 5 Oct 2026 14:13:02 +0300 Subject: [PATCH 3/3] docs: state which repeated subpaths cancel out A subpath cancels out only when it repeats another point for point: from any of its points and in either direction, but drawn with the same commands. The same shape drawn with other commands, such as a circle's arcs starting elsewhere, flattens to a different outline and is not recognised as a repeat. --- src/transformers/evenodd_to_nonzero.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/transformers/evenodd_to_nonzero.ts b/src/transformers/evenodd_to_nonzero.ts index 33b025a..bcd03ec 100644 --- a/src/transformers/evenodd_to_nonzero.ts +++ b/src/transformers/evenodd_to_nonzero.ts @@ -21,9 +21,10 @@ type PointLocation = 'inside' | 'outside' | 'outline'; * `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 drawn twice cancels out. 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. + * 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 @@ -214,7 +215,7 @@ function holdsMostOf( return inside.length > outside.length || 0 === outside.length; } -// Subpaths of equal area nest in drawing order, so a subpath drawn twice cancels out +// Subpaths of equal area nest in drawing order, so a subpath repeated point for point cancels out function canEnclose( areas: readonly number[], candidate: number,