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;
    }
}