"use strict";
/**
 * Text Renderer
 *
 * Handles rendering of text content including:
 * - Text with letter spacing
 * - Text decorations (underline, overline, line-through)
 * - Text shadows
 * - Webkit line clamp
 * - Text overflow ellipsis
 * - Paint order (fill/stroke)
 * - Font styles
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextRenderer = exports.hasCJKCharacters = void 0;
const text_1 = require("../../css/layout/text");
const color_utilities_1 = require("../../css/types/color-utilities");
const parser_1 = require("../../css/syntax/parser");
const writing_mode_1 = require("../../css/property-descriptors/writing-mode");
const text_decoration_renderer_1 = require("./text/text-decoration-renderer");
// iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
const iOSBrokenFonts = ['-apple-system', 'system-ui'];
/**
 * Detect CJK (Chinese, Japanese, Korean) characters in a string.
 * CJK characters use the ideographic baseline in browsers, which differs
 * from the alphabetic baseline used for Latin script.
 *
 * Covers:
 *   U+2E80–U+2FFF  CJK Radicals Supplement, Kangxi Radicals
 *   U+3000–U+30FF  CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
 *   U+3400–U+4DBF  CJK Extension A
 *   U+4E00–U+9FFF  CJK Unified Ideographs (most common Chinese/Japanese/Korean)
 *   U+AC00–U+D7AF  Hangul Syllables
 *   U+F900–U+FAFF  CJK Compatibility Ideographs
 *   U+FF01–U+FFEF  Halfwidth and Fullwidth Forms (Ａ Ｂ １ ２ ！ ？ etc.)
 */
const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
exports.hasCJKCharacters = hasCJKCharacters;
/**
 * Detect iOS version from user agent
 * Returns null if not iOS or version cannot be determined
 */
const getIOSVersion = () => {
    if (typeof navigator === 'undefined') {
        return null;
    }
    const userAgent = navigator.userAgent;
    // Check if it's iOS or iPadOS
    // iPadOS 13+ may identify as Macintosh, check for touch support
    const isIOS = /iPhone|iPad|iPod/.test(userAgent);
    const isIPadOS = /Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1;
    if (!isIOS && !isIPadOS) {
        return null;
    }
    // Extract version number from various iOS user agent formats:
    // - "iPhone OS 15_0" or "iPhone OS 15_0_1"
    // - "CPU OS 15_0 like Mac OS X"
    // - "CPU iPhone OS 15_0 like Mac OS X"
    // - "Version/15.0" (for iPadOS)
    const patterns = [
        /(?:iPhone|CPU(?:\siPhone)?)\sOS\s(\d+)[\._](\d+)/, // iPhone OS, CPU OS, CPU iPhone OS
        /Version\/(\d+)\.(\d+)/ // Version/15.0 (iPadOS)
    ];
    for (const pattern of patterns) {
        const match = userAgent.match(pattern);
        if (match && match[1]) {
            return parseInt(match[1], 10);
        }
    }
    return null;
};
const fixIOSSystemFonts = (fontFamilies) => {
    const iosVersion = getIOSVersion();
    // On iOS 15.0 and 15.1, system fonts have rendering issues
    // Fixed in iOS 17+
    if (iosVersion !== null && iosVersion >= 15 && iosVersion < 17) {
        return fontFamilies.map((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) !== -1
            ? `-apple-system, "Helvetica Neue", Arial, sans-serif`
            : fontFamily);
    }
    return fontFamilies;
};
const getTextStrokeLineJoin = () => {
    const currentWindow = typeof window !== 'undefined' ? window : undefined;
    return currentWindow?.chrome ? 'miter' : 'round';
};
/**
 * Text Renderer
 *
 * Specialized renderer for text content.
 * Extracted from CanvasRenderer to improve code organization and maintainability.
 */
class TextRenderer {
    constructor(deps) {
        /**
         * Per-render glyph width cache. Keyed by glyph + font string,
         * avoids redundant `measureText` calls when letter-spacing is non-zero.
         * Cleared at the start of each renderTextNode call.
         */
        this.glyphWidthCache = null;
        this.ctx = deps.ctx;
        this.options = deps.options;
        this.decorationRenderer = new text_decoration_renderer_1.TextDecorationRenderer(deps.ctx);
    }
    /**
     * Returns the cached width for a glyph+font pair, measuring via
     * measureText if not yet cached. Only used when letterSpacing !== 0
     * (the `canRenderWholeText` fast path handles the zero-spacing case).
     */
    cachedMeasureText(glyph, fontString) {
        let cache = this.glyphWidthCache;
        if (!cache) {
            cache = new Map();
            this.glyphWidthCache = cache;
        }
        const key = glyph + fontString;
        let w = cache.get(key);
        if (w === undefined) {
            w = this.ctx.measureText(glyph).width;
            cache.set(key, w);
        }
        return w;
    }
    /**
     * Iterate grapheme clusters one-by-one, applying correct letter-spacing and
     * per-script baseline for each character.
     *
     * Issue #73: When letter-spacing is non-zero, text must be rendered character by
     * character. This helper centralises two fixes applied during that iteration:
     *   1. Add `letterSpacing` to each character's advance width (was previously
     *      omitted, causing characters to render without any spacing).
     *   2. Switch to the ideographic baseline for CJK glyphs so their vertical
     *      position matches how browsers lay them out in the DOM.
     *
     * The `renderFn` callback receives (letter, x, y) and performs the actual draw
     * call (fillText or strokeText), allowing fill and stroke paths to share one
     * implementation.
     */
    iterateLettersWithLetterSpacing(text, letterSpacing, baseline, writingMode, renderFn) {
        if ((0, writing_mode_1.isVerticalWritingMode)(writingMode)) {
            this.iterateVerticalGlyphs(text, letterSpacing, baseline, writingMode, renderFn);
            return;
        }
        const letters = (0, text_1.segmentGraphemes)(text.text);
        const y = text.bounds.top + baseline;
        const fontString = this.ctx.font;
        let left = text.bounds.left;
        for (const letter of letters) {
            if ((0, exports.hasCJKCharacters)(letter)) {
                const savedBaseline = this.ctx.textBaseline;
                this.ctx.textBaseline = 'ideographic';
                renderFn(letter, left, y);
                this.ctx.textBaseline = savedBaseline;
            }
            else {
                renderFn(letter, left, y);
            }
            left += this.cachedMeasureText(letter, fontString) + letterSpacing;
        }
    }
    iterateVerticalGlyphs(text, letterSpacing, baseline, writingMode, renderFn) {
        const letters = (0, text_1.segmentGraphemes)(text.text);
        const fontString = this.ctx.font;
        const rotationAngle = writingMode === 4 /* WRITING_MODE.SIDEWAYS_LR */ ? -Math.PI / 2 : Math.PI / 2;
        let top = text.bounds.top;
        for (let i = 0; i < letters.length;) {
            const letter = letters[i];
            const isSideways = (0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(letter) && letter.trim().length > 0);
            if (!isSideways) {
                // CJK glyph in vertical writing mode — render upright
                const savedBaseline = this.ctx.textBaseline;
                if ((0, exports.hasCJKCharacters)(letter)) {
                    this.ctx.textBaseline = 'ideographic';
                }
                renderFn(letter, text.bounds.left, top + baseline);
                this.ctx.textBaseline = savedBaseline;
                top += this.cachedMeasureText(letter, fontString) + letterSpacing;
                i++;
                continue;
            }
            // ── Batch consecutive sideways glyphs into one save/restore ──
            const runStart = i;
            while (i < letters.length) {
                const ch = letters[i];
                if (!(0, writing_mode_1.isSidewaysWritingMode)(writingMode) && !(0, exports.hasCJKCharacters)(ch) && ch.trim().length === 0)
                    break;
                if ((0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(ch) && ch.trim().length > 0)) {
                    i++;
                }
                else {
                    break;
                }
            }
            // Render entire sideways run in one save/restore
            this.ctx.save();
            this.ctx.translate(text.bounds.left + baseline, top);
            this.ctx.rotate(rotationAngle);
            let runOffset = 0;
            for (let j = runStart; j < i; j++) {
                renderFn(letters[j], 0, runOffset);
                runOffset += this.cachedMeasureText(letters[j], fontString) + letterSpacing;
            }
            this.ctx.restore();
            top += runOffset;
        }
    }
    /**
     * Render text with letter-spacing applied (fill pass).
     * When letterSpacing is 0 the whole string is drawn in one call; otherwise each
     * grapheme is drawn individually so spacing and CJK baseline are applied correctly.
     */
    renderTextWithLetterSpacing(text, letterSpacing, baseline, writingMode = 0 /* WRITING_MODE.HORIZONTAL_TB */) {
        this.renderFillText(text, letterSpacing, baseline, writingMode);
    }
    canRenderWholeText(letterSpacing, writingMode) {
        return letterSpacing === 0 && !(0, writing_mode_1.isVerticalWritingMode)(writingMode);
    }
    renderFillText(text, letterSpacing, baseline, writingMode) {
        if (this.canRenderWholeText(letterSpacing, writingMode)) {
            this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
        }
        else {
            this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, writingMode, (letter, x, y) => {
                this.ctx.fillText(letter, x, y);
            });
        }
    }
    renderStrokeText(text, letterSpacing, baseline, writingMode) {
        if (this.canRenderWholeText(letterSpacing, writingMode)) {
            this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
        }
        else {
            this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, writingMode, (letter, x, y) => {
                this.ctx.strokeText(letter, x, y);
            });
        }
    }
    renderTextStrokeWithStyle(text, styles) {
        if (!styles.webkitTextStrokeWidth || !text.text.trim().length) {
            return;
        }
        this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
        this.ctx.lineWidth = styles.webkitTextStrokeWidth;
        this.ctx.lineJoin = getTextStrokeLineJoin();
        this.renderStrokeText(text, styles.letterSpacing, styles.fontSize.number, styles.writingMode);
        this.ctx.strokeStyle = '';
        this.ctx.lineWidth = 0;
        this.ctx.lineJoin = 'miter';
    }
    renderTextFillWithShadows(text, styles) {
        this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
        this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number, styles.writingMode);
        const textShadows = styles.textShadow;
        if (textShadows.length && text.text.trim().length) {
            textShadows
                .slice(0)
                .reverse()
                .forEach((textShadow) => {
                this.ctx.shadowColor = (0, color_utilities_1.asString)(textShadow.color);
                this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
                this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
                this.ctx.shadowBlur = textShadow.blur.number;
                this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number, styles.writingMode);
            });
            this.ctx.shadowColor = '';
            this.ctx.shadowOffsetX = 0;
            this.ctx.shadowOffsetY = 0;
            this.ctx.shadowBlur = 0;
        }
    }
    /**
     * Helper method to render text with paint order support
     * Reduces code duplication in line-clamp and normal rendering
     */
    renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
        paintOrderLayers.forEach((paintOrderLayer) => {
            switch (paintOrderLayer) {
                case 0 /* PAINT_ORDER_LAYER.FILL */:
                    this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
                    this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, styles.writingMode);
                    break;
                case 1 /* PAINT_ORDER_LAYER.STROKE */:
                    this.renderTextStrokeWithStyle(textBound, styles);
                    break;
            }
        });
    }
    renderTextDecoration(bounds, styles) {
        this.decorationRenderer.render(bounds, styles);
    }
    // Helper method to truncate text and add ellipsis if needed
    truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
        // Use the Unicode ellipsis character (U+2026) whose width the browser measures
        // as a single glyph, matching native text-overflow behaviour more closely.
        const ellipsis = '\u2026';
        const ellipsisWidth = this.ctx.measureText(ellipsis).width;
        // Segment into grapheme clusters so multi-byte characters (emoji, composed
        // sequences) are never split mid-character.
        const graphemes = (0, text_1.segmentGraphemes)(text);
        if (letterSpacing === 0) {
            // Measure the whole candidate string for accuracy: the browser applies
            // kerning and ligatures when rendering multiple glyphs together, so
            // measuring them as one string is more precise than summing individual widths.
            // Binary search reduces measurements from O(n) to O(log n).
            // NOTE: can't use cachedMeasureText here — the binary search measures
            // concatenated substrings, and kerning/ligatures depend on the full string.
            const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
            let lo = 0;
            let hi = graphemes.length;
            while (lo < hi) {
                const mid = (lo + hi + 1) >> 1;
                if (fits(mid)) {
                    lo = mid;
                }
                else {
                    hi = mid - 1;
                }
            }
            return graphemes.slice(0, lo).join('') + ellipsis;
        }
        else {
            const fontString = this.ctx.font;
            let width = ellipsisWidth;
            const result = [];
            for (const letter of graphemes) {
                const glyphWidth = this.cachedMeasureText(letter, fontString);
                // Check against glyph width only (no trailing spacing): letter-spacing
                // is applied *between* characters, not after the final glyph. Using
                // `glyphWidth + letterSpacing` would incorrectly discard letters that
                // fit as the last character before the ellipsis.
                if (width + glyphWidth > maxWidth) {
                    break;
                }
                result.push(letter);
                // Accumulate glyph + inter-character spacing for the *next* iteration.
                width += glyphWidth + letterSpacing;
            }
            return result.join('') + ellipsis;
        }
    }
    /**
     * Create font style array
     * Public method used by list rendering
     */
    createFontStyle(styles) {
        const fontVariant = styles.fontVariant
            .filter((variant) => variant === 'normal' || variant === 'small-caps')
            .join('');
        const fontFamily = fixIOSSystemFonts(styles.fontFamily).join(', ');
        const fontSize = (0, parser_1.isDimensionToken)(styles.fontSize)
            ? `${styles.fontSize.number}${styles.fontSize.unit}`
            : `${styles.fontSize.number}px`;
        return [
            [styles.fontStyle, fontVariant, styles.fontWeight, fontSize, fontFamily].join(' '),
            fontFamily,
            fontSize
        ];
    }
    /**
     * Render text with -webkit-line-clamp truncation.
     * Groups text bounds by their Y position into visual lines, then renders
     * only the first N-1 complete lines followed by an ellipsis on the Nth line.
     */
    renderLineClampedText(text, styles, paintOrder, containerBounds) {
        const lineHeight = styles.fontSize.number * 1.5;
        const lines = [];
        let currentLine = [];
        let currentLineTop = text.textBounds[0].bounds.top;
        text.textBounds.forEach((tb) => {
            if (Math.abs(tb.bounds.top - currentLineTop) >= lineHeight * 0.5) {
                if (currentLine.length > 0)
                    lines.push(currentLine);
                currentLine = [tb];
                currentLineTop = tb.bounds.top;
            }
            else {
                currentLine.push(tb);
            }
        });
        if (currentLine.length > 0)
            lines.push(currentLine);
        const maxLines = styles.webkitLineClamp;
        if (lines.length <= maxLines)
            return false; // fall through to normal rendering
        // Render full lines (0..N-2)
        for (let i = 0; i < maxLines - 1; i++) {
            lines[i].forEach((tb) => this.renderTextBoundWithPaintOrder(tb, styles, paintOrder));
        }
        // Nth line: truncated with ellipsis
        const lastLine = lines[maxLines - 1];
        if (lastLine?.length && containerBounds) {
            const textStr = lastLine.map((tb) => tb.text).join('');
            const first = lastLine[0];
            const avail = containerBounds.width - (first.bounds.left - containerBounds.left);
            const truncated = this.truncateTextWithEllipsis(textStr, avail, styles.letterSpacing);
            const bounds = new text_1.TextBounds(truncated, first.bounds);
            for (const layer of paintOrder) {
                if (layer === 0 /* PAINT_ORDER_LAYER.FILL */) {
                    this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
                    this.renderTextWithLetterSpacing(bounds, styles.letterSpacing, styles.fontSize.number, styles.writingMode);
                }
                else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */) {
                    this.renderTextStrokeWithStyle(bounds, styles);
                }
            }
        }
        return true;
    }
    /**
     * Render single-line text with text-overflow: ellipsis.
     * Returns true if ellipsis was applied (caller should skip normal rendering).
     */
    renderEllipsisText(text, styles, paintOrder, containerBounds) {
        const lineHeight = styles.fontSize.number * 1.5;
        const firstTop = text.textBounds[0].bounds.top;
        const isSingleLine = text.textBounds.every((tb) => Math.abs(tb.bounds.top - firstTop) < lineHeight * 0.5);
        if (!isSingleLine)
            return false;
        let fullText = text.textBounds
            .map((tb) => tb.text)
            .join('')
            .replace(/\s+/g, ' ')
            .trim();
        const fullWidth = this.ctx.measureText(fullText).width;
        if (fullWidth <= containerBounds.width)
            return false;
        const truncated = this.truncateTextWithEllipsis(fullText, containerBounds.width, styles.letterSpacing);
        const bounds = new text_1.TextBounds(truncated, text.textBounds[0].bounds);
        for (const layer of paintOrder) {
            if (layer === 0 /* PAINT_ORDER_LAYER.FILL */)
                this.renderTextFillWithShadows(bounds, styles);
            else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */)
                this.renderTextStrokeWithStyle(bounds, styles);
        }
        return true;
    }
    async renderTextNode(text, styles, containerBounds) {
        // Reset glyph width cache at the start of each text node render —
        // the font may change between nodes.
        this.glyphWidthCache = null;
        this.ctx.font = this.createFontStyle(styles)[0];
        this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'alphabetic';
        const paintOrder = styles.paintOrder;
        // -webkit-line-clamp
        const clamp = styles.webkitLineClamp > 0 &&
            (styles.display & 2 /* DISPLAY.BLOCK */) !== 0 &&
            styles.overflowY === 1 /* OVERFLOW.HIDDEN */ &&
            text.textBounds.length > 0;
        if (clamp) {
            if (this.renderLineClampedText(text, styles, paintOrder, containerBounds))
                return;
        }
        // text-overflow: ellipsis (single-line only)
        const ellipsis = styles.textOverflow === 1 /* TEXT_OVERFLOW.ELLIPSIS */ &&
            containerBounds &&
            styles.overflowX === 1 /* OVERFLOW.HIDDEN */ &&
            text.textBounds.length > 0;
        if (ellipsis && this.renderEllipsisText(text, styles, paintOrder, containerBounds))
            return;
        // Normal rendering: fill + stroke + decorations per text bound
        text.textBounds.forEach((tb) => {
            paintOrder.forEach((layer) => {
                if (layer === 0 /* PAINT_ORDER_LAYER.FILL */) {
                    this.renderTextFillWithShadows(tb, styles);
                    if (styles.textDecorationLine.length)
                        this.renderTextDecoration(tb.bounds, styles);
                }
                else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */) {
                    this.renderTextStrokeWithStyle(tb, styles);
                }
            });
        });
    }
}
exports.TextRenderer = TextRenderer;
