All files / signature-pad/src/ink bounds.ts

100% Statements 29/29
100% Branches 14/14
100% Functions 2/2
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92                                                                                    65x 65x 65x 65x 65x   65x 59x 2468x 2468x 2468x 2468x 2468x       65x 52x                                 55x 55x 44x 44x 47x 47x   44x 44x              
/**
 * Ink bounding box — the trim math shared by PNG/SVG export (Phase 2) and
 * anything that needs to know where the ink actually is (SPEC §4.2).
 * Pure and framework-free.
 */
import type { SignatureStroke } from "../types";
import { strokeSteps } from "./strokeWalker";
 
/** An axis-aligned rectangle in CSS px. */
export interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}
 
/**
 * Bounding box of the rendered ink: the bbox over every flattened draw
 * step of every stroke, inflated by each step's radius (`FlatStep.width`
 * is a half-width, so a dot at (x, y) with width w covers
 * `[x − w, x + w] × [y − w, y + w]`).
 *
 * Runs the exact same geometry pipeline as rendering ({@link strokeSteps}),
 * so the box always matches what is actually drawn — including width
 * variation. Strokes are self-contained (they carry their own width
 * parameters), so no options argument is needed. Returns `null` when
 * there is no ink (no strokes, or only empty point arrays).
 *
 * @example
 * ```ts
 * import { inkBounds } from "@usefy/signature-pad/headless";
 *
 * const dot: SignatureStroke = {
 *   points: [{ x: 10, y: 20, time: 0, pressure: 0 }],
 *   color: "#000", minWidth: 1, maxWidth: 3,
 *   velocityFilterWeight: 0.7, pressure: "auto", pointerType: "mouse",
 * };
 * inkBounds([dot]);
 * // dot radius = (1 + 3) / 2 = 2 → { x: 8, y: 18, width: 4, height: 4 }
 * ```
 */
export function inkBounds(strokes: readonly SignatureStroke[]): Rect | null {
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;
  let any = false;
 
  for (const stroke of strokes) {
    for (const step of strokeSteps(stroke)) {
      any = true;
      if (step.x - step.width < minX) minX = step.x - step.width;
      if (step.y - step.width < minY) minY = step.y - step.width;
      if (step.x + step.width > maxX) maxX = step.x + step.width;
      if (step.y + step.width > maxY) maxY = step.y + step.width;
    }
  }
 
  if (!any) return null;
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
 
/**
 * The crop box exports use: {@link inkBounds} inflated by
 * `max(padding, capSlack)`, where `capSlack` is the largest
 * `(maxWidth − minWidth) / 2` across the strokes — the amount an SVG
 * round cap (whose radius is the segment's MEAN width) can overshoot the
 * endpoint dot radius that `inkBounds` accounts for. Flooring the
 * inflation guarantees caps are never clipped, even at `padding: 0`.
 * Shared by the PNG and SVG exporters so their crop boxes stay identical
 * (parity by construction). Returns `null` for an empty document.
 */
export function exportBounds(
  strokes: readonly SignatureStroke[],
  padding: number,
): Rect | null {
  const bounds = inkBounds(strokes);
  if (!bounds) return null;
  let capSlack = 0;
  for (const stroke of strokes) {
    const slack = (stroke.maxWidth - stroke.minWidth) / 2;
    if (slack > capSlack) capSlack = slack;
  }
  const inflate = Math.max(padding, capSlack);
  return {
    x: bounds.x - inflate,
    y: bounds.y - inflate,
    width: bounds.width + 2 * inflate,
    height: bounds.height + 2 * inflate,
  };
}