@buildinternet/uploads 0.30.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 +17 -2
- package/assets/Excalifont-Regular.ttf +0 -0
- package/assets/OFL.txt +90 -0
- package/dist/annotate/index.d.ts +13 -0
- package/dist/annotate/index.js +2 -0
- package/dist/annotate/render.d.ts +18 -0
- package/dist/annotate/render.js +155 -0
- package/dist/annotate/shapes.d.ts +23 -0
- package/dist/annotate/shapes.js +147 -0
- package/dist/annotate/spec.d.ts +102 -0
- package/dist/annotate/spec.js +237 -0
- package/dist/annotate/text.d.ts +15 -0
- package/dist/annotate/text.js +39 -0
- package/dist/cli-args.d.ts +15 -0
- package/dist/cli-args.js +21 -0
- package/dist/cli-catalog.d.ts +2 -0
- package/dist/cli-catalog.js +16 -0
- package/dist/cli.js +6 -0
- package/dist/commands/annotate.d.ts +3 -0
- package/dist/commands/annotate.js +124 -0
- package/dist/commands/completion.js +21 -5
- package/dist/commands/install.js +5 -3
- package/dist/commands/screenshot.d.ts +14 -1
- package/dist/commands/screenshot.js +102 -8
- package/dist/io.d.ts +2 -0
- package/dist/io.js +8 -0
- package/dist/screenshot-local.d.ts +32 -1
- package/dist/screenshot-local.js +34 -1
- package/dist/screenshot.d.ts +20 -1
- package/dist/screenshot.js +10 -2
- package/package.json +5 -1
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)
|
|
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,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, "&")
|
|
30
|
+
.replace(/"/g, """)
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">");
|
|
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 {};
|