@altnovatrail/canvas 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/README.md +59 -2
- package/lib/core/boxes.d.ts +54 -0
- package/lib/core/boxes.js +116 -0
- package/lib/core/clock.d.ts +25 -0
- package/lib/core/clock.js +39 -0
- package/lib/core/crop.d.ts +87 -0
- package/lib/core/crop.js +227 -0
- package/lib/core/eraser.d.ts +42 -0
- package/lib/core/eraser.js +127 -0
- package/lib/core/example.d.ts +66 -0
- package/lib/core/example.js +116 -0
- package/lib/core/fields.d.ts +29 -0
- package/lib/core/fields.js +78 -0
- package/lib/core/geometry.d.ts +38 -0
- package/lib/core/geometry.js +107 -0
- package/lib/core/grid.d.ts +30 -0
- package/lib/core/grid.js +47 -0
- package/lib/core/groups.d.ts +55 -0
- package/lib/core/groups.js +172 -0
- package/lib/core/hands.d.ts +120 -0
- package/lib/core/hands.js +273 -0
- package/lib/core/history.d.ts +42 -0
- package/lib/core/history.js +228 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +15 -0
- package/lib/core/ink.d.ts +1 -0
- package/lib/core/ink.js +5 -0
- package/lib/core/manifest.d.ts +22 -0
- package/lib/core/manifest.js +50 -0
- package/lib/core/marks.d.ts +145 -0
- package/lib/core/marks.js +454 -0
- package/lib/core/outline.d.ts +22 -0
- package/lib/core/outline.js +50 -0
- package/lib/core/page.d.ts +62 -0
- package/lib/core/page.js +309 -0
- package/lib/core/png.d.ts +13 -0
- package/lib/core/png.js +231 -0
- package/lib/core/raster.d.ts +14 -0
- package/lib/core/raster.js +72 -0
- package/lib/core/recorder.d.ts +73 -0
- package/lib/core/recorder.js +127 -0
- package/lib/core/screen.d.ts +35 -0
- package/lib/core/screen.js +36 -0
- package/lib/core/sets.d.ts +10 -0
- package/lib/core/sets.js +31 -0
- package/lib/core/sheet.d.ts +9 -0
- package/lib/core/sheet.js +6 -0
- package/lib/core/submission.d.ts +41 -0
- package/lib/core/submission.js +63 -0
- package/lib/core/task.d.ts +214 -0
- package/lib/core/task.js +343 -0
- package/lib/core/widths.d.ts +57 -0
- package/lib/core/widths.js +218 -0
- package/lib/core/work.d.ts +77 -0
- package/lib/core/work.js +293 -0
- package/lib/element/attributes.d.ts +11 -0
- package/lib/element/attributes.js +22 -0
- package/lib/element/index.d.ts +219 -0
- package/lib/element/index.js +1898 -0
- package/lib/element/labels.d.ts +41 -0
- package/lib/element/labels.js +74 -0
- package/lib/element/parts.d.ts +63 -0
- package/lib/element/parts.js +191 -0
- package/lib/element/pictures.d.ts +45 -0
- package/lib/element/pictures.js +167 -0
- package/lib/element/probe.d.ts +33 -0
- package/lib/element/probe.js +18 -0
- package/lib/element/session.d.ts +3 -0
- package/lib/element/session.js +7 -0
- package/lib/element/statement.d.ts +35 -0
- package/lib/element/statement.js +116 -0
- package/lib/element/tools.d.ts +64 -0
- package/lib/element/tools.js +254 -0
- package/package.json +41 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { WorkbookItem } from "./task.js";
|
|
2
|
+
/** The numbers of the layout (R35), in page units, the type size among them. */
|
|
3
|
+
export interface Layout {
|
|
4
|
+
/** The type size of the line's text. */
|
|
5
|
+
readonly size: number;
|
|
6
|
+
/** A box, the same for the whole task (R34). */
|
|
7
|
+
readonly boxWidth: number;
|
|
8
|
+
readonly boxHeight: number;
|
|
9
|
+
/** The height of a line of text without a box. */
|
|
10
|
+
readonly line: number;
|
|
11
|
+
/** Between a line with a box and one without, in an item that wraps. */
|
|
12
|
+
readonly lead: number;
|
|
13
|
+
/** Between a box and a word beside it, and between an item's number and its line. */
|
|
14
|
+
readonly gap: number;
|
|
15
|
+
/** Between two boxes side by side, and between two lines with boxes in an item that wraps. */
|
|
16
|
+
readonly apart: number;
|
|
17
|
+
/** Between two rows of items. */
|
|
18
|
+
readonly items: number;
|
|
19
|
+
/** Between the two columns of short lines. */
|
|
20
|
+
readonly gutter: number;
|
|
21
|
+
/** The margin of the page on its sides, its top and its bottom. */
|
|
22
|
+
readonly margin: number;
|
|
23
|
+
}
|
|
24
|
+
/** The layout the workbook has (R35): the sweep of T47 chose it around the box the author chose. */
|
|
25
|
+
export declare const LAYOUT: Layout;
|
|
26
|
+
/** A piece of a line's text, or its part on one line of the page: one `<text>` (R34, R35). */
|
|
27
|
+
export interface PageText {
|
|
28
|
+
readonly text: string;
|
|
29
|
+
/** Where it starts, and its alphabetic baseline, in page units. */
|
|
30
|
+
readonly left: number;
|
|
31
|
+
readonly baseline: number;
|
|
32
|
+
/** The width SVG fits it to, `textLength`. */
|
|
33
|
+
readonly width: number;
|
|
34
|
+
}
|
|
35
|
+
/** A box, an answer slot, in page units, in the names of the format's zone. */
|
|
36
|
+
export interface PageBox {
|
|
37
|
+
readonly left: number;
|
|
38
|
+
readonly top: number;
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
}
|
|
42
|
+
/** An item on the page: its number, its line in the order it is read, and its boxes by slot. */
|
|
43
|
+
export interface PageItem {
|
|
44
|
+
readonly number: PageText;
|
|
45
|
+
readonly line: readonly (PageText | PageBox)[];
|
|
46
|
+
/** The boxes of the line, the same objects as in `line`: slot 1 first. */
|
|
47
|
+
readonly boxes: readonly PageBox[];
|
|
48
|
+
}
|
|
49
|
+
/** A workbook's page: its items in the task's order, and the bottom of the last row. */
|
|
50
|
+
export interface Page {
|
|
51
|
+
readonly items: readonly PageItem[];
|
|
52
|
+
readonly bottom: number;
|
|
53
|
+
}
|
|
54
|
+
/** Whether a part of a line is a box. */
|
|
55
|
+
export declare function isBox(part: PageText | PageBox): part is PageBox;
|
|
56
|
+
/**
|
|
57
|
+
* The page of a workbook's items (R35): a function of the items alone, in whole units, frozen. A
|
|
58
|
+
* row that does not fit is a TypeError at the first item of it, `task.items[i]`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function pageOf(items: readonly WorkbookItem[], layout?: Layout): Page;
|
|
61
|
+
/** Whether two pages are the same, part by part. */
|
|
62
|
+
export declare function samePage(a: Page, b: Page): boolean;
|
package/lib/core/page.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// The layout of a workbook's page (R34, R35): where the template puts each item's number, the
|
|
2
|
+
// pieces of its line and its boxes, in whole page units, from the task's items alone. The page is
|
|
3
|
+
// the sheet's, 1000 × 1400 units (R06), and the boxes stay where the layout puts them on any
|
|
4
|
+
// device: the widths of the text come from the canvas's own table (widths.ts), and SVG fits the
|
|
5
|
+
// text to them. Short lines stand two to a row, in the task's order; a long line takes the page's
|
|
6
|
+
// width and wraps. A task whose lines do not fit is a TypeError: nothing shrinks. The core's entry
|
|
7
|
+
// point does not give the layout, and vectors/pages.json pins its numbers: once released, they
|
|
8
|
+
// change only by a decision, for histories know neither the task nor the layout (R34).
|
|
9
|
+
import { SHEET } from "./sheet.js";
|
|
10
|
+
import { clustersOf, clusterWidth, SPACE, wordsOf, wordWidth } from "./widths.js";
|
|
11
|
+
/** The layout the workbook has (R35): the sweep of T47 chose it around the box the author chose. */
|
|
12
|
+
export const LAYOUT = Object.freeze({
|
|
13
|
+
size: 30,
|
|
14
|
+
boxWidth: 100,
|
|
15
|
+
boxHeight: 100,
|
|
16
|
+
line: 46,
|
|
17
|
+
lead: 12,
|
|
18
|
+
gap: 16,
|
|
19
|
+
apart: 32,
|
|
20
|
+
items: 32,
|
|
21
|
+
gutter: 40,
|
|
22
|
+
margin: 40,
|
|
23
|
+
});
|
|
24
|
+
/** Whether a part of a line is a box. */
|
|
25
|
+
export function isBox(part) {
|
|
26
|
+
return "top" in part;
|
|
27
|
+
}
|
|
28
|
+
/** Marks a line does not start with: they close what came before. */
|
|
29
|
+
const CLOSING = ".,:;!?)]}»…";
|
|
30
|
+
/** Marks a line does not end with: they open what comes after. */
|
|
31
|
+
const OPENING = "([{«„";
|
|
32
|
+
function measuresOf(layout) {
|
|
33
|
+
const numbers = textWidth(layout, wordWidth("00."));
|
|
34
|
+
const indent = numbers + layout.gap;
|
|
35
|
+
const content = SHEET.width - 2 * layout.margin;
|
|
36
|
+
const column = Math.floor((content - layout.gutter) / 2);
|
|
37
|
+
return {
|
|
38
|
+
layout,
|
|
39
|
+
space: textWidth(layout, SPACE),
|
|
40
|
+
numbers,
|
|
41
|
+
indent,
|
|
42
|
+
column,
|
|
43
|
+
full: content - indent,
|
|
44
|
+
second: layout.margin + column + layout.gutter,
|
|
45
|
+
floor: SHEET.height - layout.margin,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The width of a text of `hundredths` of the size, in whole units, rounded up. */
|
|
49
|
+
function textWidth(layout, hundredths) {
|
|
50
|
+
const product = layout.size * hundredths;
|
|
51
|
+
return (product - (product % 100)) / 100 + (product % 100 > 0 ? 1 : 0);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The page of a workbook's items (R35): a function of the items alone, in whole units, frozen. A
|
|
55
|
+
* row that does not fit is a TypeError at the first item of it, `task.items[i]`.
|
|
56
|
+
*/
|
|
57
|
+
export function pageOf(items, layout = LAYOUT) {
|
|
58
|
+
const measures = measuresOf(layout);
|
|
59
|
+
// Each item's line as one line, worked out once: whether it is short, and its entries.
|
|
60
|
+
const lines = [];
|
|
61
|
+
const lineOf = (n) => {
|
|
62
|
+
lines[n] ??= entriesOf(tokensOf(items[n]), measures);
|
|
63
|
+
return lines[n];
|
|
64
|
+
};
|
|
65
|
+
const short = (n) => n < items.length && partsOf(lineOf(n), measures).width <= measures.column - measures.indent;
|
|
66
|
+
const placed = [];
|
|
67
|
+
let top = layout.margin;
|
|
68
|
+
let bottom = layout.margin;
|
|
69
|
+
let i = 0;
|
|
70
|
+
while (i < items.length) {
|
|
71
|
+
const room = measures.floor - top;
|
|
72
|
+
// The column of the numbers is two digits wide.
|
|
73
|
+
if (i >= MOST)
|
|
74
|
+
throw new TypeError(noRoom(i, measures));
|
|
75
|
+
if (short(i)) {
|
|
76
|
+
const cells = short(i + 1) && i + 1 < MOST ? [i, i + 1] : [i];
|
|
77
|
+
const laid = cells.map((n, c) => itemOf([lineOf(n)], n, c === 0 ? layout.margin : measures.second, top, measures));
|
|
78
|
+
const height = Math.max(...laid.map(({ height }) => height));
|
|
79
|
+
if (height > room)
|
|
80
|
+
throw new TypeError(noRoom(i, measures));
|
|
81
|
+
placed.push(...laid.map(({ item }) => item));
|
|
82
|
+
bottom = top + height;
|
|
83
|
+
i += cells.length;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const wrapped = linesOf(lineOf(i), measures, room);
|
|
87
|
+
const laid = wrapped === undefined ? undefined : itemOf(wrapped, i, layout.margin, top, measures);
|
|
88
|
+
if (laid === undefined || laid.height > room)
|
|
89
|
+
throw new TypeError(noRoom(i, measures));
|
|
90
|
+
placed.push(laid.item);
|
|
91
|
+
bottom = top + laid.height;
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
top = bottom + layout.items;
|
|
95
|
+
}
|
|
96
|
+
return deepFreeze({ items: placed, bottom });
|
|
97
|
+
}
|
|
98
|
+
/** The most items a page holds: the column of the numbers is "00." wide. */
|
|
99
|
+
const MOST = 99;
|
|
100
|
+
/** Whether two pages are the same, part by part. */
|
|
101
|
+
export function samePage(a, b) {
|
|
102
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
103
|
+
}
|
|
104
|
+
function noRoom(i, { floor }) {
|
|
105
|
+
if (i >= MOST) {
|
|
106
|
+
return `task.items[${i}]: no room for item ${i + 1} on the workbook's page: it holds no more than ${MOST} items, whose numbers have two digits at most`;
|
|
107
|
+
}
|
|
108
|
+
return `task.items[${i}]: no room for item ${i + 1} on the workbook's page, ${SHEET.width} × ${SHEET.height} units: its row would go below ${floor}, where the page's margin begins, and the text and the boxes never shrink`;
|
|
109
|
+
}
|
|
110
|
+
/** The tokens of an item's line: the words of its pieces of text, and its boxes, in order. */
|
|
111
|
+
function tokensOf(item) {
|
|
112
|
+
return item.line.flatMap((part, piece) => {
|
|
113
|
+
if ("binding" in part)
|
|
114
|
+
return [{ piece, box: true }];
|
|
115
|
+
return wordsOf(part.text).map((word) => ({ piece, word, width: wordWidth(word) }));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function isWord(token) {
|
|
119
|
+
return "word" in token;
|
|
120
|
+
}
|
|
121
|
+
/** What lies between two tokens next to each other in a line. */
|
|
122
|
+
function jointOf(a, b, measures) {
|
|
123
|
+
const { gap, apart } = measures.layout;
|
|
124
|
+
if (isWord(a) && isWord(b)) {
|
|
125
|
+
const glued = CLOSING.includes(b.word.slice(0, 1)) || OPENING.includes(a.word.slice(-1));
|
|
126
|
+
if (a.piece === b.piece)
|
|
127
|
+
return { inner: true, width: 0, breaks: !glued };
|
|
128
|
+
return { inner: false, width: glued ? 0 : measures.space, breaks: !glued };
|
|
129
|
+
}
|
|
130
|
+
if (isWord(a))
|
|
131
|
+
return { inner: false, width: gap, breaks: !OPENING.includes(a.word.slice(-1)) };
|
|
132
|
+
// A box holds on to the word after it, as a number holds on to its unit.
|
|
133
|
+
if (isWord(b))
|
|
134
|
+
return { inner: false, width: gap, breaks: false };
|
|
135
|
+
return { inner: false, width: apart, breaks: true };
|
|
136
|
+
}
|
|
137
|
+
/** Tokens as one line, each with what lies before it. */
|
|
138
|
+
function entriesOf(tokens, measures) {
|
|
139
|
+
return tokens.map((token, n) => {
|
|
140
|
+
const before = tokens[n - 1];
|
|
141
|
+
return { token, joint: before === undefined ? undefined : jointOf(before, token, measures) };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The parts of a line of entries, from its start: the words of a piece that follow each other
|
|
146
|
+
* within it make one text, and each text and box has its place and its width.
|
|
147
|
+
*/
|
|
148
|
+
function partsOf(entries, measures) {
|
|
149
|
+
const { layout } = measures;
|
|
150
|
+
const parts = [];
|
|
151
|
+
let x = 0;
|
|
152
|
+
let words = [];
|
|
153
|
+
let hundredths = 0;
|
|
154
|
+
let start = 0;
|
|
155
|
+
const close = () => {
|
|
156
|
+
if (words.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
const width = textWidth(layout, hundredths + SPACE * (words.length - 1));
|
|
159
|
+
parts.push({ box: false, text: words.join(" "), left: start, width });
|
|
160
|
+
x = start + width;
|
|
161
|
+
words = [];
|
|
162
|
+
hundredths = 0;
|
|
163
|
+
};
|
|
164
|
+
for (const { token, joint } of entries) {
|
|
165
|
+
if (isWord(token) && joint?.inner === true && words.length > 0) {
|
|
166
|
+
words.push(token.word);
|
|
167
|
+
hundredths += token.width;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
close();
|
|
171
|
+
if (joint !== undefined)
|
|
172
|
+
x += joint.width;
|
|
173
|
+
if (isWord(token)) {
|
|
174
|
+
start = x;
|
|
175
|
+
words = [token.word];
|
|
176
|
+
hundredths = token.width;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
parts.push({ box: true, left: x, width: layout.boxWidth });
|
|
180
|
+
x += layout.boxWidth;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
close();
|
|
184
|
+
return { parts, width: x };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The lines of a long item across the page: greedily, run by run, where a run is tokens no line may
|
|
188
|
+
* break between. A run wider than a line goes token by token, and a word wider than a line is
|
|
189
|
+
* broken between its clusters. Undefined once the lines could no longer fit the room left.
|
|
190
|
+
*/
|
|
191
|
+
function linesOf(entries, measures, room) {
|
|
192
|
+
const { full } = measures;
|
|
193
|
+
const { line } = measures.layout;
|
|
194
|
+
// The lowest a line can be is a line of text: more lines than this cannot fit the room.
|
|
195
|
+
const most = Math.floor(room / line) + 1;
|
|
196
|
+
const lines = [];
|
|
197
|
+
let current = [];
|
|
198
|
+
const fits = (candidate) => partsOf(candidate, measures).width <= full;
|
|
199
|
+
const fresh = (entry) => ({ token: entry.token, joint: undefined });
|
|
200
|
+
const end = () => {
|
|
201
|
+
if (current.length > 0)
|
|
202
|
+
lines.push(current);
|
|
203
|
+
current = [];
|
|
204
|
+
return lines.length > most;
|
|
205
|
+
};
|
|
206
|
+
for (const run of runsOf(entries)) {
|
|
207
|
+
if (current.length > 0 && fits([...current, ...run])) {
|
|
208
|
+
current.push(...run);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (end())
|
|
212
|
+
return undefined;
|
|
213
|
+
const alone = [fresh(run[0]), ...run.slice(1)];
|
|
214
|
+
if (fits(alone)) {
|
|
215
|
+
current = alone;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const entry of run) {
|
|
219
|
+
if (current.length > 0 && fits([...current, entry])) {
|
|
220
|
+
current.push(entry);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (end())
|
|
224
|
+
return undefined;
|
|
225
|
+
if (fits([fresh(entry)])) {
|
|
226
|
+
current = [fresh(entry)];
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// A word wider than a line: its clusters, as many to a line as fit.
|
|
230
|
+
const token = entry.token;
|
|
231
|
+
let chunk = "";
|
|
232
|
+
let width = 0;
|
|
233
|
+
for (const cluster of clustersOf(token.word)) {
|
|
234
|
+
const more = width + clusterWidth(cluster);
|
|
235
|
+
if (chunk !== "" && textWidth(measures.layout, more) > full) {
|
|
236
|
+
current = [{ token: { piece: token.piece, word: chunk, width }, joint: undefined }];
|
|
237
|
+
if (end())
|
|
238
|
+
return undefined;
|
|
239
|
+
chunk = "";
|
|
240
|
+
width = 0;
|
|
241
|
+
}
|
|
242
|
+
chunk += cluster;
|
|
243
|
+
width += clusterWidth(cluster);
|
|
244
|
+
}
|
|
245
|
+
current = [{ token: { piece: token.piece, word: chunk, width }, joint: undefined }];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (end())
|
|
249
|
+
return undefined;
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
252
|
+
/** Entries cut where a line may break: each run starts with what lies before it. */
|
|
253
|
+
function runsOf(entries) {
|
|
254
|
+
const runs = [];
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
const last = runs.at(-1);
|
|
257
|
+
if (last === undefined || entry.joint === undefined || entry.joint.breaks)
|
|
258
|
+
runs.push([entry]);
|
|
259
|
+
else
|
|
260
|
+
last.push(entry);
|
|
261
|
+
}
|
|
262
|
+
return runs;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* An item laid out from its lines at a place on the page: its parts in their places, its number on
|
|
266
|
+
* the first line's baseline, right in the column of the numbers, and its height.
|
|
267
|
+
*/
|
|
268
|
+
function itemOf(lines, n, left, top, measures) {
|
|
269
|
+
const { layout } = measures;
|
|
270
|
+
const line = [];
|
|
271
|
+
const baselines = [];
|
|
272
|
+
let y = top;
|
|
273
|
+
let boxed;
|
|
274
|
+
for (const entries of lines) {
|
|
275
|
+
const { parts } = partsOf(entries, measures);
|
|
276
|
+
const hasBox = parts.some((part) => part.box);
|
|
277
|
+
if (boxed !== undefined)
|
|
278
|
+
y += boxed && hasBox ? layout.apart : boxed || hasBox ? layout.lead : 0;
|
|
279
|
+
const height = hasBox ? layout.boxHeight : layout.line;
|
|
280
|
+
const baseline = y + Math.floor(height / 2) + Math.floor(layout.size / 3);
|
|
281
|
+
baselines.push(baseline);
|
|
282
|
+
const x = left + measures.indent;
|
|
283
|
+
for (const part of parts) {
|
|
284
|
+
line.push(part.box
|
|
285
|
+
? { left: x + part.left, top: y, width: part.width, height: layout.boxHeight }
|
|
286
|
+
: { text: part.text, left: x + part.left, baseline, width: part.width });
|
|
287
|
+
}
|
|
288
|
+
y += height;
|
|
289
|
+
boxed = hasBox;
|
|
290
|
+
}
|
|
291
|
+
const label = `${n + 1}.`;
|
|
292
|
+
const width = textWidth(layout, wordWidth(label));
|
|
293
|
+
const number = {
|
|
294
|
+
text: label,
|
|
295
|
+
left: left + measures.numbers - width,
|
|
296
|
+
baseline: baselines[0] ?? top + Math.floor(layout.line / 2) + Math.floor(layout.size / 3),
|
|
297
|
+
width,
|
|
298
|
+
};
|
|
299
|
+
const height = Math.max(y - top, lines.length === 0 ? layout.line : 0);
|
|
300
|
+
return { item: { number, line, boxes: line.filter(isBox) }, height };
|
|
301
|
+
}
|
|
302
|
+
function deepFreeze(value) {
|
|
303
|
+
if (typeof value === "object" && value !== null) {
|
|
304
|
+
for (const field of Object.values(value))
|
|
305
|
+
deepFreeze(field);
|
|
306
|
+
Object.freeze(value);
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PNG of a grey image, a byte to a pixel, row by row from the top (R25): own code, so that the
|
|
3
|
+
* same pixels give the same bytes in every browser and in Node, as no browser's canvas promises
|
|
4
|
+
* (R05). The file is the least PNG holds: its signature, the header, one chunk of data and the end,
|
|
5
|
+
* no ancillary chunk, for a decoder may ignore them (PNG Third Edition, 11.3). The data is a zlib
|
|
6
|
+
* stream of one deflate block with the fixed Huffman codes (RFC 1950, RFC 1951): every row
|
|
7
|
+
* unfiltered, and a repeat looked for in two places only — the byte before, which takes the runs of
|
|
8
|
+
* white, and the row above, which takes the rows a stroke runs straight down through.
|
|
9
|
+
*/
|
|
10
|
+
export declare function encodePNG(gray: Uint8Array, width: number, height: number): Uint8Array<ArrayBuffer>;
|
|
11
|
+
export declare function crc32(bytes: Uint8Array): number;
|
|
12
|
+
/** zlib's checksum of the data before compression: two sums modulo 65521 (RFC 1950). */
|
|
13
|
+
export declare function adler32(bytes: Uint8Array): number;
|
package/lib/core/png.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PNG of a grey image, a byte to a pixel, row by row from the top (R25): own code, so that the
|
|
3
|
+
* same pixels give the same bytes in every browser and in Node, as no browser's canvas promises
|
|
4
|
+
* (R05). The file is the least PNG holds: its signature, the header, one chunk of data and the end,
|
|
5
|
+
* no ancillary chunk, for a decoder may ignore them (PNG Third Edition, 11.3). The data is a zlib
|
|
6
|
+
* stream of one deflate block with the fixed Huffman codes (RFC 1950, RFC 1951): every row
|
|
7
|
+
* unfiltered, and a repeat looked for in two places only — the byte before, which takes the runs of
|
|
8
|
+
* white, and the row above, which takes the rows a stroke runs straight down through.
|
|
9
|
+
*/
|
|
10
|
+
export function encodePNG(gray, width, height) {
|
|
11
|
+
for (const [name, side] of [
|
|
12
|
+
["width", width],
|
|
13
|
+
["height", height],
|
|
14
|
+
]) {
|
|
15
|
+
if (!Number.isInteger(side) || side < 1 || side > MOST) {
|
|
16
|
+
throw new RangeError(`the image's ${name} is ${side}, not a whole number from 1 to ${MOST}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (gray.length !== width * height) {
|
|
20
|
+
throw new RangeError(`${gray.length} pixels are no image ${width} × ${height}`);
|
|
21
|
+
}
|
|
22
|
+
// Each row after its filter, 0: none.
|
|
23
|
+
const stride = width + 1;
|
|
24
|
+
const rows = new Uint8Array(stride * height);
|
|
25
|
+
for (let row = 0; row < height; row++) {
|
|
26
|
+
rows.set(gray.subarray(row * width, (row + 1) * width), row * stride + 1);
|
|
27
|
+
}
|
|
28
|
+
const header = new Uint8Array(13);
|
|
29
|
+
const view = new DataView(header.buffer);
|
|
30
|
+
view.setUint32(0, width);
|
|
31
|
+
view.setUint32(4, height);
|
|
32
|
+
// A depth of 8 bits, the colour type of grey, and the one method of compression, of filtering
|
|
33
|
+
// and of no interlacing there is.
|
|
34
|
+
header.set([8, 0, 0, 0, 0], 8);
|
|
35
|
+
const stream = deflate(rows, stride);
|
|
36
|
+
const zlib = new Uint8Array(2 + stream.length + 4);
|
|
37
|
+
// The deflate method with a window of 32 KiB, and the fastest algorithm; FCHECK makes 0x7801 a
|
|
38
|
+
// multiple of 31.
|
|
39
|
+
zlib.set([0x78, 0x01], 0);
|
|
40
|
+
zlib.set(stream, 2);
|
|
41
|
+
new DataView(zlib.buffer).setUint32(2 + stream.length, adler32(rows));
|
|
42
|
+
return joined([
|
|
43
|
+
SIGNATURE,
|
|
44
|
+
chunk("IHDR", header),
|
|
45
|
+
chunk("IDAT", zlib),
|
|
46
|
+
chunk("IEND", new Uint8Array(0)),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
/** The largest width or height PNG takes: 2^31 − 1. */
|
|
50
|
+
const MOST = 0x7fffffff;
|
|
51
|
+
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
52
|
+
/** A chunk: the length of its data, its type, the data, and the CRC of the type and the data. */
|
|
53
|
+
function chunk(type, data) {
|
|
54
|
+
const bytes = new Uint8Array(12 + data.length);
|
|
55
|
+
const view = new DataView(bytes.buffer);
|
|
56
|
+
view.setUint32(0, data.length);
|
|
57
|
+
for (let k = 0; k < 4; k++)
|
|
58
|
+
bytes[4 + k] = type.charCodeAt(k);
|
|
59
|
+
bytes.set(data, 8);
|
|
60
|
+
view.setUint32(8 + data.length, crc32(bytes.subarray(4, 8 + data.length)));
|
|
61
|
+
return bytes;
|
|
62
|
+
}
|
|
63
|
+
function joined(parts) {
|
|
64
|
+
const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
65
|
+
let at = 0;
|
|
66
|
+
for (const part of parts) {
|
|
67
|
+
bytes.set(part, at);
|
|
68
|
+
at += part.length;
|
|
69
|
+
}
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
|
72
|
+
/** The CRC of PNG and zlib's gzip: the polynomial 0xEDB88320, one byte at a time from a table. */
|
|
73
|
+
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
|
|
74
|
+
let c = n;
|
|
75
|
+
for (let k = 0; k < 8; k++)
|
|
76
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
77
|
+
return c >>> 0;
|
|
78
|
+
});
|
|
79
|
+
export function crc32(bytes) {
|
|
80
|
+
let c = 0xffffffff;
|
|
81
|
+
for (const byte of bytes)
|
|
82
|
+
c = (CRC_TABLE[(c ^ byte) & 0xff] ?? 0) ^ (c >>> 8);
|
|
83
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
84
|
+
}
|
|
85
|
+
/** zlib's checksum of the data before compression: two sums modulo 65521 (RFC 1950). */
|
|
86
|
+
export function adler32(bytes) {
|
|
87
|
+
let s1 = 1;
|
|
88
|
+
let s2 = 0;
|
|
89
|
+
for (const byte of bytes) {
|
|
90
|
+
s1 = (s1 + byte) % 65521;
|
|
91
|
+
s2 = (s2 + s1) % 65521;
|
|
92
|
+
}
|
|
93
|
+
return (s2 * 65536 + s1) >>> 0;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The lengths of a match, 3 to 258, by their codes from 257 up: each code's least length and its
|
|
97
|
+
* extra bits (RFC 1951, 3.2.5). 258 has a code of its own, 285, with no extra bits: 284 with all
|
|
98
|
+
* its extra bits set would also read 258, and decoders refuse it.
|
|
99
|
+
*/
|
|
100
|
+
const LENGTHS = [
|
|
101
|
+
3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131,
|
|
102
|
+
163, 195, 227, 258,
|
|
103
|
+
];
|
|
104
|
+
const LENGTH_BITS = [
|
|
105
|
+
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0,
|
|
106
|
+
];
|
|
107
|
+
/** The distances of a match, 1 to 32768, by their codes from 0: least distance and extra bits. */
|
|
108
|
+
const DISTANCES = [
|
|
109
|
+
1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049,
|
|
110
|
+
3073, 4097, 6145, 8193, 12289, 16385, 24577,
|
|
111
|
+
];
|
|
112
|
+
const DISTANCE_BITS = [
|
|
113
|
+
0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13,
|
|
114
|
+
];
|
|
115
|
+
/** The longest match deflate allows, and the farthest back. */
|
|
116
|
+
const LONGEST = 258;
|
|
117
|
+
const WINDOW = 32768;
|
|
118
|
+
/**
|
|
119
|
+
* The fixed Huffman codes of the 288 literals and lengths (RFC 1951, 3.2.6), each with its count of
|
|
120
|
+
* bits, its bits turned end to front: a Huffman code goes out from its most significant bit, and
|
|
121
|
+
* everything else from its least, so a code turned round goes out as any other number.
|
|
122
|
+
*/
|
|
123
|
+
const LITERALS = Array.from({ length: 288 }, (_, value) => {
|
|
124
|
+
if (value < 144)
|
|
125
|
+
return reversed(0x30 + value, 8);
|
|
126
|
+
if (value < 256)
|
|
127
|
+
return reversed(0x190 + value - 144, 9);
|
|
128
|
+
if (value < 280)
|
|
129
|
+
return reversed(value - 256, 7);
|
|
130
|
+
return reversed(0xc0 + value - 280, 8);
|
|
131
|
+
});
|
|
132
|
+
function reversed(code, bits) {
|
|
133
|
+
let turned = 0;
|
|
134
|
+
for (let k = 0; k < bits; k++)
|
|
135
|
+
turned |= ((code >>> k) & 1) << (bits - 1 - k);
|
|
136
|
+
return [turned, bits];
|
|
137
|
+
}
|
|
138
|
+
/** The index of the last entry no greater than `value`, in an ascending list. */
|
|
139
|
+
function codeOf(bounds, value) {
|
|
140
|
+
let code = 0;
|
|
141
|
+
while (code + 1 < bounds.length && (bounds[code + 1] ?? Number.POSITIVE_INFINITY) <= value) {
|
|
142
|
+
code++;
|
|
143
|
+
}
|
|
144
|
+
return code;
|
|
145
|
+
}
|
|
146
|
+
/** Bits as deflate packs them: each byte from its least significant bit up (RFC 1951, 3.1.1). */
|
|
147
|
+
class Bits {
|
|
148
|
+
#bytes = new Uint8Array(1024);
|
|
149
|
+
#length = 0;
|
|
150
|
+
#buffer = 0;
|
|
151
|
+
#count = 0;
|
|
152
|
+
/** `count` bits of `value`, from its least significant. */
|
|
153
|
+
put(value, count) {
|
|
154
|
+
this.#buffer |= value << this.#count;
|
|
155
|
+
this.#count += count;
|
|
156
|
+
while (this.#count >= 8) {
|
|
157
|
+
this.#byte(this.#buffer & 0xff);
|
|
158
|
+
this.#buffer >>>= 8;
|
|
159
|
+
this.#count -= 8;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** The bits so far, the last byte filled out with zeros. */
|
|
163
|
+
done() {
|
|
164
|
+
if (this.#count > 0)
|
|
165
|
+
this.#byte(this.#buffer & 0xff);
|
|
166
|
+
this.#buffer = 0;
|
|
167
|
+
this.#count = 0;
|
|
168
|
+
return this.#bytes.slice(0, this.#length);
|
|
169
|
+
}
|
|
170
|
+
#byte(value) {
|
|
171
|
+
if (this.#length === this.#bytes.length) {
|
|
172
|
+
const larger = new Uint8Array(this.#bytes.length * 2);
|
|
173
|
+
larger.set(this.#bytes);
|
|
174
|
+
this.#bytes = larger;
|
|
175
|
+
}
|
|
176
|
+
this.#bytes[this.#length++] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** One final block of fixed Huffman codes. */
|
|
180
|
+
function deflate(data, stride) {
|
|
181
|
+
const bits = new Bits();
|
|
182
|
+
// BFINAL, then BTYPE 01: the fixed codes.
|
|
183
|
+
bits.put(1, 1);
|
|
184
|
+
bits.put(1, 2);
|
|
185
|
+
const literal = (value) => {
|
|
186
|
+
const [code, count] = LITERALS[value] ?? [0, 0];
|
|
187
|
+
bits.put(code, count);
|
|
188
|
+
};
|
|
189
|
+
let at = 0;
|
|
190
|
+
while (at < data.length) {
|
|
191
|
+
const [length, distance] = repeatAt(data, at, stride);
|
|
192
|
+
if (length < 3) {
|
|
193
|
+
literal(data[at] ?? 0);
|
|
194
|
+
at++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const lengthCode = codeOf(LENGTHS, length);
|
|
198
|
+
literal(257 + lengthCode);
|
|
199
|
+
bits.put(length - (LENGTHS[lengthCode] ?? 0), LENGTH_BITS[lengthCode] ?? 0);
|
|
200
|
+
const distanceCode = codeOf(DISTANCES, distance);
|
|
201
|
+
// A distance code is five bits of a Huffman code: from its most significant bit.
|
|
202
|
+
const [code] = reversed(distanceCode, 5);
|
|
203
|
+
bits.put(code, 5);
|
|
204
|
+
bits.put(distance - (DISTANCES[distanceCode] ?? 0), DISTANCE_BITS[distanceCode] ?? 0);
|
|
205
|
+
at += length;
|
|
206
|
+
}
|
|
207
|
+
// The end of the block.
|
|
208
|
+
literal(256);
|
|
209
|
+
return bits.done();
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The longest repeat at `at` of the byte before or of the row above: its length and how far back it
|
|
213
|
+
* starts. The byte before is tried first, and the row above only wins by being longer.
|
|
214
|
+
*/
|
|
215
|
+
function repeatAt(data, at, stride) {
|
|
216
|
+
let best = 0;
|
|
217
|
+
let distance = 0;
|
|
218
|
+
const most = Math.min(LONGEST, data.length - at);
|
|
219
|
+
for (const back of [1, stride]) {
|
|
220
|
+
if (back > at || back > WINDOW)
|
|
221
|
+
continue;
|
|
222
|
+
let length = 0;
|
|
223
|
+
while (length < most && data[at + length] === data[at + length - back])
|
|
224
|
+
length++;
|
|
225
|
+
if (length > best) {
|
|
226
|
+
best = length;
|
|
227
|
+
distance = back;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return [best, distance];
|
|
231
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Crop } from "./crop.js";
|
|
2
|
+
import type { Stroke } from "./ink.js";
|
|
3
|
+
/**
|
|
4
|
+
* The pixels of a crop (R25): grey, a byte each, row by row from the top, white 255 and the ink 0,
|
|
5
|
+
* of the strokes the crop was made of. Every segment of a stroke is a line as wide all along, with
|
|
6
|
+
* round ends, and a stroke of one point a dot as wide as the line: a pixel takes the share of it
|
|
7
|
+
* that the line covers, along a ramp a pixel wide at the line's edge, and the darkest of what
|
|
8
|
+
* covers it. The places are the crop's own whole numbers, from the points in tenths of a unit, as
|
|
9
|
+
* a sample keeps them (R09). Only adding, subtracting, multiplying, dividing and Math.sqrt go into
|
|
10
|
+
* a pixel, which ECMAScript rounds alike in every engine, so the bytes are the same in each.
|
|
11
|
+
*/
|
|
12
|
+
export declare function rasterOf(strokes: readonly Stroke[], crop: Crop): Uint8Array<ArrayBuffer>;
|
|
13
|
+
/** The PNG of a crop: its pixels, encoded (R25). */
|
|
14
|
+
export declare function pictureOf(strokes: readonly Stroke[], crop: Crop): Uint8Array<ArrayBuffer>;
|