@altnovatrail/canvas 0.0.0-stage → 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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +59 -2
  3. package/lib/core/boxes.d.ts +54 -0
  4. package/lib/core/boxes.js +116 -0
  5. package/lib/core/clock.d.ts +25 -0
  6. package/lib/core/clock.js +39 -0
  7. package/lib/core/crop.d.ts +87 -0
  8. package/lib/core/crop.js +227 -0
  9. package/lib/core/eraser.d.ts +42 -0
  10. package/lib/core/eraser.js +127 -0
  11. package/lib/core/example.d.ts +66 -0
  12. package/lib/core/example.js +116 -0
  13. package/lib/core/fields.d.ts +29 -0
  14. package/lib/core/fields.js +78 -0
  15. package/lib/core/geometry.d.ts +38 -0
  16. package/lib/core/geometry.js +107 -0
  17. package/lib/core/grid.d.ts +30 -0
  18. package/lib/core/grid.js +47 -0
  19. package/lib/core/groups.d.ts +55 -0
  20. package/lib/core/groups.js +172 -0
  21. package/lib/core/hands.d.ts +120 -0
  22. package/lib/core/hands.js +273 -0
  23. package/lib/core/history.d.ts +42 -0
  24. package/lib/core/history.js +228 -0
  25. package/lib/core/index.d.ts +12 -0
  26. package/lib/core/index.js +15 -0
  27. package/lib/core/ink.d.ts +1 -0
  28. package/lib/core/ink.js +5 -0
  29. package/lib/core/manifest.d.ts +22 -0
  30. package/lib/core/manifest.js +50 -0
  31. package/lib/core/marks.d.ts +145 -0
  32. package/lib/core/marks.js +454 -0
  33. package/lib/core/outline.d.ts +22 -0
  34. package/lib/core/outline.js +50 -0
  35. package/lib/core/page.d.ts +62 -0
  36. package/lib/core/page.js +309 -0
  37. package/lib/core/png.d.ts +13 -0
  38. package/lib/core/png.js +231 -0
  39. package/lib/core/raster.d.ts +14 -0
  40. package/lib/core/raster.js +72 -0
  41. package/lib/core/recorder.d.ts +73 -0
  42. package/lib/core/recorder.js +127 -0
  43. package/lib/core/screen.d.ts +35 -0
  44. package/lib/core/screen.js +36 -0
  45. package/lib/core/sets.d.ts +10 -0
  46. package/lib/core/sets.js +31 -0
  47. package/lib/core/sheet.d.ts +9 -0
  48. package/lib/core/sheet.js +6 -0
  49. package/lib/core/submission.d.ts +41 -0
  50. package/lib/core/submission.js +63 -0
  51. package/lib/core/task.d.ts +214 -0
  52. package/lib/core/task.js +343 -0
  53. package/lib/core/widths.d.ts +57 -0
  54. package/lib/core/widths.js +218 -0
  55. package/lib/core/work.d.ts +77 -0
  56. package/lib/core/work.js +293 -0
  57. package/lib/element/attributes.d.ts +11 -0
  58. package/lib/element/attributes.js +22 -0
  59. package/lib/element/index.d.ts +219 -0
  60. package/lib/element/index.js +1898 -0
  61. package/lib/element/labels.d.ts +41 -0
  62. package/lib/element/labels.js +74 -0
  63. package/lib/element/parts.d.ts +63 -0
  64. package/lib/element/parts.js +191 -0
  65. package/lib/element/pictures.d.ts +45 -0
  66. package/lib/element/pictures.js +167 -0
  67. package/lib/element/probe.d.ts +33 -0
  68. package/lib/element/probe.js +18 -0
  69. package/lib/element/session.d.ts +3 -0
  70. package/lib/element/session.js +7 -0
  71. package/lib/element/statement.d.ts +35 -0
  72. package/lib/element/statement.js +116 -0
  73. package/lib/element/tools.d.ts +64 -0
  74. package/lib/element/tools.js +254 -0
  75. package/package.json +41 -4
@@ -0,0 +1,145 @@
1
+ import type { CellPlace, CropNumbers, Filling, SheetSubmission, Submission, WorkbookSubmission } from "@altnovatrail/spec/canvas";
2
+ import type { Cell } from "./boxes.js";
3
+ import { type Box } from "./geometry.js";
4
+ import type { Group } from "./groups.js";
5
+ import type { Stroke } from "./ink.js";
6
+ /**
7
+ * The rule that snaps a model's box to the learner's strokes (R26): the box widens on every side by
8
+ * `margin` thousandths of the height its crop draws digits at, D′, and a stroke is taken if its
9
+ * share of points in it, its edges too, is `share` at least, and one point at least. Both are
10
+ * parameters of eval (C2). The rule counts in whole numbers, places on the crop as the crop's
11
+ * raster has them (R25), so that whoever snaps by it takes the same strokes.
12
+ */
13
+ export interface SnapRule {
14
+ /** How far the box widens on every side, in thousandths of D′: 0 to 1000. */
15
+ readonly margin: number;
16
+ /**
17
+ * The least share of a stroke's points in the widened box, a fraction of whole numbers
18
+ * [above, below]: 0 ≤ above ≤ below, and below from 1 to 1000.
19
+ */
20
+ readonly share: readonly [above: number, below: number];
21
+ }
22
+ /**
23
+ * The rule the sheet snaps by (R26): a quarter of a digit — 6 px at 24, a quarter of H on the
24
+ * sheet, H the height the crop scales by — and two thirds of the points. A sweep over the
25
+ * synthetic sheets chose two thirds over the half R43 canvas-api took: as many strokes taken
26
+ * whole, and fewer of their neighbours.
27
+ */
28
+ export declare const SNAP_RULE: SnapRule;
29
+ /**
30
+ * The context of one submission, of a sheet or of a workbook, and its parts: spec's types, of the
31
+ * contract of canvas and app (R74 spec; R07). A sheet's context, which the element gives with its
32
+ * crop and takes back with the tutor's marks, holds the crop's numbers — its `sheet_box`, the
33
+ * frames of the groups and the whole numbers they come from — the groups of the strokes, the table
34
+ * «label → stroke IDs», and the digest of it all, `digestOf` it (R27). It goes back to a sheet
35
+ * whose history holds its strokes: the same sheet, or one given the same history later. Each sheet
36
+ * numbers its strokes from 1, so a sheet of other strokes with the same IDs tells it by the digest.
37
+ * A workbook's holds the table «box → stroke IDs», each filled box by its place, in the task's
38
+ * order, and the digest of its strokes and of every box of the page, `workbookDigestOf` it (R34,
39
+ * R38): no numbers of the crops, for no mark on a workbook is snapped. An answer cell has no ID:
40
+ * its place names it, its item and its place among the item's cells, each from 1 (R51 spec).
41
+ */
42
+ export type { CellPlace, CropNumbers, SheetSubmission, Submission, WorkbookSubmission };
43
+ /**
44
+ * Where a mark is: a group of strokes by its label, an answer cell by its place, or a box on the
45
+ * crop the model saw, [ymin, xmin, ymax, xmax] in whole thousandths of its height and width, min <
46
+ * max (R53 spec). On a workbook a mark stands on a cell alone (R34).
47
+ */
48
+ export type Anchor = {
49
+ readonly group: string;
50
+ } | {
51
+ readonly cell: CellPlace;
52
+ } | {
53
+ readonly box: readonly [number, number, number, number];
54
+ };
55
+ /** A mark of the tutor's layer: a frame, so far, with where it is and its name. */
56
+ export interface Mark {
57
+ readonly kind: "frame";
58
+ readonly at: Anchor;
59
+ /** What a screen reader names it, in the consumer's words: the element words nothing. */
60
+ readonly name: string;
61
+ }
62
+ /**
63
+ * The tutor's marks of one submission, with the submission's context (R07): a sheet's or a
64
+ * workbook's.
65
+ */
66
+ export interface Marks {
67
+ readonly submission: Submission;
68
+ readonly marks: readonly Mark[];
69
+ }
70
+ /**
71
+ * A mark on the sheet, `{stroke_ids, snapped_box}` (C2): the strokes it holds on to, by ID in
72
+ * ascending order, and the rectangle of their points, in units, as the model saw them. A mark of a
73
+ * workbook's cell has the box of its cell too, which its frame stands round with the ink (R39).
74
+ */
75
+ export interface Placed {
76
+ readonly ids: readonly number[];
77
+ readonly box: Box;
78
+ readonly cell?: Box;
79
+ }
80
+ /**
81
+ * The tutor's marks a value gives, checked (R26). A value that is not the marks of a submission is
82
+ * a TypeError that names the field; fields the sheet does not read are passed over. The context's
83
+ * keys tell its kind (R38): a sheet's crop and groups, or a workbook's boxes. The crop's
84
+ * `sheet_box` must be the one its whole numbers give, to a millionth of a unit, its frames the
85
+ * groups', in their order, a group's label must be one of the submission's, and the digest 16 hex
86
+ * digits. A workbook's rows name each box once and each stroke in one box, and its marks stand on
87
+ * cells alone (R39). Whether the submission is this sheet's, its strokes tell (`placesOf`).
88
+ */
89
+ export declare function marksOf(value: unknown): Marks;
90
+ /**
91
+ * The strokes a model's box takes (R26), by ID in ascending order: of the strokes given, those with
92
+ * the rule's share of their points, and one at least, in the box widened by the rule's margin. The
93
+ * box is in thousandths of the crop the model saw, and a point counts where it lies on that crop:
94
+ * the same as the box laid on the sheet by the crop's `sheet_box` and widened there by H × margin /
95
+ * 1000. The box widens first: clipped to the sheet after, it takes the same, for the points lie on
96
+ * the sheet.
97
+ */
98
+ export declare function snapOf(box: readonly [number, number, number, number], crop: CropNumbers, strokes: readonly Stroke[], rule?: SnapRule): number[];
99
+ /**
100
+ * Where each mark of a submission is on the sheet (R26): the strokes it holds on to, or null for a
101
+ * mark that has none — the question is asked without it (R43 canvas-api). `known` is every stroke
102
+ * the sheet has known, erased or not, as its history wrote them: a mark holds on to the strokes the
103
+ * model saw, and shows those of them the sheet shows at the time (C2). A stroke written after the
104
+ * submission is in none of its groups, and no mark takes it. A group's mark holds its strokes, a
105
+ * box's the strokes it snaps to; an answer cell's holds none on a sheet, which takes no zones.
106
+ * `cells` are the boxes of a workbook's page, in the task's order, and null on a sheet: there a
107
+ * cell's mark holds the strokes of its row of the table, with its box, and a cell not in the table
108
+ * holds none (R39).
109
+ *
110
+ * A submission of other strokes is a TypeError: a stroke of its groups the sheet does not know, or
111
+ * a digest the sheet's strokes of those IDs do not give with the submission's numbers (R27). A
112
+ * sheet of other strokes would have those IDs too — each numbers its strokes from 1 — and a
113
+ * context changed since the sheet gave it would place the marks by other numbers. The sheet's own
114
+ * submission always passes, the strokes erased since too, and so does the same history given to
115
+ * another sheet. A workbook's digest holds every box of its page too, so a page of another layout
116
+ * is not its submission's (R38), and a context of the other kind of canvas is a TypeError.
117
+ */
118
+ export declare function placesOf(marks: Marks, known: readonly Stroke[], cells?: readonly Cell[] | null, rule?: SnapRule): (Placed | null)[];
119
+ /**
120
+ * The rectangle a mark shows now (R26): around the points of those of its strokes the sheet shows,
121
+ * in units, or null while it shows none of them. `shown` gives the box of a stroke's points by its
122
+ * ID while the stroke is on the screen — not erased, nor under the eraser under way (R22). New
123
+ * strokes are none of a mark's.
124
+ */
125
+ export declare function shownOf(placed: Placed, shown: (id: number) => Box | undefined): Box | null;
126
+ /**
127
+ * The digest of a submission (R27): what it was made of, in 16 hex digits — the CRC-32 and then the
128
+ * Adler-32 of PNG and zlib (png.ts) of a text that holds its strokes, by ID ascending, each with its
129
+ * points in tenths of a unit and whole milliseconds, as the rules see them (R24, R25), and its whole
130
+ * numbers: D′, the crop's size and margins, the box and the median it comes from, the groups and
131
+ * their frames. `sheet_box` is not in it: it comes from those numbers, and is checked against them
132
+ * within its slack. The same strokes and numbers give the same digest in every engine, through any
133
+ * store that keeps them as JSON does. The digest tells an accidental difference, not a forged one:
134
+ * CRC-32 tells every change within 32 bits running, and any other change keeps both sums with a
135
+ * chance of about one in 2^32 at most.
136
+ */
137
+ export declare function digestOf(strokes: readonly Stroke[], crop: Pick<CropNumbers, "drawn" | "width" | "height" | "margins" | "frames" | "exact">, groups: readonly Group[]): string;
138
+ /**
139
+ * The digest of a workbook's submission (R38), as `digestOf` makes a sheet's: of a text that holds
140
+ * the strokes of its table, by ID ascending, as a sheet's digest has them, then every box of the
141
+ * page, `cells` in the task's order, with its item, its slot and its rectangle in whole page units,
142
+ * and then the table's rows. A context of another layout gives another digest even where its
143
+ * filled boxes stand where they stood (R34).
144
+ */
145
+ export declare function workbookDigestOf(strokes: readonly Stroke[], boxes: readonly Filling[], cells: readonly Cell[]): string;
@@ -0,0 +1,454 @@
1
+ import { CROP, sheetBoxOf } from "./crop.js";
2
+ import { record, shown, whole } from "./fields.js";
3
+ import { around, boxOf, tenths } from "./geometry.js";
4
+ import { adler32, crc32 } from "./png.js";
5
+ import { SHEET } from "./sheet.js";
6
+ /**
7
+ * The rule the sheet snaps by (R26): a quarter of a digit — 6 px at 24, a quarter of H on the
8
+ * sheet, H the height the crop scales by — and two thirds of the points. A sweep over the
9
+ * synthetic sheets chose two thirds over the half R43 canvas-api took: as many strokes taken
10
+ * whole, and fewer of their neighbours.
11
+ */
12
+ export const SNAP_RULE = Object.freeze({
13
+ margin: 250,
14
+ share: Object.freeze([2, 3]),
15
+ });
16
+ /**
17
+ * How far a given `sheet_box` may be from the one the crop's whole numbers give, in units: what a
18
+ * store that keeps numbers to fewer digits than JSON may lose of it. The rule counts by the whole
19
+ * numbers, never by `sheet_box`.
20
+ */
21
+ const SHEET_BOX_SLACK = 1e-6;
22
+ /** A digest as `digestOf` writes it. */
23
+ const DIGEST = /^[0-9a-f]{16}$/;
24
+ /**
25
+ * The tutor's marks a value gives, checked (R26). A value that is not the marks of a submission is
26
+ * a TypeError that names the field; fields the sheet does not read are passed over. The context's
27
+ * keys tell its kind (R38): a sheet's crop and groups, or a workbook's boxes. The crop's
28
+ * `sheet_box` must be the one its whole numbers give, to a millionth of a unit, its frames the
29
+ * groups', in their order, a group's label must be one of the submission's, and the digest 16 hex
30
+ * digits. A workbook's rows name each box once and each stroke in one box, and its marks stand on
31
+ * cells alone (R39). Whether the submission is this sheet's, its strokes tell (`placesOf`).
32
+ */
33
+ export function marksOf(value) {
34
+ const fields = record(value, "marks");
35
+ const submission = submissionOf(fields.submission, "marks.submission");
36
+ if (!Array.isArray(fields.marks)) {
37
+ throw new TypeError(`marks.marks: a list of marks, not ${shown(fields.marks)}`);
38
+ }
39
+ // What a mark may stand on: a group of a sheet's, or on a workbook a cell alone.
40
+ const labels = "groups" in submission ? new Set(submission.groups.map(({ label }) => label)) : null;
41
+ return {
42
+ submission,
43
+ marks: fields.marks.map((mark, n) => markOf(mark, `marks.marks[${n}]`, labels)),
44
+ };
45
+ }
46
+ /**
47
+ * The strokes a model's box takes (R26), by ID in ascending order: of the strokes given, those with
48
+ * the rule's share of their points, and one at least, in the box widened by the rule's margin. The
49
+ * box is in thousandths of the crop the model saw, and a point counts where it lies on that crop:
50
+ * the same as the box laid on the sheet by the crop's `sheet_box` and widened there by H × margin /
51
+ * 1000. The box widens first: clipped to the sheet after, it takes the same, for the points lie on
52
+ * the sheet.
53
+ */
54
+ export function snapOf(box, crop, strokes, rule = SNAP_RULE) {
55
+ checkRule(rule);
56
+ const within = withinOf(box, crop, rule.margin);
57
+ return strokes
58
+ .filter(({ points }) => {
59
+ let inside = 0;
60
+ for (const point of points)
61
+ if (within(point))
62
+ inside += 1;
63
+ const [above, below] = rule.share;
64
+ return inside > 0 && below * inside >= above * points.length;
65
+ })
66
+ .map(({ id }) => id)
67
+ .sort(byNumber);
68
+ }
69
+ /**
70
+ * Where each mark of a submission is on the sheet (R26): the strokes it holds on to, or null for a
71
+ * mark that has none — the question is asked without it (R43 canvas-api). `known` is every stroke
72
+ * the sheet has known, erased or not, as its history wrote them: a mark holds on to the strokes the
73
+ * model saw, and shows those of them the sheet shows at the time (C2). A stroke written after the
74
+ * submission is in none of its groups, and no mark takes it. A group's mark holds its strokes, a
75
+ * box's the strokes it snaps to; an answer cell's holds none on a sheet, which takes no zones.
76
+ * `cells` are the boxes of a workbook's page, in the task's order, and null on a sheet: there a
77
+ * cell's mark holds the strokes of its row of the table, with its box, and a cell not in the table
78
+ * holds none (R39).
79
+ *
80
+ * A submission of other strokes is a TypeError: a stroke of its groups the sheet does not know, or
81
+ * a digest the sheet's strokes of those IDs do not give with the submission's numbers (R27). A
82
+ * sheet of other strokes would have those IDs too — each numbers its strokes from 1 — and a
83
+ * context changed since the sheet gave it would place the marks by other numbers. The sheet's own
84
+ * submission always passes, the strokes erased since too, and so does the same history given to
85
+ * another sheet. A workbook's digest holds every box of its page too, so a page of another layout
86
+ * is not its submission's (R38), and a context of the other kind of canvas is a TypeError.
87
+ */
88
+ export function placesOf(marks, known, cells = null, rule = SNAP_RULE) {
89
+ checkRule(rule);
90
+ const { submission } = marks;
91
+ const byId = new Map(known.map((stroke) => [stroke.id, stroke]));
92
+ if ("boxes" in submission) {
93
+ if (cells === null)
94
+ throw new TypeError(`marks.submission: ${otherKind("a workbook's", "a sheet")}`);
95
+ return cellsPlacesOf(marks.marks, submission, byId, cells);
96
+ }
97
+ if (cells !== null)
98
+ throw new TypeError(`marks.submission: ${otherKind("a sheet's", "a workbook")}`);
99
+ const { crop, groups, digest } = submission;
100
+ const sent = [];
101
+ for (const [n, { label, ids }] of groups.entries()) {
102
+ const frame = crop.frames[n];
103
+ if (frame === undefined || frame.label !== label) {
104
+ throw new TypeError(`marks.submission.crop.frames[${n}]: the frame of the group ${label}`);
105
+ }
106
+ sent.push(...knownOf(ids, byId, `the group ${label}`, "sheet"));
107
+ }
108
+ if (digestOf(sent, crop, groups) !== digest) {
109
+ throw new TypeError("marks.submission.digest: the sheet's strokes of its IDs give another with its numbers: " +
110
+ "the submission is not this sheet's, or it was changed");
111
+ }
112
+ const tables = new Map(groups.map(({ label, ids }) => [label, ids]));
113
+ return marks.marks.map(({ at }) => {
114
+ let ids = [];
115
+ if ("group" in at)
116
+ ids = [...(tables.get(at.group) ?? [])].sort(byNumber);
117
+ else if ("box" in at)
118
+ ids = snapOf(at.box, crop, sent, rule);
119
+ if (ids.length === 0)
120
+ return null;
121
+ return { ids, box: boxOf(ids.flatMap((id) => byId.get(id)?.points ?? [])) };
122
+ });
123
+ }
124
+ /**
125
+ * The rectangle a mark shows now (R26): around the points of those of its strokes the sheet shows,
126
+ * in units, or null while it shows none of them. `shown` gives the box of a stroke's points by its
127
+ * ID while the stroke is on the screen — not erased, nor under the eraser under way (R22). New
128
+ * strokes are none of a mark's.
129
+ */
130
+ export function shownOf(placed, shown) {
131
+ const boxes = placed.ids.flatMap((id) => shown(id) ?? []);
132
+ if (boxes.length === 0)
133
+ return null;
134
+ // A cell's frame stands round its box and its ink together, by the author's answer (R39).
135
+ return around(placed.cell === undefined ? boxes : [...boxes, placed.cell]);
136
+ }
137
+ /** What a context of the other kind of canvas is, and what this one is (R39). */
138
+ function otherKind(context, here) {
139
+ return `${context} context, and this is ${here}: the task comes before the marks`;
140
+ }
141
+ /**
142
+ * The strokes of a context's IDs, all of which the sheet or the page, `here`, must know: else it
143
+ * is not its submission.
144
+ */
145
+ function knownOf(ids, byId, of, here) {
146
+ return ids.map((id) => {
147
+ const stroke = byId.get(id);
148
+ if (stroke === undefined) {
149
+ throw new TypeError(`marks.submission: the ${here} has no stroke ${id}, of ${of}: the submission is not ` +
150
+ `this ${here}'s`);
151
+ }
152
+ return stroke;
153
+ });
154
+ }
155
+ /** Whether a field of a context is not there: left out, or null as some stores write it. */
156
+ function absent(value) {
157
+ return value === undefined || value === null;
158
+ }
159
+ /**
160
+ * Where each mark of a workbook's submission is (R39): a cell's, on the strokes of its row of the
161
+ * table and its box of the page; one of a cell not in the table, on none.
162
+ */
163
+ function cellsPlacesOf(marks, { boxes, digest }, byId, cells) {
164
+ const sent = boxes.flatMap(({ item, slot, ids }) => knownOf(ids, byId, `the box ${item}.${slot}`, "page"));
165
+ if (workbookDigestOf(sent, boxes, cells) !== digest) {
166
+ throw new TypeError("marks.submission.digest: the page's strokes of its IDs and its boxes give another: the " +
167
+ "submission is not this page's, or it was changed");
168
+ }
169
+ return marks.map(({ at }) => {
170
+ const place = "cell" in at ? at.cell : undefined;
171
+ const same = ({ item, slot }) => item === place?.item && slot === place.slot;
172
+ const row = boxes.find(same);
173
+ const cell = cells.find(same);
174
+ if (row === undefined || cell === undefined)
175
+ return null;
176
+ const { left, top, width, height } = cell.box;
177
+ return {
178
+ ids: [...row.ids].sort(byNumber),
179
+ box: boxOf(row.ids.flatMap((id) => byId.get(id)?.points ?? [])),
180
+ cell: { minX: left, minY: top, maxX: left + width, maxY: top + height },
181
+ };
182
+ });
183
+ }
184
+ /**
185
+ * The digest of a submission (R27): what it was made of, in 16 hex digits — the CRC-32 and then the
186
+ * Adler-32 of PNG and zlib (png.ts) of a text that holds its strokes, by ID ascending, each with its
187
+ * points in tenths of a unit and whole milliseconds, as the rules see them (R24, R25), and its whole
188
+ * numbers: D′, the crop's size and margins, the box and the median it comes from, the groups and
189
+ * their frames. `sheet_box` is not in it: it comes from those numbers, and is checked against them
190
+ * within its slack. The same strokes and numbers give the same digest in every engine, through any
191
+ * store that keeps them as JSON does. The digest tells an accidental difference, not a forged one:
192
+ * CRC-32 tells every change within 32 bits running, and any other change keeps both sums with a
193
+ * chance of about one in 2^32 at most.
194
+ */
195
+ export function digestOf(strokes, crop, groups) {
196
+ const lines = strokeLines(strokes);
197
+ const { drawn, width, height, margins: [top, left], exact: { box, median }, } = crop;
198
+ lines.push(`crop ${drawn} ${width} ${height} ${top} ${left} ${box.minX} ${box.minY} ${box.maxX} ${box.maxY} ${median}`);
199
+ for (const { label, ids } of groups)
200
+ lines.push(`group ${label} ${ids.join(",")}`);
201
+ for (const { label, box: frame } of crop.frames)
202
+ lines.push(`frame ${label} ${frame.join(",")}`);
203
+ return sumOf(lines);
204
+ }
205
+ /**
206
+ * The digest of a workbook's submission (R38), as `digestOf` makes a sheet's: of a text that holds
207
+ * the strokes of its table, by ID ascending, as a sheet's digest has them, then every box of the
208
+ * page, `cells` in the task's order, with its item, its slot and its rectangle in whole page units,
209
+ * and then the table's rows. A context of another layout gives another digest even where its
210
+ * filled boxes stand where they stood (R34).
211
+ */
212
+ export function workbookDigestOf(strokes, boxes, cells) {
213
+ const lines = strokeLines(strokes);
214
+ for (const { item, slot, box } of cells) {
215
+ lines.push(`cell ${item} ${slot} ${box.left} ${box.top} ${box.width} ${box.height}`);
216
+ }
217
+ for (const { item, slot, ids } of boxes)
218
+ lines.push(`box ${item} ${slot} ${ids.join(",")}`);
219
+ return sumOf(lines);
220
+ }
221
+ /** The lines of a digest's text that hold strokes, by ID ascending, their points as rules see them. */
222
+ function strokeLines(strokes) {
223
+ return [...strokes]
224
+ .sort((a, b) => a.id - b.id)
225
+ .map(({ id, points }) => `${id}${points.map(({ x, y, t }) => `;${tenths(x)},${tenths(y)},${Math.round(t)}`).join("")}`);
226
+ }
227
+ /** A digest of a text's lines: the CRC-32 and then the Adler-32 of its bytes, 16 hex digits. */
228
+ function sumOf(lines) {
229
+ const bytes = utf8(lines.join("\n"));
230
+ return hex(crc32(bytes)) + hex(adler32(bytes));
231
+ }
232
+ /**
233
+ * Whether a point lies in a box of the crop widened by `margin` thousandths of D′, its edges too:
234
+ * in whole numbers, a place on the crop times 1000 × H (R25). For a stroke of the submission every
235
+ * number stays below 2^31; the place X of a point is ((x − B.minX) × D′ + mx × H) / H px, in
236
+ * tenths.
237
+ */
238
+ function withinOf(box, crop, margin) {
239
+ const { drawn, width, height, margins: [top, left], exact: { box: points, median: unit }, } = crop;
240
+ const [ymin, xmin, ymax, xmax] = box;
241
+ const reach = margin * drawn;
242
+ const west = (xmin * width - reach) * unit;
243
+ const east = (xmax * width + reach) * unit;
244
+ const north = (ymin * height - reach) * unit;
245
+ const south = (ymax * height + reach) * unit;
246
+ return ({ x, y }) => {
247
+ const across = 1000 * ((tenths(x) - points.minX) * drawn + left * unit);
248
+ const down = 1000 * ((tenths(y) - points.minY) * drawn + top * unit);
249
+ return across >= west && across <= east && down >= north && down <= south;
250
+ };
251
+ }
252
+ function checkRule({ margin, share }) {
253
+ if (!Number.isInteger(margin) || margin < 0 || margin > 1000) {
254
+ throw new RangeError(`the snapping rule's margin: whole thousandths, 0 to 1000, not ${margin}`);
255
+ }
256
+ const [above, below] = share;
257
+ if (!Number.isInteger(above) ||
258
+ !Number.isInteger(below) ||
259
+ above < 0 ||
260
+ below < 1 ||
261
+ below > 1000 ||
262
+ above > below) {
263
+ throw new RangeError(`the snapping rule's share: a fraction of whole numbers no more than 1, below from 1 to 1000, not ${shown(share)}`);
264
+ }
265
+ }
266
+ /** A submission's context, a sheet's or a workbook's by its keys (R38). */
267
+ function submissionOf(value, path) {
268
+ const fields = record(value, path);
269
+ let parts;
270
+ if (absent(fields.boxes)) {
271
+ const groups = groupTableOf(fields.groups, `${path}.groups`);
272
+ parts = { crop: numbersOf(fields.crop, `${path}.crop`, groups), groups };
273
+ }
274
+ else if (absent(fields.crop) && absent(fields.groups)) {
275
+ parts = { boxes: rowsOf(fields.boxes, `${path}.boxes`) };
276
+ }
277
+ else {
278
+ throw new TypeError(`${path}: a sheet's context, its crop and groups, or a workbook's, its boxes, not both`);
279
+ }
280
+ const { digest } = fields;
281
+ if (typeof digest !== "string" || !DIGEST.test(digest)) {
282
+ throw new TypeError(`${path}.digest: what the submission was made of, 16 hex digits, not ${shown(digest)}`);
283
+ }
284
+ return { ...parts, digest };
285
+ }
286
+ /** The groups of a submission: each label once, each stroke in one group. */
287
+ function groupTableOf(value, path) {
288
+ if (!Array.isArray(value) || value.length === 0) {
289
+ throw new TypeError(`${path}: the groups of the strokes sent, not ${shown(value)}`);
290
+ }
291
+ const labels = new Set();
292
+ const strokes = new Set();
293
+ return value.map((group, n) => {
294
+ const fields = record(group, `${path}[${n}]`);
295
+ const { label } = fields;
296
+ if (typeof label !== "string" || label === "" || labels.has(label)) {
297
+ throw new TypeError(`${path}[${n}].label: a label of its own, not ${shown(label)}`);
298
+ }
299
+ labels.add(label);
300
+ return { label, ids: idsOf(fields.ids, `${path}[${n}].ids`, strokes, "group") };
301
+ });
302
+ }
303
+ /** The rows of a workbook's table (R38): each box once, by its place, each stroke in one box. */
304
+ function rowsOf(value, path) {
305
+ if (!Array.isArray(value) || value.length === 0) {
306
+ throw new TypeError(`${path}: the filled boxes and their strokes, not ${shown(value)}`);
307
+ }
308
+ const places = new Set();
309
+ const strokes = new Set();
310
+ return value.map((row, n) => {
311
+ const fields = record(row, `${path}[${n}]`);
312
+ const { item, slot } = placeOf(fields, `${path}[${n}]`);
313
+ if (places.has(`${item}.${slot}`)) {
314
+ throw new TypeError(`${path}[${n}]: a box of its own, not ${item}.${slot} again`);
315
+ }
316
+ places.add(`${item}.${slot}`);
317
+ return { item, slot, ids: idsOf(fields.ids, `${path}[${n}].ids`, strokes, "box") };
318
+ });
319
+ }
320
+ /** The IDs of a group's or a box's strokes: whole, from 1, each in one of them alone. */
321
+ function idsOf(value, path, taken, of) {
322
+ if (!Array.isArray(value) || value.length === 0) {
323
+ throw new TypeError(`${path}: the IDs of its strokes, not ${shown(value)}`);
324
+ }
325
+ for (const id of value) {
326
+ if (!Number.isInteger(id) || id < 1 || taken.has(id)) {
327
+ throw new TypeError(`${path}: a stroke's ID in one ${of} alone, not ${shown(id)}`);
328
+ }
329
+ taken.add(id);
330
+ }
331
+ return [...value];
332
+ }
333
+ /** An answer cell's place (R51 spec): its item, and its place among the item's cells, from 1. */
334
+ function placeOf(fields, path) {
335
+ return {
336
+ item: whole(fields.item, `${path}.item`, 1),
337
+ slot: whole(fields.slot, `${path}.slot`, 1),
338
+ };
339
+ }
340
+ /** The crop's numbers that place the marks, checked against each other and against the groups. */
341
+ function numbersOf(value, path, groups) {
342
+ const crop = record(value, path);
343
+ const drawn = whole(crop.drawn, `${path}.drawn`, 1, CROP.tallest);
344
+ const width = whole(crop.width, `${path}.width`, CROP.side, CROP.edge);
345
+ const height = whole(crop.height, `${path}.height`, CROP.side, CROP.edge);
346
+ const margins = crop.margins;
347
+ if (!Array.isArray(margins) || margins.length !== 2) {
348
+ throw new TypeError(`${path}.margins: [top, left] in px, not ${shown(margins)}`);
349
+ }
350
+ const top = whole(margins[0], `${path}.margins[0]`, CROP.margin, height - CROP.margin);
351
+ const left = whole(margins[1], `${path}.margins[1]`, CROP.margin, width - CROP.margin);
352
+ const exact = record(crop.exact, `${path}.exact`);
353
+ const median = whole(exact.median, `${path}.exact.median`, CROP.flat * 10, CROP.ceiling * 10);
354
+ const points = record(exact.box, `${path}.exact.box`);
355
+ const minX = whole(points.minX, `${path}.exact.box.minX`, 0, SHEET.width * 10);
356
+ const minY = whole(points.minY, `${path}.exact.box.minY`, 0, SHEET.height * 10);
357
+ const maxX = whole(points.maxX, `${path}.exact.box.maxX`, minX, SHEET.width * 10);
358
+ const maxY = whole(points.maxY, `${path}.exact.box.maxY`, minY, SHEET.height * 10);
359
+ const numbers = {
360
+ drawn,
361
+ width,
362
+ height,
363
+ margins: [top, left],
364
+ exact: { box: { minX, minY, maxX, maxY }, median },
365
+ };
366
+ const sheetBox = sheetBoxOf(numbers);
367
+ const given = crop.sheetBox;
368
+ if (!Array.isArray(given) ||
369
+ given.length !== 4 ||
370
+ !given.every((v, i) => typeof v === "number" && Math.abs(v - (sheetBox[i] ?? 0)) <= SHEET_BOX_SLACK)) {
371
+ throw new TypeError(`${path}.sheetBox: ${shown(sheetBox)}, where the crop's numbers put it, not ${shown(given)}`);
372
+ }
373
+ const frames = crop.frames;
374
+ if (!Array.isArray(frames) || frames.length !== groups.length) {
375
+ throw new TypeError(`${path}.frames: a frame for each group, not ${shown(frames)}`);
376
+ }
377
+ return {
378
+ ...numbers,
379
+ sheetBox,
380
+ frames: frames.map((frame, n) => {
381
+ const fields = record(frame, `${path}.frames[${n}]`);
382
+ const group = groups[n];
383
+ if (group === undefined || fields.label !== group.label) {
384
+ throw new TypeError(`${path}.frames[${n}].label: ${shown(group?.label)}, the group's, not ${shown(fields.label)}`);
385
+ }
386
+ return { label: group.label, box: fractionsOf(fields.box, `${path}.frames[${n}].box`) };
387
+ }),
388
+ };
389
+ }
390
+ function markOf(value, path, labels) {
391
+ const mark = record(value, path);
392
+ if (mark.kind !== "frame") {
393
+ throw new TypeError(`${path}.kind: the sheet draws "frame" marks, not ${shown(mark.kind)}`);
394
+ }
395
+ if (typeof mark.name !== "string" || mark.name.trim() === "") {
396
+ throw new TypeError(`${path}.name: what a screen reader names the mark, not ${shown(mark.name)}`);
397
+ }
398
+ return { kind: "frame", at: anchorOf(mark.at, `${path}.at`, labels), name: mark.name };
399
+ }
400
+ /** Where a mark is: on a sheet, `labels` its groups'; on a workbook, null, a cell alone (R39). */
401
+ function anchorOf(value, path, labels) {
402
+ const at = record(value, path);
403
+ const given = ["group", "cell", "box"].filter((key) => at[key] !== undefined);
404
+ if (given.length !== 1) {
405
+ throw new TypeError(`${path}: one of a group, a cell and a box, not ${shown(value)}`);
406
+ }
407
+ if (labels === null && at.cell === undefined) {
408
+ throw new TypeError(`${path}: a mark of a workbook stands on a cell alone, not ${shown(value)}`);
409
+ }
410
+ if (at.group !== undefined) {
411
+ if (typeof at.group !== "string" || !labels?.has(at.group)) {
412
+ throw new TypeError(`${path}.group: a label of the submission's groups, not ${shown(at.group)}`);
413
+ }
414
+ return { group: at.group };
415
+ }
416
+ if (at.cell !== undefined)
417
+ return { cell: placeOf(record(at.cell, `${path}.cell`), `${path}.cell`) };
418
+ return { box: fractionsOf(at.box, `${path}.box`) };
419
+ }
420
+ /** A box on a crop, valid as R53 spec has it: whole thousandths from 0 to 1000, min < max. */
421
+ function fractionsOf(value, path) {
422
+ if (Array.isArray(value) &&
423
+ value.length === 4 &&
424
+ value.every((n) => Number.isInteger(n) && n >= 0 && n <= 1000) &&
425
+ value[0] < value[2] &&
426
+ value[1] < value[3]) {
427
+ return [value[0], value[1], value[2], value[3]];
428
+ }
429
+ throw new TypeError(`${path}: [ymin, xmin, ymax, xmax] in whole thousandths, 0 to 1000, min < max, not ${shown(value)}`);
430
+ }
431
+ function byNumber(a, b) {
432
+ return a - b;
433
+ }
434
+ /**
435
+ * A text's bytes in UTF-8, its code units one by one — a lone surrogate too, as three bytes: the
436
+ * labels are the consumer's to give. The DOM-free part has no TextEncoder (R05).
437
+ */
438
+ function utf8(text) {
439
+ const bytes = [];
440
+ for (let i = 0; i < text.length; i++) {
441
+ const unit = text.charCodeAt(i);
442
+ if (unit < 0x80)
443
+ bytes.push(unit);
444
+ else if (unit < 0x800)
445
+ bytes.push(0xc0 | (unit >> 6), 0x80 | (unit & 0x3f));
446
+ else
447
+ bytes.push(0xe0 | (unit >> 12), 0x80 | ((unit >> 6) & 0x3f), 0x80 | (unit & 0x3f));
448
+ }
449
+ return Uint8Array.from(bytes);
450
+ }
451
+ /** A 32-bit checksum in eight hex digits. */
452
+ function hex(sum) {
453
+ return sum.toString(16).padStart(8, "0");
454
+ }
@@ -0,0 +1,22 @@
1
+ import { type Vec2 } from "perfect-freehand";
2
+ import type { Stroke } from "./ink.js";
3
+ /**
4
+ * The width of the line, in sheet units: 3, as the check board of tutor draws with a `lineWidth`
5
+ * of 3 px on its sheet 1000 wide (R19 tutor; R18).
6
+ */
7
+ export declare const INK_WIDTH = 3;
8
+ /**
9
+ * How far the ink reaches from a stroke's points, in sheet units, at most: half the widest line, a
10
+ * pen's at full pressure. perfect-freehand's radius at a point is `size × (0.5 − thinning × (0.5 −
11
+ * pressure))`, so 2.25 units with a pressure of 1.
12
+ */
13
+ export declare const INK_REACH: number;
14
+ /** Whether a stroke's line widens with its pressure on the screen: only a pen's does (R05, R18). */
15
+ export declare function followsPressure(stroke: Stroke): boolean;
16
+ /**
17
+ * The outline of a stroke on the screen, as perfect-freehand draws it: a closed polygon around the
18
+ * stroke's points, in sheet units, to be filled. A pen's line widens with its pressure; a mouse's
19
+ * or a finger's is as wide all along (R18). `complete` says the stroke has ended: its last point is
20
+ * then taken as it is, rather than eased toward.
21
+ */
22
+ export declare function outline(stroke: Stroke, complete: boolean): Vec2[];
@@ -0,0 +1,50 @@
1
+ import { getStroke } from "perfect-freehand";
2
+ /**
3
+ * The width of the line, in sheet units: 3, as the check board of tutor draws with a `lineWidth`
4
+ * of 3 px on its sheet 1000 wide (R19 tutor; R18).
5
+ */
6
+ export const INK_WIDTH = 3;
7
+ /** How much a pen's pressure changes the width: from half of it to one and a half (R05, R18). */
8
+ const PEN_THINNING = 0.5;
9
+ /**
10
+ * How far the ink reaches from a stroke's points, in sheet units, at most: half the widest line, a
11
+ * pen's at full pressure. perfect-freehand's radius at a point is `size × (0.5 − thinning × (0.5 −
12
+ * pressure))`, so 2.25 units with a pressure of 1.
13
+ */
14
+ export const INK_REACH = INK_WIDTH * (0.5 + PEN_THINNING / 2);
15
+ /**
16
+ * Every option of perfect-freehand 1.2.3 is given: its code's defaults are not its README's
17
+ * (R18). The pressure is the pointer's own, the third number of each point, never simulated, and
18
+ * the ends are round, without a taper.
19
+ */
20
+ const OPTIONS = {
21
+ size: INK_WIDTH,
22
+ smoothing: 0.5,
23
+ streamline: 0.5,
24
+ simulatePressure: false,
25
+ easing: (pressure) => pressure,
26
+ start: { cap: true, taper: false },
27
+ end: { cap: true, taper: false },
28
+ };
29
+ /** Whether a stroke's line widens with its pressure on the screen: only a pen's does (R05, R18). */
30
+ export function followsPressure(stroke) {
31
+ return stroke.kind === "pen";
32
+ }
33
+ /**
34
+ * The outline of a stroke on the screen, as perfect-freehand draws it: a closed polygon around the
35
+ * stroke's points, in sheet units, to be filled. A pen's line widens with its pressure; a mouse's
36
+ * or a finger's is as wide all along (R18). `complete` says the stroke has ended: its last point is
37
+ * then taken as it is, rather than eased toward.
38
+ */
39
+ export function outline(stroke, complete) {
40
+ return polygon(stroke, followsPressure(stroke) ? PEN_THINNING : 0, complete);
41
+ }
42
+ function polygon(stroke, thinning, complete) {
43
+ const points = stroke.points.map(({ x, y, p }) => [x, y, p]);
44
+ // One point alone perfect-freehand pairs with another a unit off and draws as an oval. The same
45
+ // point twice it draws as a round dot, as wide as the line.
46
+ const [only] = points;
47
+ if (points.length === 1 && only !== undefined)
48
+ points.push(only);
49
+ return getStroke(points, { ...OPTIONS, thinning, last: complete });
50
+ }