@corbet-labs/cink 0.1.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 ADDED
@@ -0,0 +1,27 @@
1
+ # @corbet-labs/cink
2
+
3
+ Handwritten signature image rendering for formal correspondence. Pure port
4
+ of the [cink](https://github.com/corbet-labs/cink) Rust crate: zero
5
+ dependencies, zero Node APIs, synchronous, no I/O. Pixels, not
6
+ signatures — no cryptography, no identity, no legal ceremony.
7
+
8
+ ```ts
9
+ import { normalize, signatureSize, imageSnippet } from '@corbet-labs/cink';
10
+
11
+ const img = normalize(dataUrl); // null when unusable
12
+ const [w, h] = signatureSize(img, 31.5, 120);
13
+ const snippet = imageSnippet('/signature.png', h, w === 31.5 ? undefined : w);
14
+ ```
15
+
16
+ PNG and JPEG dimensions are read from headers, never decoded; SVG passes
17
+ through dimension-less. WebP, GIF, corrupt, and empty inputs are rejected.
18
+ Oversized rasters are reported (`exceedsLimits`, `scaleToFit`), never
19
+ silently resampled.
20
+
21
+ Run `bun ./scripts/conformance.mts` and `tsc --noEmit -p ./tsconfig.json`
22
+ before pushing.
23
+
24
+ ## License
25
+
26
+ FSL-1.1-ALv2. Each published version becomes available under Apache-2.0
27
+ two years after publication.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@corbet-labs/cink",
3
+ "version": "0.1.0",
4
+ "description": "Handwritten signature image rendering for correspondence. Pixels, not signatures. Mirrors the cink Rust crate release line.",
5
+ "license": "FSL-1.1-ALv2",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "provenance": true
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/corbet-labs/cink.git",
13
+ "directory": "js/@corbet-labs/cink"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/index.ts",
17
+ "types": "./src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "files": [
22
+ "src/**/*",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "bash ./scripts/sync-assets.sh",
27
+ "conformance": "bun ./scripts/conformance.mts",
28
+ "typecheck": "tsc --noEmit -p ./tsconfig.json"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "25.8.0",
32
+ "typescript": "6.0.3"
33
+ }
34
+ }
@@ -0,0 +1,3 @@
1
+ // GENERATED by scripts/sync-assets.sh — do not edit.
2
+ export const PACKAGE_VERSION = "0.1.0";
3
+ export const DEFAULTS = {"max_pixels": 1600, "formats": ["png", "jpeg", "svg"]};
package/src/index.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Handwritten signature image rendering for formal correspondence.
3
+ *
4
+ * Pure TypeScript port of the cink Rust crate: zero dependencies, zero
5
+ * Node APIs (base64 via global atob), synchronous, no I/O. Pixels, not
6
+ * signatures — no cryptography, no identity, no legal ceremony.
7
+ *
8
+ * Behavior is defined by `tables/defaults.json` plus the byte-level rules
9
+ * in `tables/README.md`; `tests/vectors/*.json` with fixtures in
10
+ * `tests/fixtures/` is the shared conformance suite.
11
+ */
12
+ import { DEFAULTS } from './generated/tables.js';
13
+
14
+ export interface Defaults {
15
+ max_pixels: number;
16
+ formats: string[];
17
+ }
18
+
19
+ const defaults = DEFAULTS as Defaults;
20
+
21
+ export type ImageMime = 'png' | 'jpeg' | 'svg';
22
+
23
+ export interface DecodedImage {
24
+ mime: ImageMime;
25
+ width: number | null;
26
+ height: number | null;
27
+ bytes: Uint8Array;
28
+ }
29
+
30
+ /** Default oversize threshold in pixels, from `tables/defaults.json`. */
31
+ export function defaultMaxPixels(): number {
32
+ return defaults.max_pixels;
33
+ }
34
+
35
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
36
+
37
+ function pngDimensions(bytes: Uint8Array): [number, number] | null {
38
+ if (bytes.length < 24) return null;
39
+ for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_MAGIC[i]) return null;
40
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
41
+ if (view.getUint32(8) !== 13) return null;
42
+ if (view.getUint32(12) !== 0x49484452) return null;
43
+ const width = view.getUint32(16);
44
+ const height = view.getUint32(20);
45
+ if (width === 0 || height === 0) return null;
46
+ return [width, height];
47
+ }
48
+
49
+ const SOF_MARKERS = new Set([
50
+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
51
+ ]);
52
+
53
+ function jpegDimensions(bytes: Uint8Array): [number, number] | null {
54
+ if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
55
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56
+ let pos = 2;
57
+ while (pos < bytes.length) {
58
+ while (pos < bytes.length && bytes[pos] === 0xff) pos++;
59
+ if (pos >= bytes.length) return null;
60
+ const marker = bytes[pos];
61
+ pos++;
62
+ if (SOF_MARKERS.has(marker)) {
63
+ if (pos + 7 > bytes.length) return null;
64
+ const height = view.getUint16(pos + 3);
65
+ const width = view.getUint16(pos + 5);
66
+ if (width === 0 || height === 0) return null;
67
+ return [width, height];
68
+ }
69
+ if (marker === 0xd9 || marker === 0xda) return null;
70
+ if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
71
+ if (pos + 2 > bytes.length) return null;
72
+ const len = view.getUint16(pos);
73
+ if (len < 2) return null;
74
+ pos += len;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function sniff(bytes: Uint8Array): DecodedImage | null {
80
+ if (bytes.length >= 8 && PNG_MAGIC.every((b, i) => bytes[i] === b)) {
81
+ const dims = pngDimensions(bytes);
82
+ if (dims === null) return null;
83
+ return { mime: 'png', width: dims[0], height: dims[1], bytes };
84
+ }
85
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
86
+ const dims = jpegDimensions(bytes);
87
+ if (dims === null) return null;
88
+ return { mime: 'jpeg', width: dims[0], height: dims[1], bytes };
89
+ }
90
+ let start = 0;
91
+ while (start < bytes.length && /\s/.test(String.fromCharCode(bytes[start]))) start++;
92
+ const head = String.fromCharCode(...bytes.slice(start, start + 4));
93
+ if (head === '<svg') return { mime: 'svg', width: null, height: null, bytes };
94
+ return null;
95
+ }
96
+
97
+ function decodeBase64(payload: string): Uint8Array | null {
98
+ if (payload === '') return null;
99
+ try {
100
+ const text = atob(payload);
101
+ const bytes = new Uint8Array(text.length);
102
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
103
+ return bytes.length > 0 ? bytes : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Validate a signature image given as a `data:` URL or raw base64.
111
+ * Returns `null` for empty, undecodable, corrupt, or unsupported inputs.
112
+ */
113
+ export function normalize(input: string): DecodedImage | null {
114
+ let payload = input;
115
+ const comma = input.indexOf(',');
116
+ if (comma >= 0 && input.startsWith('data:')) {
117
+ const meta = input.slice(0, comma);
118
+ if (!meta.includes(';base64')) return null;
119
+ payload = input.slice(comma + 1);
120
+ }
121
+ const bytes = decodeBase64(payload);
122
+ if (bytes === null) return null;
123
+ return sniff(bytes);
124
+ }
125
+
126
+ /**
127
+ * Whether the raster exceeds a pixel budget. Dimension-less formats (SVG)
128
+ * scale without loss and never exceed.
129
+ */
130
+ export function exceedsLimits(image: DecodedImage, maxPixels: number): boolean {
131
+ if (image.width === null || image.height === null) return false;
132
+ return image.width * image.height > maxPixels;
133
+ }
134
+
135
+ /**
136
+ * Linear scale factor that fits the image into a pixel budget, never
137
+ * above 1.0 (never upscales). The caller resamples and re-submits.
138
+ */
139
+ export function scaleToFit(image: DecodedImage, maxPixels: number): number {
140
+ if (image.width === null || image.height === null) return 1.0;
141
+ const pixels = image.width * image.height;
142
+ if (pixels <= maxPixels) return 1.0;
143
+ return Math.sqrt(maxPixels / pixels);
144
+ }
145
+
146
+ /**
147
+ * Point size for a target height, preserving aspect ratio, clamped to an
148
+ * optional maximum width. Returns `null` when the image carries no
149
+ * dimensions: the caller must size it.
150
+ */
151
+ export function signatureSize(
152
+ image: DecodedImage,
153
+ heightPt: number,
154
+ maxWidthPt?: number,
155
+ ): [number, number] | null {
156
+ if (image.width === null || image.height === null) return null;
157
+ const natural = (heightPt * image.width) / image.height;
158
+ if (maxWidthPt !== undefined && natural > maxWidthPt) {
159
+ const scale = maxWidthPt / natural;
160
+ return [maxWidthPt, heightPt * scale];
161
+ }
162
+ return [natural, heightPt];
163
+ }
164
+
165
+ function escapePath(path: string): string {
166
+ return path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
167
+ }
168
+
169
+ /**
170
+ * Typst `#image` call for a sized signature. Width is emitted only when a
171
+ * max-width clamp applied; layout and placement stay with the caller.
172
+ */
173
+ export function imageSnippet(path: string, heightPt: number, widthPt?: number): string {
174
+ const safe = escapePath(path);
175
+ if (widthPt === undefined) return `#image("${safe}", height: ${heightPt}pt)`;
176
+ return `#image("${safe}", height: ${heightPt}pt, width: ${widthPt}pt)`;
177
+ }