| 1 | // Recognition helpers for model-authored SVG. They are deliberately shallow: |
| 2 | // they decide whether a fenced block is worth handing to the host, never |
| 3 | // whether the markup is safe. The host's sanitizer owns that verdict. |
| 4 | |
| 5 | /** |
| 6 | * True when the text *starts* like a single SVG document. A body that only |
| 7 | * looks like one — mixed HTML, several roots, a truncated fragment — passes |
| 8 | * here and is still refused by the host's strict parse, which is why this is |
| 9 | * used to skip work rather than to authorize a preview. |
| 10 | */ |
| 11 | export function looksLikeSvgDocument(value: string): boolean { |
| 12 | const head = value.replace(/^/, "").trimStart(); |
| 13 | let offset = 0; |
| 14 | const skipWhitespace = () => { |
| 15 | while (offset < head.length && /\s/u.test(head[offset] ?? "")) offset += 1; |
| 16 | }; |
| 17 | if (head.slice(0, 5).toLowerCase() === "<?xml") { |
| 18 | const end = head.indexOf("?>", 5); |
| 19 | if (end < 0) return false; |
| 20 | offset = end + 2; |
| 21 | skipWhitespace(); |
| 22 | } |
| 23 | while (head.startsWith("<!--", offset)) { |
| 24 | const end = head.indexOf("-->", offset + 4); |
| 25 | if (end < 0) return false; |
| 26 | offset = end + 3; |
| 27 | skipWhitespace(); |
| 28 | } |
| 29 | return head.slice(offset, offset + 4).toLowerCase() === "<svg" |
| 30 | && /[\s/>]/u.test(head[offset + 4] ?? ""); |
| 31 | } |
| 32 | |
| 33 | /** The picture's own ratio, so the block reserves space before the image loads. */ |
| 34 | export function svgAspectRatio(svg: string): number | undefined { |
| 35 | const root = /<svg[^>]*>/i.exec(svg)?.[0]; |
| 36 | if (!root) return undefined; |
| 37 | const viewBox = /viewBox\s*=\s*"([^"]*)"/i.exec(root)?.[1]?.trim().split(/[\s,]+/).map(Number); |
| 38 | if (viewBox && viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) { |
| 39 | return viewBox[2] / viewBox[3]; |
| 40 | } |
| 41 | const width = unitless(/width\s*=\s*"([^"]*)"/i.exec(root)?.[1]); |
| 42 | const height = unitless(/height\s*=\s*"([^"]*)"/i.exec(root)?.[1]); |
| 43 | return width && height ? width / height : undefined; |
| 44 | } |
| 45 | |
| 46 | function unitless(raw?: string): number | undefined { |
| 47 | if (!raw) return undefined; |
| 48 | const match = /^\s*([0-9]*\.?[0-9]+)\s*(?:px)?\s*$/i.exec(raw); |
| 49 | const value = match ? Number(match[1]) : NaN; |
| 50 | return Number.isFinite(value) && value > 0 ? value : undefined; |
| 51 | } |
| 52 |