PowerpointParagraph.java

package pro.verron.officestamper.experimental;

import org.docx4j.dml.CTRegularTextRun;
import org.docx4j.dml.CTTextCharacterProperties;
import org.docx4j.dml.CTTextParagraph;
import org.docx4j.wml.Comments;
import org.docx4j.wml.ContentAccessor;
import org.docx4j.wml.P;
import org.docx4j.wml.R;
import pro.verron.officestamper.api.*;
import pro.verron.officestamper.core.CommentUtil;
import pro.verron.officestamper.core.StandardComment;
import pro.verron.officestamper.utils.WmlFactory;
import pro.verron.officestamper.utils.WmlUtils;

import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;

import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.joining;
import static pro.verron.officestamper.api.OfficeStamperException.throwing;

/**
 * <p>A "Run" defines a region of text within a docx document with a common set of properties. Word processors are
 * relatively free in splitting a paragraph of text into multiple runs, so there is no strict rule to say over how many
 * runs a word or a string of words is spread.</p>
 * <p>This class aggregates multiple runs so they can be treated as a single text, no matter how many runs the text
 * spans.
 *
 * @author Joseph Verron
 * @author Tom Hombergs
 * @version ${version}
 * @since 1.0.8
 */
public class PowerpointParagraph
        implements Paragraph {

    private static final Random RANDOM = new Random();
    private final DocxPart source;
    private final List<PowerpointRun> runs = new ArrayList<>();
    private final CTTextParagraph paragraph;
    private int currentPosition = 0;

    /**
     * Constructs a new ParagraphWrapper for the given paragraph.
     *
     * @param paragraph the paragraph to wrap.
     */
    public PowerpointParagraph(PptxPart source, CTTextParagraph paragraph) {
        this.source = source;
        this.paragraph = paragraph;
        recalculateRuns();
    }

    /**
     * Recalculates the runs of the paragraph. This method is called automatically by the constructor, but can also be
     * called manually to recalculate the runs after a modification to the paragraph was done.
     */
    private void recalculateRuns() {
        currentPosition = 0;
        this.runs.clear();
        int index = 0;
        for (Object contentElement : paragraph.getEGTextRun()) {
            if (contentElement instanceof CTRegularTextRun r && !r.getT()
                                                                  .isEmpty()) {
                this.addRun(r, index);
            }
            index++;
        }
    }

    /**
     * Adds a run to the aggregation.
     *
     * @param run the run to add.
     */
    private void addRun(CTRegularTextRun run, int index) {
        int startIndex = currentPosition;
        int endIndex = currentPosition + run.getT()
                                            .length() - 1;
        runs.add(new PowerpointRun(startIndex, endIndex, index, run));
        currentPosition = endIndex + 1;
    }

    private static CTTextCharacterProperties apply(
            CTTextCharacterProperties source,
            CTTextCharacterProperties destination
    ) {
        ofNullable(source.getAltLang()).ifPresent(destination::setAltLang);
        ofNullable(source.getBaseline()).ifPresent(destination::setBaseline);
        ofNullable(source.getBmk()).ifPresent(destination::setBmk);
        ofNullable(source.getBlipFill()).ifPresent(destination::setBlipFill);
        ofNullable(source.getCap()).ifPresent(destination::setCap);
        ofNullable(source.getCs()).ifPresent(destination::setCs);
        ofNullable(source.getGradFill()).ifPresent(destination::setGradFill);
        ofNullable(source.getGrpFill()).ifPresent(destination::setGrpFill);
        ofNullable(source.getHighlight()).ifPresent(destination::setHighlight);
        ofNullable(source.getHlinkClick()).ifPresent(destination::setHlinkClick);
        ofNullable(source.getHlinkMouseOver()).ifPresent(destination::setHlinkMouseOver);
        ofNullable(source.getKern()).ifPresent(destination::setKern);
        ofNullable(source.getLang()).ifPresent(destination::setLang);
        ofNullable(source.getLn()).ifPresent(destination::setLn);
        ofNullable(source.getLatin()).ifPresent(destination::setLatin);
        ofNullable(source.getNoFill()).ifPresent(destination::setNoFill);
        ofNullable(source.getPattFill()).ifPresent(destination::setPattFill);
        ofNullable(source.getSpc()).ifPresent(destination::setSpc);
        ofNullable(source.getSym()).ifPresent(destination::setSym);
        ofNullable(source.getStrike()).ifPresent(destination::setStrike);
        ofNullable(source.getSz()).ifPresent(destination::setSz);
        destination.setSmtId(source.getSmtId());
        ofNullable(source.getU()).ifPresent(destination::setU);
        ofNullable(source.getUFill()).ifPresent(destination::setUFill);
        ofNullable(source.getUFillTx()).ifPresent(destination::setUFillTx);
        ofNullable(source.getULn()).ifPresent(destination::setULn);
        ofNullable(source.getULnTx()).ifPresent(destination::setULnTx);
        ofNullable(source.getULnTx()).ifPresent(destination::setULnTx);
        return destination;
    }

    @Override
    public ProcessorContext processorContext(Placeholder placeholder) {
        var comment = comment(placeholder);
        var firstRun = (R) paragraph.getEGTextRun()
                                    .getFirst();
        return new ProcessorContext(this, firstRun, comment, placeholder);
    }

    @Override
    public void remove() {
        WmlUtils.remove(getP());
    }

    @Override
    public void replace(List<P> toRemove, List<P> toAdd) {
        int index = siblings().indexOf(getP());
        if (index < 0) throw new OfficeStamperException("Impossible");

        siblings().addAll(index, toAdd);
        siblings().removeAll(toRemove);
    }

    @Override
    public P getP() {
        var p = WmlFactory.newParagraph(paragraph.getEGTextRun());
        p.setParent(paragraph.getParent());
        return p;
    }

    /**
     * Replaces the given expression with the replacement object within
     * the paragraph.
     * The replacement object must be a valid DOCX4J Object.
     *
     * @param placeholder the expression to be replaced.
     * @param replacement the object to replace the expression.
     */
    @Override
    public void replace(Placeholder placeholder, Object replacement) {
        if (!(replacement instanceof CTRegularTextRun replacementRun))
            throw new AssertionError("replacement is not a CTRegularTextRun");
        String text = asString();
        String full = placeholder.expression();
        int matchStartIndex = text.indexOf(full);
        if (matchStartIndex == -1) {
            // nothing to replace
            return;
        }
        int matchEndIndex = matchStartIndex + full.length() - 1;
        List<PowerpointRun> affectedRuns = getAffectedRuns(matchStartIndex, matchEndIndex);

        boolean singleRun = affectedRuns.size() == 1;

        List<Object> textRun = this.paragraph.getEGTextRun();
        replacementRun.setRPr(affectedRuns.getFirst()
                                          .run()
                                          .getRPr());
        if (singleRun) singleRun(replacement,
                full,
                matchStartIndex,
                matchEndIndex,
                textRun,
                affectedRuns.getFirst(),
                affectedRuns.getLast());
        else multipleRuns(replacement,
                affectedRuns,
                matchStartIndex,
                matchEndIndex,
                textRun,
                affectedRuns.getFirst(),
                affectedRuns.getLast());

    }

    private void singleRun(
            Object replacement,
            String full,
            int matchStartIndex,
            int matchEndIndex,
            List<Object> runs,
            PowerpointRun firstRun,
            PowerpointRun lastRun
    ) {
        assert firstRun == lastRun;
        boolean expressionSpansCompleteRun = full.length() == firstRun.run()
                                                                      .getT()
                                                                      .length();
        boolean expressionAtStartOfRun = matchStartIndex == firstRun.startIndex();
        boolean expressionAtEndOfRun = matchEndIndex == firstRun.endIndex();
        boolean expressionWithinRun = matchStartIndex > firstRun.startIndex() && matchEndIndex < firstRun.endIndex();


        if (expressionSpansCompleteRun) {
            runs.remove(firstRun.run());
            runs.add(firstRun.indexInParent(), replacement);
            recalculateRuns();
        }
        else if (expressionAtStartOfRun) {
            firstRun.replace(matchStartIndex, matchEndIndex, "");
            runs.add(firstRun.indexInParent(), replacement);
            recalculateRuns();
        }
        else if (expressionAtEndOfRun) {
            firstRun.replace(matchStartIndex, matchEndIndex, "");
            runs.add(firstRun.indexInParent() + 1, replacement);
            recalculateRuns();
        }
        else if (expressionWithinRun) {
            String runText = firstRun.run()
                                     .getT();
            int startIndex = runText.indexOf(full);
            int endIndex = startIndex + full.length();
            String substring1 = runText.substring(0, startIndex);
            CTRegularTextRun run1 = create(substring1, this.paragraph);
            String substring2 = runText.substring(endIndex);
            CTRegularTextRun run2 = create(substring2, this.paragraph);
            runs.add(firstRun.indexInParent(), run2);
            runs.add(firstRun.indexInParent(), replacement);
            runs.add(firstRun.indexInParent(), run1);
            runs.remove(firstRun.run());
            recalculateRuns();
        }
    }

    private void multipleRuns(
            Object replacement,
            List<PowerpointRun> affectedRuns,
            int matchStartIndex,
            int matchEndIndex,
            List<Object> runs,
            PowerpointRun firstRun,
            PowerpointRun lastRun
    ) {
        // remove the expression from first and last run
        firstRun.replace(matchStartIndex, matchEndIndex, "");
        lastRun.replace(matchStartIndex, matchEndIndex, "");

        // remove all runs between first and last
        for (PowerpointRun run : affectedRuns) {
            if (!Objects.equals(run, firstRun) && !Objects.equals(run, lastRun)) {
                runs.remove(run.run());
            }
        }

        // add replacement run between first and last run
        runs.add(firstRun.indexInParent() + 1, replacement);

        recalculateRuns();
    }

    private static CTRegularTextRun create(String text, CTTextParagraph parentParagraph) {
        CTRegularTextRun run = new CTRegularTextRun();
        run.setT(text);
        applyParagraphStyle(parentParagraph, run);
        return run;
    }

    private static void applyParagraphStyle(CTTextParagraph p, CTRegularTextRun run) {
        var properties = p.getPPr();
        if (properties == null) return;

        var textCharacterProperties = properties.getDefRPr();
        if (textCharacterProperties == null) return;

        run.setRPr(apply(textCharacterProperties));
    }

    /**
     * Returns the aggregated text over all runs.
     *
     * @return the text of all runs.
     */
    @Override
    public String asString() {
        return runs.stream()
                   .map(PowerpointRun::run)
                   .map(CTRegularTextRun::getT)
                   .collect(joining()) + "\n";
    }

    @Override
    public void apply(Consumer<P> pConsumer) {
        pConsumer.accept(getP());
    }

    @Override
    public Collection<Comments.Comment> getComment() {
        return CommentUtil.getCommentFor(paragraph.getEGTextRun(), source.document());
    }

    private List<PowerpointRun> getAffectedRuns(int startIndex, int endIndex) {
        return runs.stream()
                   .filter(run -> run.isTouchedByRange(startIndex, endIndex))
                   .toList();
    }

    @Override
    public <T> Optional<T> parent(Class<T> aClass) {
        return parent(aClass, Integer.MAX_VALUE);
    }

    private List<Object> siblings() {
        return this.parent(ContentAccessor.class, 1)
                   .orElseThrow(throwing("Not a standard Child with common parent"))
                   .getContent();
    }

    private static CTTextCharacterProperties apply(
            CTTextCharacterProperties source
    ) {
        return apply(source, new CTTextCharacterProperties());
    }

    private <T> Optional<T> parent(Class<T> aClass, int depth) {
        return WmlUtils.getFirstParentWithClass(getP(), aClass, depth);
    }

    private Comment comment(Placeholder placeholder) {
        var parent = getP();
        var id = new BigInteger(16, RANDOM);
        return StandardComment.create(source.document(), parent, placeholder, id);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return asString();
    }
}