PlaceholderHooker.java

package pro.verron.officestamper.api;

import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.utils.TraversalUtilVisitor;
import org.docx4j.wml.P;
import pro.verron.officestamper.utils.wml.WmlUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.SequencedMap;

import static java.util.Comparator.comparingInt;
import static pro.verron.officestamper.utils.wml.WmlUtils.asString;
import static pro.verron.officestamper.utils.wml.WmlUtils.insertSmartTag;

/// The [PlaceholderHooker] class is a pre-processor that prepares inline placeholders in a
/// [WordprocessingMLPackage] document. It searches for placeholders introduced by one of the configured opening
/// delimiters and wraps them with a smart tag, so the OfficeStamper engine can process them later.
///
/// ## Brace balancing
///
/// A placeholder ends at the closing brace that *balances* its opening brace, not at the first closing brace
/// encountered. Braces nested inside the placeholder are therefore part of the expression, which is what makes SpEL
/// inline lists and inline maps usable as placeholders:
///
/// ```text
/// ${ {1, 2, 3} }              -> expression " {1, 2, 3} "   (a SpEL inline list)
/// ${ {'a': 1, 'b': 2} }       -> expression " {'a': 1, 'b': 2} " (a SpEL inline map)
/// ${ {1, 2, 3}.?[#this > 1] } -> expression " {1, 2, 3}.?[#this > 1] "
/// ```
///
/// ## Malformed placeholders
///
/// An opening delimiter that is never balanced by a closing brace is *malformed*. Rather than leaving the stray
/// delimiter behind — and rather than letting the text that follows it be interpreted as further placeholders — the
/// malformed placeholder spans the remainder of the paragraph and is captured verbatim, delimiters included. The
/// engine then fails to parse it and hands it to the configured [ExceptionResolver], so the failure is reported
/// through the usual channel instead of silently corrupting the output.
///
/// ## Single pass
///
/// All delimiters are matched in one left-to-right pass, and scanning resumes *after* each placeholder that has been
/// wrapped. Consequently a placeholder nested inside another one is part of the outer expression and is never wrapped
/// on its own.
public class PlaceholderHooker
        implements PreProcessor {

    private static final char OPENING_BRACE = '{';
    private static final char CLOSING_BRACE = '}';

    private final SequencedMap<String, String> elementByOpening;

    /// Constructs a new [PlaceholderHooker] recognizing a single opening delimiter.
    ///
    /// @param opening the literal opening delimiter of a placeholder, for instance `${` or `#{`. It must end with
    ///         an opening brace.
    /// @param element the name of the smart tag type to wrap matching placeholders with.
    public PlaceholderHooker(String opening, String element) {
        this(Map.of(opening, element));
    }

    /// Constructs a new [PlaceholderHooker] recognizing several opening delimiters in a single pass.
    ///
    /// @param elementByOpening the smart tag type to use for each literal opening delimiter. Every delimiter must
    ///         end with an opening brace. When several delimiters could match at the same position, the longest one
    ///         wins.
    public PlaceholderHooker(Map<String, String> elementByOpening) {
        this.elementByOpening = elementByOpening.entrySet()
                                                .stream()
                                                .sorted(comparingInt((Map.Entry<String, String> e) -> e.getKey()
                                                                                                       .length())
                                                        .reversed())
                                                .collect(LinkedHashMap::new,
                                                        (map, e) -> map.put(validate(e.getKey()), e.getValue()),
                                                        LinkedHashMap::putAll);
    }

    private static String validate(String opening) {
        if (opening.isEmpty() || opening.charAt(opening.length() - 1) != OPENING_BRACE)
            throw new OfficeStamperException(
                    "A placeholder opening delimiter must end with '%c', but was '%s'".formatted(OPENING_BRACE,
                            opening));
        return opening;
    }

    @Override
    public void process(WordprocessingMLPackage document) {
        var visitor = new ParagraphCollector(elementByOpening.keySet());
        WmlUtils.visitDocument(document, visitor);
        for (var paragraph : visitor.paragraphs()) hook(paragraph);
    }

    /// Wraps every placeholder of the given paragraph with a smart tag, in a single left-to-right pass.
    private void hook(P paragraph) {
        var text = asString(paragraph);
        var cursor = 0;
        while (cursor < text.length()) {
            var opening = openingAt(text, cursor);
            if (opening.isEmpty()) {
                cursor++;
                continue;
            }
            var delimiter = opening.get();
            var placeholder = scan(text, cursor, delimiter);
            var expression = placeholder.expression();
            insertSmartTag(elementByOpening.get(delimiter), paragraph, expression, cursor, placeholder.end());
            // The tag holds exactly the expression, delimiters stripped, so scanning resumes right after it.
            cursor += expression.length();
            text = asString(paragraph);
        }
    }

    /// Returns the opening delimiter starting at the given index, if any.
    private Optional<String> openingAt(String text, int index) {
        return elementByOpening.keySet()
                               .stream()
                               .filter(opening -> text.startsWith(opening, index))
                               .findFirst();
    }

    /// Scans a placeholder starting at `start`, balancing nested braces.
    ///
    /// When the opening brace is balanced, the placeholder stops right after the matching closing brace and the
    /// expression excludes both delimiters. When it is never balanced, the placeholder is malformed: it spans the rest
    /// of the text and the expression keeps the delimiters, so that the engine reports it as unparseable.
    private static Placeholder scan(String text, int start, String opening) {
        var depth = 1;
        for (var index = start + opening.length(); index < text.length(); index++) {
            var character = text.charAt(index);
            if (character == OPENING_BRACE) depth++;
            else if (character == CLOSING_BRACE && --depth == 0)
                return new Placeholder(index + 1, text.substring(start + opening.length(), index));
        }
        return new Placeholder(text.length(), text.substring(start));
    }

    /// A placeholder located in a paragraph's text.
    ///
    /// @param end the index right after the placeholder.
    /// @param expression the expression the smart tag will carry.
    private record Placeholder(int end, String expression) {}

    /// A [TraversalUtilVisitor] implementation that collects the paragraphs possibly holding a placeholder.
    ///
    /// This class is used to traverse a document and collect all paragraph elements ([P]) containing at least one of
    /// the given opening delimiters. The collected paragraphs can be retrieved using the [#paragraphs()] method.
    public static class ParagraphCollector
            extends TraversalUtilVisitor<P> {

        private final List<String> openings;
        private final List<P> results = new ArrayList<>();

        /// Constructs a new [ParagraphCollector] with the specified opening delimiters.
        ///
        /// @param openings the opening delimiters to look for in paragraphs
        public ParagraphCollector(Collection<String> openings) {
            this.openings = List.copyOf(openings);
        }

        @Override
        public void apply(P element) {
            var string = asString(element);
            if (openings.stream()
                        .anyMatch(string::contains)) {
                results.add(element);
            }
        }

        /// Returns the list of collected paragraphs possibly holding a placeholder.
        ///
        /// @return a list of paragraphs containing at least one opening delimiter
        public List<P> paragraphs() {
            return results;
        }
    }
}