@buildinternet/uploads 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Inside this monorepo only, `pnpm uploads …` builds the package first so you pi
39
39
  up local source; product docs and PR “how to try it” examples should use the
40
40
  global `uploads` form above.
41
41
 
42
- Commands: `attach`, `put`, `screenshot`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
42
+ Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
43
43
  `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
44
44
  `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `mcp`,
45
45
  `completion`.
@@ -141,6 +141,21 @@ the key already exists).
141
141
  community art from [device-frames-media](https://github.com/jonnyjackson26/device-frames-media)
142
142
  into `~/.cache/uploads/frames` (not bundled).
143
143
 
144
+ ## Annotating screenshots
145
+
146
+ Bake hand-drawn boxes, arrows, labels, freeform strokes, and redactions onto
147
+ a screenshot before it's uploaded:
148
+
149
+ ```bash
150
+ uploads screenshot http://localhost:3000 --via local --annotate ./callouts.json
151
+ uploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.png
152
+ ```
153
+
154
+ `screenshot --annotate` resolves CSS selectors against the live page (local
155
+ capture backend only); `annotate` works on an existing image and accepts
156
+ pixel coordinates only, no selectors. Spec format and workflow:
157
+ [`skills/annotate-screenshots/SKILL.md`](../../skills/annotate-screenshots/SKILL.md).
158
+
144
159
  ## Public galleries
145
160
 
146
161
  Create an ordered gallery, then add existing uploads by key. The API returns the canonical
@@ -212,4 +227,4 @@ pnpm pack:check # verify the npm tarball contents
212
227
 
213
228
  Maintainer release instructions: [`docs/releasing.md`](../../docs/releasing.md).
214
229
 
215
- Agent-oriented usage: [`skills/uploads-cli/SKILL.md`](../../skills/uploads-cli/SKILL.md) (full CLI reference) and [`skills/github-screenshots/SKILL.md`](../../skills/github-screenshots/SKILL.md) (visuals into PRs/issues). REST details: [`docs/api.md`](../../docs/api.md).
230
+ Agent-oriented usage: [`skills/uploads-cli/SKILL.md`](../../skills/uploads-cli/SKILL.md) (full CLI reference), [`skills/github-screenshots/SKILL.md`](../../skills/github-screenshots/SKILL.md) (visuals into PRs/issues), and [`skills/annotate-screenshots/SKILL.md`](../../skills/annotate-screenshots/SKILL.md) (callouts and redaction). REST details: [`docs/api.md`](../../docs/api.md).
Binary file
package/assets/OFL.txt ADDED
@@ -0,0 +1,90 @@
1
+ Excalifont — Copyright (c) 2024 by Excalidraw. All rights reserved.
2
+ Designer: Your Own Font Foundry (Virgil); Ján Filípek / DizajnDesign (Excalifont, modifications)
3
+ Licensed under the SIL Open Font License, Version 1.1 (below; also at http://scripts.sil.org/OFL).
4
+ Latin subset of Excalifont-Regular, decompressed from the woff2 shipped in github.com/excalidraw/excalidraw.
5
+
6
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
7
+ -----------------------------------------------------------
8
+
9
+ PREAMBLE
10
+ The goals of the Open Font License (OFL) are to stimulate worldwide
11
+ development of collaborative font projects, to support the font creation
12
+ efforts of academic and linguistic communities, and to provide a free and
13
+ open framework in which fonts may be shared and improved in partnership
14
+ with others.
15
+
16
+ The OFL allows the licensed fonts to be used, studied, modified and
17
+ redistributed freely as long as they are not sold by themselves. The
18
+ fonts, including any derivative works, can be bundled, embedded,
19
+ redistributed and/or sold with any software provided that any reserved
20
+ names are not used by derivative works. The fonts and derivatives,
21
+ however, cannot be released under any other type of license. The
22
+ requirement for fonts to remain under this license does not apply
23
+ to any document created using the fonts or their derivatives.
24
+
25
+ DEFINITIONS
26
+ "Font Software" refers to the set of files released by the Copyright
27
+ Holder(s) under this license and clearly marked as such. This may
28
+ include source files, build scripts and documentation.
29
+
30
+ "Reserved Font Name" refers to any names specified as such after the
31
+ copyright statement(s).
32
+
33
+ "Original Version" refers to the collection of Font Software components as
34
+ distributed by the Copyright Holder(s).
35
+
36
+ "Modified Version" refers to any derivative made by adding to, deleting,
37
+ or substituting -- in part or in whole -- any of the components of the
38
+ Original Version, by changing formats or by porting the Font Software to a
39
+ new environment.
40
+
41
+ "Author" refers to any designer, engineer, programmer, technical
42
+ writer or other person who contributed to the Font Software.
43
+
44
+ PERMISSION & CONDITIONS
45
+ Permission is hereby granted, free of charge, to any person obtaining
46
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
47
+ redistribute, and sell modified and unmodified copies of the Font
48
+ Software, subject to the following conditions:
49
+
50
+ 1) Neither the Font Software nor any of its individual components,
51
+ in Original or Modified Versions, may be sold by itself.
52
+
53
+ 2) Original or Modified Versions of the Font Software may be bundled,
54
+ redistributed and/or sold with any software, provided that each copy
55
+ contains the above copyright notice and this license. These can be
56
+ included either as stand-alone text files, human-readable headers or
57
+ in the appropriate machine-readable metadata fields within text or
58
+ binary files as long as those fields can be easily viewed by the user.
59
+
60
+ 3) No Modified Version of the Font Software may use the Reserved Font
61
+ Name(s) unless explicit written permission is granted by the corresponding
62
+ Copyright Holder. This restriction only applies to the primary font name as
63
+ presented to the users.
64
+
65
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
66
+ Software shall not be used to promote, endorse or advertise any
67
+ Modified Version, except to acknowledge the contribution(s) of the
68
+ Copyright Holder(s) and the Author(s) or with their explicit written
69
+ permission.
70
+
71
+ 5) The Font Software, modified or unmodified, in part or in whole,
72
+ must be distributed entirely under this license, and must not be
73
+ distributed under any other license. The requirement for fonts to
74
+ remain under this license does not apply to any document created
75
+ using the Font Software.
76
+
77
+ TERMINATION
78
+ This license becomes null and void if any of the above conditions are
79
+ not met.
80
+
81
+ DISCLAIMER
82
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
83
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
84
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
85
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
86
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
87
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
88
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
89
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
90
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Screenshot annotation module — hand-drawn boxes, arrows, labels, freeform
3
+ * strokes, and redactions baked onto an image before upload.
4
+ *
5
+ * This file is the ONLY public surface of `src/annotate/`. Nothing outside
6
+ * this module may import from `./spec.js`, `./render.js`, `./shapes.js`, or
7
+ * `./text.js` directly — always go through here, so the renderer stays
8
+ * swappable (tldraw, a server-side `/v1/render`, …) without touching CLI
9
+ * callers.
10
+ */
11
+ export type { Annotation, AnnotationSpec, ArrowAnnotation, BoxAnnotation, DrawAnnotation, LabelAnnotation, Point, RedactAnnotation, SpecError, SvgAnnotation, } from "./spec.js";
12
+ export { AnnotateSpecError, hasSelectors, resolveSelectors, specSelectors, validateSpec, } from "./spec.js";
13
+ export { clampReport, renderAnnotations, type RenderOptions } from "./render.js";
@@ -0,0 +1,2 @@
1
+ export { AnnotateSpecError, hasSelectors, resolveSelectors, specSelectors, validateSpec, } from "./spec.js";
2
+ export { clampReport, renderAnnotations } from "./render.js";
@@ -0,0 +1,18 @@
1
+ import { type AnnotationSpec } from "./spec.js";
2
+ export interface RenderOptions {
3
+ seed?: number;
4
+ scale?: number;
5
+ }
6
+ /**
7
+ * Reports which annotations (by index) fall outside `[0, width) x [0, height)`
8
+ * and would be clamped by `renderAnnotations`. Pure and side-effect-free —
9
+ * the CLI layer decides how to surface these as warnings.
10
+ */
11
+ export declare function clampReport(spec: AnnotationSpec, width: number, height: number): string[];
12
+ /**
13
+ * Renders `spec` onto `image`, returning a new PNG buffer. Deterministic for
14
+ * a fixed `opts.seed` (defaults to 7). Throws `AnnotateSpecError` if any
15
+ * annotation still carries an unresolved `selector` — selectors must be
16
+ * resolved to pixel geometry (via `resolveSelectors`) before rendering.
17
+ */
18
+ export declare function renderAnnotations(image: Buffer | Uint8Array, spec: AnnotationSpec, opts?: RenderOptions): Promise<Buffer>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `renderAnnotations` — the sole render entry point of the annotate module.
3
+ * Pure: image + resolved spec in, PNG out. No browser/playwright knowledge.
4
+ *
5
+ * Not exported outside this module directly — only `index.ts` re-exports it
6
+ * (see the module header there). Callers that import `index.ts` and reach
7
+ * `renderAnnotations` must dynamic-`import()` it, because this file imports
8
+ * `sharp` statically and sharp must stay out of the static import graphs of
9
+ * `src/index.ts`, `src/agent.ts`, and `src/mcp/server.ts`.
10
+ */
11
+ import sharp from "sharp";
12
+ import { renderArrow, renderBox, renderDraw, renderLabel, renderRedactSolid, renderSvg, HOUSE_STYLE, } from "./shapes.js";
13
+ import { AnnotateSpecError, hasSelectors } from "./spec.js";
14
+ const DEFAULT_SEED = 7;
15
+ const BASE_SCALE_REFERENCE_WIDTH = 1280;
16
+ function clampBox(box, width, height) {
17
+ const x = Math.max(0, Math.min(box.x, width));
18
+ const y = Math.max(0, Math.min(box.y, height));
19
+ const w = Math.max(0, Math.min(box.w, width - x));
20
+ const h = Math.max(0, Math.min(box.h, height - y));
21
+ const clamped = x !== box.x || y !== box.y || w !== box.w || h !== box.h;
22
+ return { box: { x, y, w, h }, clamped };
23
+ }
24
+ /** Clamps a single geometric annotation's fields in place to a new plain object. */
25
+ function clampAnnotation(a, width, height) {
26
+ switch (a.type) {
27
+ case "box":
28
+ case "redact": {
29
+ const { box, clamped } = clampBox({ x: a.x, y: a.y, w: a.w, h: a.h }, width, height);
30
+ return { annotation: { ...a, ...box }, clamped };
31
+ }
32
+ case "arrow": {
33
+ const clampPoint = (p) => {
34
+ const cx = Math.max(0, Math.min(p[0], width));
35
+ const cy = Math.max(0, Math.min(p[1], height));
36
+ return [[cx, cy], cx !== p[0] || cy !== p[1]];
37
+ };
38
+ const [from, fromClamped] = clampPoint(a.from);
39
+ const [to, toClamped] = clampPoint(a.to);
40
+ return { annotation: { ...a, from, to }, clamped: fromClamped || toClamped };
41
+ }
42
+ case "label": {
43
+ const at = a.at ?? a.target;
44
+ if (!at)
45
+ return { annotation: a, clamped: false };
46
+ const cx = Math.max(0, Math.min(at[0], width));
47
+ const cy = Math.max(0, Math.min(at[1], height));
48
+ const clamped = cx !== at[0] || cy !== at[1];
49
+ if (!clamped)
50
+ return { annotation: a, clamped: false };
51
+ return {
52
+ annotation: a.at ? { ...a, at: [cx, cy] } : { ...a, target: [cx, cy] },
53
+ clamped,
54
+ };
55
+ }
56
+ case "draw":
57
+ case "svg":
58
+ default:
59
+ return { annotation: a, clamped: false };
60
+ }
61
+ }
62
+ /**
63
+ * Reports which annotations (by index) fall outside `[0, width) x [0, height)`
64
+ * and would be clamped by `renderAnnotations`. Pure and side-effect-free —
65
+ * the CLI layer decides how to surface these as warnings.
66
+ */
67
+ export function clampReport(spec, width, height) {
68
+ const warnings = [];
69
+ spec.annotations.forEach((a, index) => {
70
+ const { clamped } = clampAnnotation(a, width, height);
71
+ if (clamped) {
72
+ warnings.push(`annotations[${index}]: geometry out of bounds, clamped to image size`);
73
+ }
74
+ });
75
+ return warnings;
76
+ }
77
+ function scaleFor(width, opts) {
78
+ if (opts?.scale)
79
+ return opts.scale;
80
+ return Math.max(1, Math.round(width / BASE_SCALE_REFERENCE_WIDTH));
81
+ }
82
+ async function redactBlurLayer(base, region, width, height) {
83
+ const left = Math.round(region.x);
84
+ const top = Math.round(region.y);
85
+ const w = Math.round(region.w);
86
+ const h = Math.round(region.h);
87
+ if (w <= 0 || h <= 0 || left >= width || top >= height)
88
+ return null;
89
+ const clampedW = Math.min(w, width - left);
90
+ const clampedH = Math.min(h, height - top);
91
+ if (clampedW <= 0 || clampedH <= 0)
92
+ return null;
93
+ const blurred = await sharp(base)
94
+ .extract({ left, top, width: clampedW, height: clampedH })
95
+ .blur(HOUSE_STYLE.redactBlurSigma)
96
+ .png()
97
+ .toBuffer();
98
+ return { input: blurred, left, top };
99
+ }
100
+ /**
101
+ * Renders `spec` onto `image`, returning a new PNG buffer. Deterministic for
102
+ * a fixed `opts.seed` (defaults to 7). Throws `AnnotateSpecError` if any
103
+ * annotation still carries an unresolved `selector` — selectors must be
104
+ * resolved to pixel geometry (via `resolveSelectors`) before rendering.
105
+ */
106
+ export async function renderAnnotations(image, spec, opts) {
107
+ if (hasSelectors(spec)) {
108
+ throw new AnnotateSpecError([
109
+ { index: null, message: "spec still has unresolved selectors; call resolveSelectors first" },
110
+ ]);
111
+ }
112
+ const base = Buffer.isBuffer(image) ? image : Buffer.from(image);
113
+ const baseImg = sharp(base);
114
+ const meta = await baseImg.metadata();
115
+ const width = meta.width ?? 0;
116
+ const height = meta.height ?? 0;
117
+ const seed = opts?.seed ?? DEFAULT_SEED;
118
+ const scale = scaleFor(width, opts);
119
+ const ctx = { seed, scale };
120
+ const clamped = spec.annotations.map((a) => clampAnnotation(a, width, height).annotation);
121
+ // Blur redactions are applied as a pre-pass (extract -> blur -> composite
122
+ // back) BEFORE the sketchy overlay pass, per the house style. The blurs are
123
+ // independent reads of the same immutable base, so they run concurrently.
124
+ const compositeOps = (await Promise.all(clamped.map((annotation) => annotation.type === "redact" && (annotation.style ?? "solid") === "blur"
125
+ ? redactBlurLayer(base, { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h }, width, height)
126
+ : Promise.resolve(null)))).filter((layer) => layer !== null);
127
+ const overlayParts = [];
128
+ for (const annotation of clamped) {
129
+ switch (annotation.type) {
130
+ case "box":
131
+ overlayParts.push(renderBox(annotation, ctx));
132
+ break;
133
+ case "arrow":
134
+ overlayParts.push(renderArrow(annotation, ctx));
135
+ break;
136
+ case "label":
137
+ overlayParts.push(renderLabel(annotation, ctx));
138
+ break;
139
+ case "draw":
140
+ overlayParts.push(renderDraw(annotation, ctx));
141
+ break;
142
+ case "redact":
143
+ if ((annotation.style ?? "solid") === "solid") {
144
+ overlayParts.push(renderRedactSolid(annotation));
145
+ }
146
+ break;
147
+ case "svg":
148
+ overlayParts.push(renderSvg(annotation));
149
+ break;
150
+ }
151
+ }
152
+ const overlaySvg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${overlayParts.join("")}</svg>`;
153
+ compositeOps.push({ input: Buffer.from(overlaySvg), left: 0, top: 0 });
154
+ return sharp(base).composite(compositeOps).png().toBuffer();
155
+ }
@@ -0,0 +1,23 @@
1
+ import type { ArrowAnnotation, BoxAnnotation, DrawAnnotation, LabelAnnotation, RedactAnnotation, SvgAnnotation } from "./spec.js";
2
+ export declare const HOUSE_STYLE: {
3
+ readonly stroke: "#e11d48";
4
+ readonly roughness: 1.5;
5
+ readonly labelBorder: "#0f172a";
6
+ readonly labelFill: "#ffffff";
7
+ readonly labelFillOpacity: 0.92;
8
+ readonly redactFill: "#111111";
9
+ readonly redactBlurSigma: 18;
10
+ readonly labelPadding: 8;
11
+ };
12
+ interface RenderCtx {
13
+ seed: number;
14
+ scale: number;
15
+ }
16
+ export declare function renderBox(a: BoxAnnotation, ctx: RenderCtx): string;
17
+ export declare function renderArrow(a: ArrowAnnotation, ctx: RenderCtx): string;
18
+ export declare function renderLabel(a: LabelAnnotation, ctx: RenderCtx): string;
19
+ export declare function renderDraw(a: DrawAnnotation, ctx: RenderCtx): string;
20
+ /** Redact overlay drawn into the SVG pass (solid style only; blur is a sharp pre-pass). */
21
+ export declare function renderRedactSolid(a: RedactAnnotation): string;
22
+ export declare function renderSvg(a: SvgAnnotation): string;
23
+ export {};
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Per-annotation path generation: rough.js for sketchy shapes, perfect-freehand
3
+ * for freeform strokes, opentype.js paths for label text.
4
+ *
5
+ * Not exported outside this module — only `index.ts` re-exports the public
6
+ * surface (see the module header there).
7
+ */
8
+ import { getStroke } from "perfect-freehand";
9
+ import rough from "roughjs/bundled/rough.esm.js";
10
+ import { AnnotateSpecError, DEFAULT_PLACEMENT, unsafeSvgFragmentReason } from "./spec.js";
11
+ import { measureText, textToPath } from "./text.js";
12
+ export const HOUSE_STYLE = {
13
+ stroke: "#e11d48",
14
+ roughness: 1.5,
15
+ labelBorder: "#0f172a",
16
+ labelFill: "#ffffff",
17
+ labelFillOpacity: 0.92,
18
+ redactFill: "#111111",
19
+ redactBlurSigma: 18,
20
+ labelPadding: 8,
21
+ };
22
+ /**
23
+ * Escapes a user-controlled string for use inside an SVG attribute value —
24
+ * `color` comes straight from agent-authored spec JSON, and an unescaped `"`
25
+ * would break out of the attribute and inject markup into the overlay SVG.
26
+ */
27
+ function escapeAttr(value) {
28
+ return value
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/"/g, "&quot;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;");
33
+ }
34
+ function pathsToSvg(gen, drawable) {
35
+ return gen
36
+ .toPaths(drawable)
37
+ .map((p) => `<path d="${p.d}" fill="${escapeAttr(p.fill ?? "none")}" stroke="${escapeAttr(p.stroke ?? "none")}" stroke-width="${p.strokeWidth ?? 1}"/>`)
38
+ .join("");
39
+ }
40
+ function generator(ctx) {
41
+ return rough.generator({ options: { seed: ctx.seed, roughness: HOUSE_STYLE.roughness } });
42
+ }
43
+ export function renderBox(a, ctx) {
44
+ const gen = generator(ctx);
45
+ const strokeWidth = 3 * ctx.scale;
46
+ const d = gen.rectangle(a.x, a.y, a.w, a.h, {
47
+ stroke: a.color ?? HOUSE_STYLE.stroke,
48
+ strokeWidth,
49
+ fill: "none",
50
+ });
51
+ return pathsToSvg(gen, d);
52
+ }
53
+ /** Shared arrow rendering: rough shaft + two rough head strokes. */
54
+ function arrowSvg(from, to, color, ctx) {
55
+ const gen = generator(ctx);
56
+ const strokeWidth = 3 * ctx.scale;
57
+ const [x1, y1] = from;
58
+ const [x2, y2] = to;
59
+ const angle = Math.atan2(y2 - y1, x2 - x1);
60
+ const headLen = 22 * ctx.scale;
61
+ const headSpread = 0.5;
62
+ const hx1 = x2 - headLen * Math.cos(angle - headSpread);
63
+ const hy1 = y2 - headLen * Math.sin(angle - headSpread);
64
+ const hx2 = x2 - headLen * Math.cos(angle + headSpread);
65
+ const hy2 = y2 - headLen * Math.sin(angle + headSpread);
66
+ const shaft = gen.line(x1, y1, x2, y2, { stroke: color, strokeWidth });
67
+ const headA = gen.line(x2, y2, hx1, hy1, { stroke: color, strokeWidth });
68
+ const headB = gen.line(x2, y2, hx2, hy2, { stroke: color, strokeWidth });
69
+ return pathsToSvg(gen, shaft) + pathsToSvg(gen, headA) + pathsToSvg(gen, headB);
70
+ }
71
+ export function renderArrow(a, ctx) {
72
+ return arrowSvg(a.from, a.to, a.color ?? HOUSE_STYLE.stroke, ctx);
73
+ }
74
+ export function renderLabel(a, ctx) {
75
+ const color = a.color ?? HOUSE_STYLE.stroke;
76
+ const fontSize = 28 * ctx.scale;
77
+ const padding = HOUSE_STYLE.labelPadding * ctx.scale;
78
+ // With no explicit `at`, offset the bubble away from its target so it
79
+ // doesn't sit on top of the thing it labels (clamped to stay on-image).
80
+ const at = a.at ??
81
+ (a.target
82
+ ? [
83
+ a.target[0] + DEFAULT_PLACEMENT.labelAt[0] * ctx.scale,
84
+ Math.max(8, a.target[1] + DEFAULT_PLACEMENT.labelAt[1] * ctx.scale),
85
+ ]
86
+ : [0, 0]);
87
+ const [atX, atY] = at;
88
+ const metrics = measureText(a.text, fontSize);
89
+ const bubbleW = metrics.width + padding * 2;
90
+ const bubbleH = metrics.height + padding * 2;
91
+ const bubbleX = atX;
92
+ const bubbleY = atY;
93
+ const gen = generator(ctx);
94
+ const bubble = gen.rectangle(bubbleX, bubbleY, bubbleW, bubbleH, {
95
+ stroke: HOUSE_STYLE.labelBorder,
96
+ strokeWidth: 2 * ctx.scale,
97
+ fill: HOUSE_STYLE.labelFill,
98
+ fillStyle: "solid",
99
+ });
100
+ const bubbleSvg = `<g opacity="${HOUSE_STYLE.labelFillOpacity}">${pathsToSvg(gen, bubble)}</g>`;
101
+ const textBaselineX = bubbleX + padding;
102
+ const textBaselineY = bubbleY + padding + metrics.height * 0.78;
103
+ const { d } = textToPath(a.text, textBaselineX, textBaselineY, fontSize);
104
+ const textSvg = `<path d="${d}" fill="${HOUSE_STYLE.labelBorder}"/>`;
105
+ let leaderSvg = "";
106
+ if (a.target) {
107
+ const [tx, ty] = a.target;
108
+ const bubbleCenterX = bubbleX + bubbleW / 2;
109
+ const bubbleCenterY = bubbleY + bubbleH / 2;
110
+ // leader from the nearest bubble edge point toward the target
111
+ const dx = tx - bubbleCenterX;
112
+ const dy = ty - bubbleCenterY;
113
+ const edgeX = dx === 0 ? bubbleCenterX : bubbleCenterX + Math.sign(dx) * (bubbleW / 2);
114
+ const edgeY = dy === 0 ? bubbleCenterY : bubbleCenterY + Math.sign(dy) * (bubbleH / 2);
115
+ leaderSvg = arrowSvg([edgeX, edgeY], [tx, ty], color, ctx);
116
+ }
117
+ return bubbleSvg + textSvg + leaderSvg;
118
+ }
119
+ /** Turns a perfect-freehand outline polygon into a closed SVG path `d`. */
120
+ function strokeToPath(stroke) {
121
+ if (!stroke.length)
122
+ return "";
123
+ const d = stroke.reduce((acc, [x0, y0], i, arr) => {
124
+ const [x1, y1] = arr[(i + 1) % arr.length];
125
+ acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
126
+ return acc;
127
+ }, ["M", ...stroke[0], "Q"]);
128
+ return `${d.join(" ")} Z`;
129
+ }
130
+ export function renderDraw(a, ctx) {
131
+ const color = a.color ?? HOUSE_STYLE.stroke;
132
+ const stroke = getStroke(a.points.map(([x, y]) => [x, y]), { size: 4 * ctx.scale, smoothing: 0.6, streamline: 0.4 });
133
+ const d = strokeToPath(stroke);
134
+ return `<path d="${d}" fill="${escapeAttr(color)}" opacity="0.9"/>`;
135
+ }
136
+ /** Redact overlay drawn into the SVG pass (solid style only; blur is a sharp pre-pass). */
137
+ export function renderRedactSolid(a) {
138
+ return `<rect x="${a.x}" y="${a.y}" width="${a.w}" height="${a.h}" fill="${HOUSE_STYLE.redactFill}"/>`;
139
+ }
140
+ export function renderSvg(a) {
141
+ // Defense in depth: validateSpec already rejects unsafe fragments, but a
142
+ // caller could hand renderAnnotations a hand-built spec that skipped it.
143
+ const unsafe = unsafeSvgFragmentReason(a.fragment);
144
+ if (unsafe)
145
+ throw new AnnotateSpecError([{ index: null, message: unsafe }]);
146
+ return `<g>${a.fragment}</g>`;
147
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Annotation spec: types, validation, and selector resolution.
3
+ *
4
+ * NOT exported outside this module directly — only `index.ts` re-exports
5
+ * these. Keep the public surface narrow so the renderer stays swappable
6
+ * (see the module header in `index.ts`).
7
+ */
8
+ export type Point = [number, number];
9
+ export interface BoxAnnotation {
10
+ type: "box";
11
+ x: number;
12
+ y: number;
13
+ w: number;
14
+ h: number;
15
+ color?: string;
16
+ selector?: string;
17
+ }
18
+ export interface ArrowAnnotation {
19
+ type: "arrow";
20
+ from: Point;
21
+ to: Point;
22
+ color?: string;
23
+ selector?: string;
24
+ }
25
+ export interface LabelAnnotation {
26
+ type: "label";
27
+ text: string;
28
+ target?: Point;
29
+ at?: Point;
30
+ color?: string;
31
+ selector?: string;
32
+ }
33
+ export interface DrawAnnotation {
34
+ type: "draw";
35
+ points: Point[];
36
+ color?: string;
37
+ }
38
+ export interface RedactAnnotation {
39
+ type: "redact";
40
+ x: number;
41
+ y: number;
42
+ w: number;
43
+ h: number;
44
+ style?: "blur" | "solid";
45
+ selector?: string;
46
+ }
47
+ export interface SvgAnnotation {
48
+ type: "svg";
49
+ fragment: string;
50
+ }
51
+ export type Annotation = BoxAnnotation | ArrowAnnotation | LabelAnnotation | DrawAnnotation | RedactAnnotation | SvgAnnotation;
52
+ export interface AnnotationSpec {
53
+ version: 1;
54
+ annotations: Annotation[];
55
+ }
56
+ export interface SpecError {
57
+ index: number | null;
58
+ message: string;
59
+ }
60
+ /** Thrown by `validateSpec` and `resolveSelectors`; carries every collected error. */
61
+ export declare class AnnotateSpecError extends Error {
62
+ readonly errors: SpecError[];
63
+ constructor(errors: SpecError[]);
64
+ }
65
+ type Box = {
66
+ x: number;
67
+ y: number;
68
+ w: number;
69
+ h: number;
70
+ };
71
+ /**
72
+ * Default placement offsets used when a spec omits an explicit position —
73
+ * kept together so "where things land by default" has one home. Units are
74
+ * image pixels; `labelAt` is additionally multiplied by the render scale.
75
+ */
76
+ export declare const DEFAULT_PLACEMENT: {
77
+ /** Selector-only arrow: tail offset from the target center (resolveSelectors). */
78
+ arrowFrom: readonly [120, -120];
79
+ /** Label with a target but no `at`: bubble offset above-right (renderLabel). */
80
+ labelAt: readonly [30, -90];
81
+ };
82
+ /**
83
+ * Returns a rejection reason if a raw `svg` fragment is unsafe, else null.
84
+ * Shared by `validateSpec` and the renderer (defense in depth — the renderer
85
+ * re-checks in case a caller bypasses validation): `<script` is an obvious
86
+ * injection, and librsvg resolves href/xlink:href and CSS url() references,
87
+ * which has been an arbitrary-file-read vector (e.g. CVE-2023-38633).
88
+ */
89
+ export declare function unsafeSvgFragmentReason(fragment: string): string | null;
90
+ /** Throws AnnotateSpecError (carries errors: SpecError[]) on invalid input. */
91
+ export declare function validateSpec(json: unknown): AnnotationSpec;
92
+ /** True if any annotation still carries an unresolved selector. */
93
+ export declare function hasSelectors(spec: AnnotationSpec): boolean;
94
+ /** All distinct selectors in the spec, in order. */
95
+ export declare function specSelectors(spec: AnnotationSpec): string[];
96
+ /**
97
+ * Replace selector targeting with pixel geometry using measured boxes keyed
98
+ * by selector. Throws AnnotateSpecError naming any selector missing from
99
+ * boxes.
100
+ */
101
+ export declare function resolveSelectors(spec: AnnotationSpec, boxes: Record<string, Box>): AnnotationSpec;
102
+ export {};