@longsightgroup/qti3-writer
v0.13.3
Published
Framework-neutral QTI 3 assessment item XML writer for authoring applications.
Downloads
2,417
Maintainers
Readme
@longsightgroup/qti3-writer
Framework-neutral QTI 3 assessment item XML writer for authoring applications.
This package writes QTI-shaped authoring primitives to QTI 3 XML. It does not render UI or sanitize HTML. Trusted XHTML/QTI fragments must be prepared by the calling application before they are passed to the writer.
Install
npm install @longsightgroup/qti3-writerUse
import { qti3TrustedXmlFragment, writeQti3AssessmentItemResult } from "@longsightgroup/qti3-writer";
const result = writeQti3AssessmentItemResult({
interactionType: "choice",
identifier: "item-1",
title: "Choice item",
promptHtml: qti3TrustedXmlFragment("Choose one."),
responseCardinality: "single",
choices: [
{ identifier: "A", text: "Alpha" },
{ identifier: "B", text: "Beta" },
],
correctResponse: ["B"],
});
if (!result.ok) {
throw new Error(result.diagnostics.map((diagnostic) => diagnostic.message).join("; "));
}
console.log(result.xml);Choice items accept an optional maximumScore for writer-generated point scoring. It must be a
finite non-negative number. With match_correct, the complete correct response earns this value;
incorrect and unanswered responses earn zero. With map_response, each correct selected choice
earns an equal share of the maximum. Incorrect selections earn zero with no deduction, so selecting
all choices earns the maximum. For example, a 3-point item with two correct choices awards 1.5 for
one correct choice and 3 for both. This is a defined additive policy, not a penalty policy.
The point value is encoded in QTI response processing, together with a MAXSCORE default and a
positive SCORE normal-maximum. Zero-point items omit normal-maximum, whose QTI contract requires
a positive value, and declare MAXSCORE as zero. Metadata does not rescale scores. Hosts aggregate
the already-scored values. Omitting maximumScore preserves the existing one-point match or
one-point-per-correct-selection mapping.
An explicit maximum cannot replace custom modalFeedback.responseProcessingXml, reuse a
MAXSCORE response/feedback outcome, or make the complete answer unreachable through selection
limits. These conflicts return diagnostics and no XML. Choice feedback uses the same point rules.
This API constructs new scoring; it does not rewrite imported scoring programs or configure host
defaults. Converted destinations must preserve the program or disclose its loss.
Choice items can map each selected choice to modal feedback, including a feedback identifier different from the choice identifier:
const result = writeQti3AssessmentItemResult({
interactionType: "choice",
identifier: "item-feedback",
title: "Choice feedback",
responseCardinality: "single",
choices: [
{ identifier: "A", text: "Alpha" },
{ identifier: "B", text: "Beta" },
],
correctResponse: ["B"],
feedback: {
entries: [
{ choiceIdentifier: "B", identifier: "RIGHT", text: "Correct." },
{ choiceIdentifier: "A", identifier: "A", text: "Try again." },
],
},
});
if (!result.ok) {
console.error(result.diagnostics);
}Feedback works with single- and multiple-response choice items. For multiple-response items,
each selected choice with an entry displays its feedback, and unselected choices display none.
Invalid feedback configurations return typed diagnostics. Each entry needs exactly one text or contentHtml value with visible
text; accessible image alt text and dynamic printed variables count. contentHtml is a caller-supplied trusted XML fragment.
Choice and inline-choice items can instead author responseFeedback for the whole response:
responseFeedback: {
correct: { text: "That is the complete answer." },
incorrect: {
contentHtml: qti3TrustedXmlFragment(
'<p>Review <a href="https://example.org/explanation">the explanation</a>.</p>',
),
},
}Every declared response must match its correct answer to select CORRECT. An attempted response
that differs selects INCORRECT; an unanswered item selects neither. This distinction is
independent of scoring: a partial-credit response with an extra distractor is still incorrect,
and zero-point questions retain their answer feedback conditions. The generated processing
preserves scoring and clears the feedback outcome on every run. At least one explanation is
required; either explanation may be omitted. Dropdown slots must all have answer keys.
The single identifier outcome defaults to RESPONSE_FEEDBACK; outcomeIdentifier can name it
explicitly. Content uses the same validation as modal feedback. This model cannot be combined
with feedback or modalFeedback, so it cannot silently replace imported custom processing.
Hosts must separately authorize feedback display and enforce release timing; authoring these
explanations grants no permission to reveal them during an exam.
For any supported interaction, use modalFeedback to author item-level QTI feedback. Declare one
or more identifier outcomes with single or multiple cardinality, then add entries referencing
those outcomes. Entries can use showHide: "show" (the default) or "hide", an optional title, and
either plain text or a trusted XHTML/QTI fragment. Set responseProcessingXml to trusted QTI rules
when processing must assign feedback outcomes; these rules replace that interaction's default
scoring rules, so include any required SCORE assignment. The choice-specific feedback helper
and generic modalFeedback field cannot be used together. Both paths use the same content checks:
blank markup is rejected, and a blank optional HTML field does not replace nonblank text. Feedback
outcome names must differ from every response identifier declared by the item. Malformed XML and
forbidden interactions retain their parser diagnostic codes and messages, with paths to the authored
feedback entry. Custom and portable-custom items cannot provide both their own responseProcessingXml
and modalFeedback.responseProcessingXml; that conflict returns a diagnostic.
Multiple-response choice items default to max-choices="0" (unlimited), whether feedback is present or
absent. An explicit maxChoices value takes precedence.
The core parser retains both flattened text and structured feedback content. The player renders supported rich content, including printed variables, through its content renderer. Feedback titles are displayed and label their feedback groups. Parsing and rewriting an arbitrary source item is not a byte-for-byte XML round trip.
The stable application-facing API is writeQti3AssessmentItemResult(item). It returns typed
diagnostics and should be used by production authoring systems. Use
validateQti3AuthoringItem(item) when a UI or import pipeline needs diagnostics before writing XML.
writeQti3AssessmentItem(item) remains available as a convenience API for scripts and tests. It
throws Qti3WriterError when writer invariants fail.
Direct builders do not require the redundant interactionType discriminant:
import { buildQti3TextEntryItem, qti3TrustedXmlFragment } from "@longsightgroup/qti3-writer";
const xml = buildQti3TextEntryItem({
identifier: "item-2",
title: "Text entry",
bodyHtml: qti3TrustedXmlFragment(
'<p>Answer: <qti-text-entry-interaction response-identifier="RESPONSE"/></p>',
),
responses: [{ responseIdentifier: "RESPONSE", answers: [{ value: "deno" }] }],
});Packages
The package writer emits item-bank QTI 3 packages as a manifest, file map, or deterministic ZIP:
import { writeQti3PackageZipResult } from "@longsightgroup/qti3-writer";
const result = writeQti3PackageZipResult({
identifier: "package-1",
title: "Example package",
items: [
{
kind: "authoringItem",
path: "items/item-1.xml",
assets: [
{
path: "items/assets/prompt.png",
data: new Uint8Array([137, 80, 78, 71]),
},
],
item: {
interactionType: "choice",
identifier: "item-1",
title: "Choice item",
responseCardinality: "single",
choices: [
{ identifier: "A", text: "Alpha" },
{ identifier: "B", text: "Beta" },
],
correctResponse: ["B"],
},
},
],
});
if (result.ok) {
console.log(result.zip);
}Use writeQti3PackageManifestResult(input) for only imsmanifest.xml,
writeQti3PackageFilesResult(input) for an ordered file list containing imsmanifest.xml, item XML,
and item-owned assets, and writeQti3PackageZipResult(input) for deterministic ZIP bytes. Package
result APIs validate item XML through @longsightgroup/qti3-core and return typed diagnostics.
Convenience APIs without Result throw Qti3WriterError.
Package writing currently targets item-bank packages. Assets are explicit: declare each asset on the
item that owns the manifest resource. The writer does not rewrite URLs inside trusted item XML.
@longsightgroup/qti3-core infers asset media types from file extensions when packages are parsed.
Manifests declare QTI Item Bank metadata with schema version 3.0.1 and include the required
organizations element. An optional package title is stored in LOM metadata and restored by the
core package importer. Content validation of a generated package does not certify the writer.
Trusted Fragments
bodyHtml, promptHtml, and rich choice content use Qti3TrustedXmlFragment. The writer escapes
plain text fields and XML attributes, but it assembles trusted fragments as provided. Sanitization is
the calling application's responsibility.
Support Matrix
The exported qti3WriterInteractionSupport array is the authoritative support matrix for
interactions the writer can currently write and validate:
| Interaction | QTI element | Writer support |
| ----------------- | ----------------------------------- | ---------------------------------------------------------------------- |
| Choice | qti-choice-interaction | Writes and validates |
| Order | qti-order-interaction | Writes and validates ordered cardinality and choice references |
| Inline choice | qti-inline-choice-interaction | Replaces empty QTI placeholders and validates slot references |
| Hottext | qti-hottext-interaction | Replaces empty QTI placeholders and validates choice references |
| Gap match | qti-gap-match-interaction | Writes and validates gap choices, targets, and directed pairs |
| Extended text | qti-extended-text-interaction | Writes constructed-response interactions and rubric blocks |
| Upload | qti-upload-interaction | Writes file response declarations and application upload metadata |
| Media | qti-media-interaction | Writes audio, video, and object media with playback metadata |
| Associate | qti-associate-interaction | Writes and validates pair responses and associable choices |
| Text entry | qti-text-entry-interaction | Writes declarations and validates trusted body interaction references |
| Match | qti-match-interaction | Writes and validates |
| Hotspot | qti-hotspot-interaction | Writes and validates accessible object metadata and references |
| Graphic order | qti-graphic-order-interaction | Writes and validates object metadata, hotspots, and ordered responses |
| Select point | qti-select-point-interaction | Writes and validates point responses, area mappings, and object data |
| Position object | qti-position-object-interaction | Writes and validates stage/movable objects, point responses, targets |
| Slider | qti-slider-interaction | Writes and validates numeric bounds, responses, mappings, presentation |
| Custom | qti-custom-interaction | Writes trusted legacy custom markup and response processing fragments |
| Portable custom | qti-portable-custom-interaction | Writes launch metadata, modules, trusted markup, response processing |
| Drawing | qti-drawing-interaction | Writes file responses with accessible canvas object metadata |
| End attempt | qti-end-attempt-interaction | Writes boolean end-attempt responses and button metadata |
| Graphic associate | qti-graphic-associate-interaction | Writes and validates object metadata, hotspots, and pair responses |
| Graphic gap match | qti-graphic-gap-match-interaction | Writes and validates hotspot targets |
The writer test suite round-trips every supported builder through @longsightgroup/qti3-core
parsing and validateAssessmentItem() with zero diagnostics.
For order items, an explicit correctOrder must include every choice by default. Partial correct
orders are accepted only when minChoices or maxChoices explicitly configures subset ordering.
Inline choice items use trusted bodyHtml with empty QTI-shaped placeholders. The writer replaces
each <qti-inline-choice-interaction response-identifier="..."/> placeholder with the generated
interaction for the matching slot. This keeps editor markers out of the public API while still
letting applications control the surrounding inline prose. Default all_or_nothing scoring writes
the slot count as the score when every inline choice is correct, so a two-slot item awards
SCORE = 2. Use map_response scoring when the application needs per-slot partial credit or custom
normalization.
Hottext items use trusted bodyHtml inside the generated qti-hottext-interaction. Each generated
hottext choice is placed by an empty <qti-hottext identifier="..."/> placeholder in that body
fragment. When maxChoices is omitted, the writer emits cardinality="multiple"; set
maxChoices: 1 for single-select hottext items.
Gap match items use trusted bodyHtml inside the generated qti-gap-match-interaction. Targets are
QTI-shaped <qti-gap identifier="..."/> elements in that body fragment and must match the structured
targets list. Gap styling, including input-width classes, belongs on those trusted qti-gap
elements. Each gap target can have at most one associated choice; repeated target identifiers in
correctResponse are rejected. The writer supports text and image gap choices and defaults to
map_response scoring for authoring systems that need per-pair partial credit.
Extended text items write a single qti-extended-text-interaction after optional trusted bodyHtml.
The writer supports prompt, rubric, expected length/lines, min/max strings, placeholder text,
pattern-mask attributes, format="plain", format="preformatted", and format="xhtml".
The writer currently restricts Extended Text to cardinality="single" and base-type="string"
and reports diagnostics for other response shapes. Core parsing and the player additionally support
numeric values, records, and response collections.
Upload items write a single qti-upload-interaction with a cardinality="single" /
base-type="file" response declaration. Application constraints such as maximum file size, allowed file
types, and multiple-file UI behavior are emitted as data-max-size, data-file-types, and
data-multiple attributes for runtimes that understand them. When correctResponse is
provided, the writer emits a correct-response filename and the match_correct response-processing
template; otherwise upload items remain manually scored/unscored.
Media items write a single qti-media-interaction with a cardinality="single" /
base-type="integer" response declaration for play count. The writer supports audio, video, and
object media sources, video captions, transcript companion materials, autostart/loop flags,
min/max play bounds, coords, labels, dimensions, and media shared-vocabulary player controls.
Slider items write a single numeric qti-slider-interaction. The writer infers base-type as
integer when bounds, step, correct response, and mapping keys are whole numbers; otherwise it uses
float. Slider scoring defaults to map_response when mappings are present and match_correct
when they are not.
Custom interaction items write deprecated legacy qti-custom-interaction items for applications
that still need that QTI shape. The writer validates response declaration shape and XML attribute
names, but it treats widget markup and custom response processing as trusted fragments supplied by
the application.
Portable custom items write qti-portable-custom-interaction launch metadata, optional
qti-interaction-modules, and trusted qti-interaction-markup. The writer requires a custom
interaction type identifier plus either a module attribute or at least one interaction module.
Drawing items write a single qti-drawing-interaction with a cardinality="single" /
base-type="file" response declaration and an accessible canvas object.
End attempt items write a single qti-end-attempt-interaction with a cardinality="single" /
base-type="boolean" response declaration and a required button title.
Graphic interactions render generated long-description markup immediately before the interaction
element. Graphic order defaults correctOrder to the declared hotspot order when omitted. For
graphic order items, an explicit correctOrder must include every hotspot by default. Partial
correct orders are accepted only when minChoices or maxChoices explicitly configures subset
ordering. Select point and position object write map_response_point scoring and require at least
one area mapping target. When maxChoices is omitted, these point interactions emit
cardinality="single"; set maxChoices above 1 for multi-point responses.
Graphic Gap Match requires hotspot targets, emitted as qti-associable-hotspot elements on the
graphic. The writer diagnoses targetType: "inlineGap" as unsupported for this interaction.
Use Gap Match for inline qti-gap targets in text.
Use itemBodyHtml to preserve surrounding item content and the position of the rendered
interaction body. Supply trusted XML containing exactly one empty
<qti-interaction-placeholder/>. The shared item assembler replaces that placeholder
with the renderer's body; bodyHtml and promptHtml retain their existing meanings.
Malformed placement templates return invalid_item_body_template diagnostics.
Use buildQti3RubricBlock({ view, use, placement, content }) to create a validated rubric
fragment for an item's bodyHtml or itemBodyHtml. The extended-text rubricHtml convenience
still creates a scorer/scoring rubric. Nested interactions and rubrics are rejected, including
in fixed-test instructions; test instructions also reject template content. Rubric-local
stylesheets and catalogs are currently unsupported. Fixed-test rubrics and test feedback are interchange only;
core test execution rejects them rather than delivering a test without its instructions or feedback.
writeQti3FixedAssessmentTest accepts trusted static instructions on each part and
section. Section instructions serialize as a candidate/instructions rubric inside that
section, after any ordering rule and before its item references. They add no response or
item reference. Use successive sections to retain instructions between question groups;
shuffling stays within each section. parseQtiTestRubrics and
createCandidateTestRubricDelivery preserve this owner for host delivery. The host must
display the instructions when learners enter that section; the core branching executor
still rejects tests containing rubrics. Invalid rubric content returns diagnostics.
Instruction-only sections can precede, separate or follow question sections. The fixed-ordering API retains those scopes with empty item-reference arrays when generating and restoring an attempt order; it does not create scored placeholder items. A test still needs at least one question reference. Hosts must display instruction-only scopes explicitly, including ending instructions; flattening only question references would omit their authored placement.
Fixed-test time limits and session controls preserved by interchange are not execution support. The core execution classifier rejects authored limits or controls that its test runtime cannot enforce.
Inline-choice items support an explicit finite non-negative maximumScore with
all_or_nothing matching. Every dropdown must be answered correctly to earn that
item maximum; wrong or unanswered slots score zero. Omission retains the existing
one point per slot for a fully correct item. Explicit zero emits MAXSCORE zero
without a positive normal-maximum attribute. Option mappings, custom modal
processing and conflicting MAXSCORE identifiers return diagnostics rather than
being replaced or scaled. A one-slot dropdown and single-answer choice can thus
carry the same explicit matching points.
