LogoTextPostProcessor.java
/*
* Copyright © 2026 The CTAN Team and individual authors
*
* This file is distributed under the 3-clause BSD license.
* See file LICENSE for details.
*/
package org.ctan.markup.gfm.ext;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import org.commonmark.node.Code;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.Text;
import org.commonmark.parser.PostProcessor;
/**
* Post-processor that replaces plain-text occurrences of logo names (e.g.
* {@code LaTeX}, {@code TeX}) with {@link NamedEntityNode}s so they receive the
* same styled HTML as the {@code &LaTeX;} / {@code &TeX;} entities.
*
* <p>
* Only {@link Text} nodes are affected. Nodes that are descendants of
* {@link Code} or {@link FencedCodeBlock} are left untouched, so text inside
* backtick spans and fenced code blocks is never altered.
*
* <p>
* Matching is case-sensitive and whole-token-aware: a match is only accepted
* when it is <em>not</em> immediately preceded or followed by an ASCII letter
* or digit, preventing partial matches inside words like {@code "context"} from
* matching {@code "tex"}.
*
* <p>
* When multiple logo names could match at the same position the longest one
* wins (so {@code LaTeX} is preferred over {@code TeX}).
*/
public final class LogoTextPostProcessor implements PostProcessor {
/**
* Logo names sorted longest-first so that longer names (e.g. {@code LaTeX})
* are tried before shorter names that share a suffix (e.g. {@code TeX}).
* This ensures the correct name is matched when two registered names
* overlap at the same position in the input.
*/
private static void appendText(List<Node> nodes, String s) {
if (!s.isEmpty()) {
nodes.add(textNode(s));
}
}
/**
* Returns {@code true} if {@code node} is a code span or fenced-code block.
*/
private static boolean isCodeContext(Node node) {
return node instanceof Code || node instanceof FencedCodeBlock
|| node instanceof IndentedCodeBlock;
}
// -------------------------------------------------------------------------
// PostProcessor
// -------------------------------------------------------------------------
/**
* Returns {@code true} when the substring {@code input[start..end)} is not
* immediately adjacent to an ASCII letter or digit — i.e. it sits on a word
* boundary so we don't match {@code "TeX"} inside {@code "TeXture"}.
*/
private static boolean isWordBoundary(String input, int start, int end) {
if (start > 0 && Character.isLetterOrDigit(input.charAt(start - 1))) {
return false;
}
if (end < input.length()
&& Character.isLetterOrDigit(input.charAt(end))) {
return false;
}
return true;
}
// -------------------------------------------------------------------------
// Tree walk
// -------------------------------------------------------------------------
private static Text textNode(String s) {
var t = new Text();
t.setLiteral(s);
return t;
}
/**
* Logo names sorted longest-first so that longer names (e.g. {@code LaTeX})
* are tried before shorter prefixes (e.g. {@code TeX}).
*/
private final List<String> logoNames;
// -------------------------------------------------------------------------
// Text-node replacement
// -------------------------------------------------------------------------
/**
* Constructs a post-processor for the given entity map.
*
* <p>
* The entity names are extracted from the map's key set and sorted
* longest-first so that longer names (e.g. {@code LaTeX}) are always tried
* before shorter ones that share a suffix (e.g. {@code TeX}). The HTML
* values are not needed here; resolution is handled at render time by
* {@link NamedEntityNodeRenderer}.
*
* @param entityMap the entity name → HTML map owned by
* {@link NamedEntityExtension}; only the keys are used
*/
public LogoTextPostProcessor(Map<String, String> entityMap) {
this.logoNames = entityMap.keySet().stream()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
@Override
public Node process(Node document) {
// Collect Text nodes first; mutating the tree while walking it is
// unsafe.
var textNodes = new ArrayList<Text>();
collectTextNodes(document, textNodes);
textNodes.forEach(this::replaceInTextNode);
return document;
}
private void collectTextNodes(Node root, List<Text> out) {
for (Node node = root.getFirstChild(); node != null; node = node
.getNext()) {
if (isCodeContext(node)) {
continue; // skip code spans / blocks entirely
}
if (node instanceof Text text) {
out.add(text);
} else {
collectTextNodes(node, out);
}
}
}
/**
* Finds the earliest (and longest, on ties) logo-name match in
* {@code input} at or after {@code fromIndex}.
*
* @return {@code int[]{matchStart, matchEnd}} or {@code null} if none found
*/
private int[] findNextMatch(String input, int fromIndex) {
int bestStart = Integer.MAX_VALUE;
int bestEnd = -1;
for (String name : logoNames) {
int idx = input.indexOf(name, fromIndex);
while (idx != -1) {
if (isWordBoundary(input, idx, idx + name.length())) {
if (idx < bestStart || (idx == bestStart
&& idx + name.length() > bestEnd)) {
bestStart = idx;
bestEnd = idx + name.length();
}
break; // earliest occurrence for this name found
}
idx = input.indexOf(name, idx + 1);
}
}
return bestEnd == -1 ? null : new int[] { bestStart, bestEnd };
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Splits the literal of {@code textNode} on logo names and replaces it with
* a sequence of {@link Text} and {@link NamedEntityNode} siblings.
*/
private void replaceInTextNode(Text textNode) {
String literal = textNode.getLiteral();
List<Node> replacement = split(literal);
// Nothing to do if the only result is the original text unchanged.
if (replacement.size() == 1 && replacement.get(0) instanceof Text t
&& t.getLiteral().equals(literal)) {
return;
}
// Insert all replacement nodes before the original Text node, then
// remove it.
for (Node n : replacement) {
textNode.insertBefore(n);
}
textNode.unlink();
}
/**
* Splits {@code input} into a list of {@link Text} and
* {@link NamedEntityNode} nodes by scanning for logo-name occurrences
* left-to-right.
*/
private List<Node> split(String input) {
List<Node> result = new ArrayList<>();
int pos = 0;
while (pos < input.length()) {
int[] best = findNextMatch(input, pos);
if (best == null) {
// No more matches — append remaining text.
appendText(result, input.substring(pos));
break;
}
int matchStart = best[0];
int matchEnd = best[1];
String name = input.substring(matchStart, matchEnd);
// Text before the match.
if (matchStart > pos) {
appendText(result, input.substring(pos, matchStart));
}
result.add(new NamedEntityNode(name));
pos = matchEnd;
}
return result.isEmpty() ? List.of(textNode(input)) : result;
}
}