diff --git a/design/author.md b/design/author.md index 4acce64..499e2e0 100644 --- a/design/author.md +++ b/design/author.md @@ -512,6 +512,77 @@ to 3,000. Nineteen things the code does not say by itself: skipped, both given) wrote what was given and the default for what was skipped, and the run with the author named in the request asked for the licence alone. Those eight packages play too. +- **A text's own marks are said where a chat reads, and what a library cannot take apart is a + problem** (2026-11-12, #187). Haiku with a shell and the skill built each of the 32 content + types that need no media twice: 64 packages, all built, all passing the verifier with no + warning, and four played wrong with no word from either. Two were Drag and Drop, which has no + type file here. The other two were a text's marks: + - Fill in the Blanks, asked for the answers 1/2, 10:30 and km/h: `*10:30*` is the answer 10 + with the tip 30 and `*km/h*` takes km or h, since a gap's first colon starts its tip and + what is before it is split at every slash (`parseSolution`, H5P.Blanks 1.14). + - Drag the Words with a colon in a gap's feedback: the feedback ends there, what follows is a + second tip, and `lex` (H5P.DragText 1.10) takes a gap's tips out of its word with one + `replace` of all of them, which with two takes out none: the learner drags + "hablo:primera persona: termina en -o.". + + Measured where it costs most, Haiku as a chat with the skill's text and no tool, the type, the + author and "licence CC BY 4.0" given, each reply built, played, and the library's own key + (`getXAPIData`) held against what was asked for. With 0.9.3's text, of 30 first replies: + - none wrote the licence's version in its own field: 27 wrote `"license": "CC BY 4.0"`, which + the build refused with the list of names, and 3 wrote `CC BY` alone. `licenseVersion` was + named in the brief only. Pasted back, the list of names had all 12 replies drop the version. + - Fill in the Blanks: all 5 with `SKILL.md` alone wrote the answers as asked, and with the type + file 2 of 5 wrote `*1\/2*`, an escape nothing reads, and the other 3 other answers than + those asked for. + - Drag the Words: 2 of 5 with the type file wrote a colon in feedback. + + What the library does take in an answer is an entity: `parseSolution` decodes each answer's + entities, and `:` holds no colon as the gap is split. Played, `*10:30*`, + `*km/h*` and `*1/2*` gave the key 10:30, km/h and 1/2, the four answers typed scored + 4 of 4, and "10" was wrong. In Drag the Words a word written `7:45` is drawn 7:45 and + scores, and feedback with `:` shows a colon; the key in its xAPI statement keeps the + entity as written. In Mark the Words neither character is a mark. So: + - `SKILL.md` says a CC BY licence's version is a field of its own, `CC0 1.0` being one code + as it stands (cubic's review), and says of a + gap's `/` and `:` what they are and how an answer holds one; the two type files say it + with their own examples. The version is in the sentence and not in the example: with it in + the example, 3 of 7 replies gave a licence the user had named without a version one of 4.0, + and without, none of 5. + - A CC BY licence written with its version is a problem that names the licence it read and + where the version goes (`sourceOf`), the SPDX spelling too. + - A backslash before a slash or a colon in a gap's answers is a problem, the gap written out + as the library takes it (`LAYOUTS['H5P.Blanks']`). An answer that is a backslash and no + more is left alone. + - A gap of Drag the Words whose tip or feedback pattern matches twice is a problem, with the + word as the library would leave it (`LAYOUTS['H5P.DragText']`). A colon in the tip is + none: the tip runs on over it. Nor is one after `\+` or `\-` with no tip before it, + though it cuts the feedback there (cubic's review): `*habla\+Bien.:tercera persona*` + plays with the feedback "Bien." and the tip "tercera persona", a tip written after the + feedback, and a build cannot tell that from a colon meant for the feedback. The type file + says feedback holds no colon. + + With them, of the first replies that wrote a source, in the same position: Fill in the Blanks + took the three answers as asked in 23 of 23, with the type file or without; Drag the Words + with its type file drew its words whole in 9 of 9, and without it in 9 of 12, the other three + refused for a field of their own making or `distractors` written as a list, and none built + and wrong; the version was in its own field in 47 of 47, and a licence skipped was `U` in 5 of + 5. Three Mark the Words kept a sentence with 10:30 and km/h in it word for word, its words + marked as they stand; each was refused for the task description, which a chat without the + type file does not know the type requires. Over the + replies that had gone wrong, the build's problems pasted back in the build page's words: 8 of + 8 right in one round, the Blanks with the answers asked for, where the first wording, which + told a chat to word the sentence another way, had two of four make two gaps of each answer. + + `*10:30*` as written is not refused: it is a tip to the library, may be one to its author, + and a build cannot tell which. A note saying how each such gap was read, left to the chat, + did nothing: of 5 replies two sent the same source again, one wrote `\:`, one look-alike + characters, one asked. The type file handed back beside the licence's problem had 1 of 3 + write the entities. A problem in the build's form saying which gap its author did not mean, + as a page could copy once they had looked, had 4 of 4; that page is not built. Run over the + 94 real packages of the owner's corpus and the demo, 53 of these two types in them with 266 + gaps, the two checks report nothing; over the 524 distinct sources these tests stored, 199 of + the two types with 765 gaps, only the 14 known to play wrong. Not tried: a text edited afterwards in H5P's own editor, whose rich-text + widget may write an entity back as its character. Not built: editing an existing package, which a tool beside it does with `checkContent` as its check; layout for Drag and Drop and Crossword; and any judgement of whether content is right, which diff --git a/packages/author/lib/build.mjs b/packages/author/lib/build.mjs index b598fe6..9e12724 100644 --- a/packages/author/lib/build.mjs +++ b/packages/author/lib/build.mjs @@ -157,10 +157,22 @@ export function sourceOf(input, names) { } if (tag) source.language = given.language else report(at.language, 'expected a BCP 47 language tag, such as "en" or "de-AT"') + // A licence written with its version, "CC BY 4.0", as it is spoken of: H5P keeps the version in + // a field of its own. The names alone, which this problem used to list, had a chat drop the + // version to fit them. + const versioned = typeof given.license === 'string' ? /^CC[ -](BY(?:-(?:NC-SA|NC-ND|NC|ND|SA))?)[ -]v?(\d\.\d)$/i.exec(given.license.trim()) : null + const license = versioned ? `CC ${versioned[1].toUpperCase()}` : given.license if (LICENSES.includes(given.license)) source.license = given.license - else report(at.license, `use one of ${LICENSES.map((each) => JSON.stringify(each)).join(', ')}; "U" when it is not known`) + else if (versioned) { + const written = JSON.stringify(given.license) + if (given.licenseVersion !== undefined) report(at.license, `${written} holds the version too, which licenseVersion names: write "license": "${license}"`) + else if (CC_VERSIONS.includes(versioned[2])) report(at.license, `${written} holds the version too: write "license": "${license}" and "licenseVersion": "${versioned[2]}"`) + else report(at.license, `${written} holds a version too: write "license": "${license}" and "licenseVersion", one of ${CC_VERSIONS.join(', ')}`) + source.license = license + if (given.licenseVersion === undefined && CC_VERSIONS.includes(versioned[2])) source.licenseVersion = versioned[2] + } else report(at.license, `use one of ${LICENSES.map((each) => JSON.stringify(each)).join(', ')}; "U" when it is not known`) if (given.licenseVersion !== undefined) { - if (!String(given.license).startsWith('CC ') || given.license === 'CC0 1.0' || given.license === 'CC PDM') report(at.licenseVersion, 'is for a Creative Commons BY licence only') + if (!String(license).startsWith('CC ') || license === 'CC0 1.0' || license === 'CC PDM') report(at.licenseVersion, 'is for a Creative Commons BY licence only') else if (!CC_VERSIONS.includes(given.licenseVersion)) report(at.licenseVersion, `use one of ${CC_VERSIONS.join(', ')}`) else source.licenseVersion = given.licenseVersion } diff --git a/packages/author/lib/types.mjs b/packages/author/lib/types.mjs index 6cfdc9c..7bcc870 100644 --- a/packages/author/lib/types.mjs +++ b/packages/author/lib/types.mjs @@ -642,6 +642,120 @@ const imageHotspotQuestion = { } } +/* ---------------------------------------------------------------- Fill in the Blanks */ + +/** + * The gaps of one block of text, found as H5P.Blanks 1.14 finds them (`handleBlanks` in its + * `js/blanks.js`): from an asterisk to the next, the tags between them taken out, an empty pair + * passed over, and the character after a gap never read as the start of the next. The library + * takes the tags out in one pass; here they are taken out until none is left, which differs only + * for a tag written inside a tag (`<em>`), and leaves no tag that one pass would have joined. + * @param {string} question + * @returns {{ raw: string, content: string }[]} each gap as written, asterisks included, and what the library reads of it + */ +function blanksGaps(question) { + const gaps = [] + let start = question.indexOf('*') + while (start !== -1) { + const end = question.indexOf('*', start + 1) + if (end === -1) break + let content = question.slice(start + 1, end) + for (let before = ''; before !== content; ) { + before = content + content = content.replaceAll(/<\/?[a-z]*\d?>/gi, '') + } + if (content.length) gaps.push({ raw: question.slice(start, end + 1), content }) + start = question.indexOf('*', content.length ? end + 2 : end + 1) + } + return gaps +} + +/** + * A backslash written to escape a slash or a colon in a gap's answer, `*1\/2*` or `*10\:30*`. + * Nothing in H5P.Blanks escapes either (`parseSolution`: the first colon starts the tip, and what + * is before it is split at every slash), so the answer ends in the backslash and what follows is + * another answer, or the tip: the package builds, plays, and takes "1\" or "2". What the library + * does take is an entity, which holds neither character as it is split and which it decodes in + * each answer: `*1/2*` is the answer 1/2, and the problem writes the gap out that way. An + * answer that is a backslash and no more is left alone, being one a text may ask for. + * @type {Layout} + */ +const blanks = { + after(params, path, context) { + if (!Array.isArray(params?.questions)) return + params.questions.forEach((/** @type {unknown} */ question, /** @type {number} */ i) => { + if (typeof question !== 'string') return + for (const { raw, content } of blanksGaps(question)) { + const tipStart = content.indexOf(':') + const answers = (tipStart === -1 ? content : content.slice(0, tipStart)).split('/') + const escapes = answers.some((answer, at) => answer.endsWith('\\') && answer.trim() !== '\\' && (at < answers.length - 1 || tipStart !== -1)) + if (!escapes) continue + // The gap as it was meant: a backslash's slash or colon part of the answer, up to the + // first colon no backslash stands before, where the tip starts. + let meant = '' + for (let at = 0; at < content.length; at++) { + const next = content[at + 1] + if (content[at] === '\\' && (next === '/' || next === ':')) { + meant += next === '/' ? '/' : ':' + at++ + } else if (content[at] === ':') { + meant += content.slice(at) + break + } else meant += content[at] + } + const taken = answers.map((answer) => `"${answer.trim()}"`).join(' or ') + context.report( + `${path}.questions[${i}]`, + `\`${raw}\`: a backslash escapes nothing in a gap, so H5P.Blanks takes ${taken} here${tipStart === -1 ? '' : `, with the tip "${content.slice(tipStart + 1).trim()}"`}. ` + + `In an answer write \`/\` as \`/\` and \`:\` as \`:\`: \`*${meant}*\`` + ) + } + }) + } +} + +/* ---------------------------------------------------------------- Drag the Words */ + +/** A gap as a problem shows it: whole when short, its start otherwise. @param {string} text @param {number} most */ +const shortened = (text, most) => (text.length > most ? `${text.slice(0, most - 1)}…` : text) + +/** + * A gap H5P.DragText 1.10 cannot take apart. Its `lex` (in `dist/h5p-drag-text.js`) finds a gap's + * tip, from a colon up to a backslash, and its two feedbacks, from `\+` and `\-` up to a backslash + * or a colon, each with a pattern that may match more than once, and takes each out of the word + * with one `replace` of all its matches at once: with two, nothing is taken out. A colon in the + * feedback is the way to two: the feedback ends there, what follows is a second tip, and the + * learner drags the word with every tip still in it. A colon in the tip itself is none, the tip + * running on over it. The word is worked out here as the library does, to show what would be + * dragged. + * @type {Layout} + */ +const dragText = { + after(params, path, context) { + if (typeof params?.textField !== 'string') return + let gap = 0 + for (const part of params.textField.replace(/(\r\n|\n|\r)/gm, '
').split(/(\*.*?\*)/)) { + if (!part.startsWith('*') || !part.endsWith('*')) continue + gap++ + const tips = part.match(/(:([^\\*]+))/g) ?? [] + const right = part.match(/(\\\+([^\\*:]+))/g) ?? [] + const wrong = part.match(/(\\-([^\\*:]+))/g) ?? [] + if (tips.length < 2 && right.length < 2 && wrong.length < 2) continue + let word = part.slice(1, -1) + for (const matches of [tips, right, wrong]) if (matches.length) word = word.replace(String(matches), '') + const [why, write] = + tips.length > 1 && /\\[+-][^*]*:/.test(part) + ? ['its feedback holds a `:`, where H5P.DragText ends the feedback and starts another tip', 'Write `:` once in a gap, before the tip, and none in the feedback'] + : tips.length > 1 + ? ['`:` starts a tip more than once in it', 'Write `:` once in a gap, before the tip'] + : right.length > 1 + ? ['`\\+` stands more than once in it', 'Write `\\+` once in a gap, before the feedback for the right word'] + : ['`\\-` stands more than once in it', 'Write `\\-` once in a gap, before the feedback for a wrong word'] + context.report(`${path}.textField`, `gap ${gap}, \`${shortened(part, 60)}\`: ${why}, and it then leaves them in the word: the learner would drag "${shortened(word.replace(/\s+$/, ''), 70)}". ${write}`) + } + } +} + /* ---------------------------------------------------------------- Video */ /** @type {Layout} */ @@ -650,6 +764,8 @@ const video = { optional: ['visuals.poster'] } /** @type {Record} */ export const LAYOUTS = { 'H5P.QuestionSet': questionSet, + 'H5P.Blanks': blanks, + 'H5P.DragText': dragText, 'H5P.InteractiveBook': interactiveBook, 'H5P.CoursePresentation': coursePresentation, 'H5P.InteractiveVideo': interactiveVideo, diff --git a/packages/author/tests/build.test.ts b/packages/author/tests/build.test.ts index 6805a8e..e10ed7e 100644 --- a/packages/author/tests/build.test.ts +++ b/packages/author/tests/build.test.ts @@ -329,6 +329,24 @@ describe('any content type in the pack', () => { expect.stringMatching(/^license: use one of/), 'authors[0].role: use one of Author, Editor, Licensee, Originator' ]) + // A licence written with its version, as it is spoken of: the problem says where the version + // goes, and names the licence it read. The list of names alone had a chat drop the version. + const blanks = { type: 'H5P.Blanks', params: { questions: ['The *Danube* flows east.'] } } + expect(await problems({ ...META, ...blanks, license: 'CC BY 4.0' })).toEqual(['license: "CC BY 4.0" holds the version too: write "license": "CC BY" and "licenseVersion": "4.0"']) + expect(await problems({ ...META, ...blanks, license: 'cc-by-sa-4.0' })).toEqual(['license: "cc-by-sa-4.0" holds the version too: write "license": "CC BY-SA" and "licenseVersion": "4.0"']) + expect(await problems({ ...META, ...blanks, license: 'CC BY-NC 5.0' })).toEqual(['license: "CC BY-NC 5.0" holds a version too: write "license": "CC BY-NC" and "licenseVersion", one of 4.0, 3.0, 2.5, 2.0, 1.0']) + expect(await problems({ ...META, ...blanks, license: 'CC BY 4.0', licenseVersion: '4.0' })).toEqual(['license: "CC BY 4.0" holds the version too, which licenseVersion names: write "license": "CC BY"']) + expect(await problems({ ...META, ...blanks, license: 'CC-BY-4.0', licenseVersion: '9' })).toEqual([ + 'license: "CC-BY-4.0" holds the version too, which licenseVersion names: write "license": "CC BY"', + 'licenseVersion: use one of 4.0, 3.0, 2.5, 2.0, 1.0' + ]) + expect((await build({ ...META, ...blanks, license: 'CC BY', licenseVersion: '4.0' })).manifest).toMatchObject({ license: 'CC BY', licenseVersion: '4.0' }) + // CC0 1.0 is one code, version and all, and no versioned CC BY. + expect((await build({ ...META, ...blanks, license: 'CC0 1.0' })).manifest).toMatchObject({ license: 'CC0 1.0' }) + expect(await problems({ ...META, ...blanks, license: 'CC0', licenseVersion: '1.0' })).toEqual([ + expect.stringMatching(/^license: use one of "U", "CC BY", .*"CC0 1\.0"/), + 'licenseVersion: is for a Creative Commons BY licence only' + ]) expect(await problems({ ...META, type: 'H5P.TrueFalse', diff --git a/packages/author/tests/layout.test.ts b/packages/author/tests/layout.test.ts index 7c5803f..88e4d60 100644 --- a/packages/author/tests/layout.test.ts +++ b/packages/author/tests/layout.test.ts @@ -223,6 +223,50 @@ describe('Game Map', () => { }) }) +describe('Fill in the Blanks', () => { + it('refuses a backslash written to escape a slash or a colon in an answer, and writes the gap out as the library takes it', async () => { + expect(await problems('H5P.Blanks', { questions: ['Half is *1\\/2* and the train leaves at *10\\:30*.', 'Speed is in *km\\/h:a unit*.'] })).toEqual([ + 'params.questions[0]: `*1\\/2*`: a backslash escapes nothing in a gap, so H5P.Blanks takes "1\\" or "2" here. In an answer write `/` as `/` and `:` as `:`: `*1/2*`', + 'params.questions[0]: `*10\\:30*`: a backslash escapes nothing in a gap, so H5P.Blanks takes "10\\" here, with the tip "30". In an answer write `/` as `/` and `:` as `:`: `*10:30*`', + 'params.questions[1]: `*km\\/h:a unit*`: a backslash escapes nothing in a gap, so H5P.Blanks takes "km\\" or "h" here, with the tip "a unit". In an answer write `/` as `/` and `:` as `:`: `*km/h:a unit*`' + ]) + // Wherever the type sits. + expect(await problems('H5P.Column', { content: [{ content: { library: 'H5P.Blanks', params: { questions: ['Half is *1\\/2*.'] } } }] })).toEqual([ + expect.stringMatching(/^params\.content\[0\]\.content\.params\.questions\[0\]: `\*1\\\/2\*`: a backslash escapes nothing/) + ]) + }) + + it('takes every gap the library reads as its author may have meant it', async () => { + // Alternatives, a tip, an answer that is a backslash, a backslash in a tip or outside a gap, + // and the entities, which the library decodes in each answer: `*1/2*` is the answer 1/2. + const questions = [ + 'The *Danube/Donau:It flows through Vienna* reaches the Black Sea at *10:30*.', + 'A path is split by a *\\/backslash*, as in C:\\/temp, and a tip may hold one: *slash:not \\/ this*.', + 'Half is *1/2*, the train leaves at *10:30*, speed is in *km/h*.' + ] + expect((await build('H5P.Blanks', { questions })).questions).toEqual(questions.map((each) => `

${each}

`)) + }) +}) + +describe('Drag the Words', () => { + it('refuses a gap the library cannot take apart, and says what the learner would drag', async () => { + expect(await problems('H5P.DragText', { + textField: 'Yo *hablo:primera persona\\+Correcto: termina en -o.\\-No: la forma es «hablo».* español.\nTú *hablas:segunda persona* bien.\nÉl *habla\\+Bien\\+Muy bien* y ella *habla\\-No\\-Tampoco*.' + })).toEqual([ + 'params.textField: gap 1, `*hablo:primera persona\\+Correcto: termina en -o.\\-No: la fo…`: its feedback holds a `:`, where H5P.DragText ends the feedback and starts another tip, and it then leaves them in the word: the learner would drag "hablo:primera persona: termina en -o.: la forma es «hablo».". Write `:` once in a gap, before the tip, and none in the feedback', + 'params.textField: gap 3, `*habla\\+Bien\\+Muy bien*`: `\\+` stands more than once in it, and it then leaves them in the word: the learner would drag "habla\\+Bien\\+Muy bien". Write `\\+` once in a gap, before the feedback for the right word', + 'params.textField: gap 4, `*habla\\-No\\-Tampoco*`: `\\-` stands more than once in it, and it then leaves them in the word: the learner would drag "habla\\-No\\-Tampoco". Write `\\-` once in a gap, before the feedback for a wrong word' + ]) + }) + + it('takes a tip that holds a colon, feedback without one, a tip after the feedback, and a colon written as an entity', async () => { + // A colon after \\+ with no tip before it is a tip written after the feedback, as the library + // plays it: the word is whole, and a build cannot tell it from a colon meant for the feedback. + const textField = 'Yo *hablo:primera persona: yo\\+Correcto, termina en -o.\\-No, la forma es «hablo».* español.\nEl tren sale a las *7:45\\+Correcto: menos cuarto.* y va a 90 *km/h*.\nElla *habla\\+Bien.:tercera persona* mucho.' + expect((await build('H5P.DragText', { textField, distractors: '*hablas* *10:30:00*' })).textField).toBe(textField) + }) +}) + describe('chapters and question sets', () => { it('asks for a chapter\'s title, which the book\'s contents list it by', async () => { expect(await problems('H5P.InteractiveBook', { chapters: [{ library: 'H5P.Column', params: { content: [{ content: question }] } }] })).toEqual([ diff --git a/skills/h5p-author/SKILL.md b/skills/h5p-author/SKILL.md index 46b82d3..d2e0424 100644 --- a/skills/h5p-author/SKILL.md +++ b/skills/h5p-author/SKILL.md @@ -89,10 +89,16 @@ from what you know of the type: the build says, for each place that is wrong, wh - Every source names its own `title`, `language`, `license` and `authors` beside `type` and `params`, as above, whatever a type file's example leaves out; the author and licence are the - ones step 1 settled. + ones step 1 settled. A CC BY licence's version, CC BY-SA's and the others' too, is a field of + its own, written where the user gave one: "CC BY 4.0" is + `"license": "CC BY", "licenseVersion": "4.0"`. `CC0 1.0` is one code, as it stands. - `params` holds only fields that differ from their defaults. Never interface texts (Check, Retry…): they come from the libraries' translations for `language`. - Text is plain, or markup with only the tags a field marked `html` lists. +- In a gap of H5P.Blanks `/` separates answers and `:` starts a tip, and in a gap of + H5P.DragText `:` starts a tip. Where one belongs to the answer itself, write `/` for `/` + and `:` for `:`: `*10:30*` takes 10:30, where `*10:30*` is the answer 10 with the + tip 30. A backslash escapes nothing. - A file is `{ "path": "photo.jpg" }`, beside the source or a web address, with `copyright` when known. - Sub-content is `{ "library": "H5P.MultiChoice", "params": { … }, "metadata": { "title": "…" } }`. diff --git a/skills/h5p-author/references/types/H5P.Blanks.md b/skills/h5p-author/references/types/H5P.Blanks.md index 6567efe..04345f9 100644 --- a/skills/h5p-author/references/types/H5P.Blanks.md +++ b/skills/h5p-author/references/types/H5P.Blanks.md @@ -3,6 +3,7 @@ Text with gaps the learner types the missing words into. For recall of exact words: terms, names, spellings. - Each item of `questions` is one block of text with its gaps, marked as the Fields section below says: `*answer*`, alternatives with `/`, a tip after `:`. +- An answer that holds `/` or `:` itself writes them as `/` and `:`: `*km/h*` takes km/h and `*10:30*` takes 10:30, where `*km/h*` takes km or h and `*10:30*` is the answer 10 with the tip 30. A backslash escapes nothing. - Answers are compared as typed. `behaviour.caseSensitive: false` for names that may be capitalised either way; `behaviour.acceptSpellingErrors: true` forgives a wrong letter. - `text` is the instruction above the blocks. diff --git a/skills/h5p-author/references/types/H5P.DragText.md b/skills/h5p-author/references/types/H5P.DragText.md index 6f31520..da36bd5 100644 --- a/skills/h5p-author/references/types/H5P.DragText.md +++ b/skills/h5p-author/references/types/H5P.DragText.md @@ -3,6 +3,7 @@ Text with gaps the learner fills by dragging words from a list. Easier than typing them: recognition, not recall. - `textField` is plain text with the words to drag marked `*word*`; a tip after `:`, feedback after `\+` (right) and `\-` (wrong), as the Fields section shows. +- `:` stands once in a gap, before the tip, and feedback holds none: feedback ends at a `:`, and the tip then stays in the word the learner drags. A colon in the word itself is written `:`: `*7:45*` is the word 7:45, where `*7:45*` is the word 7 with the tip 45. - Each gap has one right word. Line breaks in `textField` are kept. - `distractors` adds wrong words to the list, in the same `*word*` form.