How to cite this paper

Sales, Andrew. “Beyond valid and invalid: implementing complex validation in XProc.” Presented at Balisage: The Markup Conference 2026, Washington, DC, August 3 - 7, 2026. In Proceedings of Balisage: The Markup Conference 2026. Balisage Series on Markup Technologies, vol. 31 (2026). https://doi.org/10.4242/BalisageVol31.Sales01.

Balisage: The Markup Conference 2026
August 3 - 7, 2026

Balisage Paper: Beyond valid and invalid: implementing complex validation in XProc

Andrew Sales

Andrew has led the Content Architecture team at Bloomsbury since 2016. He has worked with and for publishers in the XML space since 2000. Since 2016 he has been Project Editor for the Schematron international standard (ISO/IEC 19757-3).

Copyright © 2026, Andrew Sales

Abstract

We describe the (re-)implementation at an academic publisher of a piece of enterprise middleware which acts as quality gatekeeper for our XML-first publishing workflow. The approach covers re-factoring equivalent legacy software as a single XProc pipeline; how we have tackled testing this pipeline; and how we use the post-processing of the validation reports it produces to both refine and re-define our validity criteria, based on the “size and shape” of the documents under scrutiny and the extent to which they violate our business rules.

Table of Contents

Introduction
Background
History
Validation portal 1.0
Changing business needs
On the road
At the crossroads
Design choices
Why MorganaXProc-III?
System architecture
Simplified reports
Documaintenance
Turning the corner
Schemalets (or epischemas)
Scoring the warnings
ONIX metadata cross-checks
Testing the pipeline
Extensibility
Conclusion

Introduction

This paper is part technical implementation, part incomplete case study. As it is now written, it covers the main benefits to us as maintainers of the implementation and owners of the system, since it is only now being rolled out to its main consumers, our supplier base. As such, any data or feedback arising in the interim cannot be included here, but may be presented at a later date.

Background

Bloomsbury Publishing Group plc began as a trade publisher nearly forty years ago, adding an academic division some time later. We publish around 3,500 titles per year on the academic list.

Our digital products include Bloomsbury Collections (a library of nearly 50,000 titles, in the main academic monographs), Drama Online (home of the Arden Shakespeare and content from partners such as Faber and Faber) and the Churchill Archive, providing online access to the complete (800,000+) digitized papers of Sir Winston Churchill.

We work for this part of the business almost exclusively, in the Content Architecture (CA) team. Our team’s main responsibility is to define and maintain the content conversion documentation and supporting validation assets used by our supplier base in preparing the XML we use to publish this content online, and to apply quality assurance prior to publication: in short, we “spec and check”.

The focus of the work this paper describes is the text-based content we commission, or acquire or license from content partners and other third parties. This comprises mostly books, but also plays and some screenplays. In common with other publishers, our business grows mainly by acquisition, which can have the effect that our backlist suddenly grows in some cases by tens of thousands of titles, all eventually requiring conversion to our standard markup formats, to acceptable levels of quality.

History

Bloomsbury Academic began digital publishing in around 2013, later becoming Bloomsbury Digital Resources (BDR).

There was no in-house validation expertise at this time, but the need for a robust set of business rules encapsulated in Schematron schemas was soon recognised.

Likewise, the realisation that an in-house quality assurance team was needed, since not everything could be reliably or definitively validated by machine with the available technology and knowhow. The "QA team" sits within Content Architecture.

A “validation portal” was also set up to carry out delivery package integrity checks, and schema and Schematron validation on receipt from suppliers.

Validation portal 1.0

The first iteration of the validation portal was a bespoke web application developed, hosted and supported by one of our typesetting vendors.

Figure 1: Simplified validation portal workflow

It provided in-built issue tracking for suppliers to file issues for investigation by the CA team, and both documentation and schemas could be updated by us in a self-service manner.

The non-schema validation portions were, however, opaque and not under our control: we neither owned nor had visibility of the source code.

The gratis provision of this service meant that there was also a unreliable lead time for any changes. Support was variable in general, with downtime, ad hoc changes to infrastructure and functional regression all proving disruptive.

Reports resulting from schema validation were purely text-based (i.e., no SVRL[1] produced by Schematron validation) and a log of only the most recent 200 or so submissions was retained, meaning supplier performance was hard to track over time and analyse for areas for improvement in their work.

Changing business needs

The growth in our digital publishing throughput over the last ten years has meant that the need for greater automated validation has only grown with it. It was feasible to have a team operative quality-assure fifty new titles per month prior to release on Bloomsbury Collections in 2016; with monthly releases lately exceeding one thousand titles; however, we have no realistic commercial alternative at this scale but to automate whatever we can.

This scaling up dovetails neatly with the team’s objective to skill up: we need to hone our collective technical skills in identifying bad quality and spend less time on repetitious manual work (the more basic aspects of which we also have an objective to outsource).

On the road

Some team members were already enthusiasts for XProc and its conceptual promise, but had ultimately been baffled by version 1.0. Available time and headspace dictated pursuing other avenues with a shallower learning curve for pipeline processing, for example using XQuery, Python or batch scripts to orchestrate a series of operations.

In light of the foregoing issues, the maturity of XProc 3.0 [XProc 3.0] (and latterly 3.1 [XProc 3.1]) and two well-supported implementations of that standard led us to reconsider how and where this validation service would be best managed.

In short, XProc 3.0 was for us a game-changer. In particular, the availability of both XML Calabash 3.x and MorganaXProc-III meant we could proceed with greater confidence when recommending the adoption of a new technology and procurement of (commercial, potentially) software to support it.

At the crossroads

We realised that the functional requirements for a new implementation of the portal centred on XProc would be relatively quick and easy to write, as this was more of a migration, in effect. We wanted to emulate what the existing portal did, basically, but also seize on the chance to retire some redundant hard-coded logic there, most of it relating to legacy publishing platforms.

An important part of making the successful business case (and an impactful design decision) was to bring the validation logic entirely under our internal control.

This meant the software vendor we chose would essentially only need to implement a web application whose principal service was to execute the validation XProc pipeline on a supplier’s delivery. This would also reduce reliance on a particular vendor, as we had experienced with the previous portal.

As well as commissioning this “vessel” to house a pipeline we would maintain ourselves, we decided that the new implementation’s strategy for error handling should be to fail early. With portal 1.0, even if errors occurred at any point during validation that would render the delivery unusable, all checks were still applied, from beginning to end.

By contrast, version 2.0 terminates with an error on every such condition, saving supplier time and sparing system resources.[2]

Design choices

We had learnt from our past experience that transparency and direct control were paramount concerns for us, along with the ability to track and monitor supplier performance over time.

Architecturally, we determined that all the reports generated by the validation pipeline should be XML documents and stored in a native XML database.

We also split out the (rather clunky) issue tracking portion, in favour of our standard issue tracking system (JIRA), for greater visibility across the business.

New skills in the team led towards using AWS lambdas [AWS Lambdas] for execution of the validation pipeline. A slight downside here is that some significant changes require the lambda to be re-built and re-deployed, which we currently do need our vendor’s assistance with, but this is projected to be a relatively rare occurrence.

We chose XProc because we are invested users of, believers in and proponents of the XML technology stack; the previous portal was implemented in PHP.

This gives us the means to encapsulate the validation logic in a more maintainable, testable and visible way. Not every member of our team is conversant with XProc, but they can definitely read and understand XML and are familiar with unit testing frameworks like XSpec [XSpec].

Why MorganaXProc-III?

We need to do some work with binaries, e.g., inspecting PDFs for corruption; images for colour space, resolution, etc. For these, we use Python scripts built as executables. We authored a prototype pipeline and put Morgana and Calabash head-to-head, with a Morgana evaluation licence (Calabash supports the p:os-exec step, which we’d need for running our executables, but Morgana’s commercially-licensed “Extended Edition” (MorganaXProc-IIIee) is required for that).

We were impressed by the performance (including its startup time when compared to that of XML Calabash), prompt support, licensing model and pricing.

Once we had established with Morgana’s developer and co-editor of the XProc standard Achim Berndzen that we would be operating within the terms of the EULA, the spend was approved and we were good to go.

One notable difference encountered was that, where our validation logic threw deliberate errors (using p:error), Calabash required these to be explicitly handled (with p:try/p:catch), whereas Morgana did not.

System architecture

The new portal has a bespoke .NET front end and API, both maintained by our vendors. An existing application for a different purpose had already been built for us by the same vendor, so this choice was based on cost and time, rather than an ideal technological fit, for example, with the other, Java-based applications involved.

It is a very simple application. Beyond the login page, it serves only three main pages: one enabling the upload of a delivery (or “digital bundle”); one displaying a list of documentation and validation assets for download; and a dashboard of uploads.

Figure 2: Upload, dashboard and documentation screens

The user model is also simple, consisting of admins (people who can change stuff) and standard users (who can upload submissions and can access schemas and documentation).

Simplified reports

A BaseX database in the back end stores a digested form of each validation report, fulfilling our desire to monitor supplier performance.

SVRL is verbose, and can run very large indeed, especially for large documents with many failing tests. We recognised that we only need a count of the distinct issue IDs and an example of each, so for efficiency the final output is condensed to e.g.:

<report
    uri="file:///C:/Users/asales/git/content-architecture/xproc/validation-portal/test/warning-scores/suppress-pdf-required/fail/9781350002876_txt_xml.zip"
    timestamp="2026-02-13T15:39:05.4687649Z" warnings="208" distinctIds="41"
    schematronVersion="[schematron-version-info] schematronVersion = 11"
    workTitle="[title-value] Book title = &#34;Shakespeare and Fun&#34;"
    valid="false">
    <warning id="bib-title-end-quote" count="27">
        <svrl:successful-report test="ends-with(., '’') or ends-with(., '”')"
            id="bib-title-end-quote" role="warning"
            location="/*:book[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:part[namespace-uri()='http://docbook.org/ns/docbook'][8]/*:chapter[namespace-uri()='http://docbook.org/ns/docbook'][3]/*:bibliolist[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:bibliomixed[namespace-uri()='http://docbook.org/ns/docbook'][32]/*:bibliomset[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:title[namespace-uri()='http://docbook.org/ns/docbook'][1]">
            <svrl:text>[bib-title-end-quote] title in biblio item ends with single or double quote;
                should it be outside the title? check within "bibliomset" element with id
                "b-9781350002876-2101"</svrl:text>
        </svrl:successful-report>
    </warning>
    <warning id="bib-title-start-quote" count="25">
        <svrl:successful-report test="starts-with(., '‘') or starts-with(., '“')"
            id="bib-title-start-quote" role="warning"
        [...]

since we are mainly concerned with the overall breakdown of issues, rather than the detail of each. In most cases, this reduces the SVRL file size from megabytes to kilobytes. Note that the portal does also record who made the submission (not shown above).

Documaintenance

That list of documentation assets is just a filtered view of a local clone of our GitHub repository, so we decided for ease of maintenance to use an XProc pipeline to generate it (statically; as c:directory, etc.). Again, this means we have complete control over what is included and no software changes are required to effect a change:

                     
<p:declare-step xmlns:p="http://www.w3.org/ns/xproc"
    xmlns:c="http://www.w3.org/ns/xproc-step" version="3.0"
    xmlns:xs="http://www.w3.org/2001/XMLSchema">
    <p:input port="source" sequence="true"/>    <!-- because there is no input -->
    <p:output port="result"/>
    
    <p:option name="git-path" select="'C:/Users/asales/git/'"/>
    
    <p:variable name="schematron-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/Schematron/'"/>
    
    <p:variable name="schema-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/schema/'"/>
    
    <p:variable name="docs-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/doc/'"/>
    
    <p:variable name="schematron-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/Schematron/'"/>
    
    <p:variable name="schema-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/schema/'"/>
    
    <p:variable name="docs-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/doc/Data%20model%20XML/'"/>
    
    <p:directory-list path="{$docs-tei-base}"
        include-filter='^Bloomsbury%20TEI%20XML%20-%20plays%20and%20screenplays%20v.+\.pdf$' detailed='true'/>
    
    <!-- filter out all but the latest (by modification date): some XPath repetition here 
    because p:delete/@match can't contain variable references -->
    <p:delete match="(//c:file[xs:dateTime(@last-modified) lt max(//c:file/xs:dateTime(@last-modified))])"/>
    
    <p:variable name="docs-tei" select="."/>
    
    <p:directory-list path="{$schematron-tei-base}"
        include-filter='^bmyTEI\.sch$' detailed='true'/>
    
    <p:variable name="schematron-tei" select="."/>
    
    <p:directory-list path="{$schema-tei-base}" detailed='true'>
        <p:with-option name="include-filter" select="('^bmyTEI\.rnc$', '^bmyTEI_BASE\.rnc$')"/>
    </p:directory-list>
    
    <p:variable name="schema-tei" select="."/>
    
    <p:directory-list path="{$docs-base}"
        include-filter='^Bloomsbury%20DocBook%20XML%20Data%20Specification%20v.+\.pdf$' detailed='true'/>
    
    <!-- filter out all but the latest (by modification date): some XPath repetition here 
    because p:delete can't contain variable references -->
    <p:delete match="(//c:file[xs:dateTime(@last-modified) lt max(//c:file/xs:dateTime(@last-modified))])"/>
    
    <p:variable name="docs" select="."/>
    
    <p:directory-list path="{$schematron-base}"
        include-filter='^docbook-mods\.sch$' detailed='true'/>
    
    <p:variable name="schematron" select="."/>
    
    <p:directory-list path="{$schema-base}" detailed='true'>
        <p:with-option name="include-filter" select="('^bloomsbury-mods\.rnc$', '^publishers-51cr\.rnc$')"/>
    </p:directory-list>
    
    <p:wrap-sequence wrapper="repo">
        <p:with-input select="., $schematron, $docs, $schema-tei, $schematron-tei, $docs-tei"/>
    </p:wrap-sequence>
    
</p:declare-step>

On upload to the portal, a lambda simply executes the validation pipeline on the zip containing XML and supporting assets (images, PDFs, etc.). This encapsulates the core validation logic, so where we’ll spend a bit of time.

Turning the corner

Much of the pipeline is concerned with unexciting checks on the integrity of the delivery: after extracting the contents of the archive and resolving any XInclude instructions, it checks that file and directory naming conventions have been followed and identifies any superfluous or missing assets. These are all quite straightforward to do with XProc, but inspecting the file system and archive structure made especially convenient.

For example, once we have established the primary XML document in the zip (because there may be XIncludes) and resolved any inclusions and retained a trace of the fact with xml:base:

                  
<!-- infer the primary XML document from its filename; alternatively we could search for an appropriate root element -->
<p:variable name="primary-xml"
    select="/c:directory[@name eq $zip-isbn]/c:file[matches(@name, '^' || $this-filename-pattern || '.xml$')]/@name"/>

<p:variable name='non-primary-xml' select="/c:directory/c:file[ends-with(@name, '.xml') and @name ne $primary-xml]"/>

<!-- resolve XIncludes: we explicitly fix up xml:base, so that we can check for any extraneous XML -->
<p:xinclude fixup-xml-base="true">
    <p:with-input>
        <p:document href="{resolve-uri($primary-xml, /c:directory/@xml:base)}"/>
    </p:with-input>
</p:xinclude>

<!-- all xml:base attributes present after fixup -->
<p:variable name="xml-base-fixup" select="//@xml:base"/>

the rest of the work of identifying superfluous XML can then be expressed by some fairly lucid and succinct XPath:

                  
<!-- superfluous XML: extracted files not appearing as @xml:base values after XInclude resolution -->
<p:variable name="superfluous-xml" select="$non-primary-xml[not(base-uri(.) = $xml-base-fixup)]"/>

<!-- check for superfluous XML in the zip -->
<p:if test="exists($superfluous-xml)">
    <p:error code="superfluous-xml">
        <p:with-input>
            <message isbn='{$zip-isbn}'>XML documents supplied which are not part of this delivery: {for $x in $superfluous-xml return string-join($x/@name, '&#xA;')}</message>
        </p:with-input>
    </p:error>
</p:if>

Schemalets (or epischemas)

Although we have our main schema documentation, some content sets require special treatment which may be a one-off, so we typically avoid bloating the schema or documentation with those instructions and constraints.

Instead, we issue additional “conversion instructions” in such cases and historically have applied any programmatic checks in-house, using small, standalone Schematron schemas particular to the case in hand. (We have christened these “schemalets”, as they tend to be small-scale offshoots of the main schema they supplement.[3])

We would prefer to have these applied at point of receipt, which was not realistically feasible using the legacy portal. So the method we are pursuing is to specify an appropriate <?xml-model?> and have the pipeline parse that to determine which schemalet(s) to use.

This means we can now apply arbitrary schemalets as needed – provided of course that the XML supplier includes the correct <?xml-model?> in their delivery.

Scoring the warnings

Our Schematron schemas distinguish between two basic levels of severity deserving attention: errors and warnings.[4]

The acceptance criteria are currently that the presence of any errors means the XML is invalid, therefore rejected; but any number of warnings can be present.

However – and this is probably the case more particularly with human-authored, discursive content - some warnings are more important than others. Divining this is a significant part of what the QA team is engaged in.

With the new freedoms afforded to us by the portal 2.0, we resolved to apply a “scoring” algorithm to these warnings, to see which we could instead reject out of hand at submission-time.[5]

This meant collating information from the team about what would constitute a request for re-work from the supplier, in effect the formal expression of these implicit tolerances.

The approach is to post-process the Schematron validation report. For a given constraint ID, we can specify two parameters: a threshold, or absolute number we can accept of a certain warning; or a proportion, i.e., for what percentage of the occurrences of a particular structure the warning was reported.

The pipeline logic is that a Schematron SVRL document containing warnings, but no errors, is filtered by an XSLT stylesheet, whose main processing template is:

                     
<xsl:template match="/">
    <xsl:variable name="warnings" select="(//svrl:failed-assert|//svrl:successful-report)[@role = ('warn', 'warning')]"/>
    
    <xsl:variable name="grouped-warnings">
        <xsl:for-each-group select="$warnings" group-by="@id">
            <warning id='{current-grouping-key()}' count='{count(current-group())}'>
                <xsl:copy-of select="current-group()[1]"/>
            </warning>
        </xsl:for-each-group>
    </xsl:variable>
    
    <xsl:variable name="scored-warnings">
        <xsl:apply-templates select="$grouped-warnings" mode="score"/>
    </xsl:variable>        
    
    <report uri='{$zip-uri}' timestamp='{current-dateTime()}' warnings='{count($warnings)}' distinctIds='{count($grouped-warnings/warning)}'
        schematronVersion='{$schematron-version}' workTitle='{$work-title}'>
        <xsl:if test="exists($scored-warnings/warning/@invalid)">
            <xsl:attribute name="valid" select="'false'"/>
        </xsl:if>
        <xsl:apply-templates select="$scored-warnings/warning">
            <xsl:sort select="@count" order="descending" data-type="number"/>
        </xsl:apply-templates>
    </report> 
</xsl:template>

where the warnings are decorated with an invalid attribute if the threshold or proportion configured is reached:

<xsl:template match="warning" mode="score">
    <xsl:copy>
        <xsl:copy-of select="@*"/>
        <xsl:sequence select="(
            bmy:threshold-exceeded(@id, @count), 
            bmy:proportion-exceeded(@id, @count, $node-count-by-id?(@id))
        )[1]"/>
        <xsl:copy-of select="*"/>
    </xsl:copy>
</xsl:template>

The function threshold-exceeded() is simple enough:

<xsl:function name="bmy:threshold-exceeded" as="attribute(invalid)?">
    <xsl:param name="id" as="attribute(id)"/>
    <xsl:param name="count" as="attribute(count)"/>
    <xsl:if test="$count > $warning-thresholds?($id)">
        <xsl:attribute name="invalid" select="'thresholdExceeded'"/>
    </xsl:if>
</xsl:function>

while proportion-exceeded() calculates whether the percentage has been exceeded:

<xsl:function name="bmy:proportion-exceeded" as="attribute(invalid)?">
    <xsl:param name="id" as="attribute(id)"/>
    <xsl:param name="warningCount" as="attribute(count)"/>
    <xsl:param name="nodeCount" as="xs:integer?"/>
    <xsl:if test="($warningCount div $nodeCount) ge ($warning-percentages?($id) div 100)">
        <xsl:attribute name="invalid" select="'proportionExceeded'"/>
    </xsl:if>
</xsl:function>

If any warning is marked as invalid in this way, <report valid='false'>...</report> is output, with the upshot that the portal rejects the delivery.

It will be apparent from the snippets above that the configuration of these value limits is currently done using maps. For extensibility and ease of maintenance, we plan to move this into an XML configuration document instead, e.g.:

<config>
    <thresholds>
        <warning id='suppress-pdf-required'>5</warning>
    </thresholds>
    <proportions>
        <warning id='figure-in-label'>100</warning>
        <warning id='table-in-label'>100</warning>
    </proportions>
</config>

where each id corresponds to a Schematron warning ID.

We fully appreciate that greater complexity of algorithms is possible and do plan to exploit this as needed. For now, we need this approach to prove its worth, then we can expand on it.

The other major consideration for us is that having a portal whose validation criteria are stricter than necessary appeals in principle, but when faced with the reality of a fast-paced production environment, the risk to the business of potentially slowing down or blocking supply unnecessarily is often too great.

ONIX metadata cross-checks

While we process a reasonable amount of licensed and acquired content, the majority on balance is indigenous – commissioned by our in-house editors.

For this, our bibliographic metadata system (Biblio) produces ONIX [ONIX], which is syndicated to various recipients, including content aggregators and discovery providers.

An ongoing issue for us has been discrepancies between the metadata held in that system (i.e., as expressed in ONIX) and its equivalents contained in the XML representing the content.

This can arise because of late-breaking changes while the title is in production are not subsequently reflected in what is recorded in Biblio. For example, a work’s subtitle might change after it has been typeset, perhaps because of space constraints on the page.

The intention (not implemented as of this writing) is to take a regular feed of ONIX XML for everything in production. These are very large (sometimes multi-gigabyte) documents, but fortunately record-based XML, so the feed can easily be decomposed into small, individual ONIX Products, then stored and queried efficiently.[6]

This means for a given ISBN, we can have the validation pipeline query a database of ONIX Products and compare key items of metadata against the XML as captured, flagging or rejecting any mismatch.

This approach also leads the way to potential automated reconciliation: results of validation can be fed back into Biblio, most probably via a human-managed gateway interface.

Testing the pipeline

Testing XProc 3.0 pipelines appears to be a nascent area of the technology, so we were pleased to learn while preparing this paper that support in XSpec for this is being added [XSpec 3.4 highlights].

In the interim, we use a unit-testing approach supported by BaseX's unit module.

Each test is represented by a function with the BaseX-specific XQuery annotation %unit:test. The tests are organised to enable the entire suite to be run at once. There is one passing and one failing unit test for each of the conditions the pipeline can present. For example, this pair of tests relate to the same condition, where media assets not referenced by the XML are present in the delivered package:

                     
declare %unit:test function _:extraneous-media-fail()
{
  unit:assert(
    _:run-pipeline('extraneous-media/fail/9781350996526_txt_xml.zip')/c:errors/c:error[@code eq 'extraneous-media']
  )
};

(:~ clean up extracted files :)
declare %unit:after('extraneous-media-fail') function _:extraneous-media-fail-cleanup()
{
  file:delete(resolve-uri('extraneous-media/fail/9781350996526/', static-base-uri()), true())
};

declare %unit:test function _:extraneous-media-pass()
{
  unit:assert-equals(
    _:run-pipeline('extraneous-media/pass/9781350996526_txt_xml.zip')/c:errors/c:error[@code eq 'extraneous-media'],
    ()
  )
};

(:~ clean up extracted files :)
declare %unit:after('extraneous-media-pass') function _:extraneous-media-pass-cleanup()
{
  file:delete(resolve-uri('extraneous-media/pass/9781350996526/', static-base-uri()), true())
};

Note

The %unit:after annotation in BaseX specifies that the body of the function is executed after the named %unit:test function has been executed: here, we clean up any extracted files.

There is a performance overhead with this approach, in that we start a new instance of Morgana for every test, invoked via the BaseX function proc:execute, but an XML document representing the result of that invocation is at least returned for inspection.[7]

We do have some test cases that address individual steps in isolation (enabled, for example, by the appropriate Morgana command line options), but this is less relevant for us as we mostly use built-in steps in the pipeline as currently written. This is where our fail-early philosophy presents a slight disadvantage, because if we want to test in a way that resembles real-world scenarios closely, we have the overhead of maintaining test cases that are valid up to a certain point in the pipeline.

For most tests that is fine, as the criteria are quite stable; but our Schematron schemas do change relatively often, which can mean a pipeline test suddenly fails as a consequence. It would be onerous for us to maintain complete, shared test case documents, so the tactic we have employed here amounts to "mocking", whereby a step option accepts suitable input to produce the desired behaviour. In this unit test, setting the $use-mocking option to true() has the effect of making the step output the input document, just as would be the case if validation succeeded:

declare %unit:test function _:schema-validation-pass()
{
  let $result := _:run-library-step(
    'schema-validation/pass/9780567706652_txt_xml.xml',
      $_:STEPS_LIBRARY_PATH, 
      'Q{http://www.bloomsbury.com/xproc}bmy:validate-rnc-sch',
      map{},
      map{
        'relax-ng-uri':'../../content-models/DocBook/schema/bloomsbury-mods.rnc',
        'schematron-uri':'../../content-models/DocBook/schematron/docbook-mods.sch',
        'use-mocking':true()
      }
  )
  let $output := $result/output=>parse-xml()
  return
  unit:assert-equals($output/c:errors, ())
};

At first this can feel wrong, since we effectively bypass the test, or at least produce an artificial result; but we have reasoned that this is on balance tolerable since a) the main purpose of this test suite is to test the pipeline's behaviour, rather than that of the schemas it references; and b) we already have robust test suites in place elsewhere to test that the schemas themselves are fit for purpose.

Extensibility

Not long after development of the new portal was completed, a new use case for a content type arose. We had handled this content before but on such a small scale that it didn’t warrant building into the portal. Now, however, the scale of the incoming content is ambitious, so we feel vindicated in that we can now easily incorporate a new content type by some relatively self-contained changes to the pipeline.

The changes will amount to adding some minor decision logic to respond to the new markup to select the correct schema, and re-generating the filtered view of our cloned GitHub repository, to make the added validation assets available to users on the portal front end.

Conclusion

The changes we have made to our validation infrastructure have benefited our digital production workflows, as follows:

  1. We have traded reliance on a vendor offering a "black box" service for free, for a low-maintenance, low-cost validation service.

  2. We have brought the validation logic the service applies in house, in turn making it more adaptable and responsive to the delivery quality issues we face.

  3. We can collate and interpret data on our suppliers' performance programmatically for the first time.

In early 2017, our team was understandably challenged by senior leadership, questioning why QA work carried out by human beings was still needed if we had schemas and a validation portal to apply them automatically on receipt from the supplier. Our response contained the phrase, "we will always need to do some checking [...] because schemas alone are not enough in some cases - they have not yet reached a level of artificial intelligence." Perhaps this will be the next significant development in XML quality assurance of this nature.

References

[XProc 1.0] XProc: An XML Pipeline Language, W3C Recommendation, 11 May 2010, https://www.w3.org/TR/xproc/

[XProc 3.0] XProc 3.0: An XML Pipeline Language, https://spec.xproc.org/3.0/xproc/

[XProc 3.1] XProc 3.1: An XML Pipeline Language, https://spec.xproc.org/3.1/xproc/

[AWS Lambdas] What is AWS Lambda?, AWS Lambda Developer Guide, https://docs.aws.amazon.com/lambda/latest/dg/welcome.html (retrieved 2026-04-04).

[XSpec] XSpec, https://github.com/xspec/xspec

[XSpec 3.4 highlights] XSpec 3.4 Highlights, https://medium.com/@xspectacles/xspec-v3-4-highlights-2a1a34ee153d (retrieved 2026-04-04).

[Imsieke, 2017] Gerrit Imsieke, A Text Structure ‘Epischema’ for TEI, XML Prague 2017, https://archive.xmlprague.cz/2017/files/xmlprague-2017-proceedings.pdf, pp.195-209.

[Schematron 2025] ISO/IEC 19757-3:2025, https://www.iso.org/standard/85625.html

[ONIX] ONIX, https://www.editeur.org/8/ONIX/



[1] Schematron Validation Report Language [Schematron 2025], Annex D (informative).

[2] Suppliers are selected on that basis that they have the tools and expertise to provide good-quality capture, which we support with our schemas, documentation and active dialogue of feedback on quality. Our ideal way of working and what we are seeking to encourage under version 2.0 is that they prepare good-quality XML using these offline resources before upload to the portal, which should act for them as a final check before delivery.

[3] Multiple schemas applied in this way have been described as epischemas [Imsieke, 2017], although our own preference is to use additional Schematron schemas for their flexibility in expressing complex business rules.

[4] We note that the latest edition of the Schematron standard [Schematron 2025] gives more potential flexibility with the dedicated attribute severity (glossed at What's New In Schematron 4).

[5] We credit Dr Alex Brown with this principle.

[6] Our experimentation has shown that performance-wise these are better ingested, indexed and queried as small, individual documents in BaseX.

[7] Inverting this process, so that a single Morgana pipeline invokes the tests instead, would likely improve performance, but Morgana does not currently support invoking XQuery using BaseX.

×

XProc: An XML Pipeline Language, W3C Recommendation, 11 May 2010, https://www.w3.org/TR/xproc/

×

XProc 3.0: An XML Pipeline Language, https://spec.xproc.org/3.0/xproc/

×

XProc 3.1: An XML Pipeline Language, https://spec.xproc.org/3.1/xproc/

×

What is AWS Lambda?, AWS Lambda Developer Guide, https://docs.aws.amazon.com/lambda/latest/dg/welcome.html (retrieved 2026-04-04).

×

XSpec 3.4 Highlights, https://medium.com/@xspectacles/xspec-v3-4-highlights-2a1a34ee153d (retrieved 2026-04-04).

×

Gerrit Imsieke, A Text Structure ‘Epischema’ for TEI, XML Prague 2017, https://archive.xmlprague.cz/2017/files/xmlprague-2017-proceedings.pdf, pp.195-209.