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
ReplacementInforecord that carries positional and authorship metadata across all three phases.
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:
-
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. -
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:
-
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.
-
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).
-
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. -
Group reference expansion. Back-references (
$0,$1,$2, ...) in the replacement string are resolved using the groups captured by the originalMatcher, 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 > currentStartIndexhave theirstartIndexandendIndexshifted by Δ. -
Records accumulated in
allReplacementsForPNodefrom 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 two
→ three
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
(two
→ three
) 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 two
→ three
(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.