Skip to content

Wide KaTeX formulas are clipped on the right in 2.22.0; reconcile: true fixes them but collapses simple inline fractions (regression from 2.9.0) #474

Description

@jiaopengzi

Describe it simply

Image

First of all, thank you for maintaining snapdom — it has been working great for us, and we really appreciate the recent KaTeX fixes like #454 . 🙏

While upgrading from 2.9.0 to 2.22.0, we noticed a regression when capturing KaTeX (output: html) formulas:

  • 2.22.0 (default options): wide formulas re-wrap inside the clone — the content gets clipped on the right and overflows the bottom of the canvas.
  • 2.22.0 + reconcile: true: the wide formulas recover perfectly, but a simple inline fraction (F=\frac{G M m}{r^{2}}) collapses instead — the fraction part disappears almost entirely.

So on 2.22.0 neither configuration renders all formulas correctly, while 2.9.0 renders all of them correctly with default options.

Measured results

We measured the opaque-pixel bounding box of each output and computed a "width fill" ratio
(content width / canvas width — 100% means the formula fills the canvas as expected):

formula v2.9.0 v2.22.0 (default) v2.22.0 + reconcile: true
\iiint_{\Omega}(\frac{\partial P}{\partial x}+\dots)dv=\dots (display) ~100% ✅ 88% ❌ clipped right 100% ✅
Vandermonde determinant, 4×4 array (display) 100% ✅ 69% ❌ clipped right 100% ✅
F=\frac{G M m}{r^{2}} (inline) 96% ✅ 97% ✅ 41% ❌ fraction collapses
\left[\frac{\partial(\Delta_{r} H_{m})}{\partial T}\right]_{p}=\sum_{B} v_{B} C_{p, m}(B) (display) 98% ✅ 51% ❌ clipped right 98% ✅
Newton's 2nd law, long inline with 4 \frac 99% ✅ 87% ❌ clipped right 100% ✅

In the "clipped" cases the console also shows:

[snapdom] Text in inline/table-cell elements kept its natural width and may re-wrap
under font-fallback rasterization. Pass { reconcile: true } for pixel-exact layout
(roughly doubles capture time).

which matches what we observe (the clone re-wraps: content becomes narrower and taller, then is drawn into the fixed width × height canvas, clipping the right side). Unfortunately reconcile: true introduces the opposite problem on the simple inline fraction, so we could not find a configuration of 2.22.0 that handles all formulas.

Quick demo to reproduce

Self-contained HTML below — no build step, everything from CDN. Open it in a browser and click "Run comparison". It captures each formula with 2.9.0, 2.22.0, and 2.22.0 + reconcile: true side by side, and prints the measured bounding boxes (red dashed border = canvas, so the clipping is easy to see).

Note: we use toCanvas() for the demo output; the issue is in the capture stage, so the export format doesn't matter.

repro.html (click to expand)
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>snapdom 2.9.0 vs 2.22.0 - wide KaTeX clipped</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.18.1/dist/katex.min.css" crossorigin="anonymous" />
        <style>
            body { font-family: sans-serif; padding: 20px; }
            #status { color: #06c; margin: 8px 0; white-space: pre-wrap; }
            table { border-collapse: collapse; margin-top: 12px; }
            td, th { border: 1px solid #999; padding: 6px 8px; font-size: 12px; vertical-align: top; }
            .out img { border: 1px dashed #f00; background: #fffbe6; display: block; }
            .meta { white-space: pre; color: #555; }
            .bad { color: #c00; font-weight: bold; }
            .ok { color: #080; }
        </style>
    </head>
    <body>
        <h1>snapdom: wide KaTeX formulas clipped on the right / collapsed (2.22.0)</h1>
        <p>
            Each formula is rendered by KaTeX, wrapped in an off-screen fixed container, then captured with
            <code>snapdom(wrapper, { embedFonts: true })</code><code>toCanvas({ scale: 3, width, height })</code>.<br />
            <b>v2.9.0</b>: all formulas fill the whole canvas (correct).<br />
            <b>v2.22.0 (default)</b>: wide formulas re-wrap in the clone — content is clipped on the right and
            overflows the bottom (measured width fill: gauss 88%, vandermonde 69%, kirchhoff 51%, newton2 87%).<br />
            <b>v2.22.0 + <code>reconcile: true</code></b>: wide formulas recover to 100%, but a simple inline
            fraction (<code>F=\frac{G M m}{r^{2}}</code>) collapses instead — the fraction disappears
            (width fill drops to 41%).<br />
            So neither configuration of 2.22.0 renders all KaTeX formulas correctly, while 2.9.0 does.
        </p>
        <button id="run">Run comparison</button>
        <div id="status"></div>
        <table id="result">
            <thead>
                <tr>
                    <th>formula</th>
                    <th>wrapper rect<br />(expected)</th>
                    <th>2.9.0<br />content bbox</th>
                    <th>2.22.0<br />content bbox</th>
                    <th>2.22.0 + reconcile<br />content bbox</th>
                    <th>2.9.0 image</th>
                    <th>2.22.0 image</th>
                    <th>2.22.0 + reconcile image</th>
                </tr>
            </thead>
            <tbody></tbody>
        </table>

        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.18.1/dist/katex.min.js" crossorigin="anonymous"></script>
        <script type="module">
            // 通过 CDN ESM 动态加载两个版本, 避免 UMD 全局变量互相覆盖
            const status = document.getElementById("status")
            const log = (m) => { status.textContent += m + "\n" }

            // formulas that fail in 2.22.0 (from a real blog post) / 微信实际粘贴仍失败的公式
            const formulas = [
                { name: "gauss (display)", tex: "\\iiint_{\\Omega}\\left(\\frac{\\partial P}{\\partial x}+\\frac{\\partial Q}{\\partial y}+\\frac{\\partial R}{\\partial z}\\right) d v=\\iint_{\\Sigma} P d y d z+Q d z d x+R d x d y", display: true },
                { name: "vandermonde (display)", tex: "D_{n-1}=\\left|\\begin{array}{cccc}1 & 1 & \\dots & 1 \\\\ x_{2} & x_{3} & \\dots & x_{n} \\\\ \\vdots & \\vdots & & \\vdots \\\\ x_{2}^{n-2} & x_{3}^{n-2} & \\dots & x_{n}^{n-2}\\end{array}\\right|=\\prod_{2 \\leq j<i \\leq n}\\left(x_{i}-x_{j}\\right)", display: true },
                { name: "gravity (inline)", tex: "F=\\frac{G M m}{r^{2}}", display: false },
                { name: "kirchhoff (display)", tex: "\\left[\\frac{\\partial\\left(\\Delta_{r} H_{m}\\right)}{\\partial T}\\right]_{p}=\\sum_{B} v_{B} C_{p, m}(B)", display: true },
                { name: "newton2 (inline)", tex: "\\vec{F}=\\frac{\\mathrm{d} m}{\\mathrm{d} t} \\vec{v}+m \\frac{\\mathrm{d} \\vec{v}}{\\mathrm{d} t}=\\frac{\\mathrm{d} m}{\\mathrm{d} t} \\vec{v}+m \\vec{a}=\\frac{\\mathrm{d} m}{\\mathrm{d} t} \\vec{v}+m \\frac{\\mathrm{d}^{2} \\vec{r}}{\\mathrm{d} t^{2}}", display: false },
            ]

            // off-screen wrapper identical to the app code / 与业务代码一致的离屏包裹容器
            function makeWrapper(katexEl, isDisplay) {
                const padding = isDisplay ? { top: 8, bottom: 8 } : { top: 6, bottom: 6 }
                const wrapper = document.createElement("div")
                const clone = katexEl.cloneNode(true)
                const computed = getComputedStyle(katexEl)
                for (const prop of ["font-size", "line-height", "font-family", "font-weight", "font-style", "letter-spacing", "color"]) {
                    const v = computed.getPropertyValue(prop)
                    if (v) { wrapper.style.setProperty(prop, v); clone.style.setProperty(prop, v) }
                }
                Object.assign(wrapper.style, {
                    position: "fixed", left: "-99999px", top: "0",
                    display: "inline-block", boxSizing: "content-box",
                    margin: "0", border: "0", background: "transparent",
                    overflow: "visible", pointerEvents: "none",
                })
                wrapper.style.paddingTop = padding.top + "px"
                wrapper.style.paddingBottom = padding.bottom + "px"
                clone.style.margin = "0"
                clone.style.overflow = "visible"
                wrapper.appendChild(clone)
                document.body.appendChild(wrapper)
                const rect = wrapper.getBoundingClientRect()
                return { wrapper, width: Math.max(1, Math.ceil(rect.width)), height: Math.max(1, Math.ceil(rect.height)) }
            }

            // opaque-pixel bounding box of the png / 统计 PNG 非透明像素包围盒, 用于量化裁切
            function contentBox(img) {
                const c = document.createElement("canvas")
                c.width = img.naturalWidth
                c.height = img.naturalHeight
                const g = c.getContext("2d")
                g.drawImage(img, 0, 0)
                const d = g.getImageData(0, 0, c.width, c.height).data
                let minX = c.width, minY = c.height, maxX = -1, maxY = -1
                for (let y = 0; y < c.height; y++) {
                    for (let x = 0; x < c.width; x++) {
                        if (d[(y * c.width + x) * 4 + 3] > 10) {
                            if (x < minX) minX = x
                            if (x > maxX) maxX = x
                            if (y < minY) minY = y
                            if (y > maxY) maxY = y
                        }
                    }
                }
                if (maxX < 0) return { text: "EMPTY", fillRatio: 0 }
                const fillRatio = (maxX - minX + 1) / c.width
                return { text: `x:${minX}-${maxX}/${c.width}\ny:${minY}-${maxY}/${c.height}\nwidth fill: ${(fillRatio * 100).toFixed(0)}%`, fillRatio }
            }

            async function capture(snapdomFn, katexEl, isDisplay, extraOpts) {
                const ctx = makeWrapper(katexEl, isDisplay)
                try {
                    const snap = await snapdomFn(ctx.wrapper, { embedFonts: true, ...extraOpts })
                    // 用 toCanvas 导出: 验证的是捕获阶段布局 bug, 与导出格式无关;
                    // toPng 在部分 CDN 字体环境下 img.decode() 会挂起, toCanvas 同管线且稳定.
                    const canvas = await snap.toCanvas({ scale: 3, backgroundColor: "#ffffff00", width: ctx.width, height: ctx.height })
                    const img = new Image()
                    img.src = canvas.toDataURL("image/png")
                    await new Promise((resolve) => { img.onload = resolve; img.onerror = resolve })
                    return { img, ctx }
                } finally {
                    ctx.wrapper.remove()
                }
            }

            document.getElementById("run").onclick = async () => {
                // 防止重复点击导致结果行重复
                const runBtn = document.getElementById("run")
                if (runBtn.disabled) return
                runBtn.disabled = true
                status.textContent = ""
                log("loading snapdom 2.9.0 / 2.22.0 from CDN...")
                const [{ snapdom: snap29 }, { snapdom: snap222 }] = await Promise.all([
                    import("https://cdn.jsdelivr.net/npm/@zumer/snapdom@2.9.0/dist/snapdom.mjs"),
                    import("https://cdn.jsdelivr.net/npm/@zumer/snapdom@2.22.0/dist/snapdom.mjs"),
                ])
                log("loaded. rendering & capturing...")
                await document.fonts.ready

                const variants = [
                    { tag: "v2.9.0", fn: snap29, opts: {} },
                    { tag: "v2.22.0", fn: snap222, opts: {} },
                    { tag: "v2.22.0+reconcile", fn: snap222, opts: { reconcile: true } },
                ]

                const tbody = document.querySelector("#result tbody")
                tbody.innerHTML = ""

                for (const f of formulas) {
                    const host = document.createElement("div")
                    katex.render(f.tex, host, { displayMode: f.display, throwOnError: false })
                    document.body.appendChild(host)
                    const katexEl = host.querySelector(".katex")

                    const row = document.createElement("tr")
                    const cells = { name: "<td>" + f.name + "</td>", rect: "", boxes: [], imgs: [] }

                    for (const v of variants) {
                        try {
                            const { img, ctx } = await capture(v.fn, katexEl, f.display, v.opts)
                            cells.rect = "<td>" + ctx.width + "x" + ctx.height + "</td>"
                            const box = contentBox(img)
                            const cls = box.fillRatio > 0.9 ? "ok" : "bad"
                            cells.boxes.push('<td class="meta ' + cls + '">' + box.text + "</td>")
                            img.style.width = ctx.width + "px"
                            img.style.height = ctx.height + "px"
                            const td = document.createElement("td")
                            td.className = "out"
                            td.appendChild(img)
                            cells.imgs.push(td)
                        } catch (e) {
                            cells.boxes.push('<td class="bad">ERR: ' + e.message + "</td>")
                            cells.imgs.push(document.createElement("td"))
                        }
                    }

                    row.innerHTML = cells.name + cells.rect + cells.boxes.join("")
                    for (const td of cells.imgs) row.appendChild(td)
                    tbody.appendChild(row)
                    host.remove()
                    log(f.name + " done")
                }
                log("ALL DONE")
                document.title = "ISSUE-REPRO-DONE"
                runBtn.disabled = false
            }
        </script>
    </body>
</html>

Environment

  • snapdom: 2.22.0 (regression), 2.9.0 (works)
  • KaTeX: 0.18.1, output: html
  • Browser: Chromium (Windows), also reproduced in the embedded Playwright browser
  • Capture call: snapdom(wrapper, { embedFonts: true })toCanvas({ scale: 3, width, height }), where wrapper is an off-screen position: fixed; left: -99999px container holding a clone of the .katex element

Expected behavior

All KaTeX formulas render in the output exactly as they do in the live DOM (as in 2.9.0), without needing reconcile: true, or with reconcile: true not breaking other formulas.

Happy to provide more details or test a dev build — thanks again for your work on this library!

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions