Repository: Hopding/pdf-lib
Pull request #202 (2019-09-26 15:39:42)

Add page.drawSVG(path) method
Brought over the SVG path methods from `pdfkit`, with attribution and updated to TypeScript.

Addresses issue: https://github.com/Hopding/pdf-lib/issues/114

Here's a sample test page with the output with variations on the options.
https://gist.github.com/jlmessenger/c8323fcb7891f30843e59a391cae925a


jlmessenger: @Hopding let me know what you think.
Hopding: Hello @jlmessenger! This is certainly a feature that I'd like to get into pdf-lib. So thank you for working on this. I'm planning to do a thorough review within the next week. However, I've looked over this briefly and a few things pop out at me:
* This new code will need to have some unit tests added before it can be merged. Primarily `src/api/svgPath.ts`.
* I also try to ensure that all new API changes are tested in the three tester apps (in the `apps/` dir) to prevent regressions.
* I do not think these changes fully address #114, because these changes support drawing SVG *paths*. They do not allow SVG *files* to be embedded. It certainly does not have to be done as part of this PR, but I'm curious how much effort you think it would take to support SVG embedding?
* Along the lines of my previous note, I think it would be better to call the new method `PDFPage.drawSvgPath(...)`.

As I mentioned, I'll try to do a more thorough review of the code itself soon. Please let me know if you have any questions/concerns!
Hopding: I think the best way to test the `src/api/svgPath.ts` module would be to have a series of tests cases that call `svgPathOperators` with various path strings and then verify that the resulting array contains the correct operators and arguments in the correct sequence. The key thing would be to exercise all the different paths/functions through the code in `src/api/svgPath.ts`. I would like to have nearly 100% coverage for code like this (similar to how the PDF parser code has almost 100% test coverage). You can generate a coverage report with [`yarn testc`](https://github.com/Hopding/pdf-lib/blob/master/package.json#L21).

I hope this helps. Please let me know if you have any additional questions!
jlmessenger: @Hopding - Thanks for the direction.
I used the [SVG path specification](https://svgwg.org/svg2-draft/paths.html) as a source for path samples, and added unit tests.

Patch:
diff --git a/apps/node/index.ts b/apps/node/index.ts
index 711e613..0d14841 100644
--- a/apps/node/index.ts
+++ b/apps/node/index.ts
@@ -6,6 +6,7 @@ import readline from 'readline';
 import test1 from './tests/test1';
 import test10 from './tests/test10';
 import test11 from './tests/test11';
+import test12 from './tests/test12';
 import test2 from './tests/test2';
 import test3 from './tests/test3';
 import test4 from './tests/test4';
@@ -120,7 +121,7 @@ const main = async () => {
     // prettier-ignore
     const allTests = [
     test1, test2, test3, test4, test5, test6, test7, test8, test9, test10,
-    test11
+    test11, test12
   ];
 
     const tests = testIdx ? [allTests[testIdx - 1]] : allTests;
diff --git a/src/api/PDFPage.ts b/src/api/PDFPage.ts
index b1282e2..a90caa8 100644
--- a/src/api/PDFPage.ts
+++ b/src/api/PDFPage.ts
@@ -5,6 +5,7 @@ import {
   drawLine,
   drawLinesOfText,
   drawRectangle,
+  drawSvgPath,
 } from 'src/api/operations';
 import {
   popGraphicsState,
@@ -21,6 +22,7 @@ import {
   PDFPageDrawLineOptions,
   PDFPageDrawRectangleOptions,
   PDFPageDrawSquareOptions,
+  PDFPageDrawSVGOptions,
   PDFPageDrawTextOptions,
 } from 'src/api/PDFPageOptions';
 import { degrees, Rotation, toDegrees } from 'src/api/rotations';
@@ -676,6 +678,70 @@ export default class PDFPage {
     );
   }
 
+  /**
+   * Draw a SVG path on this page.
+   * ```js
+   * import { rgb } from 'pdf-lib'
+   *
+   * const svgPath = 'M 0,20 L 100,160 Q 130,200 150,120 C 190,-40 200,200 300,150 L 400,90';
+   *
+   * // draw path as black line
+   * page.drawSvgPath(svgPath, { x: 25, y: 75 });
+   *
+   * // change border style
+   * page.drawSvgPath(svgPath, {
+   *   x: 25,
+   *   y: 275,
+   *   borderColor: rgb(0.5, 0.5, 0.5),
+   *   borderWidth: 2
+   * });
+   *
+   * // set fill color
+   * page.drawSvgPath(svgPath, {
+   * 	 x: 25,
+   * 	 y: 475,
+   * 	 color: rgb(1.0, 0, 0)
+   * });
+   *
+   * // draw 50% of original size
+   * page.drawSvgPath(svgPath, {
+   * 	 x: 25,
+   * 	 y: 675,
+   * 	 scale: 0.5
+   * });
+   * ```
+   * @param path SVG path.
+   * @param options The options to be used when drawing the SVG.
+   */
+  drawSvgPath(path: string, options: PDFPageDrawSVGOptions = {}): void {
+    assertIs(path, 'path', ['string']);
+    assertOrUndefined(options.x, 'options.x', ['number']);
+    assertOrUndefined(options.y, 'options.y', ['number']);
+    assertOrUndefined(options.scale, 'options.scale', ['number']);
+    assertOrUndefined(options.borderWidth, 'options.borderWidth', ['number']);
+    assertOrUndefined(options.color, 'options.color', [[Object, 'Color']]);
+    assertOrUndefined(options.borderColor, 'options.borderColor', [
+      [Object, 'Color'],
+    ]);
+
+    const opts = {
+      ...options,
+      x: options.x || this.x,
+      y: options.y || this.y,
+    };
+    // if no fill color or border assigned - use black border
+    if (!options.color && !options.borderColor) {
+      opts.borderColor = rgb(0, 0, 0);
+    }
+    if (options.borderColor && !options.borderWidth) {
+      opts.borderWidth = 1;
+    }
+
+    const contentStream = this.getContentStream();
+    const commands = drawSvgPath(path, opts);
+    contentStream.push(...commands);
+  }
+
   /**
    * Draw a line on this page. For example:
    * ```js
diff --git a/src/api/PDFPageOptions.ts b/src/api/PDFPageOptions.ts
index 931916d..7798781 100644
--- a/src/api/PDFPageOptions.ts
+++ b/src/api/PDFPageOptions.ts
@@ -76,3 +76,12 @@ export interface PDFPageDrawCircleOptions {
   borderColor?: Color;
   borderWidth?: number;
 }
+
+export interface PDFPageDrawSVGOptions {
+  x?: number;
+  y?: number;
+  scale?: number;
+  borderWidth?: number;
+  color?: Color;
+  borderColor?: Color;
+}
diff --git a/src/api/operations.ts b/src/api/operations.ts
index 414eeb1..3a50e2e 100644
--- a/src/api/operations.ts
+++ b/src/api/operations.ts
@@ -25,6 +25,7 @@ import {
   translate,
 } from 'src/api/operators';
 import { Rotation, toRadians } from 'src/api/rotations';
+import svgPathOperators from 'src/api/svgPath';
 import { PDFHexString, PDFName, PDFNumber, PDFOperator } from 'src/core';
 
 export interface DrawTextOptions {
@@ -221,3 +222,34 @@ export const drawEllipse = (options: {
 
     popGraphicsState(),
   ].filter(Boolean) as PDFOperator[];
+
+export const drawSvgPath = (
+  path: string,
+  options: {
+    x: number;
+    y: number;
+    scale?: number;
+    borderWidth?: number;
+    color?: Color;
+    borderColor?: Color;
+  },
+) =>
+  [
+    pushGraphicsState(),
+    translate(options.x, options.y),
+    // prettier-ignore
+    options.scale ? scale(options.scale, -options.scale)
+  : scale(1, -1), // SVG path Y axis is opposite pdf-lib's
+    options.color && setFillingColor(options.color),
+    options.borderColor && setStrokingColor(options.borderColor),
+    options.borderWidth && setLineWidth(options.borderWidth),
+    ...svgPathOperators(path),
+
+    // prettier-ignore
+    options.color && options.borderWidth ? fillAndStroke()
+  : options.color                      ? fill()
+  : options.borderColor                ? stroke()
+  : closePath(),
+
+    popGraphicsState(),
+  ].filter(Boolean) as PDFOperator[];
diff --git a/src/api/operators.ts b/src/api/operators.ts
index bb56e3b..ab7124a 100644
--- a/src/api/operators.ts
+++ b/src/api/operators.ts
@@ -130,6 +130,19 @@ export const appendBezierCurve = (
     asPDFNumber(y3),
   ]);
 
+export const appendQuadraticCurve = (
+  x1: number | PDFNumber,
+  y1: number | PDFNumber,
+  x2: number | PDFNumber,
+  y2: number | PDFNumber,
+) =>
+  PDFOperator.of(Ops.CurveToReplicateInitialPoint, [
+    asPDFNumber(x1),
+    asPDFNumber(y1),
+    asPDFNumber(x2),
+    asPDFNumber(y2),
+  ]);
+
 export const closePath = () => PDFOperator.of(Ops.ClosePath);
 
 export const moveTo = (xPos: number | PDFNumber, yPos: number | PDFNumber) =>
diff --git a/src/api/svgPath.ts b/src/api/svgPath.ts
new file mode 100644
index 0000000..fc94da1
--- /dev/null
+++ b/src/api/svgPath.ts
@@ -0,0 +1,491 @@
+// Originated from pdfkit Copyright (c) 2014 Devon Govett
+// https://github.com/foliojs/pdfkit/blob/1e62e6ffe24b378eb890df507a47610f4c4a7b24/lib/path.js
+// MIT LICENSE
+// Updated for pdf-lib & TypeScript by Jeremy Messenger
+import {
+  appendBezierCurve,
+  appendQuadraticCurve,
+  closePath,
+  lineTo,
+  moveTo,
+} from 'src/api/operators';
+import { PDFOperator } from 'src/core';
+
+let cx: number = 0;
+let cy: number = 0;
+let px: number | null = 0;
+let py: number | null = 0;
+let sx: number = 0;
+let sy: number = 0;
+
+const parameters = new Map<string, number>([
+  ['A', 7],
+  ['a', 7],
+  ['C', 6],
+  ['c', 6],
+  ['H', 1],
+  ['h', 1],
+  ['L', 2],
+  ['l', 2],
+  ['M', 2],
+  ['m', 2],
+  ['Q', 4],
+  ['q', 4],
+  ['S', 4],
+  ['s', 4],
+  ['T', 2],
+  ['t', 2],
+  ['V', 1],
+  ['v', 1],
+  ['Z', 0],
+  ['z', 0],
+]);
+
+interface Cmd {
+  cmd?: string;
+  args: number[];
+}
+
+const parse = (path: string) => {
+  let cmd;
+  const ret: Cmd[] = [];
+  let args: number[] = [];
+  let curArg = '';
+  let foundDecimal = false;
+  let params = 0;
+
+  for (const c of path) {
+    if (parameters.has(c)) {
+      params = parameters.get(c)!;
+      if (cmd) {
+        // save existing command
+        if (curArg.length > 0) {
+          args[args.length] = +curArg;
+        }
+        ret[ret.length] = { cmd, args };
+
+        args = [];
+        curArg = '';
+        foundDecimal = false;
+      }
+
+      cmd = c;
+    } else if (
+      [' ', ','].includes(c) ||
+      (c === '-' && curArg.length > 0 && curArg[curArg.length - 1] !== 'e') ||
+      (c === '.' && foundDecimal)
+    ) {
+      if (curArg.length === 0) {
+        continue;
+      }
+
+      if (args.length === params) {
+        // handle reused commands
+        ret[ret.length] = { cmd, args };
+        args = [+curArg];
+
+        // handle assumed commands
+        if (cmd === 'M') {
+          cmd = 'L';
+        }
+        if (cmd === 'm') {
+          cmd = 'l';
+        }
+      } else {
+        args[args.length] = +curArg;
+      }
+
+      foundDecimal = c === '.';
+
+      // fix for negative numbers or repeated decimals with no delimeter between commands
+      curArg = ['-', '.'].includes(c) ? c : '';
+    } else {
+      curArg += c;
+      if (c === '.') {
+        foundDecimal = true;
+      }
+    }
+  }
+
+  // add the last command
+  if (curArg.length > 0) {
+    if (args.length === params) {
+      // handle reused commands
+      ret[ret.length] = { cmd, args };
+      args = [+curArg];
+
+      // handle assumed commands
+      if (cmd === 'M') {
+        cmd = 'L';
+      }
+      if (cmd === 'm') {
+        cmd = 'l';
+      }
+    } else {
+      args[args.length] = +curArg;
+    }
+  }
+
+  ret[ret.length] = { cmd, args };
+
+  return ret;
+};
+
+const apply = (commands: Cmd[]) => {
+  // current point, control point, and subpath starting point
+  cx = cy = px = py = sx = sy = 0;
+
+  // run the commands
+  let cmds: PDFOperator[] = [];
+  for (let i = 0; i < commands.length; i++) {
+    const c = commands[i];
+    if (c.cmd && typeof runners[c.cmd] === 'function') {
+      const cmd = runners[c.cmd](c.args);
+      if (Array.isArray(cmd)) {
+        cmds = cmds.concat(cmd);
+      } else {
+        cmds.push(cmd);
+      }
+    }
+  }
+  return cmds;
+};
+
+interface CmdToOperatorsMap {
+  [cmd: string]: (a: number[]) => PDFOperator | PDFOperator[];
+}
+
+const runners: CmdToOperatorsMap = {
+  M(a) {
+    cx = a[0];
+    cy = a[1];
+    px = py = null;
+    sx = cx;
+    sy = cy;
+    return moveTo(cx, cy);
+  },
+
+  m(a) {
+    cx += a[0];
+    cy += a[1];
+    px = py = null;
+    sx = cx;
+    sy = cy;
+    return moveTo(cx, cy);
+  },
+
+  C(a) {
+    cx = a[4];
+    cy = a[5];
+    px = a[2];
+    py = a[3];
+    return appendBezierCurve(a[0], a[1], a[2], a[3], a[4], a[5]);
+  },
+
+  c(a) {
+    const cmd = appendBezierCurve(
+      a[0] + cx,
+      a[1] + cy,
+      a[2] + cx,
+      a[3] + cy,
+      a[4] + cx,
+      a[5] + cy,
+    );
+    px = cx + a[2];
+    py = cy + a[3];
+    cx += a[4];
+    cy += a[5];
+    return cmd;
+  },
+
+  S(a) {
+    if (px === null || py === null) {
+      px = cx;
+      py = cy;
+    }
+
+    const cmd = appendBezierCurve(
+      cx - (px - cx),
+      cy - (py - cy),
+      a[0],
+      a[1],
+      a[2],
+      a[3],
+    );
+    px = a[0];
+    py = a[1];
+    cx = a[2];
+    cy = a[3];
+    return cmd;
+  },
+
+  s(a) {
+    if (px === null || py === null) {
+      px = cx;
+      py = cy;
+    }
+
+    const cmd = appendBezierCurve(
+      cx - (px - cx),
+      cy - (py - cy),
+      cx + a[0],
+      cy + a[1],
+      cx + a[2],
+      cy + a[3],
+    );
+    px = cx + a[0];
+    py = cy + a[1];
+    cx += a[2];
+    cy += a[3];
+    return cmd;
+  },
+
+  Q(a) {
+    px = a[0];
+    py = a[1];
+    cx = a[2];
+    cy = a[3];
+    return appendQuadraticCurve(a[0], a[1], cx, cy);
+  },
+
+  q(a) {
+    const cmd = appendQuadraticCurve(
+      a[0] + cx,
+      a[1] + cy,
+      a[2] + cx,
+      a[3] + cy,
+    );
+    px = cx + a[0];
+    py = cy + a[1];
+    cx += a[2];
+    cy += a[3];
+    return cmd;
+  },
+
+  T(a) {
+    if (px === null || py === null) {
+      px = cx;
+      py = cy;
+    } else {
+      px = cx - (px - cx);
+      py = cy - (py - cy);
+    }
+
+    const cmd = appendQuadraticCurve(px, py, a[0], a[1]);
+    px = cx - (px - cx);
+    py = cy - (py - cy);
+    cx = a[0];
+    cy = a[1];
+    return cmd;
+  },
+
+  t(a) {
+    if (px === null || py === null) {
+      px = cx;
+      py = cy;
+    } else {
+      px = cx - (px - cx);
+      py = cy - (py - cy);
+    }
+
+    const cmd = appendQuadraticCurve(px, py, cx + a[0], cy + a[1]);
+    cx += a[0];
+    cy += a[1];
+    return cmd;
+  },
+
+  A(a) {
+    const cmds = solveArc(cx, cy, a);
+    cx = a[5];
+    cy = a[6];
+    return cmds;
+  },
+
+  a(a) {
+    a[5] += cx;
+    a[6] += cy;
+    const cmds = solveArc(cx, cy, a);
+    cx = a[5];
+    cy = a[6];
+    return cmds;
+  },
+
+  L(a) {
+    cx = a[0];
+    cy = a[1];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  l(a) {
+    cx += a[0];
+    cy += a[1];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  H(a) {
+    cx = a[0];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  h(a) {
+    cx += a[0];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  V(a) {
+    cy = a[0];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  v(a) {
+    cy += a[0];
+    px = py = null;
+    return lineTo(cx, cy);
+  },
+
+  Z() {
+    const cmd = closePath();
+    cx = sx;
+    cy = sy;
+    return cmd;
+  },
+
+  z() {
+    const cmd = closePath();
+    cx = sx;
+    cy = sy;
+    return cmd;
+  },
+};
+
+const solveArc = (x: number, y: number, coords: number[]) => {
+  const [rx, ry, rot, large, sweep, ex, ey] = coords;
+  const segs = arcToSegments(ex, ey, rx, ry, large, sweep, rot, x, y);
+
+  const cmds: PDFOperator[] = [];
+  for (const seg of segs) {
+    const bez = segmentToBezier(...seg);
+    cmds.push(appendBezierCurve(...bez));
+  }
+  return cmds;
+};
+
+type Segment = [number, number, number, number, number, number, number, number];
+type Bezier = [number, number, number, number, number, number];
+
+// from Inkscape svgtopdf, thanks!
+const arcToSegments = (
+  x: number,
+  y: number,
+  rx: number,
+  ry: number,
+  large: number,
+  sweep: number,
+  rotateX: number,
+  ox: number,
+  oy: number,
+) => {
+  const th = rotateX * (Math.PI / 180);
+  const sinTh = Math.sin(th);
+  const cosTh = Math.cos(th);
+  rx = Math.abs(rx);
+  ry = Math.abs(ry);
+  px = cosTh * (ox - x) * 0.5 + sinTh * (oy - y) * 0.5;
+  py = cosTh * (oy - y) * 0.5 - sinTh * (ox - x) * 0.5;
+  let pl = (px * px) / (rx * rx) + (py * py) / (ry * ry);
+  if (pl > 1) {
+    pl = Math.sqrt(pl);
+    rx *= pl;
+    ry *= pl;
+  }
+
+  const a00 = cosTh / rx;
+  const a01 = sinTh / rx;
+  const a10 = -sinTh / ry;
+  const a11 = cosTh / ry;
+  const x0 = a00 * ox + a01 * oy;
+  const y0 = a10 * ox + a11 * oy;
+  const x1 = a00 * x + a01 * y;
+  const y1 = a10 * x + a11 * y;
+
+  const d = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0);
+  let sfactorSq = 1 / d - 0.25;
+  if (sfactorSq < 0) {
+    sfactorSq = 0;
+  }
+  let sfactor = Math.sqrt(sfactorSq);
+  if (sweep === large) {
+    sfactor = -sfactor;
+  }
+
+  const xc = 0.5 * (x0 + x1) - sfactor * (y1 - y0);
+  const yc = 0.5 * (y0 + y1) + sfactor * (x1 - x0);
+
+  const th0 = Math.atan2(y0 - yc, x0 - xc);
+  const th1 = Math.atan2(y1 - yc, x1 - xc);
+
+  let thArc = th1 - th0;
+  if (thArc < 0 && sweep === 1) {
+    thArc += 2 * Math.PI;
+  } else if (thArc > 0 && sweep === 0) {
+    thArc -= 2 * Math.PI;
+  }
+
+  const segments = Math.ceil(Math.abs(thArc / (Math.PI * 0.5 + 0.001)));
+  const result: Segment[] = [];
+
+  for (let i = 0; i < segments; i++) {
+    const th2 = th0 + (i * thArc) / segments;
+    const th3 = th0 + ((i + 1) * thArc) / segments;
+    result[i] = [xc, yc, th2, th3, rx, ry, sinTh, cosTh];
+  }
+
+  return result;
+};
+
+const segmentToBezier = (
+  cx1: number,
+  cy1: number,
+  th0: number,
+  th1: number,
+  rx: number,
+  ry: number,
+  sinTh: number,
+  cosTh: number,
+) => {
+  const a00 = cosTh * rx;
+  const a01 = -sinTh * ry;
+  const a10 = sinTh * rx;
+  const a11 = cosTh * ry;
+
+  const thHalf = 0.5 * (th1 - th0);
+  const t =
+    ((8 / 3) * Math.sin(thHalf * 0.5) * Math.sin(thHalf * 0.5)) /
+    Math.sin(thHalf);
+  const x1 = cx1 + Math.cos(th0) - t * Math.sin(th0);
+  const y1 = cy1 + Math.sin(th0) + t * Math.cos(th0);
+  const x3 = cx1 + Math.cos(th1);
+  const y3 = cy1 + Math.sin(th1);
+  const x2 = x3 + t * Math.sin(th1);
+  const y2 = y3 - t * Math.cos(th1);
+
+  const result: Bezier = [
+    a00 * x1 + a01 * y1,
+    a10 * x1 + a11 * y1,
+    a00 * x2 + a01 * y2,
+    a10 * x2 + a11 * y2,
+    a00 * x3 + a01 * y3,
+    a10 * x3 + a11 * y3,
+  ];
+  return result;
+};
+
+export default (path: string) => {
+  const commands = parse(path);
+  return apply(commands);
+};


Test patch:
diff --git a/apps/node/tests/test12.ts b/apps/node/tests/test12.ts
new file mode 100644
index 0000000..1bf597a
--- /dev/null
+++ b/apps/node/tests/test12.ts
@@ -0,0 +1,100 @@
+import { Assets } from '..';
+import {
+	PageSizes,
+	PDFDocument,
+	rgb
+} from '../../..';
+
+const inchToPt = (inches: number) => Math.round(inches * 72);
+
+export default async (_assets: Assets) => {
+	const pdfDoc = await PDFDocument.create();
+
+	const page1 = pdfDoc.addPage(PageSizes.Letter);
+
+	// SVG sample paths from
+	// https://svgwg.org/svg2-draft/paths.html
+
+	// downward facing triangle
+	page1.drawSvgPath('M 100 100 L 300 100 L 200 300 z', {
+		x: inchToPt(-1),
+		y: inchToPt(12),
+		color: rgb(1, 0, 0),
+		borderColor: rgb(0, 0, 1),
+	});
+
+	// bezier curve example
+	page1.drawSvgPath('M100,200 C100,100 250,100 250,200 S400,300 400,200', {
+		x: inchToPt(2),
+		y: inchToPt(12),
+	});
+
+	// bezier control point adjustments
+	page1.drawSvgPath('M100,200 C100,100 400,100 400,200', {
+		x: inchToPt(-1),
+		y: inchToPt(9.25),
+	});
+	page1.drawSvgPath('M600,200 C675,100 975,100 900,200', {
+		x: inchToPt(-4.5),
+		y: inchToPt(8.25),
+	});
+	page1.drawSvgPath('M100,500 C25,400 475,400 400,500', {
+		x: inchToPt(-1),
+		y: inchToPt(11.25),
+	});
+	page1.drawSvgPath('M600,500 C600,350 900,650 900,500', {
+		x: inchToPt(-4.5),
+		y: inchToPt(10.25),
+	});
+	page1.drawSvgPath('M100,800 C175,700 325,700 400,800', {
+		x: inchToPt(-1),
+		y: inchToPt(13.35),
+	});
+	page1.drawSvgPath('M600,800 C625,700 725,700 750,800 S875,900 900,800', {
+		x: inchToPt(-4.5),
+		y: inchToPt(12.25),
+	});
+
+	const page2 = pdfDoc.addPage(PageSizes.Letter);
+
+	// quadratic bezier example
+	page2.drawSvgPath('M200,300 Q400,50 600,300 T1000,300', {
+		x: inchToPt(-1),
+		y: inchToPt(11),
+		scale: 0.5,
+		borderWidth: 2,
+	});
+	page2.drawSvgPath('M200,300 L400,50 L600,300 L800,550 L1000,300', {
+		x: inchToPt(-1),
+		y: inchToPt(9),
+		scale: 0.5,
+		borderWidth: 2,
+	});
+
+	// arc examples
+	page2.drawSvgPath('M300,200 h-150 a150,150 0 1,0 150,-150 z', {
+		x: inchToPt(-1),
+		y: inchToPt(5.5),
+		color: rgb(1, 0, 0),
+		borderColor: rgb(0, 0, 1),
+	});
+	page2.drawSvgPath('M275,175 v-150 a150,150 0 0,0 -150,150 z', {
+		x: inchToPt(-1),
+		y: inchToPt(5.5),
+		color: rgb(1, 1, 0),
+		borderColor: rgb(0, 0, 1),
+	});
+	page2.drawSvgPath(
+		'M600,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25',
+		{
+			x: inchToPt(1),
+			y: inchToPt(3),
+			scale: 0.5,
+			borderWidth: 2,
+			borderColor: rgb(1, 0, 0),
+		},
+	);
+
+	const pdfBytes = await pdfDoc.save();
+	return pdfBytes;
+};
diff --git a/apps/rn/src/components/TestLauncher.js b/apps/rn/src/components/TestLauncher.js
index 15abb99..9bb9420 100644
--- a/apps/rn/src/components/TestLauncher.js
+++ b/apps/rn/src/components/TestLauncher.js
@@ -19,6 +19,7 @@ import test8 from '../tests/test8';
 import test9 from '../tests/test9';
 import test10 from '../tests/test10';
 import test11 from '../tests/test11';
+import test12 from '../tests/test12';
 
 const red = '#FF0000';
 
@@ -67,6 +68,7 @@ export default class TestLauncher extends Component {
         <TestButton test={[9, test9]} longRunning />
         <TestButton test={[10, test10]} />
         <TestButton test={[11, test11]} longRunning />
+        <TestButton test={[12, test12]} />
       </SafeAreaView>
     );
   }
diff --git a/apps/rn/src/tests/test12.js b/apps/rn/src/tests/test12.js
new file mode 100644
index 0000000..4258856
--- /dev/null
+++ b/apps/rn/src/tests/test12.js
@@ -0,0 +1,100 @@
+import {
+  PageSizes,
+  PDFDocument,
+  rgb
+} from 'pdf-lib';
+
+const inchToPt = (inches) => Math.round(inches * 72);
+
+export default async () => {
+  const pdfDoc = await PDFDocument.create();
+
+  const page1 = pdfDoc.addPage(PageSizes.Letter);
+
+  // SVG sample paths from
+  // https://svgwg.org/svg2-draft/paths.html
+
+  // downward facing triangle
+  page1.drawSvgPath('M 100 100 L 300 100 L 200 300 z', {
+    x: inchToPt(-1),
+    y: inchToPt(12),
+    color: rgb(1, 0, 0),
+    borderColor: rgb(0, 0, 1),
+  });
+
+  // bezier curve example
+  page1.drawSvgPath('M100,200 C100,100 250,100 250,200 S400,300 400,200', {
+    x: inchToPt(2),
+    y: inchToPt(12),
+  });
+
+  // bezier control point adjustments
+  page1.drawSvgPath('M100,200 C100,100 400,100 400,200', {
+    x: inchToPt(-1),
+    y: inchToPt(9.25),
+  });
+  page1.drawSvgPath('M600,200 C675,100 975,100 900,200', {
+    x: inchToPt(-4.5),
+    y: inchToPt(8.25),
+  });
+  page1.drawSvgPath('M100,500 C25,400 475,400 400,500', {
+    x: inchToPt(-1),
+    y: inchToPt(11.25),
+  });
+  page1.drawSvgPath('M600,500 C600,350 900,650 900,500', {
+    x: inchToPt(-4.5),
+    y: inchToPt(10.25),
+  });
+  page1.drawSvgPath('M100,800 C175,700 325,700 400,800', {
+    x: inchToPt(-1),
+    y: inchToPt(13.35),
+  });
+  page1.drawSvgPath('M600,800 C625,700 725,700 750,800 S875,900 900,800', {
+    x: inchToPt(-4.5),
+    y: inchToPt(12.25),
+  });
+
+  const page2 = pdfDoc.addPage(PageSizes.Letter);
+
+  // quadratic bezier example
+  page2.drawSvgPath('M200,300 Q400,50 600,300 T1000,300', {
+    x: inchToPt(-1),
+    y: inchToPt(11),
+    scale: 0.5,
+    borderWidth: 2,
+  });
+  page2.drawSvgPath('M200,300 L400,50 L600,300 L800,550 L1000,300', {
+    x: inchToPt(-1),
+    y: inchToPt(9),
+    scale: 0.5,
+    borderWidth: 2,
+  });
+
+  // arc examples
+  page2.drawSvgPath('M300,200 h-150 a150,150 0 1,0 150,-150 z', {
+    x: inchToPt(-1),
+    y: inchToPt(5.5),
+    color: rgb(1, 0, 0),
+    borderColor: rgb(0, 0, 1),
+  });
+  page2.drawSvgPath('M275,175 v-150 a150,150 0 0,0 -150,150 z', {
+    x: inchToPt(-1),
+    y: inchToPt(5.5),
+    color: rgb(1, 1, 0),
+    borderColor: rgb(0, 0, 1),
+  });
+  page2.drawSvgPath(
+    'M600,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25',
+    {
+      x: inchToPt(1),
+      y: inchToPt(3),
+      scale: 0.5,
+      borderWidth: 2,
+      borderColor: rgb(1, 0, 0),
+    },
+  );
+
+  const base64Pdf = await pdfDoc.saveAsBase64({ dataUri: true });
+
+  return { base64Pdf };
+};
diff --git a/apps/web/test12.html b/apps/web/test12.html
new file mode 100644
index 0000000..16ea03b
--- /dev/null
+++ b/apps/web/test12.html
@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta
+      http-equiv="Content-Security-Policy"
+      content="
+        default-src 'self' 'unsafe-inline' blob: resource: https://unpkg.com/@pdf-lib/fontkit/dist/fontkit.umd.js;
+        object-src 'self' blob:;
+        frame-src 'self' blob:;
+      "
+    />
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
+    <link rel="stylesheet" type="text/css" href="/apps/web/index.css" />
+    <title>Test 1</title>
+    <script type="text/javascript" src="/dist/pdf-lib.js"></script>
+    <script type="text/javascript" src="/apps/web/utils.js"></script>
+  </head>
+
+  <body>
+    <div id="button-container">
+      <button onclick="window.location.href = '/apps/web/test11.html'">
+        Prev
+      </button>
+      <button onclick="test()">Run Test</button>
+      <button disabled onclick="window.location.href = '/apps/web/test13.html'">
+        Next
+      </button>
+    </div>
+    <div id="animation-target"></div>
+    <iframe id="iframe"></iframe>
+  </body>
+
+  <script type="text/javascript">
+    startFpsTracker('animation-target');
+
+    const fetchBinaryAsset = (asset) =>
+      fetch(`/assets/${asset}`).then((res) => res.arrayBuffer());
+
+    const fetchStringAsset = (asset) =>
+      fetch(`/assets/${asset}`).then((res) => res.text());
+
+    const renderInIframe = (pdfBytes) => {
+      const blob = new Blob([pdfBytes], { type: 'application/pdf' });
+      const blobUrl = URL.createObjectURL(blob);
+      document.getElementById('iframe').src = blobUrl;
+    };
+
+    const inchToPt = (inches) => Math.round(inches * 72);
+
+    async function test() {
+      const {
+        PageSizes,
+        PDFDocument,
+        rgb,
+      } = PDFLib;
+
+      const pdfDoc = await PDFDocument.create();
+
+      const page1 = pdfDoc.addPage(PageSizes.Letter);
+
+      // SVG sample paths from
+      // https://svgwg.org/svg2-draft/paths.html
+
+      // downward facing triangle
+      page1.drawSvgPath('M 100 100 L 300 100 L 200 300 z', {
+        x: inchToPt(-1),
+        y: inchToPt(12),
+        color: rgb(1, 0, 0),
+        borderColor: rgb(0, 0, 1),
+      });
+
+      // bezier curve example
+      page1.drawSvgPath('M100,200 C100,100 250,100 250,200 S400,300 400,200', {
+        x: inchToPt(2),
+        y: inchToPt(12),
+      });
+
+      // bezier control point adjustments
+      page1.drawSvgPath('M100,200 C100,100 400,100 400,200', {
+        x: inchToPt(-1),
+        y: inchToPt(9.25),
+      });
+      page1.drawSvgPath('M600,200 C675,100 975,100 900,200', {
+        x: inchToPt(-4.5),
+        y: inchToPt(8.25),
+      });
+      page1.drawSvgPath('M100,500 C25,400 475,400 400,500', {
+        x: inchToPt(-1),
+        y: inchToPt(11.25),
+      });
+      page1.drawSvgPath('M600,500 C600,350 900,650 900,500', {
+        x: inchToPt(-4.5),
+        y: inchToPt(10.25),
+      });
+      page1.drawSvgPath('M100,800 C175,700 325,700 400,800', {
+        x: inchToPt(-1),
+        y: inchToPt(13.35),
+      });
+      page1.drawSvgPath('M600,800 C625,700 725,700 750,800 S875,900 900,800', {
+        x: inchToPt(-4.5),
+        y: inchToPt(12.25),
+      });
+
+      const page2 = pdfDoc.addPage(PageSizes.Letter);
+
+      // quadratic bezier example
+      page2.drawSvgPath('M200,300 Q400,50 600,300 T1000,300', {
+        x: inchToPt(-1),
+        y: inchToPt(11),
+        scale: 0.5,
+        borderWidth: 2,
+      });
+      page2.drawSvgPath('M200,300 L400,50 L600,300 L800,550 L1000,300', {
+        x: inchToPt(-1),
+        y: inchToPt(9),
+        scale: 0.5,
+        borderWidth: 2,
+      });
+
+      // arc examples
+      page2.drawSvgPath('M300,200 h-150 a150,150 0 1,0 150,-150 z', {
+        x: inchToPt(-1),
+        y: inchToPt(5.5),
+        color: rgb(1, 0, 0),
+        borderColor: rgb(0, 0, 1),
+      });
+      page2.drawSvgPath('M275,175 v-150 a150,150 0 0,0 -150,150 z', {
+        x: inchToPt(-1),
+        y: inchToPt(5.5),
+        color: rgb(1, 1, 0),
+        borderColor: rgb(0, 0, 1),
+      });
+      page2.drawSvgPath(
+        'M600,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25',
+        {
+          x: inchToPt(1),
+          y: inchToPt(3),
+          scale: 0.5,
+          borderWidth: 2,
+          borderColor: rgb(1, 0, 0),
+        },
+      );
+
+      const pdfBytes = await pdfDoc.save();
+
+      renderInIframe(pdfBytes);
+    }
+  </script>
+</html>
diff --git a/tests/api/svgPath.spec.ts b/tests/api/svgPath.spec.ts
new file mode 100644
index 0000000..49a3f53
--- /dev/null
+++ b/tests/api/svgPath.spec.ts
@@ -0,0 +1,127 @@
+import svgPathOperators from 'src/api/svgPath';
+
+// Test paths adapted from https://svgwg.org/svg2-draft/paths.html
+describe(`svgPath`, () => {
+  it(`can draw triangle`, () => {
+    const operators = svgPathOperators('M 100 100 L 300 100 L 200 300 z');
+    expect(operators.length).toBe(4);
+    expect(operators.toString()).toBe('100 100 m,300 100 l,200 300 l,h');
+  });
+  it(`can draw relative triangle`, () => {
+    const operators = svgPathOperators('m 100 100 l 200 0 l -100 200 z');
+    expect(operators.length).toBe(4);
+    expect(operators.toString()).toBe('100 100 m,300 100 l,200 300 l,h');
+  });
+  it(`can draw triangle decimal`, () => {
+    const operators = svgPathOperators('M 50.5 100 L 250.5 100 150.5 300 z');
+    expect(operators.length).toBe(4);
+    expect(operators.toString()).toBe('50.5 100 m,250.5 100 l,150.5 300 l,h');
+  });
+  it(`can draw rectangle with lines`, () => {
+    const operators = svgPathOperators('M 50 50 V 100 H 100 v -100 h -50 Z');
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '50 50 m,50 100 l,100 100 l,100 0 l,50 0 l,h',
+    );
+  });
+  it(`can draw bezier curve`, () => {
+    const operators = svgPathOperators(
+      'M100,200 C100,100 250,100 250,200 S400,300 400,200',
+    );
+    expect(operators.length).toBe(3);
+    expect(operators.toString()).toBe(
+      '100 200 m,100 100 250 100 250 200 c,250 300 400 300 400 200 c',
+    );
+  });
+  it(`can draw relative bezier curve`, () => {
+    const operators = svgPathOperators(
+      'M100,200 c0,-100 150,-100 150,0 s150,100 150,0',
+    );
+    expect(operators.length).toBe(3);
+    expect(operators.toString()).toBe(
+      '100 200 m,100 100 250 100 250 200 c,250 300 400 300 400 200 c',
+    );
+  });
+  it(`can draw quadratic curve`, () => {
+    const operators = svgPathOperators('M200,300 Q400,50 600,300 T1000,300');
+    expect(operators.length).toBe(3);
+    expect(operators.toString()).toBe(
+      '200 300 m,400 50 600 300 v,800 550 1000 300 v',
+    );
+  });
+  it(`can draw relative quadratic curve`, () => {
+    const operators = svgPathOperators('M200,300 q200,-250 400,0 t400,0');
+    expect(operators.length).toBe(3);
+    expect(operators.toString()).toBe(
+      '200 300 m,400 50 600 300 v,800 550 1000 300 v',
+    );
+  });
+  it(`can draw arc`, () => {
+    const operators = svgPathOperators(
+      'M300,200 h-150 a150,150 0 1,0 150,-150 z',
+    );
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '300 200 m,150 200 l,150 282.8427124746191 217.15728752538098 350 300 350 c,382.84271247461896 350 450 282.842712474619 450 200.00000000000003 c,450 117.15728752538101 382.84271247461896 50.00000000000003 300 50.00000000000002 c,h',
+    );
+  });
+  it(`can draw relative arc`, () => {
+    const operators = svgPathOperators(
+      'M300,200 h-150 A150,150 0 1,0 300,50 z',
+    );
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '300 200 m,150 200 l,150 282.8427124746191 217.15728752538098 350 300 350 c,382.84271247461896 350 450 282.842712474619 450 200.00000000000003 c,450 117.15728752538101 382.84271247461896 50.00000000000003 300 50.00000000000002 c,h',
+    );
+  });
+  it(`can draw cubic bezier`, () => {
+    const operators = svgPathOperators(
+      'm 100 100 S 200,100 200,200 200,200 100,200 T 0,250',
+    );
+    expect(operators.length).toBe(4);
+    expect(operators.toString()).toBe(
+      '100 100 m,100 100 200 100 200 200 c,200 300 200 200 100 200 c,0 200 0 250 v',
+    );
+  });
+  it(`draws dashed line`, () => {
+    const operators = svgPathOperators(
+      'M 0,25 5,25 M 10,25 15,25 M 20,25 25,25',
+    );
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '0 25 m,5 25 l,10 25 m,15 25 l,20 25 m,25 25 l',
+    );
+  });
+  it(`draws relative dashed line`, () => {
+    const operators = svgPathOperators('m 0,25 5,0 m 5,0 5,0 m 5,0 5,0');
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '0 25 m,5 25 l,10 25 m,15 25 l,20 25 m,25 25 l',
+    );
+  });
+  it(`interprets odd inputs`, () => {
+    const operators = svgPathOperators('M 0,25 5,25 m 5-0 5..0 m 5,0 5,0');
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '0 25 m,5 25 l,10 25 m,15 25 l,20 25 m,25 25 l',
+    );
+  });
+  it(`resets cubic bezier control points`, () => {
+    const operators = svgPathOperators(
+      'M100 100 S200,100 200,200 M95 105 s100,0 100,100',
+    );
+    expect(operators.length).toBe(4);
+    expect(operators.toString()).toBe(
+      '100 100 m,100 100 200 100 200 200 c,95 105 m,95 105 195 105 195 205 c',
+    );
+  });
+  it(`resets quadratic bezier control points`, () => {
+    const operators = svgPathOperators(
+      'M100 100 T100,200 200,200 M90,120 t0,100 100,0',
+    );
+    expect(operators.length).toBe(6);
+    expect(operators.toString()).toBe(
+      '100 100 m,100 100 100 200 v,100 300 200 200 v,90 120 m,90 120 90 220 v,90 320 190 220 v',
+    );
+  });
+});