npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pptx-studio/geometry

v0.1.2

Published

DrawingML preset and custom geometry: the 187 preset shapes, the formula evaluator, arcs

Readme

@pptx-studio/geometry

DrawingML geometry: the 187 preset shape definitions (2.1), the formula evaluator that turns one of them plus a size into numbers (2.2), the arc math that says where each of the 393 a:arcTo commands in them actually goes (2.3), the path emitter that turns all of it into SVG path data (2.4), and the adjust handles that run the whole thing backwards — drag a yellow dot and get the number to write into a:avLst (2.5). a:prstGeom and a:custGeom go through the same code, because they are the same thing written two ways.

Nothing is painted — a d string is not a fill, a stroke or an effect. That is 2.6 to 2.8.

import { getPreset } from '@pptx-studio/geometry';

getPreset('triangle');
// {
//   name: 'triangle',
//   avLst: [{ name: 'adj', fmla: ['val', '50000'] }],
//   gdLst: [{ name: 'a', fmla: ['pin', '0', 'adj', '100000'] }, ...],
//   ahLst: [{ kind: 'xy', gdRefX: 'adj', minX: '0', maxX: '100000', gdRefY: null, ... }],
//   cxnLst: [{ ang: '3cd4', pos: { x: 'x2', y: 't' } }, ...],
//   rect: { l: 'x1', t: 'vc', r: 'x3', b: 'b' },
//   pathLst: [{ w: 0, h: 0, fill: 'norm', stroke: true, extrusionOk: true, commands: [...] }],
// }

Evaluating one

import { evaluateGuides, getPreset, resolvePoint } from '@pptx-studio/geometry';

const triangle = getPreset('triangle')!;
const guides = evaluateGuides(triangle, { w: 200, h: 100 }, { adjust: { adj: 50000 } });

guides.get('x2'); // 100 — the apex, at the horizontal centre
resolvePoint(triangle.cxnLst[1].pos, guides); // { x: 50, y: 50 }

evaluateGuides seeds the built-in guides from the size, merges the deck's adjust values over the preset's defaults by name, then runs the gdLst in document order. What comes back is one map holding all three kinds, because an operand elsewhere in the shape may name any of them and cannot tell them apart.

A few things that are easy to get wrong, and are pinned by tests rather than by hope:

  • mod is the vector modulus √(x²+y²+z²), not a remainder. Arity settles it — all 48 uses pass three operands — and the names confirm it: cornerTabs.md = mod w h 0 is the diagonal.
  • pin clamps its middle operand: pin min value max.
  • at2 returns arctan(y/x) and is not normalised. It can be negative, and circularArrow normalises it by hand three times over. Wrapping it here silently reverses those arcs.
  • Angles are 60000ths of a degree, a full turn is 21600000, and because y increases downward a positive angle turns clockwise: cd4 (90°) points at the bottom of the shape.
  • +- accepts a fourth operand only when it is exactly zero — see the anomalies below.
  • sqrt takes the absolute value first — sqrt(|x|), which is what Office does and not what ECMA says. Apache POI implements the standard and returns NaN, which is contagious: the shape then draws nothing rather than drawing wrong. It is reachable — five guides across curvedDownArrow, curvedUpArrow and leftCircularArrow take a negative radicand at ordinary adjust values.
  • pin is an ordered chain, not max(x, min(y, z)). They differ on inverted bounds: pin 10 20 3 is 3, not 10.

Adjust values merge by name

Not by position. Eleven distinct adjust names appear across the presets — adj, adj1…adj8, and hf/vf on the polygons and stars — so a positional merge would assign a deck's first adjust to hf on a pentagon. An override for a name the preset does not declare is kept; one that collides with a built-in is ignored, because letting a deck redefine w would make the shape's own width a suggestion.

Division by zero is reachable, and does not throw

212 of the 1204 division formulas divide by a guide rather than a literal, and 36 shapes divide by ss. A shape with zero height is ordinary — a flat line, or a shape mid-drag — so x / 0 happens at legal sizes. Guides keep the IEEE result and nonFiniteGuides() reports which ones went non-finite; 49 of the 187 presets are affected at zero width, 55 when both dimensions collapse. Throwing there would mean a shape that vanishes mid-drag and takes an error path with it. What a renderer should do about it is 2.10's decision, taken with a renderer in front of it.

Arcs

a:arcTo names two radii and two angles, and none of the three things a path command normally carries: no centre, no destination, and no hint that its angles are not what they look like.

import { arcGeometry, arcSegments } from '@pptx-studio/geometry';

// blockArc's outer arc, at 200 x 120 with the default adjusts.
const arc = { wR: 100, hR: 60, stAng: 10800000, swAng: 21600000 };
const g = arcGeometry({ x: 0, y: 60 }, arc);
g.centre; // { x: 100, y: 60 } - derived from the start point, not given
g.end; //    { x: 0, y: 60 }   - a whole turn, so back where it started
arcSegments({ x: 0, y: 60 }, arc).length; // 2, because SVG cannot draw a closed arc

Three things to know before using it:

  • The angles are rays, not ellipse parameters. stAng is the angle of a ray from the centre; the parameter that reaches the same point is atan2(wR·sin θ, hR·cos θ). They agree on a circle and at the four cardinal angles and nowhere else, so an implementation that skips the step looks right on ellipse, donut and every rounded corner. At an ordinary non-square size 84 of the 389 non-degenerate arcs in the corpus land somewhere else without it.
  • A negative swAng really does run backwards. donut's outer ring is four arcs of +90° and its inner ring four of −90°; the opposite winding is the only reason the hole is a hole. Both windings finish at the same point, so normalising −90° to +270° is invisible until something is filled.
  • A whole turn has to be split. An SVG A whose destination equals its origin draws nothing at all, and 20 of the corpus's arcs are exactly that — smileyFace's outline and both eyes among them. arcSegments returns two half turns for those and one segment for everything else.

swAng is clamped to ±360°, which is what Office does and is not the same as normalising into that range: 400° clamped ends where it began, normalised it ends 40° along and drags every following command with it. No preset reaches the case.

A zero radius is reported as degenerate and draws nothing, without moving the pen. That is a real case rather than a guard: round2DiagRect and round2SameRect write their two square corners as arcs of radius zero, at their default adjust values.

Drawing one

import { getPreset, resolveGeometry } from '@pptx-studio/geometry';

const resolved = resolveGeometry(getPreset('flowChartDisplay')!, { w: 120, h: 60 });
resolved.paths.length; // 1 — one ResolvedPath per a:path, never merged
resolved.paths[0].d; // 'M0 30L20 0L100 0A20 30 0 0 1 100 60L20 60Z'
resolved.paths[0].fill; // 'norm'
resolved.textRect; // { l: 20, t: 0, r: 100, b: 60 }

resolveGeometry takes a Geometry, which is the preset type and the custGeom type both — an a:custGeom carries the same six children and differs only in having no name, so custGeom({...}) builds one and everything downstream is shared.

Four things worth knowing:

  • One <path> per a:path. 63 of the presets declare more than one, and 60 have paths that disagree about fill or stroke — smileyFace is a filled head with an unfilled mouth, each action button a filled plate with an unfilled glyph. Merging a shape's paths into one d cannot render any of those 60.
  • a:path/@w and @h of 0 mean no path space, and 270 of the 320 paths are that case, so reading them as a divisor without the zero check divides by zero on five paths in six. The 50 that do declare one use nine different spaces — flowChartCollate is drawn with the coordinates 0, 1 and 2 in a two-unit square. The axes are independent.
  • An arc is resolved in path space, and the figure is scaled afterwards. The other order agrees on circles, at cardinal angles and at square sizes, which covers every preset but cloud — whose ring of eleven arcs closes to 0.32% either way at 100×100, and tears open to 5.5% the other way at 200×100.
  • d is empty when something is not finite, and finite says so. A d holding a NaN is invalid rather than merely wrong: a browser drops the whole path silently. At zero width 11 presets reach a non-finite number inside a path, at zero height 16, and 19 when both collapse. The numbers stay in segments.

close maps straight to Z, which has the same meaning — the next command starts a new subpath at the closed one's first point. 35 presets rely on that and carry on drawing after a close.

Dragging one

import { dragHandle, getPreset, resolveHandles } from '@pptx-studio/geometry';

const roundRect = getPreset('roundRect')!;
const size = { w: 200, h: 100 };

resolveHandles(roundRect, size);
// [{ kind: 'xy', pos: { x: 16.667, y: 0 }, finite: true,
//    axes: [{ kind: 'x', guide: 'adj', value: 16667, min: 0, max: 50000,
//             widened: false, readout: 16.667 }] }]

dragHandle(roundRect, size, roundRect.ahLst[0]!, { x: 25, y: 0 });
// { adj: 25000 }  <- ready to write into a:prstGeom/a:avLst

dragHandle returns only the adjust values the handle it was given controls — at most two — so a caller merges the result into the shape's own a:avLst without disturbing anything else. to is in shape space, the same coordinates pos comes back in.

  • An axis is a scalar readout of the position, and there are four. gdRefX and gdRefY move the handle's x and y; gdRefR and gdRefAng move its distance and its angle about the shape centre. That origin is not in the spec — it is measured: ten of the eighteen polar angle axes place pos on the ray at their own angle to within 2.2e-8 of a 60000th of a degree, at six aspect ratios. The other eight are out by up to 156°, which is why the readout is always inverted and never read off the pointer.
  • One method for all 287 axes: sample the readout across the range, take the straddling interval nearest the current value, refine with false position. On an affine map — 243 of the 287 axes at every size — the first step is exact, so there is no separate analytic path to keep in step.
  • Ties break towards where the shape already is. Five axis-and-size combinations fold back, so more than one adjust value can put the handle under the pointer; taking the nearest is what makes a small mouse movement a small change.
  • minX/maxX bound the guide, not the position, in the guide's own units — arc bounds an angle at 21599999 and the callouts bound nothing at ±2147483647. An omitted bound freezes that axis, which is the opposite reading and is what ISO says.
  • The value written is an integer, rounded once after the clamp. The search is not.

The definitions themselves are still strings

'*', 'w', 'a', '200000' — a formula's operands are guide names, built-in names and literals mixed together, and nothing can tell them apart without a shape size and an avLst to resolve against. Parsing 200000 into a number at transcode time would mean deciding at build time which operands are numbers. evaluateGuides is where that decision is finally taken, with the information needed to take it.

Three things are worth knowing before you read a Geometry yourself rather than resolving one:

  • path.w and path.h of 0 mean no path-space scaling, not a degenerate path — see Drawing one.
  • arcTo carries no destination point. The end is computed from the two radii and the two angles, against the point the previous command left behind — see Arcs.
  • rect is null for five shapes — line, lineInv, chartPlus, chartStar, chartX — and for any custGeom that omits a:rect. Defaulting a missing text rectangle to the shape bounds is a policy, so the package does not pick one for you.

Where the data comes from

Transcoded at build time from Apache POI's presetShapeDefinitions.xml (Apache-2.0). The file is not redistributed here; only the generated buckets are, and each records the SHA-256 of the input it came from. See NOTICE and tools/geometry-codegen.

Two things in that source are outside ECMA's grammar and are carried through rather than repaired, because a transcoder that quietly corrected its input would be making semantic decisions that belong to the evaluator:

| What | Where | | ----------------------------------------- | ---------------------------------------------------------------- | | Eight +- formulas with a fourth operand | the circular-arrow family — OVER_LONG_FORMULAS | | Six formulas containing a double space | heptagon, pentagon, star5, star7, leftArrow, upArrow |

2.2 established that the first of those is provably benign rather than probably benign. All eight have the same shape — circularArrow.xB = +- xH 0 dxB 0 — and the fourth operand is the literal 0 every time, so x + y − z ignoring it, x + y − z + w and x + y − z − w all give the same number. Every reading agrees, so there is nothing to get wrong. The evaluator accepts a fourth operand when it is exactly zero and refuses it otherwise, because a non-zero one is a case where the readings genuinely diverge and the honest response to that is to stop.

There is a third, which the reader cannot detect generically and which is pinned by a test instead: pie's text rectangle reads l="il" t="ir" r="it" b="ib", with top and right transposed relative to the guides' own names. It is the only one of the 29 shapes using that guide set written that way.

Size, and the six buckets

The buckets are encoded strings rather than object literals: a faithful object literal of 539 KB of XML comes out larger than the XML, because { kind: 'lnTo', to: { x: 'x2', y: 't' } } spends 34 characters saying what L x2 t says in 6. All 187 shapes are 119 619 characters of encoded text, decoded lazily one shape at a time. The grammar is documented in src/decode.ts, which is its only reader.

Built and gzipped — which is what a browser actually downloads:

| Bucket | Presets | gzip | | ----------- | ------: | -----: | | arrows | 31 | 6.1 KB | | misc | 47 | 6.9 KB | | basic | 43 | 5.8 KB | | stars | 21 | 5.5 KB | | callouts | 16 | 2.1 KB | | flowchart | 29 | 1.8 KB | | the decoder | — | 3.2 KB |

getPreset pulls in all six buckets — about 33 KB gzipped — because a general renderer cannot know which shapes a deck holds until it opens one. A consumer that does know can import one:

import { FLOWCHART } from '@pptx-studio/geometry/presets/flowchart.gen.js';
FLOWCHART.get('flowChartDecision');

and pay 1.8 KB plus the decoder instead of 33 KB. Bucket membership is a packaging decision, not a fact about geometry — basic, arrows, callouts, flowchart, stars, misc follow PowerPoint's own galleries where a name makes that mechanical — so a direct bucket import should pin a version. getPreset never moves.

Regenerating

The input is not in the repository and nothing here downloads it. See tools/geometry-codegen/README.md.