postext 1.58.3 → 1.59.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/dist/__tests__/exercises/config.test.d.ts +2 -0
- package/dist/__tests__/exercises/config.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/config.test.js +63 -0
- package/dist/__tests__/exercises/config.test.js.map +1 -0
- package/dist/__tests__/exercises/digital.test.d.ts +2 -0
- package/dist/__tests__/exercises/digital.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/digital.test.js +163 -0
- package/dist/__tests__/exercises/digital.test.js.map +1 -0
- package/dist/__tests__/exercises/languages.test.d.ts +2 -0
- package/dist/__tests__/exercises/languages.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/languages.test.js +109 -0
- package/dist/__tests__/exercises/languages.test.js.map +1 -0
- package/dist/__tests__/exercises/layout.test.d.ts +2 -0
- package/dist/__tests__/exercises/layout.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/layout.test.js +77 -0
- package/dist/__tests__/exercises/layout.test.js.map +1 -0
- package/dist/__tests__/exercises/model.test.d.ts +2 -0
- package/dist/__tests__/exercises/model.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/model.test.js +90 -0
- package/dist/__tests__/exercises/model.test.js.map +1 -0
- package/dist/__tests__/exercises/parse.test.d.ts +2 -0
- package/dist/__tests__/exercises/parse.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/parse.test.js +37 -0
- package/dist/__tests__/exercises/parse.test.js.map +1 -0
- package/dist/__tests__/exercises/refs.test.d.ts +2 -0
- package/dist/__tests__/exercises/refs.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/refs.test.js +46 -0
- package/dist/__tests__/exercises/refs.test.js.map +1 -0
- package/dist/__tests__/exercises/solutions.test.d.ts +2 -0
- package/dist/__tests__/exercises/solutions.test.d.ts.map +1 -0
- package/dist/__tests__/exercises/solutions.test.js +53 -0
- package/dist/__tests__/exercises/solutions.test.js.map +1 -0
- package/dist/__tests__/exports.test.js +14 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/parse/containers.test.js +2 -2
- package/dist/__tests__/parse/containers.test.js.map +1 -1
- package/dist/canvas-backend/blockRender.d.ts.map +1 -1
- package/dist/canvas-backend/blockRender.js +18 -0
- package/dist/canvas-backend/blockRender.js.map +1 -1
- package/dist/canvas-backend/chip.d.ts +3 -0
- package/dist/canvas-backend/chip.d.ts.map +1 -1
- package/dist/canvas-backend/chip.js +31 -1
- package/dist/canvas-backend/chip.js.map +1 -1
- package/dist/configWarnings.d.ts +3 -1
- package/dist/configWarnings.d.ts.map +1 -1
- package/dist/configWarnings.js +5 -1
- package/dist/configWarnings.js.map +1 -1
- package/dist/defaults/chipStyles.d.ts.map +1 -1
- package/dist/defaults/chipStyles.js +7 -0
- package/dist/defaults/chipStyles.js.map +1 -1
- package/dist/defaults/exercises.d.ts +111 -0
- package/dist/defaults/exercises.d.ts.map +1 -0
- package/dist/defaults/exercises.js +255 -0
- package/dist/defaults/exercises.js.map +1 -0
- package/dist/defaults/index.d.ts +2 -0
- package/dist/defaults/index.d.ts.map +1 -1
- package/dist/defaults/index.js +16 -0
- package/dist/defaults/index.js.map +1 -1
- package/dist/defaults/shared.d.ts +4 -0
- package/dist/defaults/shared.d.ts.map +1 -1
- package/dist/defaults/shared.js +6 -0
- package/dist/defaults/shared.js.map +1 -1
- package/dist/design/elementKeys.d.ts +2 -0
- package/dist/design/elementKeys.d.ts.map +1 -1
- package/dist/design/elementKeys.js +1 -1
- package/dist/design/elementKeys.js.map +1 -1
- package/dist/exercises/build.d.ts +60 -0
- package/dist/exercises/build.d.ts.map +1 -0
- package/dist/exercises/build.js +177 -0
- package/dist/exercises/build.js.map +1 -0
- package/dist/exercises/configWarnings.d.ts +11 -0
- package/dist/exercises/configWarnings.d.ts.map +1 -0
- package/dist/exercises/configWarnings.js +175 -0
- package/dist/exercises/configWarnings.js.map +1 -0
- package/dist/exercises/designs.d.ts +38 -0
- package/dist/exercises/designs.d.ts.map +1 -0
- package/dist/exercises/designs.js +143 -0
- package/dist/exercises/designs.js.map +1 -0
- package/dist/exercises/digital.d.ts +69 -0
- package/dist/exercises/digital.d.ts.map +1 -0
- package/dist/exercises/digital.js +58 -0
- package/dist/exercises/digital.js.map +1 -0
- package/dist/exercises/expand.d.ts +74 -0
- package/dist/exercises/expand.d.ts.map +1 -0
- package/dist/exercises/expand.js +1089 -0
- package/dist/exercises/expand.js.map +1 -0
- package/dist/exercises/grids.d.ts +62 -0
- package/dist/exercises/grids.d.ts.map +1 -0
- package/dist/exercises/grids.js +535 -0
- package/dist/exercises/grids.js.map +1 -0
- package/dist/exercises/model.d.ts +24 -0
- package/dist/exercises/model.d.ts.map +1 -0
- package/dist/exercises/model.js +360 -0
- package/dist/exercises/model.js.map +1 -0
- package/dist/exercises/record.d.ts +94 -0
- package/dist/exercises/record.d.ts.map +1 -0
- package/dist/exercises/record.js +9 -0
- package/dist/exercises/record.js.map +1 -0
- package/dist/exercises/runtime.d.ts +65 -0
- package/dist/exercises/runtime.d.ts.map +1 -0
- package/dist/exercises/runtime.js +1261 -0
- package/dist/exercises/runtime.js.map +1 -0
- package/dist/exercises/shuffle.d.ts +13 -0
- package/dist/exercises/shuffle.d.ts.map +1 -0
- package/dist/exercises/shuffle.js +56 -0
- package/dist/exercises/shuffle.js.map +1 -0
- package/dist/exercises/styles.d.ts +51 -0
- package/dist/exercises/styles.d.ts.map +1 -0
- package/dist/exercises/styles.js +221 -0
- package/dist/exercises/styles.js.map +1 -0
- package/dist/exercises/text.d.ts +31 -0
- package/dist/exercises/text.d.ts.map +1 -0
- package/dist/exercises/text.js +82 -0
- package/dist/exercises/text.js.map +1 -0
- package/dist/exercises/types.d.ts +125 -0
- package/dist/exercises/types.d.ts.map +1 -0
- package/dist/exercises/types.js +9 -0
- package/dist/exercises/types.js.map +1 -0
- package/dist/exercises/warnings.d.ts +16 -0
- package/dist/exercises/warnings.d.ts.map +1 -0
- package/dist/exercises/warnings.js +55 -0
- package/dist/exercises/warnings.js.map +1 -0
- package/dist/html-backend.d.ts +6 -0
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +67 -6
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/measure/cache.d.ts.map +1 -1
- package/dist/measure/cache.js +6 -1
- package/dist/measure/cache.js.map +1 -1
- package/dist/measure/rich.d.ts.map +1 -1
- package/dist/measure/rich.js +11 -1
- package/dist/measure/rich.js.map +1 -1
- package/dist/migrate/markdownRuns.js +1 -1
- package/dist/migrate/markdownRuns.js.map +1 -1
- package/dist/numbering.d.ts +3 -0
- package/dist/numbering.d.ts.map +1 -1
- package/dist/numbering.js +6 -0
- package/dist/numbering.js.map +1 -1
- package/dist/parse/blockParser.d.ts.map +1 -1
- package/dist/parse/blockParser.js +47 -4
- package/dist/parse/blockParser.js.map +1 -1
- package/dist/parse/exerciseGaps.d.ts +50 -0
- package/dist/parse/exerciseGaps.d.ts.map +1 -0
- package/dist/parse/exerciseGaps.js +151 -0
- package/dist/parse/exerciseGaps.js.map +1 -0
- package/dist/parse/index.d.ts +1 -1
- package/dist/parse/index.d.ts.map +1 -1
- package/dist/parse/index.js.map +1 -1
- package/dist/parse/sourceMapping.d.ts.map +1 -1
- package/dist/parse/sourceMapping.js +21 -0
- package/dist/parse/sourceMapping.js.map +1 -1
- package/dist/parse/types.d.ts +49 -1
- package/dist/parse/types.d.ts.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +45 -7
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/calloutLayout.d.ts.map +1 -1
- package/dist/pipeline/calloutLayout.js +1 -0
- package/dist/pipeline/calloutLayout.js.map +1 -1
- package/dist/pipeline/chips.d.ts.map +1 -1
- package/dist/pipeline/chips.js +2 -0
- package/dist/pipeline/chips.js.map +1 -1
- package/dist/pipeline/config.d.ts.map +1 -1
- package/dist/pipeline/config.js +6 -0
- package/dist/pipeline/config.js.map +1 -1
- package/dist/pipeline/contentWarnings.d.ts.map +1 -1
- package/dist/pipeline/contentWarnings.js +29 -1
- package/dist/pipeline/contentWarnings.js.map +1 -1
- package/dist/pipeline/continuation.d.ts.map +1 -1
- package/dist/pipeline/continuation.js +21 -6
- package/dist/pipeline/continuation.js.map +1 -1
- package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
- package/dist/pipeline/measureContentBlock.js +3 -2
- package/dist/pipeline/measureContentBlock.js.map +1 -1
- package/dist/pipeline/outline.d.ts.map +1 -1
- package/dist/pipeline/outline.js +1 -0
- package/dist/pipeline/outline.js.map +1 -1
- package/dist/pipeline/textWrap.js +1 -1
- package/dist/pipeline/textWrap.js.map +1 -1
- package/dist/types.d.ts +303 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +67 -0
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exercises of a document set as ordinary blocks (#1227): each
|
|
3
|
+
* `:::exercise` becomes a box (its style's `box`, or a frameless one) of
|
|
4
|
+
* paragraphs, chips and drawn boxes the layout already knows — so an
|
|
5
|
+
* exercise is placed, split, balanced, tagged and painted as any box is.
|
|
6
|
+
* Its solutions go where the book says (#1229), and every exercise leaves
|
|
7
|
+
* a record of what it asks and answers for the digital outputs (#1230).
|
|
8
|
+
*/
|
|
9
|
+
import { CHIP_PLACEHOLDER, SWATCH_PLACEHOLDER } from '../parse/inlineFormatting.js';
|
|
10
|
+
import { pickExerciseStyle, solutionsOf } from '../defaults/exercises.js';
|
|
11
|
+
import { counterStyleOf, formatCounter } from '../numbering.js';
|
|
12
|
+
import { readExercises, containerEndOf } from './model.js';
|
|
13
|
+
import { EMPTY_RUN, joinRuns, runOf, sliceRun, textRun, trimRun, blockOfRun } from './text.js';
|
|
14
|
+
import { exerciseBoxStyles, exerciseChipStyles, exerciseParagraphStyles, exerciseStyleIds } from './styles.js';
|
|
15
|
+
import { areaElements, drawnBox } from './designs.js';
|
|
16
|
+
import { seededRandom, shuffledOrder } from './shuffle.js';
|
|
17
|
+
import { gridBox } from './grids.js';
|
|
18
|
+
/** Container ids of the synthetic boxes and groups (clear of the parser's
|
|
19
|
+
* and of the other synthetic bases: 1e9 chapter-end notes, 1.5e9
|
|
20
|
+
* listings, 1.75e9 flow columns, 1.8e9 frames, 1.9e9 comic text, 2e9
|
|
21
|
+
* page-span embeds). */
|
|
22
|
+
export const EXERCISE_CONTAINER_BASE = 1_600_000_000;
|
|
23
|
+
const SPACE = ' ';
|
|
24
|
+
/** Where a `::bank` line stands among an exercise's body, until the bank
|
|
25
|
+
* (which holds the answers the body sets) is made. */
|
|
26
|
+
const BANK_SLOT = { type: 'directive', text: '', spans: [], sourceMap: [], sourceStart: 0, sourceEnd: 0, directiveName: 'bank' };
|
|
27
|
+
/** A chip span holding `run`. */
|
|
28
|
+
function chipSpan(style, run, extra = {}) {
|
|
29
|
+
return { text: CHIP_PLACEHOLDER, bold: false, italic: false, chip: { style, spans: run.spans.length > 0 ? run.spans : [{ text: run.text, bold: false, italic: false }], ...extra } };
|
|
30
|
+
}
|
|
31
|
+
/** A run of one chip, mapped to `at`. */
|
|
32
|
+
function chipRun(style, run, at, extra = {}) {
|
|
33
|
+
return { text: CHIP_PLACEHOLDER, spans: [chipSpan(style, run, extra)], sourceMap: [at] };
|
|
34
|
+
}
|
|
35
|
+
/** A tab to the paragraph style's next stop: a tab span holds `'\t'` once
|
|
36
|
+
* mapped, as the parser leaves it. */
|
|
37
|
+
const tabRun = (at) => ({ text: '\t', spans: [{ text: '\t', bold: false, italic: false, tab: {} }], sourceMap: [at] });
|
|
38
|
+
function marker(type, name, id, at, attrs) {
|
|
39
|
+
return {
|
|
40
|
+
type, text: '', spans: [], sourceMap: [], sourceStart: at, sourceEnd: at,
|
|
41
|
+
containerName: name, containerId: id, ...(attrs ? { containerAttrs: attrs } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** The plain text of a run, its chips' words included. */
|
|
45
|
+
function plain(run) {
|
|
46
|
+
const spans = Array.isArray(run) ? run : run.spans;
|
|
47
|
+
return spans.map((s) => (s.chip ? plain(s.chip.spans) : s.tab ? ' ' : s.text)).join('');
|
|
48
|
+
}
|
|
49
|
+
/** An option's label (`a)`, `ア`, `أ)`), `''` for none. */
|
|
50
|
+
function optionLabel(style, k, locale, digits) {
|
|
51
|
+
if (style.options.labels === 'none')
|
|
52
|
+
return '';
|
|
53
|
+
return `${style.options.prefix}${formatCounter(k + 1, counterStyleOf(style.options.labels, locale), locale, digits)}${style.options.suffix}`;
|
|
54
|
+
}
|
|
55
|
+
/** The exercise numbers of a document, each exercise's own counter reset
|
|
56
|
+
* by the heading level its style names. */
|
|
57
|
+
export function numberExercises(blocks, exercises, ctx) {
|
|
58
|
+
const counters = { ...(ctx.counters ?? {}) };
|
|
59
|
+
const heads = [0, ctx.headings?.h1 ?? 0, ctx.headings?.h2 ?? 0];
|
|
60
|
+
const resetAt = new Map();
|
|
61
|
+
for (const s of ctx.settings.styles) {
|
|
62
|
+
const level = s.number.resetOn === 'never' ? 0 : Number(s.number.resetOn.slice(1));
|
|
63
|
+
resetAt.set(s.number.counter, Math.max(resetAt.get(s.number.counter) ?? 0, level));
|
|
64
|
+
}
|
|
65
|
+
const numbers = new Map();
|
|
66
|
+
let next = 0;
|
|
67
|
+
for (let i = 0; i < blocks.length && next < exercises.length; i++) {
|
|
68
|
+
const b = blocks[i];
|
|
69
|
+
if (b.type === 'heading' && b.level) {
|
|
70
|
+
if (b.level <= 2) {
|
|
71
|
+
heads[b.level] = (heads[b.level] ?? 0) + 1;
|
|
72
|
+
if (b.level === 1)
|
|
73
|
+
heads[2] = 0;
|
|
74
|
+
}
|
|
75
|
+
for (const [counter, level] of resetAt)
|
|
76
|
+
if (level > 0 && b.level <= level)
|
|
77
|
+
counters[counter] = 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const ex = exercises[next];
|
|
81
|
+
if (i !== ex.start)
|
|
82
|
+
continue;
|
|
83
|
+
next++;
|
|
84
|
+
const style = pickExerciseStyle(ctx.settings.styles, ex.attrs.style);
|
|
85
|
+
const counter = style.number.counter;
|
|
86
|
+
const written = ex.attrs.n !== undefined ? parseInt(ex.attrs.n, 10) : NaN;
|
|
87
|
+
const n = Number.isFinite(written) ? written : (counters[counter] ?? 0) + 1;
|
|
88
|
+
counters[counter] = n;
|
|
89
|
+
const num = formatCounter(n, counterStyleOf(style.number.format, ctx.locale), ctx.locale, ctx.digits);
|
|
90
|
+
numbers.set(ex.id, style.number.template.replace(/\{n\}/g, num).replace(/\{h1\}/g, String(heads[1])).replace(/\{h2\}/g, String(heads[2])));
|
|
91
|
+
}
|
|
92
|
+
return { numbers, counters };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Set the exercises of `blocks`. Blocks outside exercises pass as they are;
|
|
96
|
+
* a `:::solutions` line becomes the solutions the book prints there.
|
|
97
|
+
*/
|
|
98
|
+
export function expandExercises(blocks, ctx) {
|
|
99
|
+
const { exercises, issues } = readExercises(blocks);
|
|
100
|
+
const book = ctx.settings.exercises;
|
|
101
|
+
const out = { blocks: [], wrapped: false, boxes: [], paragraphStyles: [], chipStyles: [], records: [], issues, counters: { ...(ctx.counters ?? {}) } };
|
|
102
|
+
const hasSolutionsLine = blocks.some((b) => b.type === 'directive' && b.directiveName === 'solutions');
|
|
103
|
+
if (exercises.length === 0 && !hasSolutionsLine) {
|
|
104
|
+
out.blocks = blocks;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
out.wrapped = true;
|
|
108
|
+
const { numbers, counters } = numberExercises(blocks, exercises, ctx);
|
|
109
|
+
out.counters = counters;
|
|
110
|
+
let nextId = EXERCISE_CONTAINER_BASE;
|
|
111
|
+
const newId = () => nextId++;
|
|
112
|
+
const usedStyles = new Set();
|
|
113
|
+
const extraBoxes = [];
|
|
114
|
+
/** Solutions waiting for the end of their chapter (or a `:::solutions`). */
|
|
115
|
+
let pending = [];
|
|
116
|
+
const emitter = (ex, style, number) => new Emitter(blocks, ex, style, book, number, ctx, newId, extraBoxes);
|
|
117
|
+
const flushPending = (at, heading, sink) => {
|
|
118
|
+
if (pending.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
const id = newId();
|
|
121
|
+
const style = pending[0].style;
|
|
122
|
+
const ids = exerciseStyleIds(style);
|
|
123
|
+
const box = solutionsOf(style, book).box && ctx.boxIds.has(solutionsOf(style, book).box) ? solutionsOf(style, book).box : ids.solutionList;
|
|
124
|
+
sink.push(marker('containerStart', 'callout', id, at, { type: box, ...(heading ? { title: heading } : {}) }));
|
|
125
|
+
for (const p of pending)
|
|
126
|
+
sink.push(...p.e.solutionBlocks(true));
|
|
127
|
+
sink.push(marker('containerEnd', 'callout', id, at));
|
|
128
|
+
pending = [];
|
|
129
|
+
};
|
|
130
|
+
let exIndex = 0;
|
|
131
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
132
|
+
const b = blocks[i];
|
|
133
|
+
const ex = exercises[exIndex];
|
|
134
|
+
if (ex && i === ex.start) {
|
|
135
|
+
exIndex++;
|
|
136
|
+
const style = pickExerciseStyle(ctx.settings.styles, ex.attrs.style);
|
|
137
|
+
usedStyles.add(style.id);
|
|
138
|
+
const number = numbers.get(ex.id) ?? '';
|
|
139
|
+
const e = emitter(ex, style, number);
|
|
140
|
+
const set = e.exerciseBlocks();
|
|
141
|
+
out.records.push(e.record());
|
|
142
|
+
for (const word of e.unplaced)
|
|
143
|
+
issues.push({ kind: 'exerciseWordUnplaced', exercise: ex.id, detail: word, sourceStart: ex.sourceStart, sourceEnd: ex.sourceEnd });
|
|
144
|
+
const placement = solutionsOf(style, book).placement;
|
|
145
|
+
// The solution right after the exercise goes inside its box, before
|
|
146
|
+
// the box closes: a page break never parts them.
|
|
147
|
+
if (placement === 'after' || placement === 'inverted')
|
|
148
|
+
set.splice(set.length - 1, 0, ...e.solutionBox(placement === 'inverted'));
|
|
149
|
+
else if (placement === 'chapterEnd' || placement === 'directive')
|
|
150
|
+
pending.push({ ex, style, number, e });
|
|
151
|
+
out.blocks.push(...set);
|
|
152
|
+
i = ex.end;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// A chapter ends where the next one opens.
|
|
156
|
+
if (b.type === 'heading' && b.level === 1 && pending.some((p) => solutionsOf(p.style, book).placement === 'chapterEnd')) {
|
|
157
|
+
flushPending(b.sourceStart, pending[0] ? solutionsOf(pending[0].style, book).heading : undefined, out.blocks);
|
|
158
|
+
}
|
|
159
|
+
if (b.type === 'directive' && b.directiveName === 'solutions') {
|
|
160
|
+
const heading = b.directiveAttrs?.title ?? (pending[0] ? solutionsOf(pending[0].style, book).heading : book.solutions.heading);
|
|
161
|
+
// The whole book's, from its outline (every chapter's exercises).
|
|
162
|
+
const entries = b.directiveAttrs?.scope === 'book' ? ctx.outline?.filter((e) => e.kind === 'exercise' && e.exercise) ?? [] : [];
|
|
163
|
+
if (entries.length > 0) {
|
|
164
|
+
const style = ctx.settings.styles[0];
|
|
165
|
+
usedStyles.add(style.id);
|
|
166
|
+
out.blocks.push(...bookSolutions(entries, style, book, heading || undefined, b.sourceStart, newId, ctx));
|
|
167
|
+
pending = [];
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
flushPending(b.sourceStart, heading || undefined, out.blocks);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// A solution or hint outside an exercise prints nothing.
|
|
175
|
+
if (b.type === 'containerStart' && (b.containerName === 'solution' || b.containerName === 'hint')) {
|
|
176
|
+
i = containerEndOf(blocks, i);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
out.blocks.push(b);
|
|
180
|
+
}
|
|
181
|
+
if (pending.some((p) => solutionsOf(p.style, book).placement === 'chapterEnd')) {
|
|
182
|
+
const last = blocks[blocks.length - 1];
|
|
183
|
+
flushPending(last ? last.sourceEnd : 0, solutionsOf(pending[0].style, book).heading, out.blocks);
|
|
184
|
+
}
|
|
185
|
+
for (const id of usedStyles) {
|
|
186
|
+
const style = ctx.settings.styles.find((s) => s.id === id);
|
|
187
|
+
const solBox = solutionsOf(style, book).box;
|
|
188
|
+
out.boxes.push(...exerciseBoxStyles(style, solBox && ctx.boxIds.has(solBox) ? solBox : undefined, ctx.bodySizePt));
|
|
189
|
+
out.paragraphStyles.push(...exerciseParagraphStyles(style));
|
|
190
|
+
out.chipStyles.push(...exerciseChipStyles(style));
|
|
191
|
+
}
|
|
192
|
+
// The solutions box of a `:::solutions` line with no exercise before it.
|
|
193
|
+
if (usedStyles.size === 0 && ctx.settings.styles[0]) {
|
|
194
|
+
out.boxes.push(...exerciseBoxStyles(ctx.settings.styles[0], undefined, ctx.bodySizePt));
|
|
195
|
+
}
|
|
196
|
+
out.boxes.push(...extraBoxes);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/** The solutions of the whole book (`:::solutions{scope="book"}`): each
|
|
200
|
+
* chapter's title, then each exercise's number, key and worked solution,
|
|
201
|
+
* as the outline carries them (plain text). */
|
|
202
|
+
function bookSolutions(entries, style, book, heading, at, newId, ctx) {
|
|
203
|
+
const ids = exerciseStyleIds(style);
|
|
204
|
+
const sol = solutionsOf(style, book);
|
|
205
|
+
const boxId = sol.box && ctx.boxIds.has(sol.box) ? sol.box : ids.solutionList;
|
|
206
|
+
const box = newId();
|
|
207
|
+
const out = [marker('containerStart', 'callout', box, at, { type: boxId, ...(heading ? { title: heading } : {}) })];
|
|
208
|
+
const group = newId();
|
|
209
|
+
out.push(marker('containerStart', 'paragraphs', group, at, { style: ids.key }));
|
|
210
|
+
let chapter;
|
|
211
|
+
for (const e of entries) {
|
|
212
|
+
const ex = e.exercise;
|
|
213
|
+
if (ex.chapter !== chapter) {
|
|
214
|
+
chapter = ex.chapter;
|
|
215
|
+
if (chapter)
|
|
216
|
+
out.push(blockOfRun('paragraph', textRun(chapter, at, { bold: true }), { sourceStart: at, sourceEnd: at }));
|
|
217
|
+
}
|
|
218
|
+
const lines = [ex.key, ...(sol.layout === 'full' ? ex.solution ?? [] : [])].filter(Boolean);
|
|
219
|
+
lines.forEach((line, k) => {
|
|
220
|
+
const lead = k === 0 ? textRun(`${e.number}${style.number.suffix || '.'}`, at, { bold: true }) : EMPTY_RUN;
|
|
221
|
+
out.push(blockOfRun('paragraph', joinRuns(lead, tabRun(at), textRun(line, at)), { sourceStart: at, sourceEnd: at }));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
out.push(marker('containerEnd', 'paragraphs', group, at), marker('containerEnd', 'callout', box, at));
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
/** Sets one exercise. */
|
|
228
|
+
class Emitter {
|
|
229
|
+
blocks;
|
|
230
|
+
ex;
|
|
231
|
+
style;
|
|
232
|
+
book;
|
|
233
|
+
number;
|
|
234
|
+
ctx;
|
|
235
|
+
newId;
|
|
236
|
+
extraBoxes;
|
|
237
|
+
ids;
|
|
238
|
+
teacher;
|
|
239
|
+
/** `example`: the first item is printed answered, as a model. */
|
|
240
|
+
example;
|
|
241
|
+
at;
|
|
242
|
+
slots = [];
|
|
243
|
+
grids = [];
|
|
244
|
+
/** The key, one run per item. */
|
|
245
|
+
key = [];
|
|
246
|
+
bankWords = [];
|
|
247
|
+
/** The number, marks, icons and title, before the instruction. */
|
|
248
|
+
leadRun;
|
|
249
|
+
/** Whether the lead ends in a number whose suffix is a full-width mark
|
|
250
|
+
* (`一、`, `1.`): what follows it takes no word space. */
|
|
251
|
+
leadTight = false;
|
|
252
|
+
/** Words a grid could not place. */
|
|
253
|
+
unplaced = [];
|
|
254
|
+
random;
|
|
255
|
+
constructor(blocks, ex, style, book, number, ctx, newId, extraBoxes) {
|
|
256
|
+
this.blocks = blocks;
|
|
257
|
+
this.ex = ex;
|
|
258
|
+
this.style = style;
|
|
259
|
+
this.book = book;
|
|
260
|
+
this.number = number;
|
|
261
|
+
this.ctx = ctx;
|
|
262
|
+
this.newId = newId;
|
|
263
|
+
this.extraBoxes = extraBoxes;
|
|
264
|
+
this.ids = exerciseStyleIds(style);
|
|
265
|
+
this.teacher = book.edition === 'teacher';
|
|
266
|
+
this.example = ex.attrs.example !== undefined && ex.attrs.example !== 'false';
|
|
267
|
+
this.at = ex.sourceStart;
|
|
268
|
+
this.random = seededRandom(book.seed, ex.ordinal, ex.kind);
|
|
269
|
+
}
|
|
270
|
+
/** Whether an item prints its answer: in a teacher's edition, and the
|
|
271
|
+
* first item of an `example` exercise. */
|
|
272
|
+
answered(first) {
|
|
273
|
+
return this.teacher || (this.example && first);
|
|
274
|
+
}
|
|
275
|
+
/** The exercise slot of an item, none for the model item of an
|
|
276
|
+
* `example` exercise (it is not asked). */
|
|
277
|
+
hit(slot, first) {
|
|
278
|
+
return this.example && first && !this.teacher ? {} : { exercise: { ex: this.ex.id, slot } };
|
|
279
|
+
}
|
|
280
|
+
/** Between two answers of a key: a middle dot, but `؛` in Arabic, where
|
|
281
|
+
* a dot beside Arabic-Indic digits reads as a zero (`٠`). */
|
|
282
|
+
get sep() {
|
|
283
|
+
return this.book.language === 'ar' ? '\u061B ' : ' · ';
|
|
284
|
+
}
|
|
285
|
+
/** An item's number in the document's digits (`١`, `۱`, `1`). */
|
|
286
|
+
num(n) {
|
|
287
|
+
return formatCounter(n, counterStyleOf('decimal', this.ctx.locale), this.ctx.locale, this.ctx.digits);
|
|
288
|
+
}
|
|
289
|
+
label(k) {
|
|
290
|
+
return optionLabel(this.style, k, this.ctx.locale, this.ctx.digits);
|
|
291
|
+
}
|
|
292
|
+
/** A label without its prefix and suffix, as a key prints it. */
|
|
293
|
+
bareLabel(k) {
|
|
294
|
+
if (this.style.options.labels === 'none')
|
|
295
|
+
return '';
|
|
296
|
+
return formatCounter(k + 1, counterStyleOf(this.style.options.labels, this.ctx.locale), this.ctx.locale, this.ctx.digits);
|
|
297
|
+
}
|
|
298
|
+
open(name, attrs) {
|
|
299
|
+
const id = this.newId();
|
|
300
|
+
return { start: marker('containerStart', name, id, this.at, attrs), end: marker('containerEnd', name, id, this.at) };
|
|
301
|
+
}
|
|
302
|
+
/** The paragraphs of `runs` in the paragraph style `style`. */
|
|
303
|
+
paragraphs(style, runs) {
|
|
304
|
+
if (runs.length === 0)
|
|
305
|
+
return [];
|
|
306
|
+
const g = this.open('paragraphs', { style });
|
|
307
|
+
return [g.start, ...runs.map((r) => blockOfRun('paragraph', r, { sourceStart: this.at, sourceEnd: this.at })), g.end];
|
|
308
|
+
}
|
|
309
|
+
/** A drawn box (an area, a grid) as blocks. */
|
|
310
|
+
drawn(style) {
|
|
311
|
+
this.extraBoxes.push(style);
|
|
312
|
+
const g = this.open('callout', { type: style.id });
|
|
313
|
+
return [g.start, g.end];
|
|
314
|
+
}
|
|
315
|
+
/** A drawn grid as blocks, its geometry and slots recorded. */
|
|
316
|
+
drawnGrid(box) {
|
|
317
|
+
const { slots, key, clues, words, info, unplaced, ...style } = box;
|
|
318
|
+
void key;
|
|
319
|
+
void clues;
|
|
320
|
+
void words;
|
|
321
|
+
void unplaced;
|
|
322
|
+
const blocks = this.drawn(style);
|
|
323
|
+
const id = blocks[0].containerId;
|
|
324
|
+
if (info)
|
|
325
|
+
this.grids.push({ ...info, box: id });
|
|
326
|
+
for (const s of slots ?? [])
|
|
327
|
+
this.slots.push({ ...s, box: id });
|
|
328
|
+
return blocks;
|
|
329
|
+
}
|
|
330
|
+
/** The room across inside the exercise's box: the column, less the
|
|
331
|
+
* padding and border of the style's `box` (a card's areas and grids
|
|
332
|
+
* stay inside it). */
|
|
333
|
+
get measure() {
|
|
334
|
+
const box = this.style.box && this.ctx.boxIds.has(this.style.box) ? this.style.box : undefined;
|
|
335
|
+
return Math.max(1, this.ctx.measurePt - (box ? this.ctx.boxInsets?.get(box) ?? 0 : 0));
|
|
336
|
+
}
|
|
337
|
+
get dpiFree() {
|
|
338
|
+
return { size: this.ctx.bodySizePt, line: this.ctx.lineHeightPt, measure: this.measure };
|
|
339
|
+
}
|
|
340
|
+
/** A writing area of `lines` lines, the answers written in a teacher's
|
|
341
|
+
* edition. */
|
|
342
|
+
area(lines, slot, answers, kind, widthPt, shown = false) {
|
|
343
|
+
const a = this.style.area;
|
|
344
|
+
const k = kind ?? a.kind;
|
|
345
|
+
const spacing = a.spacing ? dimPt(a.spacing, this.dpiFree.size) : this.dpiFree.line * (k === 'grid' || k === 'tianzige' ? 1.2 : 1.35);
|
|
346
|
+
const { elements, heightPt } = areaElements({
|
|
347
|
+
kind: k, lines, spacingPt: spacing, widthPt: widthPt ?? this.dpiFree.measure, color: a.color, weightPt: dimPt(a.weight, this.dpiFree.size),
|
|
348
|
+
...((this.teacher || shown) && answers ? { answers, answerColor: this.style.answers.color, answerSizePt: this.dpiFree.size * 0.95, ...(this.style.answers.fontFamily ?? this.ctx.bodyFont ? { answerFont: this.style.answers.fontFamily ?? this.ctx.bodyFont } : {}) } : {}),
|
|
349
|
+
});
|
|
350
|
+
const id = `${this.ids.frame}-area-${this.ex.ordinal}-${slot}`;
|
|
351
|
+
const blocks = this.drawn(drawnBox(id, elements, heightPt));
|
|
352
|
+
this.slots.push({ key: slot, kind: 'text', accept: answers?.filter((s) => s !== '') ?? [], box: blocks[0].containerId, linePt: spacing });
|
|
353
|
+
return blocks;
|
|
354
|
+
}
|
|
355
|
+
// --- The exercise ---------------------------------------------------------
|
|
356
|
+
exerciseBlocks() {
|
|
357
|
+
const ex = this.ex;
|
|
358
|
+
const frame = this.style.box && this.ctx.boxIds.has(this.style.box) ? this.style.box : this.ids.frame;
|
|
359
|
+
const attrs = { type: frame, exercise: String(ex.id) };
|
|
360
|
+
const outer = { start: marker('containerStart', 'callout', ex.id, this.at, attrs), end: marker('containerEnd', 'callout', ex.id, this.blocks[ex.end].sourceStart) };
|
|
361
|
+
// A `{#id}` on the fence names the exercise.
|
|
362
|
+
if (ex.attrs.id)
|
|
363
|
+
outer.start.containerAttrs = { ...outer.start.containerAttrs, id: ex.attrs.id };
|
|
364
|
+
let head = this.head();
|
|
365
|
+
// The body first: the bank holds the answers it sets.
|
|
366
|
+
const body = this.body();
|
|
367
|
+
// An exercise that opens with its text (a gap in its first sentence):
|
|
368
|
+
// the number runs into that paragraph.
|
|
369
|
+
const firstAt = body[0]?.type === 'containerStart' && body[0].containerAttrs?.style === this.ids.text ? 1 : 0;
|
|
370
|
+
if (this.ex.instruction.length === 0 && this.leadRun && body[firstAt]?.type === 'paragraph') {
|
|
371
|
+
const first = body[firstAt];
|
|
372
|
+
const run = joinRuns(this.leadRun, textRun(this.leadTight ? '' : SPACE, this.at), runOf(first));
|
|
373
|
+
body[firstAt] = { ...first, text: run.text, spans: run.spans, sourceMap: run.sourceMap };
|
|
374
|
+
head = [];
|
|
375
|
+
}
|
|
376
|
+
const wantsBank = (this.ex.attrs.bank !== undefined && this.ex.attrs.bank !== 'false') || this.ex.banks.length > 0 || this.ex.kind === 'sort';
|
|
377
|
+
// The bank stands where its `::bank` line is written, else under the
|
|
378
|
+
// instruction.
|
|
379
|
+
const bank = wantsBank ? this.bank() : [];
|
|
380
|
+
const at = body.indexOf(BANK_SLOT);
|
|
381
|
+
if (at >= 0)
|
|
382
|
+
body.splice(at, 1, ...bank);
|
|
383
|
+
const out = [outer.start, ...head, ...(at >= 0 ? [] : bank), ...body, outer.end];
|
|
384
|
+
this.carryMarks(out);
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
/** What the exercise's own name is in a reference: "Exercise 3". */
|
|
388
|
+
anchorText() {
|
|
389
|
+
return `${this.style.number.label || this.book.words.exercise} ${this.number}`.trim();
|
|
390
|
+
}
|
|
391
|
+
/** The anchors and index marks of the blocks the expansion set anew (the
|
|
392
|
+
* instruction, the items) go on the first paragraph of the exercise. */
|
|
393
|
+
carryMarks(out) {
|
|
394
|
+
// Marks a passed-through block still carries are where they were.
|
|
395
|
+
const present = new Set();
|
|
396
|
+
for (const b of out) {
|
|
397
|
+
for (const m of b.anchorMarks ?? [])
|
|
398
|
+
present.add(`a${m.anchorId}@${m.sourceStart}`);
|
|
399
|
+
for (const m of b.indexMarks ?? [])
|
|
400
|
+
present.add(`i${m.sourceStart}`);
|
|
401
|
+
}
|
|
402
|
+
const anchors = [];
|
|
403
|
+
const index = [];
|
|
404
|
+
for (let k = this.ex.start + 1; k < this.ex.end; k++) {
|
|
405
|
+
const b = this.blocks[k];
|
|
406
|
+
for (const m of b.anchorMarks ?? [])
|
|
407
|
+
if (!present.has(`a${m.anchorId}@${m.sourceStart}`))
|
|
408
|
+
anchors.push(m);
|
|
409
|
+
for (const m of b.indexMarks ?? [])
|
|
410
|
+
if (!present.has(`i${m.sourceStart}`))
|
|
411
|
+
index.push(m);
|
|
412
|
+
}
|
|
413
|
+
if (anchors.length === 0 && index.length === 0)
|
|
414
|
+
return;
|
|
415
|
+
const host = out.find((b) => b.type === 'paragraph' || b.type === 'listItem');
|
|
416
|
+
if (!host)
|
|
417
|
+
return;
|
|
418
|
+
const i = out.indexOf(host);
|
|
419
|
+
out[i] = { ...host, anchorMarks: [...anchors, ...(host.anchorMarks ?? [])], ...(index.length > 0 ? { indexMarks: [...index, ...(host.indexMarks ?? [])] } : {}) };
|
|
420
|
+
}
|
|
421
|
+
/** The number, level marks, icons, title and instruction. */
|
|
422
|
+
head() {
|
|
423
|
+
const ex = this.ex;
|
|
424
|
+
const n = this.style.number;
|
|
425
|
+
const lead = [];
|
|
426
|
+
// A full-width suffix carries its own room: `一、看拼音`, not `一、 看拼音`.
|
|
427
|
+
const tight = /[\u3001\u3002\uFF0C\uFF0E\uFF1A]$/u.test(n.suffix);
|
|
428
|
+
if (n.show && this.number) {
|
|
429
|
+
const text = `${n.label ? `${n.label} ` : ''}${this.number}${n.suffix}`;
|
|
430
|
+
lead.push(chipRun(this.ids.number, textRun(text, this.at), this.at));
|
|
431
|
+
}
|
|
432
|
+
const level = ex.attrs.level !== undefined ? Math.max(0, Math.min(this.style.level.max, parseInt(ex.attrs.level, 10) || 0)) : 0;
|
|
433
|
+
if (level > 0) {
|
|
434
|
+
const marks = this.style.level.glyph.repeat(level) + this.style.level.empty.repeat(this.style.level.max - level);
|
|
435
|
+
lead.push(chipRun(this.ids.level, textRun(marks, this.at), this.at));
|
|
436
|
+
}
|
|
437
|
+
// Activity icons are inline symbols (#1058): a picture of the book or a
|
|
438
|
+
// glyph in its own face, in its own colour, read as their `alt`.
|
|
439
|
+
for (const name of (ex.attrs.icon ?? '').split(/\s+/).filter(Boolean)) {
|
|
440
|
+
const icon = this.style.icons[name];
|
|
441
|
+
if (!icon || (!icon.resourceId && !icon.glyph))
|
|
442
|
+
continue;
|
|
443
|
+
const symbol = {
|
|
444
|
+
alt: icon.alt ?? '',
|
|
445
|
+
...(icon.resourceId ? { resource: icon.resourceId } : { glyph: icon.glyph, ...(icon.fontFamily ? { font: icon.fontFamily } : {}) }),
|
|
446
|
+
...(icon.size !== undefined && icon.size > 0 ? { size: icon.size } : {}),
|
|
447
|
+
};
|
|
448
|
+
const color = icon.color?.paletteId ?? icon.color?.hex ?? '';
|
|
449
|
+
lead.push(textRun(SWATCH_PLACEHOLDER, this.at, { swatch: { color, icon: symbol } }));
|
|
450
|
+
}
|
|
451
|
+
if (ex.attrs.title)
|
|
452
|
+
lead.push(textRun(ex.attrs.title, this.at, { bold: true }));
|
|
453
|
+
const first = ex.instruction[0];
|
|
454
|
+
const rest = ex.instruction.slice(1);
|
|
455
|
+
const runs = [];
|
|
456
|
+
const numbered = n.show && !!this.number;
|
|
457
|
+
const leadRun = joinRuns(...lead.flatMap((r, k) => (k > 0 ? [textRun(k === 1 && numbered && tight ? '' : SPACE, this.at), r] : [r])));
|
|
458
|
+
this.leadRun = leadRun.text ? leadRun : undefined;
|
|
459
|
+
this.leadTight = numbered && tight && lead.length === 1;
|
|
460
|
+
const bold = this.style.instruction.bold;
|
|
461
|
+
const styled = (r) => (bold ? { ...r, spans: r.spans.map((s) => (s.chip ? s : { ...s, bold: true })) } : r);
|
|
462
|
+
if (first !== undefined) {
|
|
463
|
+
const body = styled(this.gapsOf(runOf(this.blocks[first])));
|
|
464
|
+
runs.push(leadRun.text ? joinRuns(leadRun, textRun(this.leadTight ? '' : SPACE, this.at), body) : body);
|
|
465
|
+
}
|
|
466
|
+
else if (leadRun.text) {
|
|
467
|
+
runs.push(leadRun);
|
|
468
|
+
}
|
|
469
|
+
for (const k of rest)
|
|
470
|
+
runs.push(styled(this.gapsOf(runOf(this.blocks[k]))));
|
|
471
|
+
return this.paragraphs(this.ids.instruction, runs);
|
|
472
|
+
}
|
|
473
|
+
/** The body: blocks passed through (their answers set), the kind's items
|
|
474
|
+
* set in their place, `'bank'` where the word bank goes. */
|
|
475
|
+
body() {
|
|
476
|
+
const ex = this.ex;
|
|
477
|
+
const out = [];
|
|
478
|
+
const done = new Set();
|
|
479
|
+
const lists = ex.body.filter((k) => this.blocks[k].type === 'listItem' && (this.blocks[k].depth ?? 1) <= 1);
|
|
480
|
+
const firstOf = (items, blockOf) => (items.length > 0 ? blockOf(items[0]) : undefined);
|
|
481
|
+
const groupStart = new Map();
|
|
482
|
+
switch (ex.kind) {
|
|
483
|
+
case 'choice':
|
|
484
|
+
ex.questions.forEach((q, qi) => groupStart.set(q.stem ?? q.options[0].block, () => this.options(qi)));
|
|
485
|
+
break;
|
|
486
|
+
case 'truefalse': {
|
|
487
|
+
const at = firstOf(ex.questions, (q) => q.options[0].block);
|
|
488
|
+
if (at !== undefined)
|
|
489
|
+
groupStart.set(at, () => this.statements());
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
case 'match': {
|
|
493
|
+
const at = firstOf(ex.pairs, (p) => p.block);
|
|
494
|
+
if (at !== undefined)
|
|
495
|
+
groupStart.set(at, () => this.match());
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case 'order': {
|
|
499
|
+
const at = ex.sequence[0];
|
|
500
|
+
if (at !== undefined)
|
|
501
|
+
groupStart.set(at, () => this.order());
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case 'scramble': {
|
|
505
|
+
const at = firstOf(ex.sentences, (s) => s.block);
|
|
506
|
+
if (at !== undefined)
|
|
507
|
+
groupStart.set(at, () => this.scramble());
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'sort': {
|
|
511
|
+
const at = firstOf(ex.categories, (c) => c.block);
|
|
512
|
+
if (at !== undefined)
|
|
513
|
+
groupStart.set(at, () => this.sort());
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'wordsearch':
|
|
517
|
+
case 'crossword':
|
|
518
|
+
case 'arithmetic': {
|
|
519
|
+
const items = ex.kind === 'arithmetic' ? ex.operations.map((o) => o.block) : ex.entries.map((e) => e.block);
|
|
520
|
+
if (items[0] !== undefined)
|
|
521
|
+
groupStart.set(items[0], () => this.grid());
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
default:
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
// The exercise's own paragraphs, a run at a time, in its text style
|
|
528
|
+
// (never indented); not inside a container the author opened.
|
|
529
|
+
let text = [];
|
|
530
|
+
let depth = 0;
|
|
531
|
+
const bankAt = ex.banks[0];
|
|
532
|
+
let bankPlaced = false;
|
|
533
|
+
const flush = () => {
|
|
534
|
+
if (text.length === 0)
|
|
535
|
+
return;
|
|
536
|
+
const g = this.open('paragraphs', { style: this.ids.text });
|
|
537
|
+
out.push(g.start, ...text, g.end);
|
|
538
|
+
text = [];
|
|
539
|
+
};
|
|
540
|
+
for (const k of ex.body) {
|
|
541
|
+
if (done.has(k))
|
|
542
|
+
continue;
|
|
543
|
+
if (bankAt !== undefined && !bankPlaced && k > bankAt) {
|
|
544
|
+
flush();
|
|
545
|
+
out.push(BANK_SLOT);
|
|
546
|
+
bankPlaced = true;
|
|
547
|
+
}
|
|
548
|
+
const group = groupStart.get(k);
|
|
549
|
+
if (group) {
|
|
550
|
+
flush();
|
|
551
|
+
out.push(...group());
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (ex.items.has(k))
|
|
555
|
+
continue;
|
|
556
|
+
const b = this.blocks[k];
|
|
557
|
+
if (b.type === 'containerStart')
|
|
558
|
+
depth++;
|
|
559
|
+
if (b.type === 'containerEnd')
|
|
560
|
+
depth--;
|
|
561
|
+
const set = this.passThrough(b, k);
|
|
562
|
+
if (depth === 0 && set.type === 'paragraph') {
|
|
563
|
+
text.push(set);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
flush();
|
|
567
|
+
out.push(set);
|
|
568
|
+
// An open question: lines under each item of the list.
|
|
569
|
+
if (ex.kind === 'open' && b.type === 'listItem' && (b.depth ?? 1) <= 1) {
|
|
570
|
+
out.push(...this.area(this.openLines(), `t${lists.indexOf(k) + 1}`, this.openAnswer(lists.indexOf(k)), this.openArea()));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
flush();
|
|
574
|
+
if (bankAt !== undefined && !bankPlaced)
|
|
575
|
+
out.push(BANK_SLOT);
|
|
576
|
+
if (ex.kind === 'open' && lists.length === 0)
|
|
577
|
+
out.push(...this.area(this.openLines(), 't1', this.openAnswer(0), this.openArea()));
|
|
578
|
+
if (ex.kind === 'gaps' || ex.kind === 'choice' || ex.kind === 'mark')
|
|
579
|
+
this.keyOfGaps();
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
/** The writing area an open question asks for (`area=grid`), else the
|
|
583
|
+
* style's. */
|
|
584
|
+
openArea() {
|
|
585
|
+
const k = this.ex.attrs.area?.trim();
|
|
586
|
+
return k === 'lines' || k === 'dots' || k === 'grid' || k === 'seyes' || k === 'tianzige' || k === 'box' || k === 'none' ? k : undefined;
|
|
587
|
+
}
|
|
588
|
+
openLines() {
|
|
589
|
+
const n = parseInt(this.ex.attrs.lines ?? '', 10);
|
|
590
|
+
return Number.isFinite(n) && n > 0 ? n : this.style.area.lines;
|
|
591
|
+
}
|
|
592
|
+
/** The model answer of an open question, cut into the lines a teacher's
|
|
593
|
+
* edition writes it on (only the first solution's first paragraph). */
|
|
594
|
+
openAnswer(k) {
|
|
595
|
+
const sol = this.ex.solutions[0];
|
|
596
|
+
if (!sol)
|
|
597
|
+
return undefined;
|
|
598
|
+
const paras = [];
|
|
599
|
+
for (let j = sol.start + 1; j < sol.end; j++) {
|
|
600
|
+
const b = this.blocks[j];
|
|
601
|
+
if (b.type === 'paragraph' || b.type === 'listItem')
|
|
602
|
+
paras.push(plain(runOf(b)));
|
|
603
|
+
}
|
|
604
|
+
const text = paras[k] ?? (k === 0 ? paras[0] : undefined);
|
|
605
|
+
return text ? [text] : undefined;
|
|
606
|
+
}
|
|
607
|
+
/** A block of the body with its answers set. */
|
|
608
|
+
passThrough(b, k) {
|
|
609
|
+
// A question's stem ends in a bracket to write the letter in (Chinese
|
|
610
|
+
// and Japanese tests), its answer written in a teacher's edition.
|
|
611
|
+
const qi = this.style.options.bracket ? this.ex.questions.findIndex((q) => q.stem === k) : -1;
|
|
612
|
+
if (!b.spans.some((s) => s.chip?.gap) && qi < 0)
|
|
613
|
+
return b;
|
|
614
|
+
let run = this.gapsOf(runOf(b));
|
|
615
|
+
if (qi >= 0) {
|
|
616
|
+
const at = b.sourceEnd;
|
|
617
|
+
const answer = this.bracketAnswer(qi);
|
|
618
|
+
run = joinRuns(run, textRun('\uFF08', at), chipRun(this.teacher ? this.ids.answer : this.ids.icon, textRun(this.teacher ? answer : '\u2003\u2003', at), at, {
|
|
619
|
+
...(this.teacher ? {} : { blank: true, blankLabel: this.book.words.blank }), exercise: { ex: this.ex.id, slot: `q${qi + 1}b` },
|
|
620
|
+
}), textRun('\uFF09', at));
|
|
621
|
+
}
|
|
622
|
+
return { ...b, text: run.text, spans: run.spans, sourceMap: run.sourceMap };
|
|
623
|
+
}
|
|
624
|
+
/** The labels of a question's right options, as its bracket holds them. */
|
|
625
|
+
bracketAnswer(qi) {
|
|
626
|
+
const q = this.ex.questions[qi];
|
|
627
|
+
return q.options.flatMap((o, k) => (o.correct ? [this.label(k).replace(/[).\uFF0E\u3001]$/u, '')] : [])).join('');
|
|
628
|
+
}
|
|
629
|
+
// --- Answers in the text -----------------------------------------------------
|
|
630
|
+
longestAnswer() {
|
|
631
|
+
let best = '';
|
|
632
|
+
for (const g of this.ex.gaps) {
|
|
633
|
+
if (g.gap.choice)
|
|
634
|
+
continue;
|
|
635
|
+
const t = g.gap.texts[g.gap.correct[0] ?? 0] ?? '';
|
|
636
|
+
if (t.length > best.length)
|
|
637
|
+
best = t;
|
|
638
|
+
}
|
|
639
|
+
return best;
|
|
640
|
+
}
|
|
641
|
+
/** `run` with each `[[…]]` set: a blank, an inline choice or a word to
|
|
642
|
+
* mark. */
|
|
643
|
+
gapsOf(run) {
|
|
644
|
+
if (!run.spans.some((s) => s.chip?.gap))
|
|
645
|
+
return run;
|
|
646
|
+
const pieces = [];
|
|
647
|
+
let at = 0;
|
|
648
|
+
for (const span of run.spans) {
|
|
649
|
+
const len = span.text.length;
|
|
650
|
+
const piece = sliceRun(run, at, at + len);
|
|
651
|
+
const src = run.sourceMap[at] ?? this.at;
|
|
652
|
+
at += len;
|
|
653
|
+
const gap = span.chip?.gap;
|
|
654
|
+
if (!gap) {
|
|
655
|
+
pieces.push(piece);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const ref = this.ex.gaps.find((g) => g.gap === gap);
|
|
659
|
+
const n = ref?.n ?? 0;
|
|
660
|
+
pieces.push(this.ex.kind === 'mark' ? this.markRun(gap, n, src) : gap.choice ? this.choiceRun(gap, n, src) : this.blankRun(gap, n, src, span.bold, span.italic));
|
|
661
|
+
}
|
|
662
|
+
return joinRuns(...pieces);
|
|
663
|
+
}
|
|
664
|
+
blankRun(gap, n, at, bold, italic) {
|
|
665
|
+
const answer = { text: gap.texts[gap.correct[0] ?? 0] ?? '', spans: gap.options[gap.correct[0] ?? 0] ?? [], sourceMap: [] };
|
|
666
|
+
const width = this.style.blank.width;
|
|
667
|
+
// Room past the answer: em spaces as many as `extra` holds ems.
|
|
668
|
+
const extra = '\u2003'.repeat(Math.max(0, Math.round(dimPt(this.style.blank.extra, this.ctx.bodySizePt) / this.ctx.bodySizePt)));
|
|
669
|
+
const widthText = width === 'longest' ? this.longestAnswer() + extra : width === 'answer' ? answer.text + extra : undefined;
|
|
670
|
+
const words = answer.spans.length > 0 ? answer : textRun(answer.text || '\u00A0', at);
|
|
671
|
+
const slot = `g${n}`;
|
|
672
|
+
const shown = this.answered(n === 1);
|
|
673
|
+
if (!(this.example && n === 1 && !this.teacher))
|
|
674
|
+
this.slots.push({ key: slot, kind: 'gap', accept: gap.texts.filter((_, k) => gap.correct.includes(k)) });
|
|
675
|
+
// The model item's answer is already written: the bank leaves it out.
|
|
676
|
+
if (!(this.example && n === 1))
|
|
677
|
+
this.bankWords.push(textRun(answer.text, at));
|
|
678
|
+
// `[[went]]{width=6}`: a blank six ems wide.
|
|
679
|
+
const ems = parseFloat(gap.attrs?.width ?? '');
|
|
680
|
+
const run = chipRun(shown ? this.ids.blankAnswer : this.ids.blank, words, at, {
|
|
681
|
+
...(Number.isFinite(ems) && ems > 0 ? { widthText: '\u2003'.repeat(Math.round(ems)) } : widthText ? { widthText } : {}),
|
|
682
|
+
...(shown ? {} : { blank: true, blankLabel: this.book.words.blank }),
|
|
683
|
+
...this.hit(slot, n === 1),
|
|
684
|
+
});
|
|
685
|
+
run.spans[0] = { ...run.spans[0], bold, italic };
|
|
686
|
+
return run;
|
|
687
|
+
}
|
|
688
|
+
choiceRun(gap, n, at) {
|
|
689
|
+
// Chinese and Japanese print the choice in full-width brackets, the
|
|
690
|
+
// options an ideographic space (`(zhǒng zhòng)`) or a middle dot
|
|
691
|
+
// (`(ちりました・のぼりました)`) apart.
|
|
692
|
+
const lang = this.book.language;
|
|
693
|
+
const wide = lang === 'zh' || lang === 'ja';
|
|
694
|
+
const parts = [textRun(wide ? '\uFF08' : '(', at)];
|
|
695
|
+
gap.options.forEach((opt, k) => {
|
|
696
|
+
if (k > 0)
|
|
697
|
+
parts.push(textRun(lang === 'zh' ? '\u3000' : lang === 'ja' ? '\u30FB' : ' / ', at));
|
|
698
|
+
const right = gap.correct.includes(k);
|
|
699
|
+
const words = { text: gap.texts[k] ?? '', spans: opt.length > 0 ? opt : [{ text: gap.texts[k] || '\u00A0', bold: false, italic: false }], sourceMap: [] };
|
|
700
|
+
parts.push(chipRun(this.answered(n === 1) && right ? this.ids.choiceRing : this.ids.choice, words, at, this.hit(`g${n}o${k + 1}`, n === 1)));
|
|
701
|
+
});
|
|
702
|
+
parts.push(textRun(wide ? '\uFF09' : ')', at));
|
|
703
|
+
this.slots.push({ key: `g${n}`, kind: 'choice', options: gap.options.length, correct: gap.correct.map((k) => k + 1), multiple: false });
|
|
704
|
+
return joinRuns(...parts);
|
|
705
|
+
}
|
|
706
|
+
markRun(gap, n, at) {
|
|
707
|
+
const words = { text: gap.texts[0] ?? '', spans: gap.options[0] ?? [], sourceMap: [] };
|
|
708
|
+
this.slots.push({ key: `m${n}`, kind: 'mark', correct: [1] });
|
|
709
|
+
// How the answer shows the word: a ring (the default), a box, a line
|
|
710
|
+
// under it, a highlight; per word (`[[riega]]{mark=underline}`) or for
|
|
711
|
+
// the exercise.
|
|
712
|
+
const look = (gap.attrs?.mark ?? this.ex.attrs.mark ?? 'circle').trim();
|
|
713
|
+
const on = look === 'box' || look === 'underline' || look === 'highlight' ? `${this.ids.markOn}-${look}` : this.ids.markOn;
|
|
714
|
+
return chipRun(this.answered(n === 1) ? on : this.ids.mark, words.spans.length > 0 ? words : textRun(words.text, at), at, this.hit(`m${n}`, n === 1));
|
|
715
|
+
}
|
|
716
|
+
keyOfGaps() {
|
|
717
|
+
for (const g of this.ex.gaps) {
|
|
718
|
+
const answer = g.gap.correct.map((k) => g.gap.texts[k] ?? '').filter(Boolean)[0] ?? '';
|
|
719
|
+
if (answer)
|
|
720
|
+
this.key.push(textRun(this.ex.gaps.length > 1 ? `${this.num(g.n)} ${answer}` : answer, this.at));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** The word bank: the blanks' answers and the distractors of `::bank`
|
|
724
|
+
* lines, in a box. */
|
|
725
|
+
bank() {
|
|
726
|
+
const words = this.ex.kind === 'sort'
|
|
727
|
+
? this.ex.categories.flatMap((c) => c.items)
|
|
728
|
+
: [...this.bankWords.filter((w) => w.text !== '')];
|
|
729
|
+
for (const k of this.ex.banks) {
|
|
730
|
+
const b = this.blocks[k];
|
|
731
|
+
if (b.text.trim()) {
|
|
732
|
+
const run = runOf(b);
|
|
733
|
+
let last = 0;
|
|
734
|
+
for (let j = 0; j <= run.text.length; j++) {
|
|
735
|
+
if (j === run.text.length || run.text[j] === '|') {
|
|
736
|
+
const w = trimRun(sliceRun(run, last, j));
|
|
737
|
+
if (w.text)
|
|
738
|
+
words.push(w);
|
|
739
|
+
last = j + 1;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// The answers of a gaps exercise are known only once the body is set.
|
|
745
|
+
if (words.length === 0 && this.ex.kind !== 'sort') {
|
|
746
|
+
for (const g of this.ex.gaps)
|
|
747
|
+
if (!g.gap.choice && !(this.example && g.n === 1))
|
|
748
|
+
words.push(textRun(g.gap.texts[g.gap.correct[0] ?? 0] ?? '', this.at));
|
|
749
|
+
}
|
|
750
|
+
const order = this.style.bank.order === 'written'
|
|
751
|
+
? words.map((_, k) => k)
|
|
752
|
+
: this.style.bank.order === 'alpha'
|
|
753
|
+
? words.map((_, k) => k).sort((a, b) => words[a].text.localeCompare(words[b].text, this.ctx.locale))
|
|
754
|
+
: shuffledOrder(words.length, seededRandom(this.book.seed, this.ex.ordinal, 'bank'));
|
|
755
|
+
const parts = [];
|
|
756
|
+
order.forEach((k, j) => {
|
|
757
|
+
if (j > 0)
|
|
758
|
+
parts.push(textRun(this.style.bank.separator, this.at));
|
|
759
|
+
const slot = this.ex.kind === 'sort' ? { exercise: { ex: this.ex.id, slot: `s${k + 1}` } } : {};
|
|
760
|
+
parts.push(chipRun(this.ids.word, words[k], words[k].sourceMap[0] ?? this.at, slot));
|
|
761
|
+
});
|
|
762
|
+
if (parts.length === 0)
|
|
763
|
+
return [];
|
|
764
|
+
const boxId = this.style.bank.box && this.ctx.boxIds.has(this.style.bank.box) ? this.style.bank.box : this.ids.bankBox;
|
|
765
|
+
const g = this.open('callout', { type: boxId });
|
|
766
|
+
return [g.start, ...this.paragraphs(this.ids.text, [joinRuns(...parts)]), g.end];
|
|
767
|
+
}
|
|
768
|
+
// --- Choice and true/false ---------------------------------------------------
|
|
769
|
+
optionText(block) {
|
|
770
|
+
return this.gapsOf(trimRun(runOf(this.blocks[block])));
|
|
771
|
+
}
|
|
772
|
+
options(qi) {
|
|
773
|
+
const q = this.ex.questions[qi];
|
|
774
|
+
const o = this.style.options;
|
|
775
|
+
const order = o.shuffle ? shuffledOrder(q.options.length, seededRandom(this.book.seed, this.ex.ordinal, 'q', qi), false) : q.options.map((_, k) => k);
|
|
776
|
+
const group = `q${qi + 1}`;
|
|
777
|
+
this.slots.push({ key: group, kind: 'choice', options: q.options.length, correct: order.flatMap((k, pos) => (q.options[k].correct ? [pos + 1] : [])), multiple: q.multiple });
|
|
778
|
+
// Side by side (`columns=n` or a row layout), a label and its words are
|
|
779
|
+
// a space apart: the tabs go between the options.
|
|
780
|
+
const asked = parseInt(this.ex.attrs.columns ?? '', 10);
|
|
781
|
+
const inRows = (Number.isFinite(asked) && asked > 0) || o.layout === 'row';
|
|
782
|
+
const items = order.map((k, pos) => {
|
|
783
|
+
const opt = q.options[k];
|
|
784
|
+
const at = this.blocks[opt.block].sourceStart;
|
|
785
|
+
const label = this.label(pos);
|
|
786
|
+
const parts = [];
|
|
787
|
+
const slot = this.hit(`${group}o${pos + 1}`, qi === 0);
|
|
788
|
+
const shown = this.answered(qi === 0);
|
|
789
|
+
if (o.marker !== 'none') {
|
|
790
|
+
parts.push(chipRun(shown && opt.correct ? this.ids.tickOn : this.ids.tick, textRun('\u00A0', at), at, slot));
|
|
791
|
+
parts.push(textRun(SPACE, at));
|
|
792
|
+
}
|
|
793
|
+
if (label) {
|
|
794
|
+
const labelRun = textRun(label, at);
|
|
795
|
+
parts.push(o.marker === 'none' ? chipRun(shown && opt.correct ? this.ids.choiceRing : this.ids.choice, labelRun, at, slot) : labelRun);
|
|
796
|
+
}
|
|
797
|
+
if (parts.length > 0)
|
|
798
|
+
parts.push(inRows ? textRun('\u00A0', at) : tabRun(at));
|
|
799
|
+
parts.push(this.optionText(opt.block));
|
|
800
|
+
return joinRuns(...parts);
|
|
801
|
+
});
|
|
802
|
+
const rightLabels = order.flatMap((k, pos) => (q.options[k].correct ? [this.bareLabel(pos) || plain(this.optionText(q.options[k].block))] : []));
|
|
803
|
+
// One question: its right option's label and words; several: the labels.
|
|
804
|
+
const single = this.ex.questions.length === 1 && rightLabels.length === 1 && o.labels !== 'none';
|
|
805
|
+
if (single) {
|
|
806
|
+
const right = order.findIndex((k) => q.options[k].correct);
|
|
807
|
+
this.key.push(joinRuns(textRun(`${this.label(right)} `, this.at), this.optionText(q.options[order[right]].block)));
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
this.key.push(textRun(`${this.ex.questions.length > 1 ? `${this.num(qi + 1)} ` : ''}${rightLabels.join(', ')}`, this.at));
|
|
811
|
+
}
|
|
812
|
+
// The stem's bracket to write the letter in (Chinese and Japanese tests).
|
|
813
|
+
const blocks = [];
|
|
814
|
+
// The stem, numbered as the questions are counted.
|
|
815
|
+
if (q.stem !== undefined) {
|
|
816
|
+
const stem = this.passThrough(this.blocks[q.stem], q.stem);
|
|
817
|
+
const at = stem.sourceStart;
|
|
818
|
+
const n = this.ex.questions.filter((x) => x.stem !== undefined).indexOf(q) + 1;
|
|
819
|
+
blocks.push(...this.paragraphs(this.ids.item, [joinRuns(textRun(`${this.num(n)}.`, at, { bold: true }), tabRun(at), runOf(stem))]));
|
|
820
|
+
}
|
|
821
|
+
const optionStyle = q.stem !== undefined ? this.ids.optionUnder : this.ids.option;
|
|
822
|
+
// `columns=3` on the fence, or a row layout: the options side by side.
|
|
823
|
+
if (inRows) {
|
|
824
|
+
const per = Math.max(1, Math.min(6, Number.isFinite(asked) && asked > 0 ? asked : o.columns ?? items.length));
|
|
825
|
+
const rows = [];
|
|
826
|
+
for (let k = 0; k < items.length; k += per) {
|
|
827
|
+
rows.push(joinRuns(...items.slice(k, k + per).flatMap((r, j) => (j > 0 ? [tabRun(this.at), r] : [r]))));
|
|
828
|
+
}
|
|
829
|
+
blocks.push(...this.paragraphs(`${this.ids.row}-${Math.min(6, per)}`, rows));
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
blocks.push(...this.paragraphs(optionStyle, items));
|
|
833
|
+
}
|
|
834
|
+
return blocks;
|
|
835
|
+
}
|
|
836
|
+
statements() {
|
|
837
|
+
const [t, f] = this.style.trueFalse.labels;
|
|
838
|
+
const runs = [];
|
|
839
|
+
this.ex.questions.forEach((q, k) => {
|
|
840
|
+
const opt = q.options[0];
|
|
841
|
+
const at = this.blocks[opt.block].sourceStart;
|
|
842
|
+
const slot = `tf${k + 1}`;
|
|
843
|
+
this.slots.push({ key: slot, kind: 'truefalse', correct: [opt.correct ? 1 : 2] });
|
|
844
|
+
const parts = [textRun(`${this.num(k + 1)}.`, at), tabRun(at), this.optionText(opt.block), tabRun(at)];
|
|
845
|
+
if (this.style.trueFalse.look === 'bracket') {
|
|
846
|
+
// One bracket at the line's end to write the answer in: full width
|
|
847
|
+
// in Chinese and Japanese, `( )`.
|
|
848
|
+
const wide = this.book.language === 'zh' || this.book.language === 'ja';
|
|
849
|
+
const answer = textRun(opt.correct ? t : f, at);
|
|
850
|
+
const shown = this.answered(k === 0);
|
|
851
|
+
parts.push(textRun(wide ? '\uFF08' : '(', at), chipRun(shown ? this.ids.answer : this.ids.icon, answer, at, {
|
|
852
|
+
widthText: `${t.length > f.length ? t : f}\u2003`, ...(shown ? {} : { blank: true, blankLabel: this.book.words.blank }), ...this.hit(slot, k === 0),
|
|
853
|
+
}), textRun(wide ? '\uFF09' : ')', at));
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
parts.push(textRun(`${t}\u00A0`, at), chipRun(this.answered(k === 0) && opt.correct ? this.ids.tickOn : this.ids.tick, textRun('\u00A0', at), at, this.hit(`${slot}t`, k === 0)));
|
|
857
|
+
parts.push(textRun(`\u2003${f}\u00A0`, at), chipRun(this.answered(k === 0) && !opt.correct ? this.ids.tickOn : this.ids.tick, textRun('\u00A0', at), at, this.hit(`${slot}f`, k === 0)));
|
|
858
|
+
}
|
|
859
|
+
runs.push(joinRuns(...parts));
|
|
860
|
+
this.key.push(textRun(`${this.num(k + 1)} ${opt.correct ? t : f}`, this.at));
|
|
861
|
+
});
|
|
862
|
+
return this.paragraphs(this.ids.statement, runs);
|
|
863
|
+
}
|
|
864
|
+
// --- Match, order, scramble, sort -------------------------------------------
|
|
865
|
+
match() {
|
|
866
|
+
const pairs = this.ex.pairs;
|
|
867
|
+
const lefts = pairs.filter((p) => p.left.text !== '');
|
|
868
|
+
const rights = pairs.map((p) => p.right);
|
|
869
|
+
const order = this.ex.attrs.shuffle === 'false' ? rights.map((_, k) => k) : shuffledOrder(rights.length, seededRandom(this.book.seed, this.ex.ordinal, 'match'));
|
|
870
|
+
// Position of each pair's right item in the printed column.
|
|
871
|
+
const posOf = new Map();
|
|
872
|
+
order.forEach((k, pos) => posOf.set(k, pos));
|
|
873
|
+
lefts.forEach((p, i) => {
|
|
874
|
+
const pos = posOf.get(pairs.indexOf(p));
|
|
875
|
+
// Written as the right item's label (the letters look).
|
|
876
|
+
const label = this.style.match.look === 'dots' ? undefined : this.bareLabel(pos);
|
|
877
|
+
this.slots.push({ key: `p${i + 1}`, kind: 'pair', correct: [pos + 1], ...(label ? { accept: [label] } : {}) });
|
|
878
|
+
this.key.push(this.style.match.look === 'dots'
|
|
879
|
+
? joinRuns(this.gapsOf(p.left), textRun(' \u2013 ', this.at), this.gapsOf(p.right))
|
|
880
|
+
: textRun(`${this.num(i + 1)} ${this.bareLabel(pos) || this.num(pos + 1)}`, this.at));
|
|
881
|
+
});
|
|
882
|
+
if (this.style.match.look === 'dots') {
|
|
883
|
+
return this.drawnGrid(gridBox({
|
|
884
|
+
kind: 'match', id: `${this.ids.frame}-match-${this.ex.ordinal}`, style: this.style,
|
|
885
|
+
left: lefts.map((p) => plain(this.gapsOf(p.left))), right: order.map((k) => plain(this.gapsOf(rights[k]))),
|
|
886
|
+
links: this.teacher ? lefts.map((p) => posOf.get(pairs.indexOf(p))) : this.example && lefts[0] ? [posOf.get(pairs.indexOf(lefts[0]))] : undefined,
|
|
887
|
+
bodySizePt: this.ctx.bodySizePt, lineHeightPt: this.ctx.lineHeightPt, measurePt: this.measure, labels: order.map((_, pos) => this.label(pos)),
|
|
888
|
+
...(this.ctx.bodyFont ? { font: this.ctx.bodyFont } : {}),
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
const rows = Math.max(lefts.length, order.length);
|
|
892
|
+
const runs = [];
|
|
893
|
+
for (let i = 0; i < rows; i++) {
|
|
894
|
+
const parts = [];
|
|
895
|
+
const p = lefts[i];
|
|
896
|
+
if (p) {
|
|
897
|
+
const pos = posOf.get(pairs.indexOf(p));
|
|
898
|
+
parts.push(textRun(`${this.num(i + 1)}. `, this.at), this.gapsOf(p.left), textRun(' ', this.at));
|
|
899
|
+
parts.push(chipRun(this.answered(i === 0) ? this.ids.blankAnswer : this.ids.blank, textRun(this.bareLabel(pos) || this.num(pos + 1), this.at), this.at, {
|
|
900
|
+
widthText: '\u2003\u2003', ...(this.answered(i === 0) ? {} : { blank: true, blankLabel: this.book.words.blank }), ...this.hit(`p${i + 1}`, i === 0),
|
|
901
|
+
}));
|
|
902
|
+
}
|
|
903
|
+
parts.push(tabRun(this.at));
|
|
904
|
+
const k = order[i];
|
|
905
|
+
if (k !== undefined)
|
|
906
|
+
parts.push(textRun(`${this.label(i) || this.num(i + 1)} `, this.at), this.gapsOf(rights[k]));
|
|
907
|
+
runs.push(joinRuns(...parts));
|
|
908
|
+
}
|
|
909
|
+
return this.paragraphs(this.ids.pairs, runs);
|
|
910
|
+
}
|
|
911
|
+
order() {
|
|
912
|
+
const seq = this.ex.sequence;
|
|
913
|
+
const order = this.ex.attrs.shuffle === 'false' ? seq.map((_, k) => k) : shuffledOrder(seq.length, seededRandom(this.book.seed, this.ex.ordinal, 'order'));
|
|
914
|
+
const runs = order.map((k, pos) => {
|
|
915
|
+
const at = this.blocks[seq[k]].sourceStart;
|
|
916
|
+
const slot = `o${pos + 1}`;
|
|
917
|
+
this.slots.push({ key: slot, kind: 'rank', accept: [String(k + 1), this.num(k + 1)].filter((v, j, a) => a.indexOf(v) === j) });
|
|
918
|
+
return joinRuns(chipRun(this.answered(k === 0) ? this.ids.rankAnswer : this.ids.rank, textRun(this.num(k + 1), at), at, {
|
|
919
|
+
...(this.answered(k === 0) ? {} : { blank: true, blankLabel: this.book.words.blank }), ...this.hit(slot, k === 0),
|
|
920
|
+
}), tabRun(at), this.optionText(seq[k]));
|
|
921
|
+
});
|
|
922
|
+
this.key.push(textRun(order.map((k) => this.num(k + 1)).join(this.sep), this.at));
|
|
923
|
+
return this.paragraphs(this.ids.item, runs);
|
|
924
|
+
}
|
|
925
|
+
scramble() {
|
|
926
|
+
const out = [];
|
|
927
|
+
this.ex.sentences.forEach((s, k) => {
|
|
928
|
+
const at = this.blocks[s.block].sourceStart;
|
|
929
|
+
const order = shuffledOrder(s.pieces.length, seededRandom(this.book.seed, this.ex.ordinal, 'scramble', k));
|
|
930
|
+
const words = order.flatMap((j, pos) => (pos > 0 ? [textRun(' / ', at), s.pieces[j]] : [s.pieces[j]]));
|
|
931
|
+
out.push(...this.paragraphs(this.ids.item, [joinRuns(textRun(`${this.num(k + 1)}.`, at), tabRun(at), ...words)]));
|
|
932
|
+
const sentence = s.pieces.map((p) => plain(p)).join(' ');
|
|
933
|
+
out.push(...this.area(1, `sc${k + 1}`, [sentence], this.style.area.kind === 'none' ? 'lines' : this.style.area.kind, undefined, this.example && k === 0));
|
|
934
|
+
this.key.push(textRun(`${this.num(k + 1)} ${sentence}`, at));
|
|
935
|
+
});
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
sort() {
|
|
939
|
+
const cats = this.ex.categories;
|
|
940
|
+
const rows = Math.max(1, ...cats.map((c) => c.items.length));
|
|
941
|
+
const out = [];
|
|
942
|
+
const count = Math.min(cats.length, 4);
|
|
943
|
+
const cols = this.open('columns', { count: String(count) });
|
|
944
|
+
// A sub-column's width: the gap between two is a line of text.
|
|
945
|
+
const colWidth = (this.measure - (count - 1) * this.ctx.lineHeightPt) / count;
|
|
946
|
+
out.push(cols.start);
|
|
947
|
+
cats.forEach((c, ci) => {
|
|
948
|
+
out.push(...this.paragraphs(this.ids.instruction, [{ ...c.name, spans: c.name.spans.map((s) => ({ ...s, bold: true })) }]));
|
|
949
|
+
out.push(...this.area(rows, `c${ci + 1}`, c.items.map((it) => plain(it)), 'lines', colWidth));
|
|
950
|
+
this.key.push(joinRuns(textRun(`${plain(c.name)}: `, this.at), textRun(c.items.map((it) => plain(it)).join(', '), this.at)));
|
|
951
|
+
});
|
|
952
|
+
out.push(cols.end);
|
|
953
|
+
// The words to sort: which category each answers.
|
|
954
|
+
// The bank's word `s<n>` is the n-th item counted through the categories.
|
|
955
|
+
cats.flatMap((c) => c.items.map(() => plain(c.name))).forEach((name, k) => this.slots.push({ key: `s${k + 1}`, kind: 'category', accept: [name] }));
|
|
956
|
+
return out;
|
|
957
|
+
}
|
|
958
|
+
grid() {
|
|
959
|
+
const ex = this.ex;
|
|
960
|
+
const box = gridBox({
|
|
961
|
+
kind: ex.kind, id: `${this.ids.frame}-grid-${ex.ordinal}`, style: this.style, exercise: ex,
|
|
962
|
+
blocks: this.blocks, seed: this.book.seed, teacher: this.teacher, bodySizePt: this.ctx.bodySizePt, lineHeightPt: this.ctx.lineHeightPt, measurePt: this.measure,
|
|
963
|
+
words: { across: this.book.words.across, down: this.book.words.down, comma: this.book.language === 'ar' ? '\u060C ' : this.book.language === 'zh' || this.book.language === 'ja' ? '\u3001' : ', ' },
|
|
964
|
+
num: (n) => this.num(n),
|
|
965
|
+
...(this.ctx.digits ? { digits: this.ctx.digits } : {}),
|
|
966
|
+
...(this.ctx.rtl ? { rtl: true } : {}),
|
|
967
|
+
...(this.ctx.bodyFont ? { font: this.ctx.bodyFont } : {}),
|
|
968
|
+
});
|
|
969
|
+
const out = this.drawnGrid(box);
|
|
970
|
+
if (box.key)
|
|
971
|
+
this.key.push(...box.key.map((k) => textRun(k, this.at)));
|
|
972
|
+
for (const w of box.unplaced ?? [])
|
|
973
|
+
this.unplaced.push(w);
|
|
974
|
+
if (box.clues && box.clues.length > 0) {
|
|
975
|
+
out.push(...this.paragraphs(this.ids.key, box.clues.map((c) => (c.heading
|
|
976
|
+
? textRun(c.label, this.at, { bold: true })
|
|
977
|
+
: joinRuns(textRun(c.label, this.at, { bold: true }), tabRun(this.at), c.clue ? this.gapsOf(c.clue) : EMPTY_RUN)))));
|
|
978
|
+
}
|
|
979
|
+
if (box.words && box.words.length > 0 && ex.attrs.hidden === undefined) {
|
|
980
|
+
out.push(...this.paragraphs(this.ids.row, [joinRuns(...box.words.flatMap((w, k) => (k > 0 ? [textRun('\u2003', this.at), textRun(w, this.at)] : [textRun(w, this.at)])))]));
|
|
981
|
+
}
|
|
982
|
+
return out;
|
|
983
|
+
}
|
|
984
|
+
// --- Solutions --------------------------------------------------------------
|
|
985
|
+
/** The solution as paragraphs: the number and the key, then (in the
|
|
986
|
+
* full layout) the worked solution's blocks. `listed`: among others at
|
|
987
|
+
* the end of a chapter. */
|
|
988
|
+
solutionBlocks(listed) {
|
|
989
|
+
if (this.key.length === 0)
|
|
990
|
+
this.exerciseBlocks();
|
|
991
|
+
const sol = solutionsOf(this.style, this.book);
|
|
992
|
+
const lead = listed && this.number ? [textRun(`${this.number}${this.style.number.suffix || '.'}`, this.at, { bold: true }), tabRun(this.at)] : [];
|
|
993
|
+
const keyRun = this.key.length > 0 ? joinRuns(...this.key.flatMap((r, k) => (k > 0 ? [textRun(this.sep, this.at), r] : [r]))) : EMPTY_RUN;
|
|
994
|
+
const out = [];
|
|
995
|
+
const worked = [];
|
|
996
|
+
if (sol.layout === 'full') {
|
|
997
|
+
for (const s of this.ex.solutions)
|
|
998
|
+
for (let j = s.start + 1; j < s.end; j++)
|
|
999
|
+
worked.push(this.blocks[j]);
|
|
1000
|
+
}
|
|
1001
|
+
// Nothing to print (an open question with no model answer): no entry.
|
|
1002
|
+
if (!keyRun.text && worked.length === 0)
|
|
1003
|
+
return [];
|
|
1004
|
+
// A worked solution's first paragraph goes on the number's line when
|
|
1005
|
+
// there is no key (an open question).
|
|
1006
|
+
if (!keyRun.text && worked[0]?.type === 'paragraph') {
|
|
1007
|
+
const first = worked.shift();
|
|
1008
|
+
out.push(...this.paragraphs(this.ids.key, [joinRuns(...lead, runOf(first))]));
|
|
1009
|
+
}
|
|
1010
|
+
else if (keyRun.text || lead.length > 0) {
|
|
1011
|
+
out.push(...this.paragraphs(this.ids.key, [joinRuns(...lead, keyRun)]));
|
|
1012
|
+
}
|
|
1013
|
+
const paras = [];
|
|
1014
|
+
const flush = () => {
|
|
1015
|
+
if (paras.length > 0)
|
|
1016
|
+
out.push(...this.paragraphs(listed ? this.ids.key : this.ids.text, paras.splice(0).map((b) => listed ? joinRuns(tabRun(b.sourceStart), runOf(b)) : runOf(b))));
|
|
1017
|
+
};
|
|
1018
|
+
for (const b of worked) {
|
|
1019
|
+
if (b.type === 'paragraph') {
|
|
1020
|
+
paras.push(b);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
flush();
|
|
1024
|
+
out.push(b);
|
|
1025
|
+
}
|
|
1026
|
+
flush();
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
/** The solution in a box after the exercise, turned upside down when
|
|
1030
|
+
* `inverted`. */
|
|
1031
|
+
solutionBox(inverted) {
|
|
1032
|
+
const blocks = this.solutionBlocks(false);
|
|
1033
|
+
if (blocks.length === 0)
|
|
1034
|
+
return [];
|
|
1035
|
+
const sol = solutionsOf(this.style, this.book);
|
|
1036
|
+
const box = sol.box && this.ctx.boxIds.has(sol.box) ? sol.box : this.ids.solutionBox;
|
|
1037
|
+
const g = this.open('callout', { type: box, ...(inverted ? { turn: '180' } : {}) });
|
|
1038
|
+
return [g.start, ...blocks, g.end];
|
|
1039
|
+
}
|
|
1040
|
+
/** What the digital outputs need. */
|
|
1041
|
+
record() {
|
|
1042
|
+
const sol = this.ex.solutions.flatMap((s) => {
|
|
1043
|
+
const out = [];
|
|
1044
|
+
for (let j = s.start + 1; j < s.end; j++) {
|
|
1045
|
+
const b = this.blocks[j];
|
|
1046
|
+
if (b.type === 'paragraph' || b.type === 'listItem')
|
|
1047
|
+
out.push(plain(runOf(b)));
|
|
1048
|
+
}
|
|
1049
|
+
return out;
|
|
1050
|
+
});
|
|
1051
|
+
const hints = this.ex.hints.flatMap((h) => {
|
|
1052
|
+
const out = [];
|
|
1053
|
+
for (let j = h.start + 1; j < h.end; j++) {
|
|
1054
|
+
const b = this.blocks[j];
|
|
1055
|
+
if (b.type === 'paragraph' || b.type === 'listItem')
|
|
1056
|
+
out.push(plain(runOf(b)));
|
|
1057
|
+
}
|
|
1058
|
+
return out;
|
|
1059
|
+
});
|
|
1060
|
+
return {
|
|
1061
|
+
id: this.ex.id,
|
|
1062
|
+
ordinal: this.ex.ordinal,
|
|
1063
|
+
number: this.number,
|
|
1064
|
+
kind: this.ex.kind,
|
|
1065
|
+
style: this.style.id,
|
|
1066
|
+
slots: this.slots,
|
|
1067
|
+
key: this.key.map((r) => plain(r)).join(' · '),
|
|
1068
|
+
...(sol.length > 0 ? { solution: sol } : {}),
|
|
1069
|
+
...(hints.length > 0 ? { hints } : {}),
|
|
1070
|
+
...(this.ex.attrs.id ? { anchor: this.ex.attrs.id } : {}),
|
|
1071
|
+
...(this.ex.attrs.mark ? { mark: this.ex.attrs.mark } : {}),
|
|
1072
|
+
...(this.grids.length > 0 ? { grids: this.grids } : {}),
|
|
1073
|
+
...(this.ex.attrs.id ? { anchorText: this.anchorText() } : {}),
|
|
1074
|
+
...(this.ex.attrs.title ? { title: this.ex.attrs.title } : {}),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/** A length in points, `em` read against `sizePt`. */
|
|
1079
|
+
export function dimPt(d, sizePt) {
|
|
1080
|
+
switch (d.unit) {
|
|
1081
|
+
case 'pt': return d.value;
|
|
1082
|
+
case 'mm': return (d.value / 25.4) * 72;
|
|
1083
|
+
case 'cm': return (d.value / 2.54) * 72;
|
|
1084
|
+
case 'in': return d.value * 72;
|
|
1085
|
+
case 'px': return d.value * 0.75;
|
|
1086
|
+
default: return d.value * sizePt;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
//# sourceMappingURL=expand.js.map
|