R/verify_qti.R

Defines functions parse_errors is_schema_import_error qti_schema_search_dirs qti22_schema_file system_schema_file find_schema_file resolve_qti_schema make_result make_snippet clean_message extract_allowed extract_element extract_line extract_attribute print.qti_validation_results_list print.qti_validation_result verify_qti_impl

Documented in print.qti_validation_result print.qti_validation_results_list verify_qti_impl

#' Validate an XML document against the QTI schema
#'
#' Validates an XML document against the QTI schema or the extended rqti QTI
#' schema. Input can be either a file path, a character string containing XML,
#' or an `xml2::xml_document`.
#'
#' By default, the function chooses a validation backend automatically. If
#' `xmllint` is available, the input is a file path, and the platform is not
#' Windows, the `xmllint` backend is used. Otherwise, validation falls back to
#' `xml2`.
#'
#' The function returns an object of class `"qti_validation_result"`. By
#' default, that object is also printed in a human-readable form.
#'
#' @param doc A QTI XML document. This can be a file path, a character string
#'   containing XML, or an `xml2::xml_document`.
#' @param extended_schema Logical. Should the extended rqti schema be used?
#'   Defaults to `FALSE`.
#' @param ctx Integer. Number of characters of context shown before and after
#'   the offending XML element in printed snippets. Defaults to `40`.
#' @param color Logical. Should ANSI colors be used in printed output? Defaults
#'   to `TRUE`.
#' @param engine Character string specifying the validation backend. One of
#'   `"auto"`, `"xml2"`, or `"xmllint"`. Defaults to `"auto"`.
#' @param ignore_import Logical. If `TRUE`, warnings related to `<import>`
#'   statements in the schema are ignored. Default is `TRUE` because rqti uses a
#'   locally saved schema instead of downloading from the internet.
#' @param print Logical. Should the validation result be printed before it is
#'   returned? Defaults to `TRUE`.
#' @param schema Character string or `NULL`. Schema to validate against. Use
#'   `NULL` for the default QTI 2.1 schema, `"extended"` for the extended rqti
#'   QTI 2.1 schema, `"qti21"` for the default QTI 2.1 schema, `"qti22"` for a
#'   local QTI 2.2 schema, or a file path to a custom XSD. The legacy
#'   `extended_schema = TRUE` argument is still supported and is equivalent to
#'   `schema = "extended"` when `schema` is `NULL`.
#'
#' @return An object of class `"qti_validation_result"` with components:
#'   \describe{
#'     \item{valid}{Logical scalar.}
#'     \item{errors}{A list of parsed validation errors.}
#'     \item{engine}{The backend used for validation.}
#'   }
#'
#' @examples
#' \dontrun{
#' f <- system.file("extdata", "sc1d.xml", package = "rqti")
#'
#' res <- verify_qti(f)
#' res$valid
#'
#' x <- xml2::read_xml(f)
#' res2 <- verify_qti(x)
#'
#' summary(res2)
#' }
#'
verify_qti_impl <- function(doc,
                            extended_schema = FALSE,
                            ctx = 40,
                            color = TRUE,
                            engine = c("auto", "xml2", "xmllint"),
                            ignore_import = TRUE,
                            print = TRUE,
                            schema = NULL) {
    engine <- match.arg(engine)

    # Handle non-file inputs for potential xmllint use
    if (!is.character(doc) || !file.exists(doc)) {
        # Check if xmllint might be used
        will_use_xmllint <- (
            (engine == "xmllint") ||
                (engine == "auto" && .Platform$OS.type != "windows" && nzchar(Sys.which("xmllint")))
        )

        if (will_use_xmllint) {
            # Write XML document to temporary file for xmllint
            tmp_file <- tempfile(fileext = ".xml")
            # doc is a character string, write directly
            writeLines(doc, tmp_file, useBytes = TRUE)

            doc <- tmp_file  # Assign to doc so it's treated as a path
            on.exit(unlink(tmp_file), add = TRUE)
        }
    }

    # read XML / path handling
    doc_is_path <- is.character(doc) && length(doc) == 1 && file.exists(doc)

    if (doc_is_path) {
        file_in <- normalizePath(doc, mustWork = TRUE)
        xml_lines <- readLines(file_in, warn = FALSE, encoding = "UTF-8")
    } else {
        file_in <- "<xml_document>"
        xml_doc_tmp <- if (inherits(doc, "xml_document")) doc else xml2::read_xml(doc)
        xml_txt <- as.character(xml_doc_tmp)
        xml_lines <- strsplit(xml_txt, "\n", fixed = TRUE)[[1]]
    }

    # schema
    schema_file <- resolve_qti_schema(schema, extended_schema)
    schema_label <- attr(schema_file, "label", exact = TRUE)

    # colors
    red <- if (isTRUE(color)) "\033[31m" else ""
    blue <- if (isTRUE(color)) "\033[34m" else ""
    reset <- if (isTRUE(color)) "\033[0m" else ""



    # choose engine ---------------------------------------------------------

    xmllint_available <- nzchar(Sys.which("xmllint"))
    if (engine == "auto") {
        engine <- if (.Platform$OS.type != "windows" && xmllint_available && doc_is_path) {
            "xmllint"
        } else {
            "xml2"
        }
    }

    # xmllint backend -------------------------------------------------------

    if (engine == "xmllint") {
        if (.Platform$OS.type == "windows") {
            warning("`xmllint` backend requested on Windows; falling back to `xml2`.")
            engine <- "xml2"
        } else if (!xmllint_available) {
            warning("`xmllint` not found; falling back to `xml2`.")
            engine <- "xml2"
        } else {
            out <- suppressWarnings(system2(
                "xmllint",
                args = c("--noout", "--schema", schema_file, file_in),
                stdout = TRUE,
                stderr = TRUE
            ))

            status <- attr(out, "status")
            has_schema_errors <- any(grepl("Schemas validity error", out, fixed = TRUE))

            if ((is.null(status) || identical(status, 0L)) && !has_schema_errors) {
                res <- make_result(
                    valid = TRUE,
                    errors = list(),
                    engine = "xmllint",
                    color = color,
                    schema = schema_label
                )
                if (isTRUE(print)) print(res)
                return(invisible(res))
            }

            err_lines <- out[grepl("Schemas validity error", out, fixed = TRUE)]

            if (!length(err_lines)) {
                parsed <- list(list(
                    file = file_in,
                    line = NA_integer_,
                    element = NA_character_,
                    message = paste(out, collapse = " "),
                    allowed = character(0),
                    snippet = NA_character_,
                    raw_message = out
                ))
            } else {
                parsed <- parse_errors(
                    raw_errors = err_lines,
                    xml_lines = xml_lines,
                    file_in = file_in,
                    ctx = ctx,
                    red = red,
                    reset = reset
                )
            }

            if (ignore_import) {
                parsed <- Filter(
                    function(x) !is_schema_import_error(x),
                    parsed
                )
            }

            res <- make_result(
                valid = !length(parsed),
                errors = parsed,
                engine = "xmllint",
                color = color,
                schema = schema_label
            )

            if (isTRUE(print)) print(res)
            return(invisible(res))
        }
    }

    # xml2 backend ----------------------------------------------------------

    if (doc_is_path) {
        xml_doc <- xml2::read_xml(file_in)
    } else {
        xml_doc <- if (inherits(doc, "xml_document")) doc else xml2::read_xml(doc)
    }

    schema <- xml2::read_xml(schema_file)
    validation <- xml2::xml_validate(xml_doc, schema)

    if (isTRUE(validation)) {
        res <- make_result(
            valid = TRUE,
            errors = list(),
            engine = "xml2",
            color = color,
            schema = schema_label
        )
        if (isTRUE(print)) print(res)
        return(invisible(res))
    }

    raw_errors <- attr(validation, "errors")
    if (is.null(raw_errors)) {
        raw_errors <- as.character(validation)
    }
    raw_errors <- as.character(raw_errors)

    parsed <- parse_errors(
        raw_errors = raw_errors,
        xml_lines = xml_lines,
        file_in = file_in,
        ctx = ctx,
        red = red,
        reset = reset
    )

    if (ignore_import) {
        parsed <- Filter(
            function(x) !is_schema_import_error(x),
            parsed
        )
    }

    res <- make_result(
        valid = !length(parsed),
        errors = parsed,
        engine = "xml2",
        color = color,
        schema = schema_label
    )

    if (isTRUE(print)) print(res)
    invisible(res)
}


#' Print a QTI validation result
#'
#' @param x A `qti_validation_result` object.
#' @param ... Unused.
#'
#' @return The input object, invisibly.
#' @export
#' @method print qti_validation_result
print.qti_validation_result <- function(x, ...) {
    stopifnot(inherits(x, "qti_validation_result"))

    blue <- if (isTRUE(x$color)) "\033[34m" else ""
    reset <- if (isTRUE(x$color)) "\033[0m" else ""

    if (isTRUE(x$valid)) {
        cat("QTI validation: valid")
        if (!is.null(x$engine) && nzchar(x$engine)) {
            cat(" [engine: ", x$engine, "]", sep = "")
        }
        if (!is.null(x$schema) && nzchar(x$schema)) {
            cat(" [schema: ", x$schema, "]", sep = "")
        }
        cat("\n")
        return(invisible(x))
    }

    cat("QTI validation: invalid")
    if (!is.null(x$engine) && nzchar(x$engine)) {
        cat(" [engine: ", x$engine, "]", sep = "")
    }
    if (!is.null(x$schema) && nzchar(x$schema)) {
        cat(" [schema: ", x$schema, "]", sep = "")
    }
    cat("\n\n")

    for (err in x$errors) {
        loc <- if (!is.na(err$line)) paste0(err$file, ":", err$line) else err$file
        cat(loc, ": ", err$message, "\n", sep = "")

        if (!is.na(err$snippet) && nzchar(err$snippet)) {
            cat("  -> ", err$snippet, "\n", sep = "")
        }

        if (length(err$allowed)) {
            cat("  ", blue, "Allowed tags: ", reset, paste(err$allowed, collapse = ", "), "\n", sep = "")
        }

        cat("\n")
    }

    invisible(x)
}

#' Validate QTI XML
#'
#' S4 generic for validating QTI documents in various formats.
#'
#' @param doc A QTI document (file path, character string, xml_document, or S4 object)
#' @param extended_schema Logical. Use extended rqti schema?
#' @param ctx Integer. Context characters for error snippets.
#' @param color Logical. Use ANSI colors?
#' @param engine Character. Validation backend ("auto", "xml2", "xmllint").
#' @param ignore_import Logical. Ignore import warnings?
#' @param print Logical. Print results?
#' @param schema Character string or `NULL`. Schema selector or custom XSD path.
#'   Supported selectors are `"qti21"`, `"extended"`, and `"qti22"`. Defaults
#'   to `NULL`, which uses the legacy `extended_schema` flag.
#'
#' @return A `qti_validation_result` or `qti_validation_results_list` object.
#' @export
setGeneric("verify_qti", function(doc, extended_schema = FALSE, ctx = 40, color = TRUE,
                                  engine = c("auto", "xml2", "xmllint"),
                                  ignore_import = TRUE, print = TRUE,
                                  schema = NULL) {
    standardGeneric("verify_qti")
})

#' @describeIn verify_qti Validate character input (file path or XML string)
setMethod("verify_qti", signature(doc = "character"),
          function(doc, extended_schema = FALSE, ctx = 40, color = TRUE,
                   engine = c("auto", "xml2", "xmllint"),
                   ignore_import = TRUE, print = TRUE,
                   schema = NULL) {
              engine <- match.arg(engine)

              # Check if it's a file path
              if (file.exists(doc)) {
                  # Check if it's an Rmd file
                  if (grepl("\\.Rmd$", doc, ignore.case = TRUE)) {
                      # Create temp XML file
                      tmp_xml <- tempfile(fileext = ".xml")
                      on.exit(unlink(tmp_xml))

                      # Render Rmd to XML
                      tryCatch({
                          rmd2xml(doc, tmp_xml)

                          # Verify the resulting XML
                          verify_qti_impl(tmp_xml, extended_schema, ctx, color, engine, ignore_import, print, schema)

                      }, error = function(e) {
                          stop("Failed to render Rmd file: ", doc, "\nError: ", e$message)
                      })

                  } else {
                      # Handle XML or other file paths
                      verify_qti_impl(doc, extended_schema, ctx, color, engine, ignore_import, print, schema)
                  }

              } else {
                  # Not a file path, treat as XML string content
                  # First validate that it looks like XML
                  doc_trimmed <- trimws(doc)
                  if (!grepl("^<", doc_trimmed)) {
                      stop("Input is not a valid file path or XML string (must start with '<')")
                  }

                  verify_qti_impl(doc, extended_schema, ctx, color, engine, ignore_import, print, schema)
              }
          }
)

#' @describeIn verify_qti Validate xml_document objects
setMethod("verify_qti", signature(doc = "xml_document"),
          function(doc, extended_schema = FALSE, ctx = 40, color = TRUE,
                   engine = c("auto", "xml2", "xmllint"),
                   ignore_import = TRUE, print = TRUE,
                   schema = NULL) {
              # Handle xml_document - convert to character
              verify_qti_impl(as.character(doc), extended_schema, ctx, color, engine, ignore_import, print, schema)
          }
)

#' @describeIn verify_qti Validate assessmentItem objects
setMethod("verify_qti", signature(doc = "AssessmentItem"),
          function(doc, extended_schema = FALSE, ctx = 40, color = TRUE,
                   engine = c("auto", "xml2", "xmllint"),
                   ignore_import = TRUE, print = TRUE,
                   schema = NULL) {
              engine <- match.arg(engine)
              tmp_file <- tempfile(fileext = ".xml")
              on.exit(unlink(tmp_file))

              createQtiTask(doc, tmp_file)
              verify_qti(tmp_file, extended_schema, ctx, color, engine, ignore_import, print, schema)
          }
)

#' @describeIn verify_qti Validate assessmentTest objects
setMethod("verify_qti", signature(doc = "AssessmentTest"),
          function(doc, extended_schema = FALSE, ctx = 40, color = TRUE,
                   engine = c("auto", "xml2", "xmllint"),
                   ignore_import = TRUE, print = TRUE,
                   schema = NULL) {
              engine <- match.arg(engine)
              tmp_dir <- tempfile(pattern = "test_")
              dir.create(tmp_dir)
              on.exit(unlink(tmp_dir, recursive = TRUE))

              createQtiTest(doc, tmp_dir)
              xml_files <- list.files(tmp_dir, pattern = "\\.xml$", full.names = TRUE, recursive = TRUE)
              xml_files <- xml_files[!grepl("manifest\\.xml$", xml_files, ignore.case = TRUE)]

              results <- lapply(xml_files, function(f) {
                  verify_qti(f, extended_schema, ctx, color, engine, ignore_import, print = FALSE, schema = schema)
              })

              names(results) <- basename(xml_files)

              if (isTRUE(print)) {
                  cat("QTI Validation Results - Assessment Test\n")
                  cat("Files:", length(results), "\n")
                  cat("Valid:", sum(sapply(results, function(x) isTRUE(x$valid))), "\n\n")
                  for (i in seq_along(results)) {
                      cat("===", names(results)[i], "===\n")
                      print(results[[i]])
                      cat("\n")
                  }
              }

              invisible(structure(results, class = c("qti_validation_results_list", "list")))
          }
)

#' Print validation results list
#' @keywords internal
print.qti_validation_results_list <- function(x, ...) {
    invisible(x)
}

extract_attribute <- function(msg) {
    m <- regexec("attribute '([^']+)'", msg, perl = TRUE)
    r <- regmatches(msg, m)[[1]]
    if (length(r) >= 2) {
        attr <- r[2]
        # Strip namespace prefix if present
        attr <- sub(".*}", "", attr)
        attr
    } else {
        NA_character_
    }
}

# helpers ---------------------------------------------------------------

extract_line <- function(msg) {
    pats <- c(
        ":(\\d+):",
        "^[^:]+:(\\d+):",
        "^(\\d+):"
    )

    for (p in pats) {
        m <- regexec(p, msg, perl = TRUE)
        r <- regmatches(msg, m)[[1]]
        if (length(r) >= 2) {
            return(as.integer(r[2]))
        }
    }

    NA_integer_
}

extract_element <- function(msg) {
    m <- regexec("Element '([^']+)'", msg, perl = TRUE)
    r <- regmatches(msg, m)[[1]]
    if (length(r) >= 2) {
        elem <- r[2]
        # Strip namespace prefix if present
        elem <- sub(".*}", "", elem)  # Removes everything up to and including the last }
        elem
    } else {
        NA_character_
    }
}

extract_allowed <- function(msg) {
    allowed <- character(0)

    m1 <- regexec("Expected is one of \\(([^)]*)\\)", msg, perl = TRUE)
    r1 <- regmatches(msg, m1)[[1]]
    if (length(r1) >= 2) {
        allowed <- trimws(strsplit(r1[2], ",", fixed = TRUE)[[1]])
    }

    allowed <- gsub("^.*:", "", allowed)
    allowed <- trimws(allowed)
    allowed[nzchar(allowed)]
}

clean_message <- function(msg) {
    msg <- gsub("\\{[^}]+\\}", "", msg)
    msg <- sub(".*Schemas validity error ?: ?", "", msg)
    msg <- sub("This element is not expected\\.", "not expected.", msg)
    msg <- sub("\\s*Expected is one of \\([^)]*\\)\\.?", "", msg)
    msg <- sub("\\s*Expected is \\([^)]*\\)\\.?", "", msg)
    msg <- sub("\\s*Expected:.*$", "", msg)
    trimws(msg)
}

make_snippet <- function(xml_lines, line, elem, attr, ctx, red, reset) {
    snippet <- NA_character_

    if (!is.na(line) && line >= 1 && line <= length(xml_lines)) {
        line_text <- xml_lines[line]

        # Determine what to search for: attribute or element
        search_target <- if (!is.na(attr) && nzchar(attr)) attr else elem

        if (!is.na(search_target) && nzchar(search_target)) {
            # Search for the target in the line
            pos <- regexpr(search_target, line_text, fixed = TRUE)[1]

            if (pos > 0) {
                start <- max(1, pos - ctx)
                end <- min(nchar(line_text), pos + nchar(search_target) + ctx + 10)
                snippet <- substr(line_text, start, end)
            } else {
                snippet <- substr(line_text, 1, min(nchar(line_text), 2 * ctx + 40))
            }

            # Highlight the target with red
            if (!is.na(snippet) && nzchar(snippet)) {
                # For attributes, we want to highlight just the attribute name
                # Pattern: attributeName= or attributeName =
                if (!is.na(attr) && nzchar(attr)) {
                    attr_pattern <- paste0(attr, "\\s*=")
                    snippet <- gsub(
                        attr_pattern,
                        paste0(red, attr, reset, "="),
                        snippet,
                        perl = TRUE
                    )
                } else if (!is.na(elem) && nzchar(elem)) {
                    # For elements, highlight the tag name
                    tag_pattern <- paste0("<", elem, "(\\s|/|>)")
                    snippet <- gsub(
                        tag_pattern,
                        paste0(red, "<", elem, reset, "\\1"),
                        snippet,
                        perl = TRUE
                    )
                }
            }
        } else {
            snippet <- substr(line_text, 1, min(nchar(line_text), 2 * ctx + 40))
        }
    }

    snippet
}

make_result <- function(valid, errors, engine, color, schema = NULL) {
    structure(
        list(
            valid = valid,
            errors = errors,
            engine = engine,
            schema = schema,
            color = isTRUE(color)
        ),
        class = "qti_validation_result"
    )
}

resolve_qti_schema <- function(schema = NULL, extended_schema = FALSE) {
    if (is.null(schema)) {
        schema <- if (isTRUE(extended_schema)) "extended" else "qti21"
    }

    if (!is.character(schema) || length(schema) != 1 || !nzchar(schema)) {
        stop("`schema` must be NULL, a schema selector, or a single XSD file path.", call. = FALSE)
    }

    schema_key <- tolower(schema)
    schema_key <- gsub("[._-]", "", schema_key)

    schema_file <- switch(
        schema_key,
        default = system_schema_file("imsqti_v2p1p2.xsd"),
        qti21 = system_schema_file("imsqti_v2p1p2.xsd"),
        qti2p1 = system_schema_file("imsqti_v2p1p2.xsd"),
        qti21default = system_schema_file("imsqti_v2p1p2.xsd"),
        extended = system_schema_file("qti_v2p1p2_extension.xsd"),
        extendedschema = system_schema_file("qti_v2p1p2_extension.xsd"),
        extendedxsd = system_schema_file("qti_v2p1p2_extension.xsd"),
        qti21extended = system_schema_file("qti_v2p1p2_extension.xsd"),
        qti2p1extended = system_schema_file("qti_v2p1p2_extension.xsd"),
        qti22 = qti22_schema_file(),
        qti2p2 = qti22_schema_file(),
        NULL
    )

    if (is.null(schema_file)) {
        schema_file <- find_schema_file(schema)

        if (is.null(schema_file)) {
            stop(
                "Unknown schema selector or missing XSD file: ", schema, "\n",
                "Use one of 'qti21', 'extended', 'qti22', or pass a path to an XSD file.",
                call. = FALSE
            )
        }
    }

    if (!file.exists(schema_file)) {
        stop("Could not find schema file: ", schema_file, call. = FALSE)
    }

    schema_file <- normalizePath(schema_file, mustWork = TRUE)
    attr(schema_file, "label") <- basename(schema_file)
    schema_file
}

find_schema_file <- function(schema) {
    candidates <- c(
        schema,
        file.path(system.file(package = "rqti"), schema),
        file.path(system.file(package = "rqti"), "xsd", schema),
        file.path(qti_schema_search_dirs(), schema)
    )

    matches <- candidates[file.exists(candidates)]

    if (length(matches)) {
        normalizePath(matches[[1]], mustWork = TRUE)
    } else {
        NULL
    }
}

system_schema_file <- function(schema_name) {
    schema_file <- find_schema_file(schema_name)

    if (is.null(schema_file)) {
        stop("Could not find schema file: ", schema_name, call. = FALSE)
    }

    schema_file
}

qti22_schema_file <- function() {
    schema_file <- system_schema_file(file.path("xsd", "imsqti_v2p2.xsd"))

    schema_file
}

qti_schema_search_dirs <- function(start = getwd(), max_depth = 5) {
    dirs <- normalizePath(start, mustWork = TRUE)

    current <- dirs[[1]]
    for (i in seq_len(max_depth)) {
        parent <- dirname(current)
        if (identical(parent, current)) {
            break
        }
        dirs <- c(dirs, parent)
        current <- parent
    }

    unique(dirs)
}

is_schema_import_error <- function(err) {
    isTRUE(err$element %in% c("{http://www.w3.org/2001/XMLSchema}import", "import")) ||
        (
            !is.null(err$raw_message) &&
                grepl("Skipping import of schema", err$raw_message, fixed = TRUE) &&
                grepl("already imported", err$raw_message, fixed = TRUE)
        )
}

parse_errors <- function(raw_errors, xml_lines, file_in, ctx, red, reset) {
    lapply(raw_errors, function(msg) {
        line <- extract_line(msg)
        elem <- extract_element(msg)
        attr <- extract_attribute(msg)  # Add this
        allowed <- extract_allowed(gsub("\\{[^}]+\\}", "", msg))
        message <- clean_message(msg)
        snippet <- make_snippet(xml_lines, line, elem, attr, ctx, red, reset)

        list(
            file = file_in,
            line = line,
            element = elem,
            attribute = attr,  # Add this
            message = message,
            allowed = allowed,
            snippet = snippet,
            raw_message = msg
        )
    })
}

Try the rqti package in your browser

Any scripts or data that you put into this service are public.

rqti documentation built on Sept. 21, 2026, 5:08 p.m.