/**
 * PowerPoint Presentation (PPTX) Parser
 *
 * **PPTX Format Overview:**
 * PPTX is the default format for Microsoft PowerPoint since Office 2007, based on OOXML.
 *
 * **File Structure:**
 * - `ppt/presentation.xml` - Presentation structure and slide list
 * - `ppt/slides/slide1.xml` - Individual slide content
 * - `ppt/notesSlides/notesSlide1.xml` - Speaker notes
 * - `ppt/slideLayouts/*` - Slide layout definitions
 * - `ppt/media/*` - Embedded images and media
 *
 * **Key Elements:**
 * - `<p:sld>` - Slide
 * - `<p:txBody>` - Text body containing paragraphs
 * - `<a:p>` - Paragraph
 * - `<a:r>` - Text run with formatting
 * - `<a:t>` - Text content
 *
 * @module PowerPointParser
 * @see https://www.ecma-international.org/publications-and-standards/standards/ecma-376/
 */
import { XMLSerializer } from '@xmldom/xmldom';
import { extractChartData } from '../utils/chartUtils';
import { logWarning } from '../utils/errorUtils';
import { createAttachment } from '../utils/imageUtils';
import { performOcr } from '../utils/ocrUtils';
import { getElementsByTagName, parseOfficeMetadata, parseXmlString } from '../utils/xmlUtils';
import { mapOoxmlPrstToDrawableKind } from '../utils/pptPrstToDrawableKind';
import { extractFiles } from '../utils/zipUtils';
/** `p:spPr/a:xfrm` or `p:xfrm` on `p:graphicFrame` — slide coordinates in EMU. */
function readPptXfrmEmu(el) {
    const spPr = getElementsByTagName(el, 'p:spPr')[0];
    let xfrm = spPr ? getElementsByTagName(spPr, 'a:xfrm')[0] : undefined;
    if (!xfrm) {
        xfrm = getElementsByTagName(el, 'p:xfrm')[0];
    }
    if (!xfrm) {
        return undefined;
    }
    const off = getElementsByTagName(xfrm, 'a:off')[0];
    const ext = getElementsByTagName(xfrm, 'a:ext')[0];
    if (!off || !ext) {
        return undefined;
    }
    const x = parseInt(off.getAttribute('x') || '0', 10) || 0;
    const y = parseInt(off.getAttribute('y') || '0', 10) || 0;
    const cx = parseInt(ext.getAttribute('cx') || '0', 10) || 0;
    const cy = parseInt(ext.getAttribute('cy') || '0', 10) || 0;
    return { x, y, cx, cy };
}
function readGroupTransformEmu(grpSp) {
    const grpSpPr = getElementsByTagName(grpSp, 'p:grpSpPr')[0];
    if (!grpSpPr) {
        return undefined;
    }
    const spPr = getElementsByTagName(grpSpPr, 'p:spPr')[0];
    let xfrm = spPr ? getElementsByTagName(spPr, 'a:xfrm')[0] : undefined;
    if (!xfrm) {
        xfrm = getElementsByTagName(grpSpPr, 'a:xfrm')[0];
    }
    if (!xfrm) {
        return undefined;
    }
    const off = getElementsByTagName(xfrm, 'a:off')[0];
    const ext = getElementsByTagName(xfrm, 'a:ext')[0];
    if (!off || !ext) {
        return undefined;
    }
    const chOff = getElementsByTagName(xfrm, 'a:chOff')[0];
    const chExt = getElementsByTagName(xfrm, 'a:chExt')[0];
    const offX = parseInt(off.getAttribute('x') || '0', 10) || 0;
    const offY = parseInt(off.getAttribute('y') || '0', 10) || 0;
    const extCx = parseInt(ext.getAttribute('cx') || '0', 10) || 0;
    const extCy = parseInt(ext.getAttribute('cy') || '0', 10) || 0;
    const chOffX = chOff ? parseInt(chOff.getAttribute('x') || '0', 10) || 0 : 0;
    const chOffY = chOff ? parseInt(chOff.getAttribute('y') || '0', 10) || 0 : 0;
    const chExtCx = chExt ? parseInt(chExt.getAttribute('cx') || '0', 10) || 0 : extCx;
    const chExtCy = chExt ? parseInt(chExt.getAttribute('cy') || '0', 10) || 0 : extCy;
    return { offX, offY, extCx, extCy, chOffX, chOffY, chExtCx, chExtCy };
}
/** Map shape `a:xfrm` from group inner coordinates to slide EMU (innermost group first). */
function mapShapeEmuThroughGroupStack(stack, emu) {
    let x = emu.x;
    let y = emu.y;
    let cx = emu.cx;
    let cy = emu.cy;
    for (let i = stack.length - 1; i >= 0; i--) {
        const g = stack[i];
        const sx = g.chExtCx > 0 ? g.extCx / g.chExtCx : 1;
        const sy = g.chExtCy > 0 ? g.extCy / g.chExtCy : 1;
        x = g.offX + (x - g.chOffX) * sx;
        y = g.offY + (y - g.chOffY) * sy;
        cx = cx * sx;
        cy = cy * sy;
    }
    return { x, y, cx, cy };
}
/**
 * Parses a PowerPoint presentation (.pptx) and extracts slides and notes.
 *
 * @param buffer - The PPTX file as a Buffer
 * @param config - Parser configuration
 * @returns A promise resolving to the parsed AST
 */
export const parsePowerPoint = async (buffer, config) => {
    const allFilesRegex = /ppt\/(notesSlides|slides)\/(notesSlide|slide)\d+.xml/g;
    const slidesRegex = /ppt\/slides\/slide\d+.xml/g;
    const slideRelsRegex = /ppt\/slides\/_rels\/slide\d+\.xml\.rels/;
    const slideNumberRegex = /lide(\d+)\.xml/;
    const mediaFileRegex = /ppt\/media\/.*/;
    const chartFileRegex = /ppt\/charts\/chart\d+\.xml/;
    const corePropsFileRegex = /docProps\/core\.xml/;
    const xmlSerializer = new XMLSerializer();
    const files = await extractFiles(buffer, x => !!x.match(config.ignoreNotes ? slidesRegex : allFilesRegex) ||
        !!x.match(corePropsFileRegex) ||
        !!x.match(slideRelsRegex) ||
        x === 'ppt/presentation.xml' ||
        /^ppt\/theme\/theme\d+\.xml$/i.test(x) ||
        (!!config.extractAttachments && (!!x.match(mediaFileRegex) || !!x.match(chartFileRegex))));
    // Extract metadata
    const corePropsFile = files.find(f => f.path.match(corePropsFileRegex));
    const metadata = corePropsFile ? parseOfficeMetadata(corePropsFile.content.toString()) : {};
    // Sort files
    files.sort((a, b) => {
        const aMatch = a.path.match(slideNumberRegex);
        const bMatch = b.path.match(slideNumberRegex);
        const aNum = aMatch ? parseInt(aMatch[1]) : 0;
        const bNum = bMatch ? parseInt(bMatch[1]) : 0;
        return aNum - bNum;
    });
    /** `ppt/presentation.xml` → `p:sldSz`（与元素 `a:xfrm` 同 EMU 坐标系）。 */
    let presentationSlideSizeEmu = { x: 0, y: 0, cx: 12192000, cy: 6858000 };
    const presFile = files.find((f) => f.path === 'ppt/presentation.xml');
    if (presFile) {
        try {
            const presDoc = parseXmlString(presFile.content.toString());
            const sldSz = getElementsByTagName(presDoc, 'p:sldSz')[0];
            if (sldSz) {
                const cx = parseInt(sldSz.getAttribute('cx') || '', 10);
                const cy = parseInt(sldSz.getAttribute('cy') || '', 10);
                if (Number.isFinite(cx) && cx > 0 && Number.isFinite(cy) && cy > 0) {
                    presentationSlideSizeEmu = { x: 0, y: 0, cx, cy };
                }
            }
        }
        catch {
            /* ignore */
        }
    }
    /** `a:clrScheme` 子元素名 → `#RRGGBB`，供 `a:schemeClr` 解析。 */
    const themeSchemeHex = {};
    const themeFile = files.find((f) => /^ppt\/theme\/theme\d+\.xml$/i.test(f.path));
    if (themeFile) {
        try {
            const tdoc = parseXmlString(themeFile.content.toString());
            const clrScheme = getElementsByTagName(tdoc, 'a:clrScheme')[0];
            if (clrScheme) {
                /** `DOMParser` 下 OOXML 的 `a:clrScheme` 常出现 `.children` 为空，需用 `childNodes` 才能读到 `lt2/accent1` 等槽位。 */
                const kids = Array.from(clrScheme.childNodes ?? []).filter((n) => n.nodeType === 1);
                for (const el of kids) {
                    const name = el.localName || el.tagName.replace(/^.*:/, '');
                    if (!name || name === 'extLst') {
                        continue;
                    }
                    const srgb = getElementsByTagName(el, 'a:srgbClr')[0];
                    if (srgb) {
                        const val = srgb.getAttribute('val');
                        if (val) {
                            themeSchemeHex[name] = '#' + val;
                        }
                    }
                    else {
                        const sysClr = getElementsByTagName(el, 'a:sysClr')[0];
                        if (sysClr) {
                            const last = sysClr.getAttribute('lastClr');
                            if (last) {
                                themeSchemeHex[name] = '#' + last;
                            }
                        }
                    }
                }
            }
        }
        catch {
            /* ignore */
        }
    }
    /** 只取 `p:spPr` 的直接子级，避免命中 `a:ln/a:solidFill` 等后代把「线色」误当「面填」。 */
    const spPrDirectChild = (spPr, localName) => {
        for (let i = 0; i < spPr.childNodes.length; i++) {
            const c = spPr.childNodes[i];
            if (c.nodeType !== 1) {
                continue;
            }
            const el = c;
            const ln = el.localName || el.tagName.replace(/^.*:/, '');
            if (ln === localName) {
                return el;
            }
        }
        return undefined;
    };
    const applyDrawingMlColorMods = (baseHexNoHash, colorEl) => {
        const parseMod = (tag, def) => {
            const n = getElementsByTagName(colorEl, tag)[0];
            if (!n) {
                return def;
            }
            const v = parseInt(n.getAttribute('val') || String(def), 10);
            return Number.isFinite(v) ? v : def;
        };
        const lumMod = parseMod('a:lumMod', 100000);
        const lumOff = parseMod('a:lumOff', 0);
        const alpha = parseMod('a:alpha', 100000);
        const hex = baseHexNoHash.replace(/^#/, '');
        if (!/^[0-9a-fA-F]{6}$/u.test(hex)) {
            return '#' + hex;
        }
        let r = parseInt(hex.slice(0, 2), 16);
        let g = parseInt(hex.slice(2, 4), 16);
        let b = parseInt(hex.slice(4, 6), 16);
        const f = Math.max(0, Math.min(200000, lumMod)) / 100000;
        const off = (Math.max(0, Math.min(100000, lumOff)) / 100000) * 255;
        r = Math.min(255, Math.max(0, Math.round(r * f + off)));
        g = Math.min(255, Math.max(0, Math.round(g * f + off)));
        b = Math.min(255, Math.max(0, Math.round(b * f + off)));
        const a = Math.max(0, Math.min(100000, alpha)) / 100000;
        if (a < 0.999) {
            return `rgba(${r},${g},${b},${a})`;
        }
        return `rgb(${r},${g},${b})`;
    };
    const resolveOpenXmlColorHex = (root) => {
        if (!root) {
            return undefined;
        }
        const srgb = getElementsByTagName(root, 'a:srgbClr')[0];
        if (srgb) {
            const v = srgb.getAttribute('val');
            if (v) {
                return applyDrawingMlColorMods(v, srgb);
            }
        }
        const scheme = getElementsByTagName(root, 'a:schemeClr')[0];
        if (scheme) {
            const key = scheme.getAttribute('val');
            if (key && themeSchemeHex[key]) {
                const base = themeSchemeHex[key].replace(/^#/, '');
                return applyDrawingMlColorMods(base, scheme);
            }
        }
        return undefined;
    };
    const hexFromSolidFillEl = (solidFill) => resolveOpenXmlColorHex(solidFill);
    /** `a:gs` 内可直接挂 `a:schemeClr`/`a:srgbClr`，不一定包在 `a:solidFill` 里（幻灯片面/线渐变常见）。 */
    const hexFromGradGsEl = (gs) => {
        const solid = getElementsByTagName(gs, 'a:solidFill')[0];
        if (solid) {
            return hexFromSolidFillEl(solid);
        }
        return resolveOpenXmlColorHex(gs);
    };
    const hexFromGradFillFirstStop = (gradFill) => {
        if (!gradFill) {
            return undefined;
        }
        const gsLst = getElementsByTagName(gradFill, 'a:gsLst')[0];
        const gsNodes = gsLst ? getElementsByTagName(gsLst, 'a:gs') : [];
        for (let i = 0; i < gsNodes.length; i++) {
            const h = hexFromGradGsEl(gsNodes[i]);
            if (h) {
                return h;
            }
        }
        return undefined;
    };
    const cssColorEffectiveAlpha = (css) => {
        const m = /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i.exec(css.trim());
        if (m) {
            const a = parseFloat(m[1]);
            return Number.isFinite(a) ? a : 1;
        }
        return 1;
    };
    /** 取渐变中「不透明」端点色，避免首点 `alpha=0` 把整面填成透明。 */
    const hexFromGradFillRepresentative = (gradFill) => {
        if (!gradFill) {
            return undefined;
        }
        const gsLst = getElementsByTagName(gradFill, 'a:gsLst')[0];
        const gsNodes = gsLst ? Array.from(getElementsByTagName(gsLst, 'a:gs')) : [];
        gsNodes.sort((a, b) => parseInt(b.getAttribute('pos') || '0', 10) - parseInt(a.getAttribute('pos') || '0', 10));
        for (let i = 0; i < gsNodes.length; i++) {
            const h = hexFromGradGsEl(gsNodes[i]);
            if (h && cssColorEffectiveAlpha(h) > 0.04) {
                return h;
            }
        }
        return hexFromGradFillFirstStop(gradFill);
    };
    /** `a:lin@ang` 为 60000 分之一度；映射为 CSS `linear-gradient` 角度（近似与 PowerPoint 观感一致）。 */
    const buildPptGradFillCss = (gradFill) => {
        if (!gradFill) {
            return undefined;
        }
        const gsLst = getElementsByTagName(gradFill, 'a:gsLst')[0];
        const gsNodes = gsLst ? Array.from(getElementsByTagName(gsLst, 'a:gs')) : [];
        if (gsNodes.length < 2) {
            return undefined;
        }
        const lin = getElementsByTagName(gradFill, 'a:lin')[0];
        const angEmu = parseInt(lin?.getAttribute('ang') || '0', 10);
        const ooDeg = ((angEmu / 60000) % 360 + 360) % 360;
        const cssDeg = (90 - ooDeg + 360) % 360;
        const sorted = [...gsNodes].sort((a, b) => parseInt(a.getAttribute('pos') || '0', 10) - parseInt(b.getAttribute('pos') || '0', 10));
        const parts = [];
        for (let i = 0; i < sorted.length; i++) {
            const pos = parseInt(sorted[i].getAttribute('pos') || '0', 10);
            const pct = Math.max(0, Math.min(100, pos / 1000));
            const c = hexFromGradGsEl(sorted[i]);
            if (!c) {
                continue;
            }
            parts.push(`${c} ${pct}%`);
        }
        if (parts.length < 2) {
            return undefined;
        }
        return `linear-gradient(${cssDeg}deg, ${parts.join(', ')})`;
    };
    const content = [];
    const rawContents = [];
    const slideRelsMap = {};
    let currentListId = 0;
    let runningListIndex = 0;
    let lastWasList = false;
    let lastListType = null;
    let lastListIndent = 0;
    // per indent counters (for nested lists)
    const levelCounters = {};
    /** Walk `a:p` children in document order so `a:br` line breaks match PowerPoint. */
    const appendPptParagraphRuns = (p, pNode, slideNumber) => {
        let firstRunForMeta = null;
        /** xmldom 部分环境下 `Element.children` 可能不存在，用 childNodes 遍历元素子节点。 */
        const pKids = p.children ? Array.from(p.children) : Array.from(p.childNodes ?? []).filter((n) => n.nodeType === 1);
        for (const child of pKids) {
            const tag = child.localName || '';
            if (tag === 'r') {
                const r = child;
                if (!firstRunForMeta) {
                    firstRunForMeta = r;
                }
                const t = getElementsByTagName(r, "a:t")[0];
                if (t && t.childNodes[0]) {
                    const textContent = t.childNodes[0].nodeValue || '';
                    pNode.text += textContent;
                    const rPr = getElementsByTagName(r, "a:rPr")[0];
                    const formatting = {};
                    if (rPr) {
                        if (rPr.getAttribute("b") === "1")
                            formatting.bold = true;
                        if (rPr.getAttribute("i") === "1")
                            formatting.italic = true;
                        if (rPr.getAttribute("u") === "sng")
                            formatting.underline = true;
                        if (rPr.getAttribute("strike") === "sngStrike")
                            formatting.strikethrough = true;
                        const sz = rPr.getAttribute("sz");
                        if (sz)
                            formatting.size = (parseInt(sz) / 100).toString() + 'pt';
                        const solidFill = getElementsByTagName(rPr, "a:solidFill")[0];
                        const fillHex = hexFromSolidFillEl(solidFill);
                        if (fillHex) {
                            formatting.color = fillHex;
                        }
                        const highlight = getElementsByTagName(rPr, "a:highlight")[0];
                        if (highlight) {
                            const hiHex = resolveOpenXmlColorHex(highlight);
                            if (hiHex) {
                                formatting.backgroundColor = hiHex;
                            }
                        }
                        const latin = getElementsByTagName(rPr, "a:latin")[0];
                        if (latin) {
                            const typeface = latin.getAttribute("typeface");
                            if (typeface)
                                formatting.font = typeface;
                        }
                        const baseline = rPr.getAttribute("baseline");
                        if (baseline) {
                            const baselineVal = parseInt(baseline);
                            if (baselineVal < 0)
                                formatting.subscript = true;
                            if (baselineVal > 0)
                                formatting.superscript = true;
                        }
                    }
                    const textNode = {
                        type: 'text',
                        text: textContent,
                        formatting: formatting
                    };
                    const hlinkClick = getElementsByTagName(r, "a:hlinkClick")[0];
                    if (hlinkClick) {
                        const rId = hlinkClick.getAttribute("r:id");
                        const action = hlinkClick.getAttribute("action");
                        let link;
                        let linkType;
                        if (rId
                            && slideRelsMap[slideNumber]
                            && slideRelsMap[slideNumber][rId]
                            && slideRelsMap[slideNumber][rId].type === "hyperlink") {
                            link = slideRelsMap[slideNumber][rId].target;
                            linkType = "external";
                        }
                        else if (rId
                            && slideRelsMap[slideNumber]
                            && slideRelsMap[slideNumber][rId]
                            && slideRelsMap[slideNumber][rId].type === "slide") {
                            link = slideRelsMap[slideNumber][rId].target;
                            linkType = "internal";
                        }
                        else if (action) {
                            link = action;
                            linkType = "internal";
                        }
                        if (link) {
                            textNode.metadata = { link, linkType };
                        }
                    }
                    pNode.children?.push(textNode);
                }
            }
            else if (tag === 'br') {
                pNode.text += '\n';
                pNode.children?.push({ type: 'text', text: '\n', formatting: {} });
            }
        }
        return firstRunForMeta;
    };
    // Helper to parse a table node
    const parseTable = (tblNode, slideNumberForCell) => {
        const rows = [];
        const trNodes = getElementsByTagName(tblNode, "a:tr");
        for (let rIndex = 0; rIndex < trNodes.length; rIndex++) {
            const trNode = trNodes[rIndex];
            const cells = [];
            const tcNodes = getElementsByTagName(trNode, "a:tc");
            for (let cIndex = 0; cIndex < tcNodes.length; cIndex++) {
                const tcNode = tcNodes[cIndex];
                const cellChildren = [];
                let cellText = '';
                // Cells contain text bodies (txBody) which contain paragraphs
                const txBody = getElementsByTagName(tcNode, "a:txBody")[0];
                if (txBody) {
                    const paragraphs = getElementsByTagName(txBody, "a:p");
                    for (const p of paragraphs) {
                        // Reuse paragraph parsing logic if possible, or duplicate for now
                        // For simplicity, duplicating basic logic here as the main loop one is tied to shapes
                        const pNode = {
                            type: 'paragraph',
                            text: '',
                            children: [],
                            metadata: {}
                        };
                        if (config.includeRawContent) {
                            pNode.rawContent = p.toString();
                        }
                        appendPptParagraphRuns(p, pNode, slideNumberForCell);
                        cellChildren.push(pNode);
                        cellText += pNode.text;
                    }
                }
                const cellNode = {
                    type: 'cell',
                    text: cellText,
                    children: cellChildren,
                    metadata: { row: rIndex, col: cIndex }
                };
                cells.push(cellNode);
            }
            const rowNode = {
                type: 'row',
                children: cells
            };
            rows.push(rowNode);
        }
        return {
            type: 'table',
            children: rows
        };
    };
    /** Extract an AST node for p:pic */
    const extractImageNode = (imageNode, slideNumber, combinedEmu) => {
        const blip = getElementsByTagName(imageNode, "a:blip")[0];
        if (!blip)
            return null;
        const rId = blip.getAttribute("r:embed");
        if (!rId)
            return null;
        const rel = slideRelsMap[slideNumber]?.[rId];
        if (!rel || rel.type !== "image")
            return null;
        const attachmentName = rel.target;
        const nvPicPr = getElementsByTagName(imageNode, "p:nvPicPr")[0];
        const cNvPr = nvPicPr ? getElementsByTagName(nvPicPr, "p:cNvPr")[0] : null;
        const altText = cNvPr?.getAttribute("descr") || undefined;
        const posEmu = combinedEmu ?? readPptXfrmEmu(imageNode);
        const spPrPic = getElementsByTagName(imageNode, "p:spPr")[0];
        const prstGeomPic = spPrPic ? getElementsByTagName(spPrPic, "a:prstGeom")[0] : undefined;
        const imagePrst = prstGeomPic?.getAttribute("prst")?.trim() || undefined;
        return {
            type: "image",
            text: '',
            metadata: {
                attachmentName,
                altText,
                ...(posEmu ? { pptPositionEmu: posEmu } : {}),
                ...(imagePrst ? { pptImagePrstGeom: imagePrst } : {}),
            }
        };
    };
    /**
     * Extract an AST node for p:graphicFrame that contains a chart.
     * This mirrors extractImageNode, but for charts instead of images.
     *
     * Steps:
     * 1. Find <a:graphicData> inside the graphicFrame.
     * 2. Check if uri is the chart namespace.
     * 3. Extract <c:chart> and read r:id.
     * 4. Resolve the relationship using slideRelsMap.
     * 5. Produce a chart node with attachmentName (like images).
     * 6. Chart text/data will be injected later in the pipeline.
     *
     * @param frameNode p:graphicFrame element
     * @param slideNumber Current slide number for relationship resolution
     * @returns A chart OfficeContentNode or null if not a chart.
     */
    const extractChartNode = (frameNode, slideNumber, combinedEmu) => {
        // Step 1: Find <a:graphicData>
        const graphicData = getElementsByTagName(frameNode, "a:graphicData")[0];
        if (!graphicData) {
            return null;
        }
        // Step 2: Verify chart namespace
        // Must be: http://schemas.openxmlformats.org/drawingml/2006/chart
        const uri = graphicData.getAttribute("uri");
        const isChartGraphic = uri === "http://schemas.openxmlformats.org/drawingml/2006/chart";
        if (!isChartGraphic) {
            return null;
        }
        // Step 3: Find <c:chart>
        const cChart = getElementsByTagName(graphicData, "c:chart")[0];
        if (!cChart) {
            return null;
        }
        // Step 4: Extract r:id (relationship id)
        const rId = cChart.getAttribute("r:id");
        if (!rId) {
            return null;
        }
        // Step 5: Resolve relationship target from slideRelsMap
        const rel = slideRelsMap[slideNumber]?.[rId];
        if (!rel || rel.type !== "chart") {
            return null;
        }
        // rel.target will be something like "chart1.xml"
        const attachmentName = rel.target;
        const posEmu = combinedEmu ?? readPptXfrmEmu(frameNode);
        // Step 6: Build AST node
        const chartNode = {
            type: "chart",
            text: "", // chart text gets filled later
            metadata: {
                attachmentName, // name used to link to attachments & chartData
                ...(posEmu ? { pptPositionEmu: posEmu } : {}),
            }
        };
        // Optional: include raw XML of the whole frame
        if (config.includeRawContent) {
            chartNode.rawContent = xmlSerializer.serializeToString(frameNode);
        }
        return chartNode;
    };
    /** Extract an AST node for p:graphicFrame */
    const extractGraphicFrameNode = (frameNode, slideNumber, combinedEmu) => {
        const tbl = getElementsByTagName(frameNode, "a:tbl")[0];
        if (tbl) {
            const tableNode = parseTable(tbl, slideNumber);
            const posEmu = combinedEmu ?? readPptXfrmEmu(frameNode);
            if (posEmu) {
                tableNode.metadata = { pptPositionEmu: posEmu };
            }
            if (config.includeRawContent) {
                tableNode.rawContent = xmlSerializer.serializeToString(frameNode);
            }
            if (tableNode.children && tableNode.children.length > 0) {
                return tableNode;
            }
        }
        if (frameNode.getElementsByTagName("c:chart").length > 0) {
            const chartNode = extractChartNode(frameNode, slideNumber, combinedEmu);
            if (chartNode) {
                return chartNode;
            }
        }
        return null;
    };
    /**
     * `p:sp` / `p:cxnSp` 的 `p:spPr` → 单层 `drawing`。
     * 无 `a:prstGeom`/`a:custGeom` 但若存在填充或描边时回退为 `rect`，避免仅有 scheme 填充等仍被 `mapOoxmlPrstToDrawableKind` 判空时整块形状从图层里消失。
     */
    const trySpPrToDrawingNode = (visualEl, shapeEmu) => {
        const spPr0 = getElementsByTagName(visualEl, "p:spPr")[0];
        if (!spPr0) {
            return null;
        }
        const prstGeom = getElementsByTagName(spPr0, "a:prstGeom")[0];
        const custGeom = getElementsByTagName(spPr0, "a:custGeom")[0];
        const prst = prstGeom?.getAttribute("prst") ?? "";
        let drawKind = mapOoxmlPrstToDrawableKind(prst || undefined, {
            hasCustGeom: !!custGeom,
            hasPrstGeom: !!prstGeom,
        });
        const noFillDirect = spPrDirectChild(spPr0, "noFill");
        const solidFill = noFillDirect ? undefined : spPrDirectChild(spPr0, "solidFill");
        const gradFill = noFillDirect ? undefined : spPrDirectChild(spPr0, "gradFill");
        const ln = spPrDirectChild(spPr0, "ln") ?? getElementsByTagName(spPr0, "a:ln")[0];
        if (!drawKind) {
            if (custGeom) {
                drawKind = "custGeom";
            }
            else if (solidFill || gradFill || ln || prstGeom) {
                drawKind = "rect";
            }
        }
        if (!drawKind) {
            return null;
        }
        /** 纯 `rect` 且无几何/无填/无线时，不造透明占位（与 `a:noFill` 且无几何一致）。 */
        if (drawKind === "rect" && !prstGeom && !custGeom && !solidFill && !gradFill && !ln) {
            return null;
        }
        const fillColor = hexFromSolidFillEl(solidFill) ?? hexFromGradFillRepresentative(gradFill) ?? hexFromGradFillFirstStop(gradFill);
        const fillGradientCss = buildPptGradFillCss(gradFill);
        let lineColor;
        let lineWidthPt;
        if (ln) {
            const wAttr = ln.getAttribute("w");
            if (wAttr) {
                const emuW = parseInt(wAttr, 10);
                if (emuW > 0) {
                    lineWidthPt = emuW / 12700;
                }
            }
            const lnSolid = getElementsByTagName(ln, "a:solidFill")[0];
            const lnGrad = getElementsByTagName(ln, "a:gradFill")[0];
            const lnHex = hexFromSolidFillEl(lnSolid) ?? hexFromGradFillRepresentative(lnGrad) ?? hexFromGradFillFirstStop(lnGrad);
            if (lnHex) {
                lineColor = lnHex;
            }
        }
        let pptRoundRectAdj;
        if (prstGeom) {
            const prstLow = prst.trim().toLowerCase();
            if (prstLow === "roundrect") {
                const avLst = getElementsByTagName(prstGeom, "a:avLst")[0];
                if (avLst) {
                    const gds = getElementsByTagName(avLst, "a:gd");
                    for (let gi = 0; gi < gds.length; gi++) {
                        if (gds[gi].getAttribute("name") === "adj") {
                            const fmla = (gds[gi].getAttribute("fmla") || "").trim();
                            const mv = /^val\s+(\d+)/i.exec(fmla);
                            if (mv) {
                                const v = parseInt(mv[1], 10);
                                if (Number.isFinite(v)) {
                                    pptRoundRectAdj = Math.max(0, Math.min(50000, v));
                                }
                            }
                            break;
                        }
                    }
                }
            }
        }
        return {
            type: "drawing",
            text: "",
            metadata: {
                pptPositionEmu: shapeEmu,
                pptShapeKind: drawKind,
                ...(prst.trim() !== ""
                    ? { pptPrst: prst.trim() }
                    : drawKind === "custGeom"
                        ? { pptPrst: "custGeom" }
                        : {}),
                ...(fillColor ? { pptFillColor: fillColor } : {}),
                ...(fillGradientCss ? { pptFillGradientCss: fillGradientCss } : {}),
                ...(lineColor ? { pptLineColor: lineColor } : {}),
                ...(lineWidthPt !== undefined ? { pptLineWidthPt: lineWidthPt } : {}),
                ...(pptRoundRectAdj !== undefined ? { pptRoundRectAdj } : {}),
            },
        };
    };
    /** Extract text and hyperlinks from a p:sp shape. */
    const extractShapeNodes = (spNode, slideNumber, combinedEmu) => {
        const nodes = [];
        const shapeEmu = combinedEmu ?? readPptXfrmEmu(spNode);
        // Check for placeholder type (title, body, etc.)
        const nvSpPr = getElementsByTagName(spNode, "p:nvSpPr")[0];
        const nvPr = nvSpPr ? getElementsByTagName(nvSpPr, "p:nvPr")[0] : null;
        const ph = nvPr ? getElementsByTagName(nvPr, "p:ph")[0] : null;
        const type = ph ? ph.getAttribute("type") : "body";
        const isTitle = type === "title" || type === "ctrTitle";
        /** 有 `p:txBody` 的文本框也可能带 `a:prstGeom` 底图；先输出矢量底再输出文本。 */
        if (shapeEmu) {
            const drawingNode = trySpPrToDrawingNode(spNode, shapeEmu);
            if (drawingNode) {
                nodes.push(drawingNode);
            }
        }
        const txBody = getElementsByTagName(spNode, "p:txBody")[0];
        if (txBody) {
            const bodyPrEl = getElementsByTagName(txBody, "a:bodyPr")[0];
            const rawBodyAnchor = bodyPrEl?.getAttribute("anchor")?.trim();
            const bodyAnchorCtr = bodyPrEl?.getAttribute("anchorCtr") === "1";
            const paragraphs = getElementsByTagName(txBody, "a:p");
            for (let i = 0; i < paragraphs.length; i++) {
                const p = paragraphs[i];
                const pNode = {
                    type: isTitle ? 'heading' : 'paragraph',
                    text: '',
                    children: [],
                    metadata: isTitle ? { level: 1 } : {}
                };
                // Paragraph Alignment and List Detection
                const pPr = getElementsByTagName(p, "a:pPr")[0];
                let isList = false;
                let listType = 'unordered';
                let lvl = 0;
                if (pPr) {
                    const lvlAttr = pPr.getAttribute("lvl");
                    if (lvlAttr)
                        lvl = parseInt(lvlAttr);
                    const buAutoNum = getElementsByTagName(pPr, "a:buAutoNum")[0];
                    const buChar = getElementsByTagName(pPr, "a:buChar")[0];
                    const buBlip = getElementsByTagName(pPr, "a:buBlip")[0];
                    const buNode = getElementsByTagName(pPr, "a:bu")[0];
                    if (buAutoNum) {
                        isList = true;
                        listType = 'ordered';
                    }
                    else if (buChar || buBlip) {
                        isList = true;
                        listType = 'unordered';
                    }
                    else if (buNode) {
                        // inherited bullet from a list style
                        isList = true;
                        listType = 'unordered';
                    }
                    const algn = pPr.getAttribute("algn");
                    if (algn) {
                        const alignMap = {
                            'l': 'left',
                            'ctr': 'center',
                            'r': 'right',
                            'just': 'justify'
                        };
                        if (alignMap[algn]) {
                            pNode.metadata.alignment = alignMap[algn];
                        }
                    }
                }
                /** `a:bodyPr`：垂直锚点写入 metadata；无 `a:pPr@algn` 时 `anchor=ctr` 或 `anchorCtr=1` 常表示水平居中。 */
                if (rawBodyAnchor) {
                    pNode.metadata.pptBodyAnchor = rawBodyAnchor;
                }
                if (bodyAnchorCtr) {
                    pNode.metadata.pptBodyAnchorCtr = true;
                }
                const mdAlign = pNode.metadata;
                if (!mdAlign.alignment && (bodyAnchorCtr || rawBodyAnchor === "ctr")) {
                    mdAlign.alignment = "center";
                }
                if (isList) {
                    pNode.type = 'list';
                    const ilvl = lvl;
                    // detect a new list when bullet type changes or previous was not a list
                    const newList = !lastWasList ||
                        listType !== lastListType;
                    if (newList) {
                        // new list → new ID
                        currentListId++;
                        // clear counters for nested levels
                        for (const k in levelCounters) {
                            delete levelCounters[k];
                        }
                        // start item index at 1
                        runningListIndex = 0;
                        levelCounters[ilvl] = 0;
                    }
                    else {
                        // same listId, but indentation may change
                        // if going deeper → start at 1 for that level
                        if (ilvl > lastListIndent) {
                            runningListIndex = 0;
                            levelCounters[ilvl] = 0;
                        }
                        // if going shallower → restore previous level counter + 1
                        else if (ilvl < lastListIndent) {
                            // remove deeper counters
                            for (const lvlKey in levelCounters) {
                                const lv = parseInt(lvlKey);
                                if (lv > ilvl)
                                    delete levelCounters[lv];
                            }
                            // continue counter at this level
                            const prev = levelCounters[ilvl] || 0;
                            runningListIndex = prev + 1;
                            levelCounters[ilvl] = runningListIndex;
                        }
                        // same level → increment
                        else {
                            const prev = levelCounters[ilvl] || 0;
                            runningListIndex = prev + 1;
                            levelCounters[ilvl] = runningListIndex;
                        }
                    }
                    // update tracking state
                    lastWasList = true;
                    lastListType = listType;
                    lastListIndent = ilvl;
                    // metadata output
                    pNode.metadata = {
                        ...pNode.metadata,
                        listType,
                        indentation: ilvl,
                        listId: currentListId.toString(),
                        itemIndex: runningListIndex,
                        alignment: pNode.metadata?.alignment || 'left',
                    };
                }
                else {
                    lastWasList = false;
                    lastListType = null;
                    lastListIndent = 0;
                }
                if (isTitle) {
                    pNode.metadata = { ...pNode.metadata, level: 1 };
                }
                if (config.includeRawContent) {
                    pNode.rawContent = p.toString();
                }
                const firstRunForMeta = appendPptParagraphRuns(p, pNode, slideNumber);
                if (pPr) {
                    const lnSpc = getElementsByTagName(pPr, 'a:lnSpc')[0];
                    if (lnSpc) {
                        const spcPct = getElementsByTagName(lnSpc, 'a:spcPct')[0];
                        if (spcPct) {
                            const val = parseInt(spcPct.getAttribute('val') || '0', 10);
                            if (val > 0) {
                                pNode.metadata.pptLineSpacing = val / 100000;
                            }
                        }
                    }
                }
                if (firstRunForMeta) {
                    const r0 = firstRunForMeta;
                    const rPr0 = getElementsByTagName(r0, 'a:rPr')[0];
                    if (rPr0) {
                        const szAttr = rPr0.getAttribute('sz');
                        if (szAttr) {
                            pNode.metadata.pptFontSizePt = parseInt(szAttr, 10) / 100;
                        }
                        const solidFill = getElementsByTagName(rPr0, 'a:solidFill')[0];
                        const runHex = hexFromSolidFillEl(solidFill);
                        if (runHex) {
                            pNode.metadata.pptRunColor = runHex;
                        }
                    }
                }
                if (pNode.text) {
                    if (shapeEmu) {
                        pNode.metadata = { ...(pNode.metadata || {}), pptPositionEmu: shapeEmu };
                    }
                    nodes.push(pNode);
                }
            }
        }
        return nodes;
    };
    /** 连接线 `p:cxnSp`：与 `p:sp` 共用 `trySpPrToDrawingNode`，保留在 `p:spTree` 中的叠层顺序。 */
    const extractCxnSpNodes = (cxnEl, slideNumber, combinedEmu) => {
        void slideNumber;
        const shapeEmu = combinedEmu ?? readPptXfrmEmu(cxnEl);
        if (!shapeEmu) {
            return [];
        }
        const d = trySpPrToDrawingNode(cxnEl, shapeEmu);
        return d ? [d] : [];
    };
    /**
     * Recursively traverses a PowerPoint shape tree (p:spTree),
     * including grouped shapes (p:grpSp), and dispatches each element
     * to the appropriate handler (shape, image, chart, etc.).
     *
     * This function preserves the visual order because it processes
     * children in the order they appear in the XML. It also accumulates
     * transforms so nested groups inherit positional transforms.
     *
     * @param treeNode The XML node representing <p:spTree>
     * @param parentTransform Transform inherited from parent groups (defaults to identity)
     */
    function dispatchVisualElement(element, slideNumber, groupStack) {
        const tag = element.tagName;
        if (tag === 'p:sp') {
            const raw = readPptXfrmEmu(element);
            const emu = raw && groupStack.length > 0 ? mapShapeEmuThroughGroupStack(groupStack, raw) : raw;
            return extractShapeNodes(element, slideNumber, emu);
        }
        if (tag === 'p:cxnSp') {
            const raw = readPptXfrmEmu(element);
            const emu = raw && groupStack.length > 0 ? mapShapeEmuThroughGroupStack(groupStack, raw) : raw;
            return extractCxnSpNodes(element, slideNumber, emu);
        }
        if (tag === 'p:pic') {
            const raw = readPptXfrmEmu(element);
            const emu = raw && groupStack.length > 0 ? mapShapeEmuThroughGroupStack(groupStack, raw) : raw;
            const imageNode = extractImageNode(element, slideNumber, emu);
            return imageNode ? [imageNode] : [];
        }
        if (tag === 'p:graphicFrame') {
            const raw = readPptXfrmEmu(element);
            const emu = raw && groupStack.length > 0 ? mapShapeEmuThroughGroupStack(groupStack, raw) : raw;
            const n = extractGraphicFrameNode(element, slideNumber, emu);
            return n ? [n] : [];
        }
        if (tag === 'p:grpSp') {
            const gt = readGroupTransformEmu(element);
            const nextStack = gt ? [...groupStack, gt] : groupStack;
            /** 只认「直接子级」`p:spTree`，避免 `getElementsByTagName` 误命中深层后代。 */
            let nestedTree;
            for (const c of Array.from(element.childNodes)) {
                if (c.nodeType !== 1) {
                    continue;
                }
                const el = c;
                const local = el.localName || el.tagName.replace(/^.*:/, '');
                if (local === 'spTree') {
                    nestedTree = el;
                    break;
                }
            }
            if (nestedTree) {
                return traverseSpTree(nestedTree, slideNumber, nextStack);
            }
            const out = [];
            for (const c of Array.from(element.childNodes)) {
                if (c.nodeType !== 1) {
                    continue;
                }
                out.push(...dispatchVisualElement(c, slideNumber, nextStack));
            }
            return out;
        }
        return [];
    }
    function traverseSpTree(treeNode, slideNumber, groupStack = []) {
        const nodes = [];
        for (const child of Array.from(treeNode?.childNodes || [])) {
            if (child.nodeType !== 1) {
                continue;
            }
            nodes.push(...dispatchVisualElement(child, slideNumber, groupStack));
        }
        return nodes;
    }
    // First pass: Process relationships
    for (const file of files) {
        // Check whether this file is a slideX.xml.rels file
        if (file.path.match(slideRelsRegex)) {
            /**
             * Builds a map of slide number to a map of relationship IDs containing:
             * - type: The relationship category (image, hyperlink, chart, etc)
             * - target: The fully normalized target path inside the PPTX zip
             *
             * Example structure:
             * {
             *     1: {
             *         "rId2": { type: "image", target: "ppt/media/image3.png" },
             *         "rId5": { type: "hyperlink", target: "https://example.com" }
             *     }
             * }
             *
             * @param files All extracted PPTX ZIP files.
             * @param slideRelsMap A map of slide number to relationship info.
             */
            // Extract slide number from path
            const match = file.path.match(/slide(\d+)\.xml\.rels/);
            if (match) {
                // Convert matched number to integer
                const slideNum = parseInt(match[1]);
                // Prepare map for this slide
                slideRelsMap[slideNum] = {};
                // Parse the rels XML
                const relsXml = parseXmlString(file.content.toString());
                // Get all Relationship nodes
                const relationships = getElementsByTagName(relsXml, "Relationship");
                // Loop through each relationship node
                for (let i = 0; i < relationships.length; i++) {
                    // Relationship ID, Example: "rId2"
                    const id = relationships[i].getAttribute("Id");
                    // Relationship Type, Example: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
                    const typeAttr = relationships[i].getAttribute("Type");
                    // Raw Target, may be relative or absolute
                    const targetRaw = relationships[i].getAttribute("Target");
                    // Only proceed if ID and Type exist
                    if (id && typeAttr && targetRaw) {
                        // Simplify Type to a short keyword (image, hyperlink, chart, etc)
                        // This is optional but very useful.
                        let simplifiedType = "other";
                        // Check image
                        if (typeAttr.includes("relationships/image")) {
                            simplifiedType = "image";
                        }
                        // Check hyperlink
                        else if (typeAttr.includes("relationships/hyperlink")) {
                            simplifiedType = "hyperlink";
                        }
                        // Check chart
                        else if (typeAttr.includes("relationships/chart")) {
                            simplifiedType = "chart";
                        }
                        // Check slide references
                        else if (typeAttr.includes("relationships/slide")) {
                            simplifiedType = "slide";
                        }
                        // Check notes
                        else if (typeAttr.includes("relationships/notesSlide")) {
                            simplifiedType = "notes";
                        }
                        // Now normalize the target only if it is a local file path.
                        // Hyperlinks are external and should not be normalized.
                        let normalizedTarget = targetRaw;
                        // Local paths never contain "http" or "https"
                        const isExternal = targetRaw.startsWith("http://") || targetRaw.startsWith("https://");
                        // If not external, normalize the target which is just the name of the item.
                        if (!isExternal) {
                            normalizedTarget = normalizedTarget.split('/').pop() || '';
                        }
                        // Finally store full relationship info
                        slideRelsMap[slideNum][id] = {
                            type: simplifiedType,
                            target: normalizedTarget
                        };
                    }
                }
            }
        }
    }
    // Now for processing all the other files - slides and notes.
    for (const file of files) {
        if (file.path.match(mediaFileRegex))
            continue;
        if (file.path.match(chartFileRegex))
            continue;
        if (file.path.match(slideRelsRegex))
            continue;
        if (file.path.match(corePropsFileRegex))
            continue;
        const xmlContentString = file.content.toString();
        const xml = parseXmlString(xmlContentString);
        if (config.includeRawContent) {
            rawContents.push(xmlContentString);
        }
        const slideMatch = file.path.match(slideNumberRegex);
        const slideNumber = slideMatch ? parseInt(slideMatch[1]) : 0;
        const isNote = file.path.includes("notesSlide");
        const slideNode = {
            type: isNote ? 'note' : 'slide',
            children: [],
            metadata: {
                slideNumber: slideNumber,
                ...(isNote ? { noteId: `slide-note-${slideNumber}` } : {})
            }
        };
        if (config.includeRawContent) {
            slideNode.rawContent = file.content.toString();
        }
        /**
         * `p:cSld/p:bg` 先于 `p:spTree`：全幅背景图插入子列表最前，避免背景图层与后续 `p:pic` 顺序错乱。
         * `p:spTree` 仍按 XML 顺序追加。
         */
        const slideChildren = [];
        if (!isNote) {
            const cSld = getElementsByTagName(xml, "p:cSld")[0];
            const bg = cSld ? getElementsByTagName(cSld, "p:bg")[0] : undefined;
            if (bg) {
                const bgPr = getElementsByTagName(bg, "p:bgPr")[0];
                if (bgPr) {
                    const blipFill = getElementsByTagName(bgPr, "a:blipFill")[0];
                    const blip = blipFill ? getElementsByTagName(blipFill, "a:blip")[0] : undefined;
                    const embed = blip?.getAttribute("r:embed");
                    if (embed &&
                        slideRelsMap[slideNumber]?.[embed] &&
                        slideRelsMap[slideNumber][embed].type === "image") {
                        const target = slideRelsMap[slideNumber][embed].target;
                        slideChildren.push({
                            type: "image",
                            text: "",
                            metadata: {
                                attachmentName: target,
                                pptPositionEmu: { ...presentationSlideSizeEmu },
                                pptIsSlideBackground: true,
                            },
                        });
                    }
                    else {
                        const solidFill = getElementsByTagName(bgPr, "a:solidFill")[0];
                        const gradFill = getElementsByTagName(bgPr, "a:gradFill")[0];
                        const bgHex = hexFromSolidFillEl(solidFill) ?? hexFromGradFillFirstStop(gradFill);
                        if (bgHex) {
                            slideNode.metadata.pptPageBackgroundRgb = bgHex.replace(/^#/, "");
                        }
                    }
                }
            }
        }
        const spTree = getElementsByTagName(xml, "p:spTree")[0];
        if (spTree) {
            slideChildren.push(...traverseSpTree(spTree, slideNumber));
        }
        slideNode.children = slideChildren;
        const sm = slideNode.metadata;
        if (slideChildren.length > 0 || (!isNote && sm.pptPageBackgroundRgb)) {
            content.push(slideNode);
        }
    }
    const attachments = [];
    const mediaFiles = files.filter(f => f.path.match(/ppt\/media\/.*/));
    const chartFiles = files.filter(f => f.path.match(/ppt\/charts\/chart\d+\.xml/));
    // First run to extract attachments and to assign ocr to image files.
    if (config.extractAttachments) {
        // Extract media files as attachments
        for (const media of mediaFiles) {
            const attachment = createAttachment(media.path.split('/').pop() || 'image', media.content);
            attachments.push(attachment);
            if (config.ocr) {
                if (attachment.mimeType.startsWith('image/')) {
                    try {
                        attachment.ocrText = (await performOcr(media.content, config.ocrLanguage)).trim();
                    }
                    catch (e) {
                        logWarning(`OCR failed for ${attachment.name}:`, config, e);
                    }
                }
            }
        }
        // Extract chart files as attachments
        for (const chart of chartFiles) {
            const attachment = {
                type: 'chart',
                mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Generic XML type for now
                data: chart.content.toString('base64'),
                name: chart.path.split('/').pop() || '',
                extension: 'xml'
            };
            attachments.push(attachment);
            // Extract text from chart XML
            try {
                const chartData = await extractChartData(chart.content);
                // Assign chartData to attachment
                attachment.chartData = chartData;
            }
            catch (e) {
                logWarning(`Failed to extract text from chart ${chart.path}:`, config, e);
            }
        }
        // Loop through nodes to find images and charts and link their text and chartData
        const assignAttachmentData = (nodes) => {
            for (const node of nodes) {
                if ('attachmentName' in (node.metadata || {})) {
                    const meta = node.metadata;
                    const attachment = attachments.find(a => a.name === meta.attachmentName);
                    if (attachment) {
                        if (node.type === 'image') {
                            attachment.altText = meta.altText;
                            if (attachment.ocrText)
                                node.text = attachment.ocrText;
                        }
                        if (node.type === 'chart') {
                            const raw = attachment.chartData?.rawTexts;
                            node.text = Array.isArray(raw)
                                ? raw.join(config.newlineDelimiter || '\n')
                                : '';
                        }
                    }
                }
                if (node.children) {
                    assignAttachmentData(node.children);
                }
            }
        };
        assignAttachmentData(content);
    }
    // Finally, if the notes are required to be at the end of the document, move them there.
    if (!config.ignoreNotes && config.putNotesAtLast) {
        content.sort((a, b) => {
            const aIsNote = a.type === 'note' ? 1 : 0;
            const bIsNote = b.type === 'note' ? 1 : 0;
            return aIsNote - bIsNote;
        });
    }
    return {
        type: 'pptx',
        metadata: {
            ...metadata,
            pptSlideSizeEmu: {
                cx: presentationSlideSizeEmu.cx,
                cy: presentationSlideSizeEmu.cy,
            },
        },
        content: content,
        attachments: attachments,
        toText: () => content.map(c => {
            // Recursive text extraction
            const getText = (node) => {
                let t = '';
                if (node.children) {
                    t += node.children.map(getText).filter(t => t != '').join(!node.children[0]?.children ? '' : config.newlineDelimiter ?? '\n');
                }
                else
                    t += node.text || '';
                return t;
            };
            return getText(c);
        }).filter(t => t != '').join(config.newlineDelimiter ?? '\n')
    };
};
