DocxRenderer.java

package pro.verron.officestamper.utils.wml;

import jakarta.xml.bind.JAXBElement;
import org.docx4j.TextUtils;
import org.docx4j.dml.CTBlip;
import org.docx4j.dml.CTBlipFillProperties;
import org.docx4j.dml.Graphic;
import org.docx4j.dml.GraphicData;
import org.docx4j.dml.picture.Pic;
import org.docx4j.dml.wordprocessingDrawing.Inline;
import org.docx4j.mce.AlternateContent;
import org.docx4j.model.structure.HeaderFooterPolicy;
import org.docx4j.model.structure.SectionWrapper;
import org.docx4j.openpackaging.exceptions.Docx4JException;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.Part;
import org.docx4j.openpackaging.parts.WordprocessingML.*;
import org.docx4j.vml.CTShadow;
import org.docx4j.vml.CTShapetype;
import org.docx4j.vml.CTTextbox;
import org.docx4j.vml.VmlShapeElements;
import org.docx4j.wml.*;
import org.jspecify.annotations.NonNull;
import pro.verron.officestamper.utils.UtilsException;

import java.math.BigInteger;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Stream;

import static java.util.Optional.*;
import static java.util.stream.Collectors.joining;
import static pro.verron.officestamper.utils.bit.ByteUtils.readableSize;
import static pro.verron.officestamper.utils.bit.ByteUtils.sha1b64;

/// A utility class for rendering DOCX documents into string representations. This class provides comprehensive
/// functionality to convert various DOCX elements including paragraphs, tables, images, headers, footers, and other
/// document components into human-readable string format for debugging, testing, or display purposes.
///
/// The renderer handles complex document structures and maintains relationships between different document elements
/// while providing meaningful string representations of the content.
///
/// @author Joseph Verron
/// @version ${version}
/// @since 1.6.5
public class DocxRenderer {

    private final StyleDefinitionsPart styleDefinitionsPart;
    private final WordprocessingMLPackage wmlPackage;

    private DocxRenderer(WordprocessingMLPackage wmlPackage) {
        this.wmlPackage = wmlPackage;
        var mainDocumentPart = wmlPackage.getMainDocumentPart();
        this.styleDefinitionsPart = mainDocumentPart.getStyleDefinitionsPart(true);
    }

    /// Converts a DOCX document represented by a [WordprocessingMLPackage] into its string representation. The method
    /// processes the main document content, headers, footers, footnotes, and endnotes (if present), and generates a
    /// unified string representation of the document.
    ///
    /// @param wordprocessingMLPackage the [WordprocessingMLPackage] instance representing the DOCX document to
    ///         be converted into a string; must not be null
    ///
    /// @return a string containing the text and structural elements of the provided DOCX document
    public static String docxToString(WordprocessingMLPackage wordprocessingMLPackage) {
        return new DocxRenderer(wordprocessingMLPackage).stringify(wordprocessingMLPackage);
    }

    private static String stringify(Map<String, String> map) {
        return map.entrySet()
                  .stream()
                  .map(e -> "%s=%s".formatted(e.getKey(), e.getValue()))
                  .collect(joining(",", "{", "}"));
    }

    private static <T> Optional<String> stringify(List<T> list, Function<T, Optional<String>> stringify) {
        if (list == null) return empty();
        if (list.isEmpty()) return empty();
        return of(list.stream()
                      .map(stringify)
                      .flatMap(Optional::stream)
                      .collect(joining(",", "[", "]")));
    }

    private String stringify(WordprocessingMLPackage mlPackage) {
        var header = stringifyHeaders(getHeaderPart(mlPackage));
        var mainDocumentPart = mlPackage.getMainDocumentPart();
        var body = stringify(mainDocumentPart);

        var footer = stringifyFooters(getFooterPart(mlPackage));
        var hStr = header.map("%s\n\n"::formatted)
                         .orElse("");
        var fStr = footer.map("\n%s\n"::formatted)
                         .orElse("");
        var footnotesPart = mainDocumentPart.getFootnotesPart();
        var endnotesPart = mainDocumentPart.getEndNotesPart();
        return hStr + body + stringify(footnotesPart).orElse("") + stringify(endnotesPart).orElse("") + fStr;
    }

    private Optional<String> stringifyHeaders(Stream<HeaderPart> headerPart) {
        return headerPart.map(this::stringify)
                         .flatMap(Optional::stream)
                         .reduce((a, b) -> a + "\n\n" + b);
    }

    private Stream<HeaderPart> getHeaderPart(WordprocessingMLPackage document) {
        var sections = document.getDocumentModel()
                               .getSections();

        var set = new LinkedHashSet<HeaderPart>();
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getFirstHeader)
                           .filter(Objects::nonNull)
                           .toList());
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getDefaultHeader)
                           .filter(Objects::nonNull)
                           .toList());
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getEvenHeader)
                           .filter(Objects::nonNull)
                           .toList());

        return set.stream();
    }

    private Object stringify(MainDocumentPart mainDocumentPart) {
        return stringify(mainDocumentPart.getContent(), mainDocumentPart);
    }

    private Optional<String> stringifyFooters(Stream<FooterPart> footerPart) {
        return footerPart.map(this::stringify)
                         .flatMap(Optional::stream)
                         .reduce((a, b) -> a + "\n\n" + b);
    }

    private Stream<FooterPart> getFooterPart(WordprocessingMLPackage document) {
        var sections = document.getDocumentModel()
                               .getSections();

        var set = new LinkedHashSet<FooterPart>();
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getFirstFooter)
                           .filter(Objects::nonNull)
                           .toList());
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getDefaultFooter)
                           .filter(Objects::nonNull)
                           .toList());
        set.addAll(sections.stream()
                           .map(SectionWrapper::getHeaderFooterPolicy)
                           .map(HeaderFooterPolicy::getEvenFooter)
                           .filter(Objects::nonNull)
                           .toList());

        return set.stream();
    }

    private Optional<String> stringify(FootnotesPart footnotesPart) {
        if (footnotesPart == null) return Optional.empty();
        try {
            var list = footnotesPart.getContents()
                                    .getFootnote()
                                    .stream()
                                    .map(c -> stringify(c, footnotesPart))
                                    .flatMap(Optional::stream)
                                    .toList();
            if (list.isEmpty()) return Optional.empty();
            return Optional.of(list.stream()
                                   .collect(joining("\n", "[footnotes]\n---\n", "\n---\n")));

        } catch (Docx4JException e) {
            throw new UtilsException("Error processing footnotes", e);
        }
    }

    private Optional<String> stringify(EndnotesPart endnotesPart) {
        if (endnotesPart == null) return Optional.empty();
        try {
            var list = endnotesPart.getContents()
                                   .getEndnote()
                                   .stream()
                                   .map(c -> stringify(c, endnotesPart))
                                   .flatMap(Optional::stream)
                                   .toList();
            if (list.isEmpty()) return Optional.empty();
            return Optional.of(list.stream()
                                   .collect(joining("\n", "[endnotes]\n---\n", "\n---\n")));
        } catch (Docx4JException e) {
            throw new UtilsException("Error processing footnotes", e);
        }
    }

    private Optional<String> stringify(HeaderPart part) {
        var content = stringify(part.getContent(), part);
        if (content.isEmpty()) return empty();
        return of("""
                [header, name="%s"]
                ----
                %s
                ----""".formatted(part.getPartName(), content));
    }

    private String stringify(List<?> list, Part part) {
        return list.stream()
                   .map(o -> stringify(o, part))
                   .collect(joining());
    }

    private Optional<String> stringify(FooterPart part) {
        var content = stringify(part.getContent(), part);
        if (content.isEmpty()) return empty();
        return of("""
                [footer, name="%s"]
                ----
                %s
                ----""".formatted(part.getPartName(), content));
    }

    private Optional<String> stringify(CTFtnEdn c, Part part) {
        var type = ofNullable(c.getType()).orElse(STFtnEdn.NORMAL);
        if (STFtnEdn.NORMAL != type) return Optional.empty();
        return Optional.of("[%s]%s".formatted(c.getId(), stringify(c.getContent(), part)));
    }

    /// Converts a [PPrBase.Spacing] object into an optional string representation. The method extracts and includes
    /// non-null spacing properties such as "after", "before", "beforeLines", "afterLines", "line", and "lineRule" in
    /// the resulting string. If all properties are null, the method returns an empty optional.
    ///
    /// @param spacing the [PPrBase.Spacing] object containing paragraph spacing properties; can be null
    ///
    /// @return an [Optional] containing the string representation of the spacing properties, or an empty optional if
    ///         the spacing object is null or contains no non-null properties
    private Optional<String> stringify(PPrBase.Spacing spacing) {
        if (spacing == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(spacing.getAfter()).ifPresent(value -> map.put("after", String.valueOf(value)));
        ofNullable(spacing.getBefore()).ifPresent(value -> map.put("before", String.valueOf(value)));
        ofNullable(spacing.getBeforeLines()).ifPresent(value -> map.put("beforeLines", String.valueOf(value)));
        ofNullable(spacing.getAfterLines()).ifPresent(value -> map.put("afterLines", String.valueOf(value)));
        ofNullable(spacing.getLine()).ifPresent(value -> map.put("line", String.valueOf(value)));
        ofNullable(spacing.getLineRule()).ifPresent(value -> map.put("lineRule", value.value()));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private String stringify(Text text) {
        return TextUtils.getText(text);
    }

    private String styleID(String name) {
        return styleDefinitionsPart.getNameForStyleID(name);
    }

    private String stringify(Br br) {
        var type = br.getType();
        if (type == STBrType.PAGE) return "\n[page-break]\n<<<\n";
        else if (type == STBrType.COLUMN) return "\n[col-break]\n<<<\n";
        else if (type == STBrType.TEXT_WRAPPING) return "<br/>\n";
        else if (type == null) return "<br/>\n";
        else throw new UtilsException("Unexpected type: " + type);
    }

    /// Converts the given [CTBlip] object into a formatted string representation. This method extracts image data
    /// associated with the blip, calculates its size, and generates a detailed string with metadata including the part
    /// name, embed identifier, content type, readable size, SHA-1 hash, and a custom value.
    ///
    /// @param blip the [CTBlip] object representing an embedded image for which the string representation is to
    ///         be generated.
    ///
    /// @return a formatted string containing metadata about the image extracted from the [CTBlip], including its size
    ///         and hash
    private String stringify(CTBlip blip, Part part) {
        var image = (BinaryPartAbstractImage) part.getRelationshipsPart()
                                                  .getPart(blip.getEmbed());
        byte[] imageBytes = image.getBytes();
        return "%s:%s:%s:%s:sha1=%s:cy=$d".formatted(image.getPartName(),
                blip.getEmbed(),
                image.getContentType(),
                readableSize(imageBytes.length),
                sha1b64(imageBytes));
    }

    private String stringify(PPrBase.Ind ind) {
        if (ind == null) return "";
        var map = new TreeMap<String, String>();
        ofNullable(ind.getLeft()).ifPresent(value -> map.put("l", String.valueOf(value)));
        ofNullable(ind.getRight()).ifPresent(value -> map.put("r", String.valueOf(value)));
        ofNullable(ind.getFirstLine()).ifPresent(value -> map.put("fl", String.valueOf(value)));
        ofNullable(ind.getHanging()).ifPresent(value -> map.put("h", String.valueOf(value)));
        ofNullable(ind.getFirstLineChars()).ifPresent(value -> map.put("flc", String.valueOf(value)));
        // TODO: no getEnd() before version 11.5.6 of docx4j, add it to the Treemap stop supporting early versions
        // TODO: no getEndChars() before version 11.5.6 of docx4j, add it to the Treemap stop supporting early versions
        return stringify(map);
    }

    /// Converts the given object into its string representation based on its type. The method supports various object
    /// types and applies the appropriate logic to stringify the content. For unsupported object types, an exception is
    /// thrown.
    ///
    /// @param o the object to be converted into a string representation. It supports specific types including
    ///         JAXBElement, WordprocessingMLPackage, Tbl, Tr, Tc, MainDocumentPart, Body, List, Text, P, R, Drawing,
    ///         Inline, Graphic, and other types defined in the method. Passing null or unsupported types will result in
    ///         an exception.
    ///
    /// @return the string representation of the provided object. For some specific types like `R.LastRenderedPageBreak`
    ///         or `CTMarkupRange`, an empty string may be returned. For objects like `R.Tab` or `R.Cr`, specific
    ///         strings such as tab or carriage return characters may be returned. In case of unsupported types or null,
    ///         an exception is thrown.
    private String stringify(Object o, Part part) {
        return switch (o) {
            case JAXBElement<?> jaxb -> stringify(jaxb, part);
            case WordprocessingMLPackage mlPackage -> stringify(mlPackage);
            case Tbl tbl -> stringify(tbl, part);
            case Tr tr -> stringify(tr, part);
            case Tc tc -> stringify(tc, part);
            case MainDocumentPart mainDocumentPart -> stringify(mainDocumentPart.getContent(), mainDocumentPart);
            case Body body -> stringify(body.getContent(), part);
            case List<?> list -> stringify(list, part);
            case Text text -> stringify(text);
            case P p -> stringify(p, part);
            case R r -> stringify(r, part);
            case Drawing drawing -> stringify(drawing, part);
            case Inline inline -> stringify(inline, part);
            case Graphic graphic -> stringify(graphic, part);
            case GraphicData graphicData -> stringify(graphicData, part);
            case Pic pic -> stringify(pic, part);
            case CTBlipFillProperties bfp -> stringify(bfp, part);
            case CTBlip blip -> stringify(blip, part);
            case Br br -> stringify(br);
            case R.Tab _ -> "\t";
            case R.Cr _ -> "<carriage return>\n";
            case R.CommentReference cr -> stringify(cr);
            case CommentRangeStart crs -> stringify(crs);
            case CommentRangeEnd cre -> stringify(cre);
            case SdtBlock block -> stringify(block, part);
            case Pict pict -> stringify(pict.getAnyAndAny(), part);
            case CTShapetype _ -> "jsldkflksdhlkfhszdlkfnsdl";
            case VmlShapeElements vmlShapeElements -> stringify(vmlShapeElements, part);
            case CTTextbox ctTextbox -> stringify(ctTextbox.getTxbxContent(), part);
            case CTTxbxContent content -> stringify(content.getContent(), part);
            case SdtRun run -> stringify(run.getSdtContent(), part);
            case SdtContent content -> stringify(content, part);
            case CTFtnEdnRef ref -> "[%s]".formatted(ref.getId());
            case FootnotesPart footnotesPart -> stringify(footnotesPart).orElse("");
            case EndnotesPart endnotesPart -> stringify(endnotesPart).orElse("");
            case FldChar fldChar -> stringify(fldChar).orElse("");
            case P.Hyperlink hyperlink -> stringify(hyperlink, part).orElse("");
            case CTSmartTagRun smartTagRun -> stringify(smartTagRun, part);
            case CTAttr ctAttr -> stringify(ctAttr);
            case R.Separator _, R.ContinuationSeparator _ -> "\n";
            case AlternateContent _, R.LastRenderedPageBreak _, CTShadow _, CTMarkupRange _, ProofErr _,
                 R.AnnotationRef _, R.FootnoteRef _, R.EndnoteRef _ -> "";
            case null -> throw new RuntimeException("Unsupported content: NULL");
            default -> throw new RuntimeException("Unsupported content: " + o.getClass());
        };
    }

    private @NonNull String stringify(CTAttr ctAttr) {
        return "%s:%s".formatted(ctAttr.getName(), ctAttr.getVal());
    }

    private @NonNull String stringify(CTSmartTagRun smartTagRun, Part part) {
        var smartTagPr = smartTagRun.getSmartTagPr();
        return "<tag element=\"%s\" attr=\"%s\">%s<\\tag>".formatted(smartTagRun.getElement(),
                stringify(smartTagPr.getAttr(), part),
                stringify(smartTagRun.getContent(), part));
    }

    private String stringify(JAXBElement<?> element, Part part) {
        if (element == null) return "";
        var elementName = element.getName();
        var localPart = elementName.getLocalPart();
        if (localPart.equals("instrText")) return "[instrText=" + stringify(element.getValue(), part) + "]";
        return stringify(element.getValue(), part);
    }

    private Optional<String> stringify(FldChar fldChar) {
        var fldData = fldChar.getFldData();
        return ofNullable(fldData).map(Text::getValue)
                                  .map("[fldchar %s]"::formatted);
    }

    private Optional<String> stringify(P.Hyperlink hyperlink, Part part) {
        return of("[link data=%s]".formatted(stringify(hyperlink.getContent(), part)));
    }

    private String stringify(SdtBlock block, Part part) {
        return stringify(block.getSdtContent(), part) + "\n";
    }

    private String stringify(SdtContent content, Part part) {
        return "[" + stringify(content.getContent(), part).trim() + "]";
    }

    private String stringify(VmlShapeElements vmlShapeElements, Part part) {
        return "[" + stringify(vmlShapeElements.getEGShapeElements(), part).trim() + "]\n";
    }

    private String stringify(CommentRangeStart crs) {
        return "<" + crs.getId() + "|";
    }

    private String stringify(CommentRangeEnd cre) {
        return "|" + cre.getId() + ">";
    }

    private String stringify(Tc tc, Part part) {
        var content = stringify(tc.getContent(), part);
        return "|%s\n".formatted(content.trim());
    }

    private String stringify(Tr tr, Part part) {
        var content = stringify(tr.getContent(), part);
        return "%s\n".formatted(content);
    }

    private String stringify(Tbl tbl, Part part) {
        var content = stringify(tbl.getContent(), part);
        return ("|===\n%s\n|===\n").formatted(content);
    }

    private String stringify(Pic pic, Part part) {
        return stringify(pic.getBlipFill(), part);
    }

    private String stringify(CTBlipFillProperties blipFillProperties, Part part) {
        return stringify(blipFillProperties.getBlip(), part);
    }

    private String stringify(R.CommentReference commentReference) {
        var id = commentReference.getId();
        var stringifiedComment = stringifyComment(id);
        return "<%s|%s>".formatted(id, stringifiedComment);
    }

    private String stringifyComment(BigInteger id) {
        return WmlUtils.findComment(wmlPackage, id)
                       .map(Comments.Comment::getContent)
                       .map(l -> stringify(l, wmlPackage.getMainDocumentPart()))
                       .orElseThrow()
                       .strip();
    }

    private String stringify(GraphicData graphicData, Part part) {
        return stringify(graphicData.getPic(), part);
    }

    private String stringify(Graphic graphic, Part part) {
        return stringify(graphic.getGraphicData(), part);
    }

    private String stringify(Inline inline, Part part) {
        var graphic = inline.getGraphic();
        var extent = inline.getExtent();
        return "%s:%d".formatted(stringify(graphic, part), extent.getCx());
    }

    private String stringify(Drawing drawing, Part part) {
        return stringify(drawing.getAnchorOrInline(), part);
    }

    private Function<String, String> stringify(PPr pPr) {
        if (pPr == null) return Function.identity();
        var set = new TreeMap<String, String>();
        ofNullable(pPr.getPStyle()).ifPresent(element -> set.put("pStyle", styleID(element.getVal())));
        ofNullable(pPr.getJc()).ifPresent(element -> set.put("jc",
                element.getVal()
                       .toString()));
        ofNullable(pPr.getInd()).ifPresent(element -> set.put("ind", stringify(element)));
        ofNullable(pPr.getKeepLines()).ifPresent(element -> set.put("keepLines", String.valueOf(element.isVal())));
        ofNullable(pPr.getKeepNext()).ifPresent(element -> set.put("keepNext", String.valueOf(element.isVal())));
        ofNullable(pPr.getOutlineLvl()).ifPresent(element -> set.put("outlineLvl",
                element.getVal()
                       .toString()));
        ofNullable(pPr.getPageBreakBefore()).ifPresent(element -> set.put("pageBreakBefore",
                String.valueOf(element.isVal())));
        ofNullable(pPr.getPBdr()).ifPresent(_ -> set.put("pBdr", "xxx"));
        ofNullable(pPr.getPPrChange()).ifPresent(_ -> set.put("pPrChange", "xxx"));
        stringify(pPr.getRPr()).ifPresent(key -> set.put("rPr", key));
        stringify(pPr.getSectPr()).ifPresent(key -> set.put("sectPr", key));
        ofNullable(pPr.getShd()).ifPresent(_ -> set.put("shd", "xxx"));
        stringify(pPr.getSpacing()).ifPresent(spacing -> set.put("spacing", spacing));
        ofNullable(pPr.getSuppressAutoHyphens()).ifPresent(_ -> set.put("suppressAutoHyphens", "xxx"));
        ofNullable(pPr.getSuppressLineNumbers()).ifPresent(_ -> set.put("suppressLineNumbers", "xxx"));
        ofNullable(pPr.getSuppressOverlap()).ifPresent(_ -> set.put("suppressOverlap", "xxx"));
        ofNullable(pPr.getTabs()).ifPresent(_ -> set.put("tabs", "xxx"));
        ofNullable(pPr.getTextAlignment()).ifPresent(_ -> set.put("textAlignment", "xxx"));
        ofNullable(pPr.getTextDirection()).ifPresent(_ -> set.put("textDirection", "xxx"));
        ofNullable(pPr.getTopLinePunct()).ifPresent(_ -> set.put("topLinePunct", "xxx"));
        ofNullable(pPr.getWidowControl()).ifPresent(_ -> set.put("widowControl", "xxx"));
        ofNullable(pPr.getFramePr()).ifPresent(_ -> set.put("framePr", "xxx"));
        ofNullable(pPr.getWordWrap()).ifPresent(_ -> set.put("wordWrap", "xxx"));
        ofNullable(pPr.getDivId()).ifPresent(_ -> set.put("divId", "xxx"));
        ofNullable(pPr.getCnfStyle()).ifPresent(style -> set.put("cnfStyle", style.getVal()));
        return set.entrySet()
                  .stream()
                  .reduce(Function.identity(), (f, entry) -> switch (entry.getKey()) {
                      case "pStyle" -> f.compose(decorateWithStyle(entry.getValue()));
                      case "sectPr" -> f.compose(str -> str + "\n[section-break, " + entry.getValue() + "]\n<<<");
                      default -> f.andThen(s -> s + "<%s=%s>".formatted(entry.getKey(), entry.getValue()));
                  }, Function::andThen);
    }

    private String stringify(P p, Part part) {
        var runs = stringify(p.getContent(), part);
        var ppr = stringify(p.getPPr());
        return ppr.apply(runs) + "\n\n";
    }

    /// Converts the properties of the provided [RPrAbstract] object into a string representation. The method extracts
    /// various attributes of the [RPrAbstract] object, such as formatting, styling, and other properties, and maps them
    /// to a key-value representation. If no attributes are present or the provided object is null, an empty `Optional`
    /// is returned.
    ///
    /// @param rPr the [RPrAbstract] object containing formatting and style attributes; can be null
    ///
    /// @return an [Optional<String>] containing the string representation of the given attributes, or an empty
    ///         [Optional] if the input is null or no attributes are present
    private Optional<String> stringify(RPrAbstract rPr) {
        if (rPr == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(rPr.getB()).ifPresent(value -> map.put("b", String.valueOf(value.isVal())));
        ofNullable(rPr.getBdr()).ifPresent(_ -> map.put("bdr", "xxx"));
        ofNullable(rPr.getCaps()).ifPresent(value -> map.put("caps", String.valueOf(value.isVal())));
        ofNullable(rPr.getColor()).ifPresent(value -> map.put("color", value.getVal()));
        ofNullable(rPr.getDstrike()).ifPresent(value -> map.put("dstrike", String.valueOf(value.isVal())));
        ofNullable(rPr.getI()).ifPresent(value -> map.put("i", String.valueOf(value.isVal())));
        ofNullable(rPr.getKern()).ifPresent(value -> map.put("kern", String.valueOf(value.getVal())));
        ofNullable(rPr.getLang()).ifPresent(value -> map.put("lang", value.getVal()));
        stringify(rPr.getRFonts()).ifPresent(e -> map.put("rFont", e));
        ofNullable(rPr.getRStyle()).ifPresent(value -> map.put("rStyle", value.getVal()));
        ofNullable(rPr.getRtl()).ifPresent(value -> map.put("rtl", String.valueOf(value.isVal())));
        ofNullable(rPr.getShadow()).ifPresent(value -> map.put("shadow", String.valueOf(value.isVal())));
        ofNullable(rPr.getShd()).ifPresent(value -> map.put("shd", value.getColor()));
        ofNullable(rPr.getSmallCaps()).ifPresent(value -> map.put("smallCaps", String.valueOf(value.isVal())));
        ofNullable(rPr.getVertAlign()).ifPresent(value -> map.put("vertAlign",
                value.getVal()
                     .value()));
        ofNullable(rPr.getSpacing()).ifPresent(value -> map.put("spacing", String.valueOf(value.getVal())));
        ofNullable(rPr.getStrike()).ifPresent(value -> map.put("strike", String.valueOf(value.isVal())));
        ofNullable(rPr.getOutline()).ifPresent(value -> map.put("outline", String.valueOf(value.isVal())));
        ofNullable(rPr.getEmboss()).ifPresent(value -> map.put("emboss", String.valueOf(value.isVal())));
        ofNullable(rPr.getImprint()).ifPresent(value -> map.put("imprint", String.valueOf(value.isVal())));
        ofNullable(rPr.getNoProof()).ifPresent(value -> map.put("noProof", String.valueOf(value.isVal())));
        ofNullable(rPr.getSpecVanish()).ifPresent(value -> map.put("specVanish", String.valueOf(value.isVal())));
        ofNullable(rPr.getU()).ifPresent(value -> map.put("u",
                value.getVal()
                     .value()));
        ofNullable(rPr.getVanish()).ifPresent(value -> map.put("vanish", String.valueOf(value.isVal())));
        ofNullable(rPr.getW()).ifPresent(value -> map.put("w", String.valueOf(value.getVal())));
        ofNullable(rPr.getWebHidden()).ifPresent(value -> map.put("webHidden", String.valueOf(value.isVal())));
        ofNullable(rPr.getHighlight()).ifPresent(value -> map.put("highlight", value.getVal()));
        ofNullable(rPr.getEffect()).ifPresent(value -> map.put("effect",
                value.getVal()
                     .value()));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Function<? super String, String> decorateWithStyle(String value) {
        return switch (value) {
            case "Title" -> "= %s\n"::formatted;
            case "heading 1" -> "== %s\n"::formatted;
            case "heading 2" -> "=== %s\n"::formatted;
            case "heading 3" -> "==== %s\n"::formatted;
            case "heading 4" -> "===== %s\n"::formatted;
            case "heading 5" -> "====== %s\n"::formatted;
            case "heading 6" -> "======= %s\n"::formatted;
            case "caption" -> ".%s"::formatted;
            case "annotation text", "footnote text", "endnote text" -> string -> string;
            default -> "[%s] %%s".formatted(value)::formatted;
        };
    }

    private String stringify(R run, Part part) {
        String serialized = stringify(run.getContent(), part);
        if (serialized.isEmpty()) return "";
        return ofNullable(run.getRPr()).flatMap(this::stringify)
                                       .map(rPr -> "❬%s❘%s❭".formatted(serialized, rPr))
                                       .orElse(serialized);
    }

    private Optional<String> stringify(RFonts rFonts) {
        if (rFonts == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(rFonts.getAscii()).ifPresent(value -> map.put("ascii", value));
        ofNullable(rFonts.getHAnsi()).ifPresent(value -> map.put("hAnsi", value));
        ofNullable(rFonts.getCs()).ifPresent(value -> map.put("cs", value));
        ofNullable(rFonts.getEastAsia()).ifPresent(value -> map.put("eastAsia", value));
        ofNullable(rFonts.getAsciiTheme()).ifPresent(value -> map.put("asciiTheme", value.value()));
        ofNullable(rFonts.getHAnsiTheme()).ifPresent(value -> map.put("hAnsiTheme", value.value()));
        ofNullable(rFonts.getCstheme()).ifPresent(value -> map.put("cstheme", value.value()));
        ofNullable(rFonts.getEastAsiaTheme()).ifPresent(value -> map.put("eastAsiaTheme", value.value()));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Optional<String> stringify(SectPr sectPr) {
        if (sectPr == null) return empty();
        var map = new TreeMap<String, String>();
        stringify(sectPr.getEGHdrFtrReferences(), this::stringify).ifPresent(value -> map.put("eGHdrFtrReferences",
                value));
        stringify(sectPr.getPgSz()).ifPresent(value -> map.put("pgSz", value));
        stringify(sectPr.getPgMar()).ifPresent(value -> map.put("pgMar", value));
        ofNullable(sectPr.getPaperSrc()).ifPresent(_ -> map.put("paperSrc", "xxx"));
        ofNullable(sectPr.getBidi()).ifPresent(_ -> map.put("bidi", "xxx"));
        ofNullable(sectPr.getRtlGutter()).ifPresent(_ -> map.put("rtlGutter", "xxx"));
        stringify(sectPr.getDocGrid()).ifPresent(value -> map.put("docGrid", value));
        ofNullable(sectPr.getFormProt()).ifPresent(_ -> map.put("formProt", "xxx"));
        ofNullable(sectPr.getVAlign()).ifPresent(_ -> map.put("vAlign", "xxx"));
        ofNullable(sectPr.getNoEndnote()).ifPresent(_ -> map.put("noEndnote", "xxx"));
        ofNullable(sectPr.getTitlePg()).ifPresent(_ -> map.put("titlePg", "xxx"));
        ofNullable(sectPr.getTextDirection()).ifPresent(_ -> map.put("textDirection", "xxx"));
        ofNullable(sectPr.getRtlGutter()).ifPresent(_ -> map.put("rtlGutter", "xxx"));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Optional<String> stringify(CTRel ctRel) {
        if (ctRel == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(ctRel.getId()).ifPresent(value -> map.put("id", value));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Optional<String> stringify(SectPr.PgSz pgSz) {
        if (pgSz == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(pgSz.getOrient()).ifPresent(value -> map.put("orient", String.valueOf(value)));
        ofNullable(pgSz.getW()).ifPresent(value -> map.put("w", String.valueOf(value)));
        ofNullable(pgSz.getH()).ifPresent(value -> map.put("h", String.valueOf(value)));
        ofNullable(pgSz.getCode()).ifPresent(value -> map.put("code", String.valueOf(value)));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Optional<String> stringify(SectPr.PgMar pgMar) {
        if (pgMar == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(pgMar.getHeader()).ifPresent(value -> map.put("header", String.valueOf(value)));
        ofNullable(pgMar.getFooter()).ifPresent(value -> map.put("footer", String.valueOf(value)));
        ofNullable(pgMar.getGutter()).ifPresent(value -> map.put("gutter", String.valueOf(value)));
        ofNullable(pgMar.getTop()).ifPresent(value -> map.put("top", String.valueOf(value)));
        ofNullable(pgMar.getLeft()).ifPresent(value -> map.put("left", String.valueOf(value)));
        ofNullable(pgMar.getBottom()).ifPresent(value -> map.put("bottom", String.valueOf(value)));
        ofNullable(pgMar.getRight()).ifPresent(value -> map.put("right", String.valueOf(value)));
        return map.isEmpty() ? empty() : of(stringify(map));
    }

    private Optional<String> stringify(CTDocGrid ctDocGrid) {
        if (ctDocGrid == null) return empty();
        var map = new TreeMap<String, String>();
        ofNullable(ctDocGrid.getCharSpace()).ifPresent(value -> map.put("charSpace", String.valueOf(value)));
        ofNullable(ctDocGrid.getLinePitch()).ifPresent(value -> map.put("linePitch", String.valueOf(value)));
        ofNullable(ctDocGrid.getType()).ifPresent(value -> map.put("type", String.valueOf(value)));
        return map.isEmpty() ? empty() : of(stringify(map));
    }
}