@corbet-labs/cink 0.1.2 → 0.1.3
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 +11 -7
- package/dist/browser.js +48 -20
- package/dist/index.cjs +48 -20
- package/dist/index.js +48 -20
- package/dist/types/index.d.cts +4 -3
- package/dist/types/index.d.ts +4 -3
- package/package.json +1 -1
- package/src/generated/tables.ts +1 -1
- package/src/index.ts +47 -19
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Prepare handwritten signature images for correspondence.**
|
|
4
4
|
|
|
5
|
-
[](https://crates.io/crates/cink) [](https://www.npmjs.com/package/@corbet-labs/cink) [](https://docs.rs/cink)
|
|
5
|
+
[](https://crates.io/crates/cink) [](https://www.npmjs.com/package/@corbet-labs/cink) [](https://docs.rs/cink)
|
|
6
6
|
|
|
7
7
|
Recognize PNG, JPEG, and SVG input, inspect raster dimensions, calculate an aspect-preserving size, and generate a Typst image call. Runs without an image-decoding library.
|
|
8
8
|
|
|
@@ -24,7 +24,7 @@ imageSnippet('/signature.png', 31.5);
|
|
|
24
24
|
| Bun | `bun add @corbet-labs/cink` |
|
|
25
25
|
| Deno | `deno add npm:@corbet-labs/cink` |
|
|
26
26
|
|
|
27
|
-
The 0.1.
|
|
27
|
+
The 0.1.3 JavaScript distribution includes compiled ESM, CommonJS,
|
|
28
28
|
TypeScript declarations, and a standalone browser module. Node.js 20+ is
|
|
29
29
|
supported; no TypeScript loader is required.
|
|
30
30
|
|
|
@@ -35,7 +35,7 @@ const { imageSnippet } = require('@corbet-labs/cink');
|
|
|
35
35
|
|
|
36
36
|
```html
|
|
37
37
|
<script type="module">
|
|
38
|
-
import { imageSnippet } from 'https://cdn.jsdelivr.net/npm/@corbet-labs/cink@0.1.
|
|
38
|
+
import { imageSnippet } from 'https://cdn.jsdelivr.net/npm/@corbet-labs/cink@0.1.3/dist/browser.js';
|
|
39
39
|
console.log(imageSnippet('/signature.png', 31.5));
|
|
40
40
|
</script>
|
|
41
41
|
```
|
|
@@ -70,7 +70,9 @@ assert image_snippet("/signature.png", 31.5) == '#image("/signature.png", height
|
|
|
70
70
|
| `signatureSize` / `signature_size` | Fit dimensions to a point size |
|
|
71
71
|
| `imageSnippet` / `image_snippet` | Generate a Typst image call |
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
Base64 input uses the standard alphabet, required padding, and canonical trailing bits; embedded whitespace is rejected.
|
|
74
|
+
|
|
75
|
+
PNG and JPEG dimensions are read from headers; this is not a full raster integrity check. SVG passes through without sanitization or known dimensions. Unrecognized headers and unsupported formats return `null`/`None`. Nonpositive, nonfinite, and unrepresentable requested sizes return `null`/`None`. Resampling and placement belong to the caller. These are handwriting images, not cryptographic or electronic signatures.
|
|
74
76
|
|
|
75
77
|
## Correspondence family
|
|
76
78
|
|
|
@@ -87,9 +89,11 @@ PNG and JPEG dimensions are read from headers; this is not a full raster integri
|
|
|
87
89
|
|
|
88
90
|
Behavior is defined by [the locale tables](https://github.com/corbet-labs/cink/tree/main/tables)
|
|
89
91
|
and [shared conformance vectors](https://github.com/corbet-labs/cink/tree/main/tests/vectors).
|
|
90
|
-
Rust, JavaScript, and Python run the same vectors. CI
|
|
91
|
-
JavaScript tarballs
|
|
92
|
-
|
|
92
|
+
Rust, JavaScript, and Python run the same vectors. Selected CI checks exercise
|
|
93
|
+
installed JavaScript tarballs, Python wheels and command-line entrypoints, and
|
|
94
|
+
Typst packages. Release validation records the actual runtime and platform;
|
|
95
|
+
Linux results do not establish native Windows or macOS coverage.
|
|
96
|
+
All five Rust crates forbid unsafe code in their own source.
|
|
93
97
|
|
|
94
98
|
See [the release guide](https://github.com/corbet-labs/cink/blob/main/docs/releasing.md)
|
|
95
99
|
for generation, verification, and publication commands.
|
package/dist/browser.js
CHANGED
|
@@ -11,7 +11,7 @@ function supportedFormats() {
|
|
|
11
11
|
}
|
|
12
12
|
var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
13
13
|
function pngDimensions(bytes) {
|
|
14
|
-
if (bytes.length <
|
|
14
|
+
if (bytes.length < 33)
|
|
15
15
|
return null;
|
|
16
16
|
for (let i = 0;i < 8; i++)
|
|
17
17
|
if (bytes[i] !== PNG_MAGIC[i])
|
|
@@ -23,7 +23,12 @@ function pngDimensions(bytes) {
|
|
|
23
23
|
return null;
|
|
24
24
|
const width = view.getUint32(16);
|
|
25
25
|
const height = view.getUint32(20);
|
|
26
|
-
if (width === 0 || height === 0)
|
|
26
|
+
if (width === 0 || height === 0 || width > 2147483647 || height > 2147483647)
|
|
27
|
+
return null;
|
|
28
|
+
const depth = bytes[24];
|
|
29
|
+
const color = bytes[25];
|
|
30
|
+
const validDepth = color === 0 ? [1, 2, 4, 8, 16].includes(depth) : color === 3 ? [1, 2, 4, 8].includes(depth) : [2, 4, 6].includes(color) && [8, 16].includes(depth);
|
|
31
|
+
if (!validDepth || bytes[26] !== 0 || bytes[27] !== 0 || bytes[28] > 1)
|
|
27
32
|
return null;
|
|
28
33
|
return [width, height];
|
|
29
34
|
}
|
|
@@ -48,14 +53,28 @@ function jpegDimensions(bytes) {
|
|
|
48
53
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
49
54
|
let pos = 2;
|
|
50
55
|
while (pos < bytes.length) {
|
|
56
|
+
if (bytes[pos] !== 255)
|
|
57
|
+
return null;
|
|
51
58
|
while (pos < bytes.length && bytes[pos] === 255)
|
|
52
59
|
pos++;
|
|
53
60
|
if (pos >= bytes.length)
|
|
54
61
|
return null;
|
|
55
62
|
const marker = bytes[pos];
|
|
56
63
|
pos++;
|
|
64
|
+
if (marker === 1)
|
|
65
|
+
continue;
|
|
66
|
+
if (marker === 0 || marker >= 208 && marker <= 218)
|
|
67
|
+
return null;
|
|
68
|
+
if (pos + 2 > bytes.length)
|
|
69
|
+
return null;
|
|
70
|
+
const len = view.getUint16(pos);
|
|
71
|
+
if (len < 2 || len > bytes.length - pos)
|
|
72
|
+
return null;
|
|
57
73
|
if (SOF_MARKERS.has(marker)) {
|
|
58
|
-
if (
|
|
74
|
+
if (len < 8)
|
|
75
|
+
return null;
|
|
76
|
+
const components = bytes[pos + 7];
|
|
77
|
+
if (components === 0 || len !== 8 + 3 * components)
|
|
59
78
|
return null;
|
|
60
79
|
const height = view.getUint16(pos + 3);
|
|
61
80
|
const width = view.getUint16(pos + 5);
|
|
@@ -63,15 +82,6 @@ function jpegDimensions(bytes) {
|
|
|
63
82
|
return null;
|
|
64
83
|
return [width, height];
|
|
65
84
|
}
|
|
66
|
-
if (marker === 217 || marker === 218)
|
|
67
|
-
return null;
|
|
68
|
-
if (marker === 0 || marker === 1 || marker >= 208 && marker <= 215)
|
|
69
|
-
continue;
|
|
70
|
-
if (pos + 2 > bytes.length)
|
|
71
|
-
return null;
|
|
72
|
-
const len = view.getUint16(pos);
|
|
73
|
-
if (len < 2)
|
|
74
|
-
return null;
|
|
75
85
|
pos += len;
|
|
76
86
|
}
|
|
77
87
|
return null;
|
|
@@ -90,7 +100,7 @@ function sniff(bytes) {
|
|
|
90
100
|
return { mime: "jpeg", width: dims[0], height: dims[1], bytes };
|
|
91
101
|
}
|
|
92
102
|
let start = 0;
|
|
93
|
-
while (start < bytes.length &&
|
|
103
|
+
while (start < bytes.length && [9, 10, 12, 13, 32].includes(bytes[start]))
|
|
94
104
|
start++;
|
|
95
105
|
const head = String.fromCharCode(...bytes.slice(start, start + 4));
|
|
96
106
|
if (head === "<svg")
|
|
@@ -102,6 +112,8 @@ function decodeBase64(payload) {
|
|
|
102
112
|
return null;
|
|
103
113
|
try {
|
|
104
114
|
const text = atob(payload);
|
|
115
|
+
if (btoa(text) !== payload)
|
|
116
|
+
return null;
|
|
105
117
|
const bytes = new Uint8Array(text.length);
|
|
106
118
|
for (let i = 0;i < text.length; i++)
|
|
107
119
|
bytes[i] = text.charCodeAt(i);
|
|
@@ -115,7 +127,7 @@ function normalize(input) {
|
|
|
115
127
|
const comma = input.indexOf(",");
|
|
116
128
|
if (comma >= 0 && input.startsWith("data:")) {
|
|
117
129
|
const meta = input.slice(0, comma);
|
|
118
|
-
if (!meta.
|
|
130
|
+
if (!meta.endsWith(";base64"))
|
|
119
131
|
return null;
|
|
120
132
|
payload = input.slice(comma + 1);
|
|
121
133
|
}
|
|
@@ -138,17 +150,33 @@ function scaleToFit(image, maxPixels) {
|
|
|
138
150
|
return Math.sqrt(maxPixels / pixels);
|
|
139
151
|
}
|
|
140
152
|
function signatureSize(image, heightPt, maxWidthPt) {
|
|
141
|
-
if (image.width === null || image.height === null)
|
|
153
|
+
if (image.width === null || image.height === null || !Number.isInteger(image.width) || !Number.isInteger(image.height) || image.width <= 0 || image.height <= 0 || image.width > 4294967295 || image.height > 4294967295 || !Number.isFinite(heightPt) || heightPt <= 0 || maxWidthPt !== undefined && (!Number.isFinite(maxWidthPt) || maxWidthPt <= 0))
|
|
142
154
|
return null;
|
|
143
|
-
const
|
|
155
|
+
const ratio = image.width / image.height;
|
|
156
|
+
const natural = heightPt * ratio;
|
|
157
|
+
let width = natural;
|
|
158
|
+
let height = heightPt;
|
|
144
159
|
if (maxWidthPt !== undefined && natural > maxWidthPt) {
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
width = maxWidthPt;
|
|
161
|
+
height = maxWidthPt / ratio;
|
|
147
162
|
}
|
|
148
|
-
return [
|
|
163
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? [width, height] : null;
|
|
149
164
|
}
|
|
150
165
|
function escapePath(path) {
|
|
151
|
-
return path.replace(
|
|
166
|
+
return path.replace(/["\\\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
167
|
+
if (character === '"')
|
|
168
|
+
return "\\\"";
|
|
169
|
+
if (character === "\\")
|
|
170
|
+
return "\\\\";
|
|
171
|
+
if (character === `
|
|
172
|
+
`)
|
|
173
|
+
return "\\n";
|
|
174
|
+
if (character === "\r")
|
|
175
|
+
return "\\r";
|
|
176
|
+
if (character === "\t")
|
|
177
|
+
return "\\t";
|
|
178
|
+
return `\\u{${character.charCodeAt(0).toString(16)}}`;
|
|
179
|
+
});
|
|
152
180
|
}
|
|
153
181
|
function imageSnippet(path, heightPt, widthPt) {
|
|
154
182
|
const safe = escapePath(path);
|
package/dist/index.cjs
CHANGED
|
@@ -62,7 +62,7 @@ function supportedFormats() {
|
|
|
62
62
|
}
|
|
63
63
|
var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
64
64
|
function pngDimensions(bytes) {
|
|
65
|
-
if (bytes.length <
|
|
65
|
+
if (bytes.length < 33)
|
|
66
66
|
return null;
|
|
67
67
|
for (let i = 0;i < 8; i++)
|
|
68
68
|
if (bytes[i] !== PNG_MAGIC[i])
|
|
@@ -74,7 +74,12 @@ function pngDimensions(bytes) {
|
|
|
74
74
|
return null;
|
|
75
75
|
const width = view.getUint32(16);
|
|
76
76
|
const height = view.getUint32(20);
|
|
77
|
-
if (width === 0 || height === 0)
|
|
77
|
+
if (width === 0 || height === 0 || width > 2147483647 || height > 2147483647)
|
|
78
|
+
return null;
|
|
79
|
+
const depth = bytes[24];
|
|
80
|
+
const color = bytes[25];
|
|
81
|
+
const validDepth = color === 0 ? [1, 2, 4, 8, 16].includes(depth) : color === 3 ? [1, 2, 4, 8].includes(depth) : [2, 4, 6].includes(color) && [8, 16].includes(depth);
|
|
82
|
+
if (!validDepth || bytes[26] !== 0 || bytes[27] !== 0 || bytes[28] > 1)
|
|
78
83
|
return null;
|
|
79
84
|
return [width, height];
|
|
80
85
|
}
|
|
@@ -99,14 +104,28 @@ function jpegDimensions(bytes) {
|
|
|
99
104
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
100
105
|
let pos = 2;
|
|
101
106
|
while (pos < bytes.length) {
|
|
107
|
+
if (bytes[pos] !== 255)
|
|
108
|
+
return null;
|
|
102
109
|
while (pos < bytes.length && bytes[pos] === 255)
|
|
103
110
|
pos++;
|
|
104
111
|
if (pos >= bytes.length)
|
|
105
112
|
return null;
|
|
106
113
|
const marker = bytes[pos];
|
|
107
114
|
pos++;
|
|
115
|
+
if (marker === 1)
|
|
116
|
+
continue;
|
|
117
|
+
if (marker === 0 || marker >= 208 && marker <= 218)
|
|
118
|
+
return null;
|
|
119
|
+
if (pos + 2 > bytes.length)
|
|
120
|
+
return null;
|
|
121
|
+
const len = view.getUint16(pos);
|
|
122
|
+
if (len < 2 || len > bytes.length - pos)
|
|
123
|
+
return null;
|
|
108
124
|
if (SOF_MARKERS.has(marker)) {
|
|
109
|
-
if (
|
|
125
|
+
if (len < 8)
|
|
126
|
+
return null;
|
|
127
|
+
const components = bytes[pos + 7];
|
|
128
|
+
if (components === 0 || len !== 8 + 3 * components)
|
|
110
129
|
return null;
|
|
111
130
|
const height = view.getUint16(pos + 3);
|
|
112
131
|
const width = view.getUint16(pos + 5);
|
|
@@ -114,15 +133,6 @@ function jpegDimensions(bytes) {
|
|
|
114
133
|
return null;
|
|
115
134
|
return [width, height];
|
|
116
135
|
}
|
|
117
|
-
if (marker === 217 || marker === 218)
|
|
118
|
-
return null;
|
|
119
|
-
if (marker === 0 || marker === 1 || marker >= 208 && marker <= 215)
|
|
120
|
-
continue;
|
|
121
|
-
if (pos + 2 > bytes.length)
|
|
122
|
-
return null;
|
|
123
|
-
const len = view.getUint16(pos);
|
|
124
|
-
if (len < 2)
|
|
125
|
-
return null;
|
|
126
136
|
pos += len;
|
|
127
137
|
}
|
|
128
138
|
return null;
|
|
@@ -141,7 +151,7 @@ function sniff(bytes) {
|
|
|
141
151
|
return { mime: "jpeg", width: dims[0], height: dims[1], bytes };
|
|
142
152
|
}
|
|
143
153
|
let start = 0;
|
|
144
|
-
while (start < bytes.length &&
|
|
154
|
+
while (start < bytes.length && [9, 10, 12, 13, 32].includes(bytes[start]))
|
|
145
155
|
start++;
|
|
146
156
|
const head = String.fromCharCode(...bytes.slice(start, start + 4));
|
|
147
157
|
if (head === "<svg")
|
|
@@ -153,6 +163,8 @@ function decodeBase64(payload) {
|
|
|
153
163
|
return null;
|
|
154
164
|
try {
|
|
155
165
|
const text = atob(payload);
|
|
166
|
+
if (btoa(text) !== payload)
|
|
167
|
+
return null;
|
|
156
168
|
const bytes = new Uint8Array(text.length);
|
|
157
169
|
for (let i = 0;i < text.length; i++)
|
|
158
170
|
bytes[i] = text.charCodeAt(i);
|
|
@@ -166,7 +178,7 @@ function normalize(input) {
|
|
|
166
178
|
const comma = input.indexOf(",");
|
|
167
179
|
if (comma >= 0 && input.startsWith("data:")) {
|
|
168
180
|
const meta = input.slice(0, comma);
|
|
169
|
-
if (!meta.
|
|
181
|
+
if (!meta.endsWith(";base64"))
|
|
170
182
|
return null;
|
|
171
183
|
payload = input.slice(comma + 1);
|
|
172
184
|
}
|
|
@@ -189,17 +201,33 @@ function scaleToFit(image, maxPixels) {
|
|
|
189
201
|
return Math.sqrt(maxPixels / pixels);
|
|
190
202
|
}
|
|
191
203
|
function signatureSize(image, heightPt, maxWidthPt) {
|
|
192
|
-
if (image.width === null || image.height === null)
|
|
204
|
+
if (image.width === null || image.height === null || !Number.isInteger(image.width) || !Number.isInteger(image.height) || image.width <= 0 || image.height <= 0 || image.width > 4294967295 || image.height > 4294967295 || !Number.isFinite(heightPt) || heightPt <= 0 || maxWidthPt !== undefined && (!Number.isFinite(maxWidthPt) || maxWidthPt <= 0))
|
|
193
205
|
return null;
|
|
194
|
-
const
|
|
206
|
+
const ratio = image.width / image.height;
|
|
207
|
+
const natural = heightPt * ratio;
|
|
208
|
+
let width = natural;
|
|
209
|
+
let height = heightPt;
|
|
195
210
|
if (maxWidthPt !== undefined && natural > maxWidthPt) {
|
|
196
|
-
|
|
197
|
-
|
|
211
|
+
width = maxWidthPt;
|
|
212
|
+
height = maxWidthPt / ratio;
|
|
198
213
|
}
|
|
199
|
-
return [
|
|
214
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? [width, height] : null;
|
|
200
215
|
}
|
|
201
216
|
function escapePath(path) {
|
|
202
|
-
return path.replace(
|
|
217
|
+
return path.replace(/["\\\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
218
|
+
if (character === '"')
|
|
219
|
+
return "\\\"";
|
|
220
|
+
if (character === "\\")
|
|
221
|
+
return "\\\\";
|
|
222
|
+
if (character === `
|
|
223
|
+
`)
|
|
224
|
+
return "\\n";
|
|
225
|
+
if (character === "\r")
|
|
226
|
+
return "\\r";
|
|
227
|
+
if (character === "\t")
|
|
228
|
+
return "\\t";
|
|
229
|
+
return `\\u{${character.charCodeAt(0).toString(16)}}`;
|
|
230
|
+
});
|
|
203
231
|
}
|
|
204
232
|
function imageSnippet(path, heightPt, widthPt) {
|
|
205
233
|
const safe = escapePath(path);
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ function supportedFormats() {
|
|
|
11
11
|
}
|
|
12
12
|
var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
13
13
|
function pngDimensions(bytes) {
|
|
14
|
-
if (bytes.length <
|
|
14
|
+
if (bytes.length < 33)
|
|
15
15
|
return null;
|
|
16
16
|
for (let i = 0;i < 8; i++)
|
|
17
17
|
if (bytes[i] !== PNG_MAGIC[i])
|
|
@@ -23,7 +23,12 @@ function pngDimensions(bytes) {
|
|
|
23
23
|
return null;
|
|
24
24
|
const width = view.getUint32(16);
|
|
25
25
|
const height = view.getUint32(20);
|
|
26
|
-
if (width === 0 || height === 0)
|
|
26
|
+
if (width === 0 || height === 0 || width > 2147483647 || height > 2147483647)
|
|
27
|
+
return null;
|
|
28
|
+
const depth = bytes[24];
|
|
29
|
+
const color = bytes[25];
|
|
30
|
+
const validDepth = color === 0 ? [1, 2, 4, 8, 16].includes(depth) : color === 3 ? [1, 2, 4, 8].includes(depth) : [2, 4, 6].includes(color) && [8, 16].includes(depth);
|
|
31
|
+
if (!validDepth || bytes[26] !== 0 || bytes[27] !== 0 || bytes[28] > 1)
|
|
27
32
|
return null;
|
|
28
33
|
return [width, height];
|
|
29
34
|
}
|
|
@@ -48,14 +53,28 @@ function jpegDimensions(bytes) {
|
|
|
48
53
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
49
54
|
let pos = 2;
|
|
50
55
|
while (pos < bytes.length) {
|
|
56
|
+
if (bytes[pos] !== 255)
|
|
57
|
+
return null;
|
|
51
58
|
while (pos < bytes.length && bytes[pos] === 255)
|
|
52
59
|
pos++;
|
|
53
60
|
if (pos >= bytes.length)
|
|
54
61
|
return null;
|
|
55
62
|
const marker = bytes[pos];
|
|
56
63
|
pos++;
|
|
64
|
+
if (marker === 1)
|
|
65
|
+
continue;
|
|
66
|
+
if (marker === 0 || marker >= 208 && marker <= 218)
|
|
67
|
+
return null;
|
|
68
|
+
if (pos + 2 > bytes.length)
|
|
69
|
+
return null;
|
|
70
|
+
const len = view.getUint16(pos);
|
|
71
|
+
if (len < 2 || len > bytes.length - pos)
|
|
72
|
+
return null;
|
|
57
73
|
if (SOF_MARKERS.has(marker)) {
|
|
58
|
-
if (
|
|
74
|
+
if (len < 8)
|
|
75
|
+
return null;
|
|
76
|
+
const components = bytes[pos + 7];
|
|
77
|
+
if (components === 0 || len !== 8 + 3 * components)
|
|
59
78
|
return null;
|
|
60
79
|
const height = view.getUint16(pos + 3);
|
|
61
80
|
const width = view.getUint16(pos + 5);
|
|
@@ -63,15 +82,6 @@ function jpegDimensions(bytes) {
|
|
|
63
82
|
return null;
|
|
64
83
|
return [width, height];
|
|
65
84
|
}
|
|
66
|
-
if (marker === 217 || marker === 218)
|
|
67
|
-
return null;
|
|
68
|
-
if (marker === 0 || marker === 1 || marker >= 208 && marker <= 215)
|
|
69
|
-
continue;
|
|
70
|
-
if (pos + 2 > bytes.length)
|
|
71
|
-
return null;
|
|
72
|
-
const len = view.getUint16(pos);
|
|
73
|
-
if (len < 2)
|
|
74
|
-
return null;
|
|
75
85
|
pos += len;
|
|
76
86
|
}
|
|
77
87
|
return null;
|
|
@@ -90,7 +100,7 @@ function sniff(bytes) {
|
|
|
90
100
|
return { mime: "jpeg", width: dims[0], height: dims[1], bytes };
|
|
91
101
|
}
|
|
92
102
|
let start = 0;
|
|
93
|
-
while (start < bytes.length &&
|
|
103
|
+
while (start < bytes.length && [9, 10, 12, 13, 32].includes(bytes[start]))
|
|
94
104
|
start++;
|
|
95
105
|
const head = String.fromCharCode(...bytes.slice(start, start + 4));
|
|
96
106
|
if (head === "<svg")
|
|
@@ -102,6 +112,8 @@ function decodeBase64(payload) {
|
|
|
102
112
|
return null;
|
|
103
113
|
try {
|
|
104
114
|
const text = atob(payload);
|
|
115
|
+
if (btoa(text) !== payload)
|
|
116
|
+
return null;
|
|
105
117
|
const bytes = new Uint8Array(text.length);
|
|
106
118
|
for (let i = 0;i < text.length; i++)
|
|
107
119
|
bytes[i] = text.charCodeAt(i);
|
|
@@ -115,7 +127,7 @@ function normalize(input) {
|
|
|
115
127
|
const comma = input.indexOf(",");
|
|
116
128
|
if (comma >= 0 && input.startsWith("data:")) {
|
|
117
129
|
const meta = input.slice(0, comma);
|
|
118
|
-
if (!meta.
|
|
130
|
+
if (!meta.endsWith(";base64"))
|
|
119
131
|
return null;
|
|
120
132
|
payload = input.slice(comma + 1);
|
|
121
133
|
}
|
|
@@ -138,17 +150,33 @@ function scaleToFit(image, maxPixels) {
|
|
|
138
150
|
return Math.sqrt(maxPixels / pixels);
|
|
139
151
|
}
|
|
140
152
|
function signatureSize(image, heightPt, maxWidthPt) {
|
|
141
|
-
if (image.width === null || image.height === null)
|
|
153
|
+
if (image.width === null || image.height === null || !Number.isInteger(image.width) || !Number.isInteger(image.height) || image.width <= 0 || image.height <= 0 || image.width > 4294967295 || image.height > 4294967295 || !Number.isFinite(heightPt) || heightPt <= 0 || maxWidthPt !== undefined && (!Number.isFinite(maxWidthPt) || maxWidthPt <= 0))
|
|
142
154
|
return null;
|
|
143
|
-
const
|
|
155
|
+
const ratio = image.width / image.height;
|
|
156
|
+
const natural = heightPt * ratio;
|
|
157
|
+
let width = natural;
|
|
158
|
+
let height = heightPt;
|
|
144
159
|
if (maxWidthPt !== undefined && natural > maxWidthPt) {
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
width = maxWidthPt;
|
|
161
|
+
height = maxWidthPt / ratio;
|
|
147
162
|
}
|
|
148
|
-
return [
|
|
163
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? [width, height] : null;
|
|
149
164
|
}
|
|
150
165
|
function escapePath(path) {
|
|
151
|
-
return path.replace(
|
|
166
|
+
return path.replace(/["\\\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
167
|
+
if (character === '"')
|
|
168
|
+
return "\\\"";
|
|
169
|
+
if (character === "\\")
|
|
170
|
+
return "\\\\";
|
|
171
|
+
if (character === `
|
|
172
|
+
`)
|
|
173
|
+
return "\\n";
|
|
174
|
+
if (character === "\r")
|
|
175
|
+
return "\\r";
|
|
176
|
+
if (character === "\t")
|
|
177
|
+
return "\\t";
|
|
178
|
+
return `\\u{${character.charCodeAt(0).toString(16)}}`;
|
|
179
|
+
});
|
|
152
180
|
}
|
|
153
181
|
function imageSnippet(path, heightPt, widthPt) {
|
|
154
182
|
const safe = escapePath(path);
|
package/dist/types/index.d.cts
CHANGED
|
@@ -14,7 +14,8 @@ export declare function defaultMaxPixels(): number;
|
|
|
14
14
|
/** Accepted image formats, from `tables/defaults.json`. */
|
|
15
15
|
export declare function supportedFormats(): string[];
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Recognize a signature image given as a `data:` URL or raw canonical base64.
|
|
18
|
+
* Required padding must be present; whitespace and nonzero unused bits are rejected.
|
|
18
19
|
* Returns `null` for empty, undecodable, corrupt, or unsupported inputs.
|
|
19
20
|
*/
|
|
20
21
|
export declare function normalize(input: string): DecodedImage | null;
|
|
@@ -30,8 +31,8 @@ export declare function exceedsLimits(image: DecodedImage, maxPixels: number): b
|
|
|
30
31
|
export declare function scaleToFit(image: DecodedImage, maxPixels: number): number;
|
|
31
32
|
/**
|
|
32
33
|
* Point size for a target height, preserving aspect ratio, clamped to an
|
|
33
|
-
* optional maximum width. Returns `null`
|
|
34
|
-
*
|
|
34
|
+
* optional maximum width. Returns `null` for missing or invalid dimensions,
|
|
35
|
+
* nonpositive/nonfinite point sizes, or a nonrepresentable result.
|
|
35
36
|
*/
|
|
36
37
|
export declare function signatureSize(image: DecodedImage, heightPt: number, maxWidthPt?: number): [number, number] | null;
|
|
37
38
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -14,7 +14,8 @@ export declare function defaultMaxPixels(): number;
|
|
|
14
14
|
/** Accepted image formats, from `tables/defaults.json`. */
|
|
15
15
|
export declare function supportedFormats(): string[];
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Recognize a signature image given as a `data:` URL or raw canonical base64.
|
|
18
|
+
* Required padding must be present; whitespace and nonzero unused bits are rejected.
|
|
18
19
|
* Returns `null` for empty, undecodable, corrupt, or unsupported inputs.
|
|
19
20
|
*/
|
|
20
21
|
export declare function normalize(input: string): DecodedImage | null;
|
|
@@ -30,8 +31,8 @@ export declare function exceedsLimits(image: DecodedImage, maxPixels: number): b
|
|
|
30
31
|
export declare function scaleToFit(image: DecodedImage, maxPixels: number): number;
|
|
31
32
|
/**
|
|
32
33
|
* Point size for a target height, preserving aspect ratio, clamped to an
|
|
33
|
-
* optional maximum width. Returns `null`
|
|
34
|
-
*
|
|
34
|
+
* optional maximum width. Returns `null` for missing or invalid dimensions,
|
|
35
|
+
* nonpositive/nonfinite point sizes, or a nonrepresentable result.
|
|
35
36
|
*/
|
|
36
37
|
export declare function signatureSize(image: DecodedImage, heightPt: number, maxWidthPt?: number): [number, number] | null;
|
|
37
38
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@corbet-labs/cink",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Handwritten signature image rendering for correspondence. Pixels, not signatures. Mirrors the cink Rust crate release line.",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"publishConfig": {
|
package/src/generated/tables.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -40,14 +40,20 @@ export function supportedFormats(): string[] {
|
|
|
40
40
|
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
41
41
|
|
|
42
42
|
function pngDimensions(bytes: Uint8Array): [number, number] | null {
|
|
43
|
-
if (bytes.length <
|
|
43
|
+
if (bytes.length < 33) return null;
|
|
44
44
|
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_MAGIC[i]) return null;
|
|
45
45
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
46
46
|
if (view.getUint32(8) !== 13) return null;
|
|
47
47
|
if (view.getUint32(12) !== 0x49484452) return null;
|
|
48
48
|
const width = view.getUint32(16);
|
|
49
49
|
const height = view.getUint32(20);
|
|
50
|
-
if (width === 0 || height === 0) return null;
|
|
50
|
+
if (width === 0 || height === 0 || width > 0x7fffffff || height > 0x7fffffff) return null;
|
|
51
|
+
const depth = bytes[24];
|
|
52
|
+
const color = bytes[25];
|
|
53
|
+
const validDepth = color === 0 ? [1, 2, 4, 8, 16].includes(depth)
|
|
54
|
+
: color === 3 ? [1, 2, 4, 8].includes(depth)
|
|
55
|
+
: [2, 4, 6].includes(color) && [8, 16].includes(depth);
|
|
56
|
+
if (!validDepth || bytes[26] !== 0 || bytes[27] !== 0 || bytes[28] > 1) return null;
|
|
51
57
|
return [width, height];
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -60,22 +66,25 @@ function jpegDimensions(bytes: Uint8Array): [number, number] | null {
|
|
|
60
66
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
61
67
|
let pos = 2;
|
|
62
68
|
while (pos < bytes.length) {
|
|
69
|
+
if (bytes[pos] !== 0xff) return null;
|
|
63
70
|
while (pos < bytes.length && bytes[pos] === 0xff) pos++;
|
|
64
71
|
if (pos >= bytes.length) return null;
|
|
65
72
|
const marker = bytes[pos];
|
|
66
73
|
pos++;
|
|
74
|
+
if (marker === 0x01) continue;
|
|
75
|
+
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xda)) return null;
|
|
76
|
+
if (pos + 2 > bytes.length) return null;
|
|
77
|
+
const len = view.getUint16(pos);
|
|
78
|
+
if (len < 2 || len > bytes.length - pos) return null;
|
|
67
79
|
if (SOF_MARKERS.has(marker)) {
|
|
68
|
-
if (
|
|
80
|
+
if (len < 8) return null;
|
|
81
|
+
const components = bytes[pos + 7];
|
|
82
|
+
if (components === 0 || len !== 8 + 3 * components) return null;
|
|
69
83
|
const height = view.getUint16(pos + 3);
|
|
70
84
|
const width = view.getUint16(pos + 5);
|
|
71
85
|
if (width === 0 || height === 0) return null;
|
|
72
86
|
return [width, height];
|
|
73
87
|
}
|
|
74
|
-
if (marker === 0xd9 || marker === 0xda) return null;
|
|
75
|
-
if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
76
|
-
if (pos + 2 > bytes.length) return null;
|
|
77
|
-
const len = view.getUint16(pos);
|
|
78
|
-
if (len < 2) return null;
|
|
79
88
|
pos += len;
|
|
80
89
|
}
|
|
81
90
|
return null;
|
|
@@ -93,7 +102,7 @@ function sniff(bytes: Uint8Array): DecodedImage | null {
|
|
|
93
102
|
return { mime: 'jpeg', width: dims[0], height: dims[1], bytes };
|
|
94
103
|
}
|
|
95
104
|
let start = 0;
|
|
96
|
-
while (start < bytes.length &&
|
|
105
|
+
while (start < bytes.length && [9, 10, 12, 13, 32].includes(bytes[start])) start++;
|
|
97
106
|
const head = String.fromCharCode(...bytes.slice(start, start + 4));
|
|
98
107
|
if (head === '<svg') return { mime: 'svg', width: null, height: null, bytes };
|
|
99
108
|
return null;
|
|
@@ -103,6 +112,8 @@ function decodeBase64(payload: string): Uint8Array | null {
|
|
|
103
112
|
if (payload === '') return null;
|
|
104
113
|
try {
|
|
105
114
|
const text = atob(payload);
|
|
115
|
+
// Rust STANDARD accepts only canonical base64, including required padding.
|
|
116
|
+
if (btoa(text) !== payload) return null;
|
|
106
117
|
const bytes = new Uint8Array(text.length);
|
|
107
118
|
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
|
108
119
|
return bytes.length > 0 ? bytes : null;
|
|
@@ -112,7 +123,8 @@ function decodeBase64(payload: string): Uint8Array | null {
|
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
/**
|
|
115
|
-
*
|
|
126
|
+
* Recognize a signature image given as a `data:` URL or raw canonical base64.
|
|
127
|
+
* Required padding must be present; whitespace and nonzero unused bits are rejected.
|
|
116
128
|
* Returns `null` for empty, undecodable, corrupt, or unsupported inputs.
|
|
117
129
|
*/
|
|
118
130
|
export function normalize(input: string): DecodedImage | null {
|
|
@@ -120,7 +132,7 @@ export function normalize(input: string): DecodedImage | null {
|
|
|
120
132
|
const comma = input.indexOf(',');
|
|
121
133
|
if (comma >= 0 && input.startsWith('data:')) {
|
|
122
134
|
const meta = input.slice(0, comma);
|
|
123
|
-
if (!meta.
|
|
135
|
+
if (!meta.endsWith(';base64')) return null;
|
|
124
136
|
payload = input.slice(comma + 1);
|
|
125
137
|
}
|
|
126
138
|
const bytes = decodeBase64(payload);
|
|
@@ -150,25 +162,41 @@ export function scaleToFit(image: DecodedImage, maxPixels: number): number {
|
|
|
150
162
|
|
|
151
163
|
/**
|
|
152
164
|
* Point size for a target height, preserving aspect ratio, clamped to an
|
|
153
|
-
* optional maximum width. Returns `null`
|
|
154
|
-
*
|
|
165
|
+
* optional maximum width. Returns `null` for missing or invalid dimensions,
|
|
166
|
+
* nonpositive/nonfinite point sizes, or a nonrepresentable result.
|
|
155
167
|
*/
|
|
156
168
|
export function signatureSize(
|
|
157
169
|
image: DecodedImage,
|
|
158
170
|
heightPt: number,
|
|
159
171
|
maxWidthPt?: number,
|
|
160
172
|
): [number, number] | null {
|
|
161
|
-
if (image.width === null || image.height === null
|
|
162
|
-
|
|
173
|
+
if (image.width === null || image.height === null
|
|
174
|
+
|| !Number.isInteger(image.width) || !Number.isInteger(image.height)
|
|
175
|
+
|| image.width <= 0 || image.height <= 0
|
|
176
|
+
|| image.width > 0xffffffff || image.height > 0xffffffff
|
|
177
|
+
|| !Number.isFinite(heightPt) || heightPt <= 0
|
|
178
|
+
|| (maxWidthPt !== undefined && (!Number.isFinite(maxWidthPt) || maxWidthPt <= 0))) return null;
|
|
179
|
+
const ratio = image.width / image.height;
|
|
180
|
+
const natural = heightPt * ratio;
|
|
181
|
+
let width = natural;
|
|
182
|
+
let height = heightPt;
|
|
163
183
|
if (maxWidthPt !== undefined && natural > maxWidthPt) {
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
width = maxWidthPt;
|
|
185
|
+
height = maxWidthPt / ratio;
|
|
166
186
|
}
|
|
167
|
-
return
|
|
187
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
|
|
188
|
+
? [width, height] : null;
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
function escapePath(path: string): string {
|
|
171
|
-
return path.replace(
|
|
192
|
+
return path.replace(/["\\\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
193
|
+
if (character === '"') return '\\"';
|
|
194
|
+
if (character === '\\') return '\\\\';
|
|
195
|
+
if (character === '\n') return '\\n';
|
|
196
|
+
if (character === '\r') return '\\r';
|
|
197
|
+
if (character === '\t') return '\\t';
|
|
198
|
+
return `\\u{${character.charCodeAt(0).toString(16)}}`;
|
|
199
|
+
});
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/**
|