@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.
- 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,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
|
+
}
|