How to cite this paper

Udeshani, K.A.G. “Track Changes Support for Automated Regex-Based Text Replacement in XML Documents.” 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.Udeshani01.

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

Balisage Paper: Track Changes Support for Automated Regex-Based Text Replacement in XML Documents

K.A.G. Udeshani

Typefi Systems

K.A.G. Udeshani is an Associate Software Architect at Typefi Systems working on XML-based publishing automation tools.

© 2026 Typefi Systems Pty Ltd and its licensors. All rights reserved. Typefi, the Typefi logo, Typefi Onion, and the Typefi Orion logo are either registered trademarks or trademarks of Typefi Systems Pty Ltd in the United States and/or other countries. All other trademarks, logos and copyrights are the property of their respective owners.

Abstract

Automated text replacement in structured XML documents presents a fundamental editorial challenge: how can a processing tool apply programmatic changes while preserving the human-readable audit trail that reviewers and editors rely on? This paper describes a three-phase pipeline—unwrap, replace, and rewrap—that separates existing change-tracking markup from document content prior to processing, applies regex-based transformation rules to the normalized text, and reconstructs a well-formed change-tracking structure that integrates both pre-existing editorial annotations and new programmatic changes.

The paper addresses two key technical challenges. First, nested track changes, where programmatic edits overlap with or occur within existing editorial marks, require careful recursive reconstruction. Second, offset management across multiple sequential replacements within a single paragraph, where each modification shifts the character positions of subsequent matches, must be handled precisely—a simplified instance of operational transformation. We introduce the ReplacementInfo data structure, which maintains contextual and positional consistency throughout the pipeline. The approach is validated through a suite of integration tests covering overlapping replacements, protected inline elements, Unicode code-point indexing, and multi-group rule interactions.

Table of Contents

Introduction
Related Work
Change Tracking Representations in XML
Tree-Based vs. String-Based Text Replacement
Offset Management and Operational Transformation
The Track Changes Markup Model
The Three-Phase Pipeline
Phase 1: Unwrapping Pre-existing Track Changes
Phase 2: Regex Replacement with Position Tracking
Phase 3: Reconstructing Track Changes Markup
The Nested Track Changes Problem
Cross-Group Positional Nesting
Offset Management
Code-Point Indexing
Intra-Group Offset Propagation
Cross-Group Consistency
Tag Context Detection
The ReplacementInfo Data Structure
Discussion
Design Trade-offs
Limitations
Comparison with XSLT-Based Approaches
Conclusion
Acknowledgements
Appendix A. Validation Test Case — Nested Track Changes
Input
Rule
Expected Output
Annotations

Introduction

Automated search-and-replace is a routine operation in publishing workflows—style-guide enforcement, terminology standardization, and citation formatting all involve applying batches of regex rules to document text. When the target documents are in XML, the problem acquires two dimensions that plain-text replacement tools do not face.

First, the replacement engine must not treat markup as content. A rule that replaces colour with color should not match the literal text status="deleted" that happens to appear inside a change-tracking element. Because the document is serialized XML, the engine must distinguish text content from tag syntax.

Second, in many editorial workflows the documents already carry a body of tracked changes—insertions and deletions recorded by human editors—that must not be silently overwritten or lost. Moreover, every automated change the tool itself makes should appear as a tracked change, attributable to the tool and the specific rule that produced it, so that a downstream editor can accept or reject each change individually.

This paper addresses both dimensions in combination. The implementation context is Orion Smart Replace, a Java plugin for Typefi's automated publishing platform that processes documents in the CXML element-wrapping change-tracking format (described in section “The Track Changes Markup Model”). The plugin reads dictionary files specifying regex-based find/replace rules and applies them to paragraph elements. While the specific XML vocabulary is defined by the CXML schema [6], the algorithmic problems—separating editorial markup from searchable text, maintaining character-position consistency across sequential replacements, and reconstructing nested change representations—are general to any system that combines automated text transformation with element-based change tracking.

The contributions of this paper are:

  • A three-phase unwrap/replace/rewrap pipeline that isolates pre-existing track changes, applies regex replacements to clean text, and reconstructs a merged change-tracking representation.

  • A treatment of the nested track changes problem: when a programmatic replacement targets text that is itself inside, or spans, a pre-existing editorial change, the resulting markup must accurately represent all layers of change.

  • A treatment of the offset management problem: as multiple replacements are applied sequentially within a single paragraph, each replacement changes the length of the text, shifting the start and end positions of all subsequent candidates—a simplified instance of operational transformation [7].

  • A description of the ReplacementInfo record that carries positional and authorship metadata across all three phases.

Related Work

Change Tracking Representations in XML

Change tracking in XML documents takes three principal forms, each with different implications for automated processing.

Element wrapping. The most common approach uses dedicated elements to delimit inserted and deleted content. OOXML (ISO/IEC 29500) [1] wraps insertions in <w:ins> and deletions in <w:del>, each carrying author and timestamp attributes. ODF [2] uses analogous <text:insertion> and <text:deletion> elements. The ContentXML (CXML) format used in our implementation employs <style> elements with a status attribute whose value is "added" or "deleted", described in section “The Track Changes Markup Model”. In all these formats, the document contains both the original and proposed text simultaneously, and the change-tracking elements alter the document's tree structure by introducing additional nesting levels.

Processing instructions. An alternative approach uses processing instructions (PIs) to mark change boundaries without altering the element tree. The oXygen XML editor, for example, uses <?oxy_insert_start?> / <?oxy_insert_end?> and <?oxy_delete?> PIs [8]. Because PIs are not elements, they do not affect document nesting—a property that simplifies some processing scenarios but complicates others, since PIs cannot directly contain the deleted text.

Delta files. DeltaXML and similar tools represent changes as a separate delta document or as annotations merged into the original tree, rather than inline element pairs [9]. La Fontaine [10] proposes a standard change-tracking vocabulary for XML. La Fontaine's earlier work [11] provides a taxonomy of change-tracking models and discusses the design trade-offs among them.

The element-wrapping model is defined by the CXML (ContentXML) schema, maintained by Typefi [6], and is a constraint of the platform rather than a design choice. However, the pipeline described in this paper would apply, with straightforward adaptations, to any element-wrapping change-tracking vocabulary.

Tree-Based vs. String-Based Text Replacement

There are two broad strategies for applying regex replacements to XML content.

Tree-native replacement operates on text nodes within the parsed tree. The XSLT instruction xsl:analyze-string (available since XSLT 2.0 [12]) and the XPath function fn:analyze-string (available since XPath 3.0 [13]) decompose a string into matching and non-matching substrings, enabling regex-driven transformations without leaving the tree model. Kalvesmaki's TAN library [14] extends this approach with tan:batch-replace(), which applies an ordered sequence of find/replace pairs to text nodes, handling back-references and case folding within XSLT. The advantage of tree-native replacement is that tag boundaries are never at risk: the processor sees only text-node content, not serialized markup.

String-based replacement serializes an XML subtree to a string, applies regex matches to the serialized form, and parses the result back into the tree. This is the approach taken in the present work. The choice is driven by compatibility with a large corpus of existing dictionaries whose regex patterns were authored against serialized paragraph text, including patterns that span inline element boundaries—for example, matching text that is partly in one inline element and partly in adjacent text. The cost is that the serialized string contains tag syntax that the regex engine must be taught to avoid (section “Tag Context Detection”), and each round-trip through serialization and parsing introduces potential for namespace or whitespace artifacts (section Design Trade-offs, paragraph Serialization round-tripping).

Offset Management and Operational Transformation

When multiple replacements are applied sequentially to a single text, each replacement may change the text's length, shifting the positions of all subsequent match candidates. This is a well-known problem in collaborative editing, where it is addressed by operational transformation (OT) [7]. In OT, each editing operation carries positional metadata that is transformed against preceding operations to account for insertions and deletions that have shifted the text.

Our offset propagation strategy (section “Offset Management”) is a simplified instance of OT: replacements are applied sequentially rather than concurrently, and the transformation function is a simple additive shift rather than the more complex inclusion/exclusion transforms required for concurrent edits. The connection is worth noting because it clarifies the correctness invariant: after all replacements have been applied, every positional record must refer to the same final text state—exactly the convergence property that OT guarantees.

The Track Changes Markup Model

The target format uses element wrapping for change tracking. A <style> element with a status attribute value of "deleted" or "added", together with author and dateTime attributes, records a tracked change. A tracked deletion of the word colour followed by its replacement color is represented as:

<p type="Text para">The
  <style author="Editor"
         dateTime="2025-09-24T11:02:58"
         status="deleted">colour</style>
  <style author="Editor"
         dateTime="2025-09-24T11:02:58"
         status="added">color</style>
  was vivid.</p>

The deleted element carries the original text; the added element carries the replacement. A pure insertion (text added with nothing deleted) uses an empty deleted element. A pure deletion uses an empty added element.

Two properties of this model are important for the pipeline design:

  1. Change-tracking elements are inline. They appear as children of <p> elements alongside text nodes and other inline markup (bold, italic, superscript). This means that serializing a paragraph to a string will include the change-tracking elements as literal tag text.

  2. Nesting is permitted. A <style status="added"> element may itself contain further <style status> elements, representing a subsequent change to text that was itself introduced by an earlier change. This nesting is the mechanism by which the format records layered editing history.

The Three-Phase Pipeline

The processing method iterates over every <p> element in the document (in reverse DOM order to avoid index invalidation as nodes are replaced). For each paragraph, it executes three phases:

unwrapTrackChanges(pElement, ...)   ← Phase 1
smartReplace(...)                   ← Phase 2
processTrackChanges(...)            ← Phase 3

A single list, allReplacementsForPNode, of ReplacementInfo records threads information through all three phases. After Phase 3, the reconstructed XML string is wrapped in a <p> element and replaces the original node in the DOM.

The following diagram illustrates the data flow through all three phases:

┌─────────────────────────────────────────────────────────────┐
│  Input: <p> with existing <style status> track changes      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 1 │ unwrapTrackChanges()
                           │  • Remove <style status> wrappers
                           │  • Record positions → ReplacementInfo
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Clean text (no TC markup) + allReplacementsForPNode        │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 2 │ smartReplace()
                           │  • Match regex rules per group
                           │  • Apply replacements + offset propagation
                           │  • Append new ReplacementInfo records
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Replaced text + allReplacementsForPNode (all records)      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 3 │ processTrackChanges()
                           │  • Walk records in order
                           │  • Emit <style deleted>/<style added> pairs
                           │  • Recurse for nested records
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Output: <p> with merged TC markup (old + new changes)      │
└─────────────────────────────────────────────────────────────┘

Phase 1: Unwrapping Pre-existing Track Changes

The purpose of the first phase is to transform the paragraph from its mixed markup/track-changes form into clean text (still XML, but without <style status> wrapper elements), while recording the position and authorship of every pre-existing track change so it can be reinstated in Phase 3.

The method traverses the child elements of the paragraph looking for <style> elements with a status attribute. For each status="deleted" element, it records the inner XML content as deletedText, finds the element's character offset within the serialized paragraph string, removes the element from the DOM, and creates a ReplacementInfo record. For each status="added" element, it moves the element's children directly into the parent paragraph (unwrapping the <style> wrapper), records the addedText, and creates a corresponding ReplacementInfo record. In both cases, the author and dateTime attributes from the original element are preserved in the ReplacementInfo.

                     
// Phase 1 — simplified excerpt
if ("deleted".equalsIgnoreCase(status)) {
    deletedText = convertNodeToString(tc, transformer);
    start = pText.indexOf("<style author=\"" + tc.getAttribute("author")
                          + "\" ... status=\"deleted\">" + deletedText + "</style>");
    pElement.removeChild(tc);
}
if ("added".equalsIgnoreCase(status)) {
    addedText = convertNodeToString(tc, transformer);
    while (tc.hasChildNodes())
        pElement.insertBefore(tc.getFirstChild(), tc);
    pElement.removeChild(tc);
}
ReplacementInfo rInfo = new ReplacementInfo(
    "", 0, start, start + codePointCount(addedText),
    deletedText, addedText, true,
    tc.getAttribute("author"), tc.getAttribute("dateTime"));
allReplacementsForPNode.add(rInfo);

After Phase 1, pElement contains the paragraph with track changes unwrapped—the added text is present inline and the deleted text is captured in allReplacementsForPNode. The paragraph is now ready for regex processing without any change-tracking markup interfering with pattern matching.

Phase 2: Regex Replacement with Position Tracking

Phase 2 processes the paragraph through each rule group in order. For each group it performs the following steps.

Serialization. The paragraph node is serialized to a string. Protected inline elements are replaced with fixed-width placeholder strings padded to the same code-point length as the serialized element. This allows the text positions of surrounding content to remain stable while preventing replacements from being applied inside protected markup.

Pattern matching. For each rule whose scope matches the paragraph's style, a java.util.regex.Pattern is compiled and applied to the serialized text. For each match, the code checks:

  1. Overlap detection. If any previously found replacement within the same group overlaps the current match's [start, end) range, the match is skipped, preventing two rules from modifying the same character range within a single group. Overlaps across groups are permitted because each group operates on the output of the preceding group.

  2. Tag context. A scan of the serialized text determines whether the match's span straddles or falls inside an XML tag boundary. Matches that overlap a tag are discarded; matches that encompass a complete tag are permitted (the match spans content around the tag, not inside it).

  3. Case adjustment. For case-insensitive rules, the replacement's capitalization is adjusted to match the source: all-uppercase matches produce an uppercased replacement; initial-capital matches produce a capitalized replacement. This mirrors the case-preserving behavior of Microsoft Word's Find & Replace when Match case is unchecked.

  4. Group reference expansion. Back-references ($0, $1, $2, ...) in the replacement string are resolved using the groups captured by the original Matcher, preserving the surrounding context that lookahead and lookbehind assertions depend on.

For each accepted match, a ReplacementInfo is added to the per-group local list.

Offset-aware application. After all rules in the group have been scanned, the per-group replacements are sorted by start position and applied sequentially to a StringBuffer:

                     
for (ReplacementInfo info : allReplacementsForGroup) {
    int offset = codePointCount(info.getReplacedText())
               - codePointCount(info.getOriginalText());
    buffer.replace(info.getStartIndex(), info.getEndIndex(),
                   info.getReplacedText());

    // Shift all subsequent replacements in the same group
    allReplacementsForGroup.stream()
        .filter(r -> r.getStartIndex() > info.getStartIndex())
        .forEach(r -> {
            r.setStartIndex(r.getStartIndex() + offset);
            r.setEndIndex(r.getEndIndex() + offset);
        });

    // Shift TC records from earlier groups that fall after this position
    allReplacementsForPNode.stream()
        .filter(r -> r.getStartIndex() > info.getStartIndex()
                  && r.getGroup() < info.getGroup())
        .forEach(r -> {
            r.setStartIndex(r.getStartIndex() + offset);
            r.setEndIndex(r.getEndIndex() + offset);
        });

    if (info.getEnableTc() && !isSameText) {
        allReplacementsForPNode.add(new ReplacementInfo(
            info.getRegexId(), groupIndex,
            info.getStartIndex(), info.getEndIndex() + offset,
            info.getOriginalText(), info.getReplacedText(),
            true, "", ""));
    }
}

The critical invariant is that every ReplacementInfo in allReplacementsForPNode—whether from Phase 1 (pre-existing track changes) or accumulated during Phase 2—always refers to positions in the current processed text, not in any earlier snapshot. The two-level filtering (same-group records vs. earlier-group records) maintains this invariant as groups are processed in sequence.

Placeholder restoration. After each group's replacements are applied, the placeholder strings for protected elements are expanded back to their full XML serializations.

Phase 3: Reconstructing Track Changes Markup

After Phase 2 completes, currentProcessedText is the fully replaced paragraph text as a flat string (still containing inline XML markup for bold, superscript, etc., but no <style status> wrappers), and allReplacementsForPNode is a sorted list of every change that should be recorded as a track change—both pre-existing editorial changes and new programmatic ones.

The reconstruction method walks through allReplacementsForPNode in order and produces a new XML string:

                     
public String processTrackChanges(String text,
        List<ReplacementInfo> replacements,
        int start, int end, ...) {

    StringBuilder result = new StringBuilder();
    int currentIndex = start;

    for (ReplacementInfo info : replacements) {
        // Skip records that are "shadows" of pre-existing TC changes
        boolean isSameText = replacements.stream().anyMatch(r ->
            r.getStartIndex() == info.getStartIndex()
            && r.getReplacedText().equals(info.getOriginalText()));

        if (info.getStartIndex() > end
                || info.getEndIndex() <= currentIndex
                || isSameText)
            continue;

        // Emit any text between the previous change and this one
        if (info.getStartIndex() > currentIndex)
            result.append(text.substring(currentIndex, info.getStartIndex()));

        // Collect nested replacements
        List<ReplacementInfo> nested = replacements.stream()
            .filter(other -> other != info
                && (other.getStartIndex() >= info.getStartIndex()
                    && other.getEndIndex() <= info.getEndIndex()
                    && other.getGroup() >= info.getGroup()
                    || other.getStartIndex() == info.getStartIndex()
                    && other.getOriginalText().equals(info.getReplacedText())))
            .collect(toList());

        String addedContent = nested.isEmpty()
            ? info.getReplacedText()
            : processTrackChanges(text, nested,
                                  info.getStartIndex(), info.getEndIndex(), ...);

        String author = info.getAuthor().isEmpty()
            ? "Tool: " + info.getRegexId()
            : info.getAuthor();
        String timestamp = info.getDateTime().isEmpty()
            ? config.getTimeStamp()
            : info.getDateTime();

        result.append("<style status=\"deleted\" author=\"" + author
                      + "\" dateTime=\"" + timestamp + "\">"
                      + info.getOriginalText() + "</style>");
        result.append("<style status=\"added\" author=\"" + author
                      + "\" dateTime=\"" + timestamp + "\">"
                      + addedContent + "</style>");

        currentIndex = info.getEndIndex();
    }

    if (currentIndex < end)
        result.append(text.substring(currentIndex, end));

    return result.toString();
}

For programmatic changes, the author string is synthesized from the rule identifier and the timestamp is the current system time (or a fixed value for reproducible testing). For pre-existing editorial changes, the original author and timestamp are forwarded unchanged.

The Nested Track Changes Problem

The most algorithmically interesting case arises when a programmatic replacement targets text that overlaps with a pre-existing editorial track change. Consider the following input paragraph:

<p type="Text para">Confirm that the
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"> One</style>
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added">two</style>
  is changed to a three</p>

An editor has replaced One with two. Now suppose a dictionary rule maps two → three. After Phase 1, the paragraph text is "Confirm that the two is changed to a three" (the deleted text One is captured in allReplacementsForPNode, the added text two is unwrapped into the paragraph body). The rule matches two at its position in this text and adds a new ReplacementInfo to allReplacementsForPNode.

After Phase 2, allReplacementsForPNode contains two records:

Table I

ReplacementInfo records after Phase 2 for the nested TC example (indices are illustrative)

# startIndex endIndex originalText replacedText author
1 17 20 One two test user
2 17 22 two three Tool

The relationship between these records—and the nesting that Phase 3 produces—is visualized below:

allReplacementsForPNode after Phase 2:

Record 1 (editorial):  |--- " One" → "two" ---|     author: test user
Record 2 (programmatic):    |-- "two" → "three" --|  author: Orion Smart Replace
                        17                       22
                             ↑
                     Record 2.originalText == Record 1.replacedText
                     → Record 2 is nested inside Record 1

Phase 3 output:

  <style deleted> One</style>          ← Record 1 outer
  <style added>                        ← Record 1 outer
    <style deleted>two</style>         ← Record 2 (nested)
    <style added>three</style>         ← Record 2 (nested)
  </style>

Record 2's originalText equals Record 1's replacedText. This is the nested case: the programmatic replacement targets exactly the text that the editorial change introduced.

In Phase 3, when processing Record 1, the algorithm finds that Record 2 satisfies the nesting condition:

                  
other.getStartIndex() == info.getStartIndex()
&& other.getOriginalText().equals(info.getReplacedText())
// → Record 2.startIndex == Record 1.startIndex
// → Record 2.originalText ("two") == Record 1.replacedText ("two")

Record 2 is placed in the nested list, and processTrackChanges() is called recursively with nested as the replacement list. The recursive call produces:

<style status="deleted" author="Orion Smart Replace: rule-id"
       dateTime="2024-01-09T12:00:00">two</style>
<style status="added" author="Orion Smart Replace: rule-id"
       dateTime="2024-01-09T12:00:00">three</style>

This becomes the body of the status="added" element for Record 1, yielding the final output:

<p type="Text para">Confirm that the
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"> One</style>
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added">
    <style author="Orion Smart Replace: rule-id"
           dateTime="2024-01-09T12:00:00"
           status="deleted">two</style>
    <style author="Orion Smart Replace: rule-id"
           dateTime="2024-01-09T12:00:00"
           status="added">three</style>
  </style>
  is changed to a three</p>

This representation accurately captures both layers of the editing history: a human editor changed One to two, and the automated tool subsequently changed two to three. A reviewer can accept or reject each change independently. The outer pair of <style> elements carries the editor's authorship; the inner pair carries the rule's identifier.

This design requires a guard in Phase 2: when the regex engine matches two at position 17, Record 1 already exists in allReplacementsForPNode with the same startIndex and replacedText equal to the matched text. If Phase 2 were to add a second record unconditionally, the result would be a duplicate sibling rather than a nested child. An isSameText predicate detects this situation—it checks whether an existing record already covers the same position with a replacedText matching the current match's originalText—and suppresses the sibling record. Phase 3's recursive call then picks up the match via the semantic nesting condition, producing the correct nested structure shown above.

Cross-Group Positional Nesting

The nesting condition in Phase 3 has two disjuncts. The second (semantic match: other.originalText.equals(info.replacedText)) detects genuine nesting as described above—a later rule modifies text that an earlier rule introduced. The first disjunct is purely positional: it checks whether one record's range falls inside another's. However, because offset propagation (section “Cross-Group Consistency”) retroactively shifts earlier-group records to account for length changes made by later groups, an earlier-group record's position may end up inside a later-group record's range as an artifact of the arithmetic, even though the two records operated on independent text in separate passes.

To prevent the positional condition from misclassifying such cases as nested, the condition includes a group-ordering guard: positional containment is only recognized when the inner record belongs to the same or a later group than the outer record. Records from an earlier group can only be nested via the semantic condition. This separation ensures that the two disjuncts handle complementary cases: same-or-later-group nesting is detected by position, cross-group nesting by textual identity.

Offset Management

A separate but closely related challenge is keeping all character positions in allReplacementsForPNode consistent as replacements of varying lengths are applied to the text. As noted in section “Offset Management and Operational Transformation”, this is a simplified instance of operational transformation: each replacement is an operation that may shift the positions of all subsequent operations.

Code-Point Indexing

The system uses Java's String.codePointCount(0, length) rather than String.length() for all position computations. This ensures correctness for Unicode content containing characters outside the Basic Multilingual Plane (represented as surrogate pairs in Java's UTF-16 char encoding), which appear in scientific and mathematical content—a common case in academic publishing.

Intra-Group Offset Propagation

Within a single rule group, replacements are sorted by start position and applied left-to-right. After each application, the offset Δ = codePointCount(replacedText) − codePointCount(originalText) is propagated forward:

  • All remaining records in the same group with startIndex > currentStartIndex have their startIndex and endIndex shifted by Δ.

  • Records accumulated in allReplacementsForPNode from earlier groups (i.e., group < currentGroup) that fall after the current replacement position are also shifted. This is necessary because those records were computed against earlier snapshots of the text.

The directional filter (startIndex > info.getStartIndex()) ensures that only records positioned after the current replacement are shifted, since the replacement does not affect text before its start position.

A concrete example illustrates how positions shift after a replacement:

Before replacement:   "the  h  of onset"
                       0  4  6  8
                          ↑ match: " h " → " hour " (Δ = +3)

After replacement:    "the  hour  of onset"
                       0  4      9  11
                                 ↑  ↑
                          positions shifted by +3

Records after this position:  startIndex += 3, endIndex += 3
Records before this position: unchanged

Cross-Group Consistency

Rules in later groups see the text as modified by all earlier groups. When a later group produces a ReplacementInfo, its positions are already correct for the current text state. Earlier-group records in allReplacementsForPNode must be retroactively adjusted whenever a later-group replacement changes the text length. The condition r.getGroup() < info.getGroup() in the offset propagation loop performs this retroactive adjustment.

This approach means that after all groups are processed, every record in allReplacementsForPNode—regardless of which phase or which group produced it—is positioned relative to the same final text snapshot that Phase 3 operates on. A consequence is that an earlier-group record's position may be shifted into a later-group record's range, creating a positional coincidence that Phase 3 distinguishes from semantic nesting via the group-ordering guard (section “Cross-Group Positional Nesting”).

Tag Context Detection

A particularly subtle offset concern involves XML tags embedded in the serialized paragraph text. A scanning method examines angle brackets to determine whether a regex match's span falls entirely within a tag's angle-bracket delimiters, partially straddles a tag boundary, or falls entirely between tags. Only matches classified as falling entirely outside of tags are accepted. A match that straddles a tag opening or closing is discarded without generating a ReplacementInfo, preventing the replacement engine from corrupting the XML markup.

The ReplacementInfo Data Structure

All information needed to reconstruct track changes flows through a single record, ReplacementInfo, whose fields encode both positional and authorship data:

Table II

Fields of the ReplacementInfo record

Field Type Purpose
startIndex int Code-point offset of the match start in the current text
endIndex int Code-point offset one past the match end
originalText String The matched (deleted) text
replacedText String The replacement (added) text
regexId String ID of the rule that produced this replacement (empty for Phase 1 records)
group int Rule group number (used for offset filtering and nesting guard)
enableTc boolean Whether this replacement should appear as a track change in the output
author String Pre-existing track-changes author; empty for programmatic changes
dateTime String Pre-existing track-changes timestamp; empty for programmatic changes

The duality of author / dateTime being either populated (Phase 1, pre-existing track changes) or empty (Phase 2, programmatic) drives the authorship logic in Phase 3: a non-empty author means the original metadata is forwarded as-is; an empty author means the tool attribution and the current timestamp are synthesized.

The enableTc field allows per-rule opt-out of track change generation. A rule with enable-tc="false" applies its replacement silently: the text changes, but no <style status> elements are emitted. This is useful for normalization passes (e.g., collapsing redundant whitespace) that should not clutter the reviewer's view. It is also necessary when the replacement text is not well-formed XML in isolation—for example, a rule that inserts an opening tag without a corresponding closing tag, or vice versa. Wrapping such a replacement in <style status="added"> would produce invalid XML; disabling track changes for the rule avoids this problem.

Discussion

Design Trade-offs

DOM vs. streaming. The implementation uses DOM (Document Object Model) [3] parsing throughout. For the document sizes encountered in publishing workflows—typically tens of thousands of words per document—DOM's memory model is acceptable. A streaming approach (SAX or StAX) would complicate the paragraph-reversal traversal and the node-replacement operations that are central to the pipeline.

Paragraph-level granularity. Track changes reconstruction operates at the paragraph (<p>) level. Each paragraph is processed independently: it is extracted as a DOM subtree, serialized to string, processed, and replaced in the document. This granularity matches the editorial workflow assumption that reviewers accept or reject changes paragraph by paragraph. It also provides error isolation: if a single paragraph produces malformed XML during reconstruction, only that paragraph fails; the remaining paragraphs are still processed.

Serialization round-tripping. The pipeline repeatedly converts between DOM nodes and strings. Each round-trip wraps the target string in a temporary element before parsing, then imports the resulting child nodes into the main document as a DocumentFragment. The implementation configures the serializer with OMIT_XML_DECLARATION=yes and INDENT=no to prevent namespace or whitespace artifacts.

Limitations

Single-layer nesting. The current implementation handles the two most common scenarios: (a) programmatic changes applied to a clean document, and (b) programmatic changes applied to a document that already contains at most one layer of pre-existing track changes per position. Deeply nested track changes (three or more layers, arising from multiple sequential editing passes—human or programmatic—whose results have not yet been accepted) are not excluded by the data model but are not a tested path, and the recursive processTrackChanges() call would need to be evaluated carefully for correctness in that case.

Overlapping changes across positions. Consider the case where an editor has changed alpha to beta and an automated rule targets alphabet in the original text. The string alphabet spans the boundary of the editorial change: alpha has been replaced by beta, and bet is now followed by whatever came after. In the element-wrapping model, there is no clean representation of a change that straddles a pre-existing change boundary. The current implementation handles this conservatively: Phase 1 unwraps the editorial change, so the text seen by Phase 2 reflects the current state (beta...), not the original (alpha...). A rule targeting the original text alphabet would simply not match. This is correct behavior—the tool processes the document as it stands—but it means the tool cannot reason about the original pre-change text. Word processors with richer change-tracking models (such as OOXML's run-fragmentation and anchor-based approach) can represent finer-grained overlapping edits, at the cost of considerably more complex markup.

Rule ordering. Because replacements within a group are sorted by start position and applied left-to-right, the result is deterministic given a fixed rule order. However, the interaction between rule groups—where a later group may replace text introduced by an earlier group—is order-dependent. This is intentional: dictionary precedence gives authors control over the order in which transformations are applied. The nested track-changes representation faithfully records this layering so reviewers can see which automated step produced which change.

Comparison with XSLT-Based Approaches

An alternative implementation strategy would use XSLT to apply the replacement rules. The tree-native approach to regex replacement, via xsl:analyze-string (XSLT 2.0+) or fn:analyze-string (XPath/XQuery 3.0+), provides a solid foundation for this kind of work [12], [13]. In a purely tree-native design, regex operations would target individual text nodes, avoiding serialization of the full paragraph markup. However, since the existing dictionaries contain patterns that match across inline element boundaries, an XSLT implementation would still need to serialize paragraph content to a string for matching and parse the result back into the tree—the same round-trip the current Java implementation performs.

The regex capabilities of XPath 3.0 cover most of Java's java.util.regex features. The notable gaps are: lookbehind assertions (XPath regex supports lookahead (?=...) and (?!...) but not lookbehind (?<=...) and (?<!...)), possessive quantifiers, and certain Unicode property shortcuts. Saxon's j flag for fn:matches and fn:replace provides access to Java's full regex engine, bridging most of these gaps for deployments that use Saxon [15]. XPath 4.0 (currently in draft) proposes adding lookbehind support to the standard [16].

Kalvesmaki's tan:batch-replace() [14] could serve as the replacement engine for Phase 2, but it is opaque with respect to per-replacement positional metadata: it returns transformed text but not the replacement log—start position, end position, original text, replaced text, and group number for each match—that Phase 3's track-changes reconstruction requires.

However, the core algorithm described in this paper is fundamentally stateful and imperative. ReplacementInfo records are accumulated, mutated in-place, filtered by group, sorted, and passed through recursive calls. The offset propagation requires in-place mutation of a shared list with different filtering criteria at each step. While XSLT 3.0 can model this using maps, arrays, xsl:iterate, and accumulators [12], the natural grain of XSLT is declarative tree transformation—not maintaining and mutating shared position records across sequential passes. XSLT accumulators, in particular, could be used for offset tracking: an accumulator that observes text nodes could maintain a running position counter. But the two-level offset adjustment (same-group vs. earlier-group) and the recursive nesting detection would require threading complex state through the accumulator or restructuring the algorithm into a more functional form.

The decision to use Java was driven by three factors: integration requirements with Typefi's plugin API, the natural fit of the imperative algorithm with Java's mutable data structures, and compatibility with a large corpus of existing Orion dictionaries whose regex patterns were authored against serialized paragraph text. An XSLT-based reimplementation is feasible and might benefit from the tree-native approach for the replacement step, but would require either rewriting these dictionaries to operate on text nodes or replicating the serialization strategy, and the track-changes reconstruction logic would likely need to be restructured rather than transliterated.

Conclusion

This paper has described a three-phase pipeline for applying regex-based text replacements to XML documents while generating well-formed track changes markup that integrates both pre-existing editorial changes and new programmatic changes. The core technical challenge is maintaining correct character-position accounting across multiple sequential replacements with varying lengths—a simplified instance of operational transformation solved by the ReplacementInfo record and the two-level offset propagation strategy. The secondary challenge is correctly handling cases where a programmatic replacement targets text that was itself introduced by a prior editorial change; this is resolved by recursive track changes reconstruction in Phase 3. A related subtlety is distinguishing genuine semantic nesting from positional coincidence introduced by cross-group offset propagation; the nesting condition uses a group-ordering guard to separate these cases (section “Cross-Group Positional Nesting”).

The approach is implemented as Orion Smart Replace, a Java plugin for Typefi that processes documents in the CXML element-wrapping change-tracking format [6]. The string-based replacement strategy is driven by compatibility with a large corpus of existing Orion dictionaries whose regex patterns were authored against serialized paragraph text. The implementation is validated by a suite of over 40 integration tests covering scenarios including overlapping replacements, protected inline elements, Unicode code-point indexing, nested track changes, and multi-group rule interactions.

Future work includes extending the model to handle multiple layers of pre-existing track-changes nesting and investigating whether a hybrid approach—tree-native replacement (e.g., via xsl:analyze-string) combined with imperative track-changes reconstruction—could reduce the serialization round-trips while preserving both the algorithm's correctness properties and compatibility with existing dictionary patterns.

Acknowledgements

The author thanks the Typefi team for their contributions to this work. The architecture of the pipeline was developed in collaboration with the Typefi XSLT/XML team, particularly Max Zhaloba, whose insights into the interaction between string-based replacement and tree-based processing informed key architectural decisions. Ben Hauser inspired the initial decision to implement the solution in Java. Test user provided valuable feedback through his review of the manuscript.

Appendix A. Validation Test Case — Nested Track Changes

This appendix presents the key test case from the integration test suite that validates the nested track-changes scenario described in section “The Nested Track Changes Problem”.

Input

The input document contains a paragraph with a pre-existing editorial change: an editor (test user) has replaced One with two.

<p type="Text para">Confirm that the
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"> One</style>
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added">two</style>
  is changed to a three</p>

Rule

A replacement rule maps twothree with track changes enabled:

<regex case-sensitive="true" enable-tc="true"
       id="Test-58-1" whole-word="false" format="wildcard">
  <find>two</find>
  <replace>three</replace>
  <scope><include><item>Body</item></include></scope>
</regex>

Expected Output

The expected output nests the automated change inside the editorial change. Robin Dunford's outer change ( One → ...) is preserved. Inside the <style status="added"> element for that change, the tool's change (twothree) appears as a nested pair:

<p type="Text para">Confirm that the
  <style author="Orion Smart Replace: Test-58-1"
         dateTime="2024-01-09T12:00:00"
         status="deleted">two</style>
  <style author="Orion Smart Replace: Test-58-1"
         dateTime="2024-01-09T12:00:00"
         status="added">three</style>
  in the text
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"> One</style>
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"/>
  <style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added">
    <style author="Orion Smart Replace: Test-58-1"
           dateTime="2024-01-09T12:00:00"
           status="deleted">two</style>
    <style author="Orion Smart Replace: Test-58-1"
           dateTime="2024-01-09T12:00:00"
           status="added">three</style>
  </style>
  is changed to a three</p>

The first occurrence of twothree (at the start of the paragraph text) is a direct replacement. The second occurrence, inside the <style status="added"> element, is the nested case: the rule has modified text that was itself introduced by the editor's earlier change. Both layers of editing history are preserved and independently reviewable.

Annotations

The test uses a fixed timestamp (2024-01-09T12:00:00) for all tool-generated changes, making the output deterministic and directly comparable to the expected file. The comparison is performed using XMLUnit [5] with whitespace normalization, so that serialization formatting differences do not cause false failures.

References

[[1]] Bryan Schnabel, ed. Office Open XML File Formats — Part 1: Fundamentals and Markup Language Reference. ECMA-376, 5th edition. ECMA International, 2016.

[[2]] OASIS. Open Document Format for Office Applications (OpenDocument) Version 1.3. OASIS Standard, 2021.

[[3]] W3C. Document Object Model (DOM) Level 3 Core Specification. W3C Recommendation, April 2004.

[[4]] Oracle. Java SE 21: java.util.regex — Pattern. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html, 2023.

[[5]] XMLUnit Contributors. XMLUnit for Java, Version 2. GitHub, 2024.

[[6]] Typefi Systems. ContentXML Schema Reference, Version 3.4. Typefi documentation, 2025.

[[7]] C. A. Ellis and S. J. Gibbs. Concurrency Control in Groupware Systems. Proceedings of the 1989 ACM SIGMOD International Conference on Management of Data, pp. 399–407, 1989. doi:https://doi.org/10.1145/67544.6696.

[[8]] SyncRO Soft. oXygen XML Editor: Track Changes. https://www.oxygenxml.com/doc/versions/26.0/ug-editor/topics/track-changes-format.html.

[[9]] DeltaXML Ltd. DeltaXML: XML-Aware Differencing and Merging. https://www.deltaxml.com/.

[[10]] Robin La Fontaine. Standard Change Tracking for XML. Proceedings of Balisage: The Markup Conference 2014. Balisage Series on Markup Technologies, vol. 13, 2014. doi:https://doi.org/10.4242/BalisageVol13.LaFontaine01.

[[11]] Robin La Fontaine. Approaches to Change Tracking in XML. XML Prague 2010, March 2010.

[[12]] Michael Kay, ed. XSL Transformations (XSLT) Version 3.0. W3C Recommendation, 8 June 2017. https://www.w3.org/TR/xslt-30/.

[[13]] Jonathan Robie et al., eds. XML Path Language (XPath) 3.1. W3C Recommendation, 21 March 2017. https://www.w3.org/TR/xpath-31/.

[[14]] Joel Kalvesmaki. A New ⟨u⟩: Extending XPath Regular Expressions for Unicode. Proceedings of Balisage: The Markup Conference 2020. Balisage Series on Markup Technologies, vol. 25, 2020. doi:https://doi.org/10.4242/BalisageVol25.Kalvesmaki01.

[[15]] Saxonica. Saxon XSLT and XQuery Processor: Regular Expressions. https://www.saxonica.com/documentation12/index.html#!functions/fn/matches.

[[16]] Michael Kay et al., eds. XML Path Language (XPath) 4.0. W3C Community Group Draft. https://qt4cg.org/specifications/xquery-40/xpath-40.html.

×

Bryan Schnabel, ed. Office Open XML File Formats — Part 1: Fundamentals and Markup Language Reference. ECMA-376, 5th edition. ECMA International, 2016.

×

OASIS. Open Document Format for Office Applications (OpenDocument) Version 1.3. OASIS Standard, 2021.

×

W3C. Document Object Model (DOM) Level 3 Core Specification. W3C Recommendation, April 2004.

×

XMLUnit Contributors. XMLUnit for Java, Version 2. GitHub, 2024.

×

Typefi Systems. ContentXML Schema Reference, Version 3.4. Typefi documentation, 2025.

×

C. A. Ellis and S. J. Gibbs. Concurrency Control in Groupware Systems. Proceedings of the 1989 ACM SIGMOD International Conference on Management of Data, pp. 399–407, 1989. doi:https://doi.org/10.1145/67544.6696.

×

DeltaXML Ltd. DeltaXML: XML-Aware Differencing and Merging. https://www.deltaxml.com/.

×

Robin La Fontaine. Standard Change Tracking for XML. Proceedings of Balisage: The Markup Conference 2014. Balisage Series on Markup Technologies, vol. 13, 2014. doi:https://doi.org/10.4242/BalisageVol13.LaFontaine01.

×

Robin La Fontaine. Approaches to Change Tracking in XML. XML Prague 2010, March 2010.

×

Michael Kay, ed. XSL Transformations (XSLT) Version 3.0. W3C Recommendation, 8 June 2017. https://www.w3.org/TR/xslt-30/.

×

Jonathan Robie et al., eds. XML Path Language (XPath) 3.1. W3C Recommendation, 21 March 2017. https://www.w3.org/TR/xpath-31/.

×

Joel Kalvesmaki. A New ⟨u⟩: Extending XPath Regular Expressions for Unicode. Proceedings of Balisage: The Markup Conference 2020. Balisage Series on Markup Technologies, vol. 25, 2020. doi:https://doi.org/10.4242/BalisageVol25.Kalvesmaki01.

×

Saxonica. Saxon XSLT and XQuery Processor: Regular Expressions. https://www.saxonica.com/documentation12/index.html#!functions/fn/matches.

×

Michael Kay et al., eds. XML Path Language (XPath) 4.0. W3C Community Group Draft. https://qt4cg.org/specifications/xquery-40/xpath-40.html.

Author's keywords for this paper:
track changes; XML processing; regex replacement; change tracking; offset management; operational transformation; publishing automation