<!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>
Describe it simply
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: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):
reconcile: true\iiint_{\Omega}(\frac{\partial P}{\partial x}+\dots)dv=\dots(display)array(display)F=\frac{G M m}{r^{2}}(inline)\left[\frac{\partial(\Delta_{r} H_{m})}{\partial T}\right]_{p}=\sum_{B} v_{B} C_{p, m}(B)(display)\fracIn the "clipped" cases the console also shows:
which matches what we observe (the clone re-wraps: content becomes narrower and taller, then is drawn into the fixed
width × heightcanvas, clipping the right side). Unfortunatelyreconcile: trueintroduces 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: trueside 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)
Environment
output: htmlsnapdom(wrapper, { embedFonts: true })→toCanvas({ scale: 3, width, height }), wherewrapperis an off-screenposition: fixed; left: -99999pxcontainer holding a clone of the.katexelementExpected 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 withreconcile: truenot breaking other formulas.Happy to provide more details or test a dev build — thanks again for your work on this library!