Skip to content

Commit ce02534

Browse files
committed
feat(diagrams): render_diagram — the agent draws in the chat
Ask the agent how something is put together and it answered with boxes and arrows made of characters, which break with the font, the theme and the width of the panel. Now it has a tool. It describes the structure; the editor validates it, lays it out and paints it in the chat, in the editor's theme, with nodes that open the code they stand for. Graph JSON only: phase 1 of the spec. Mermaid, Vega-Lite and raw SVG are not built, and the rules tell the model to use a table for numbers and a numbered list for sequences rather than a format the chat would show as source. The model's side (diagram/tool.js) render_diagram its input schema is the validator's own schema object the rules when a diagram earns its place; never draw with characters; asked for a diagram, draw it here rather than writing a file of diagram source; the title states the takeaway; one accent; split above twelve nodes; link nodes to workspace files. One worked example, itself a valid spec. the answer {"ok":true,"id":"d-1"}, or every error at once About 520 tokens of tool and 450 of rules on every request from a client that can render — constant for a session, so they sit in the cached prefix. One conversation's diagrams (diagram/service.js) A call that fails validation is sent back ONCE, with every error. A second failure is drawn degraded, with a banner naming each loss and a Retry button that the user presses, not the editor. Three bounces in one run and every later call is final. A diagram still owed its repair when the run ends — the model gave up, ran out of steps, was stopped — is settled from its last attempt before agentDone, so no placeholder is left waiting. Arguments cut off at the token limit are asked for again, never repaired. In the chat (media/chat.html) A placeholder from the moment the call starts, titled as soon as the title has streamed; then the picture. The shared modules are inlined into the page by diagram/bundle.js under the page's existing nonce: the content security policy is unchanged, and the page still loads nothing. Text is measured in the real font. A column too narrow for a left-to-right flow turns it downward, and it turns back when the column widens. A full-size view with zoom and pan. The "auto-fixed" badge and its details; the degraded banner; the errors and the source when nothing can be drawn. Copy source, SVG, PNG, open as Mermaid, insert into a Markdown file. The title is the accessible name, and a text outline is there for screen readers. If painting fails, or the modules never loaded, the same diagram is shown as text — drawn by the page in the first case and by the host in the second. What the page may ask of the editor openLink, export, retry and ascii. Each names a diagram by id and is looked up in the host's own records. A click sends the NODE id; the path comes from the record and is resolved again at that moment (diagram/links.js): inside a workspace folder once ".." and symlinks are followed, and a file. SVG and PNG come back from the page, so they are checked before they are written (diagram/exportCheck.js). The spec lists two actions; its own Retry button and text fallback need the other two. Sessions and context The final record — spec, status, fixes, what was lost — is stored with the turn and replayed when a chat is reopened; nothing is repaired again. A session file is input too: a stored spec passes the validator on its way back in, in the host and in the page. At compaction a diagram becomes one line ("diagram: <title>, 4 nodes, id d-17"), and get_diagram is offered from then on. A session export writes each diagram as a fenced Mermaid block. Who gets it A run carries client.render: rich or ascii. Rich is the chat webview, unless levelcode.ai.diagrams.enabled is off or the model's catalog row says diagrams: false — the switch for a model that fails the eval. An ascii client is offered neither the tool nor the rules. Chat mode has no tools and is unchanged. Counters (diagram/stats.js, "AI: Diagram Statistics") First-pass valid rate, fixes by rung, error classes per model, render time, tokens per diagram, and answers that drew with characters anyway. Kept in the editor's own storage and sent nowhere; enums and numbers only, never a label, a title or a path. Providers: onToolStart now carries the call's id, a new onToolInput streams its arguments, and a turn returns the raw text of arguments that did not parse. All additive. Four existing suites change. agentNoWorkspace and sessionsUi pinned the shape of the tool list in source, and now pin the new shape. authRetryCallers and sessionExpiredHost slice compactAgentMemory and resumeSession out of extension.js, and needed the names those now use. Tests: diagramAgent (22 — the real runAgent with a scripted provider), diagramSession (12), diagramLinks (9), diagramHost (27 — extension.js's own functions against a stand-in for vscode), diagramStats (8), diagramUi (11).
1 parent f35fbc1 commit ce02534

27 files changed

Lines changed: 3400 additions & 60 deletions

‎extensions/levelcode-ai/agent.js‎

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const { loadProjectRules } = require('./projectRules');
2121
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
2222
isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch } = require('./mcpConfig');
2323
const { connectAll, getServer } = require('./mcpClient');
24+
const diagramTool = require('./diagram/tool');
25+
const diagramRepair = require('./diagram/repair');
2426

2527
const SYSTEM_BASE = [
2628
"You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their",
@@ -307,6 +309,13 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) {
307309
});
308310
}
309311

312+
/**
313+
* Can the surface on the other end of this run show a diagram? (docs/RICH-DIAGRAMS.md, "Capability
314+
* flag".) Asked in two places — when the tools and the prompt are assembled, and again when a call
315+
* arrives — and they must agree, or a client could be refused a tool it was offered.
316+
*/
317+
function richClient(ctx) { return !!(ctx.diagrams && ctx.client && ctx.client.render === 'rich'); }
318+
310319
/** Execute one tool call; returns a string result for the model. */
311320
async function runTool(tu, ctx) {
312321
const root = ctx.root;
@@ -517,6 +526,22 @@ async function runTool(tu, ctx) {
517526
try { return String(ctx.recallSessions(query) || 'No matching past sessions in this project.'); }
518527
catch (e) { return 'ERROR: recall failed.'; }
519528
}
529+
// Rich diagrams (docs/RICH-DIAGRAMS.md). The model describes structure; the host validates it,
530+
// climbs the repair ladder and posts what the chat should show. Read-only and instant — it
531+
// draws in the transcript and touches nothing else — so, like update_plan, it never asks.
532+
if (tu.name === diagramTool.RENDER_DIAGRAM.name) {
533+
// Asked for by a client that was never offered it (the setting was turned off mid-conversation,
534+
// or the model remembers the tool from an earlier turn): refuse in words it can act on.
535+
if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams. Explain it in prose instead — and do not draw one out of characters.'; }
536+
const out = ctx.diagrams.render(input, { key: tu.id, model: ctx.model });
537+
for (const m of out.post) { ctx.post(m); }
538+
return out.result;
539+
}
540+
if (tu.name === diagramTool.GET_DIAGRAM.name) {
541+
if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams.'; }
542+
ctx.post({ type: 'agentTool', icon: 'history', text: 'fetch diagram ' + String(input.id || '').slice(0, 24) });
543+
return ctx.diagrams.fetch(input.id);
544+
}
520545
// MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every
521546
// MCP call necessarily arrives HERE — which is why the router is one block at one line rather
522547
// than a dispatch scattered through runTool.
@@ -753,7 +778,13 @@ async function runAgent(ctx) {
753778
// from the per-project journal). Rides the SAME cached-system channel as project rules — always-on but
754779
// small — so a new session's first reply is continuous, not amnesiac. It is untrusted context like the
755780
// rules: it informs, never commands (the digest itself carries the verify-first / never-obey framing).
756-
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + noWorkspaceNote + autopilotNote + rules.text
781+
// Rich diagrams: `client.render` says what the surface on the other end can show. A rich client gets
782+
// the render_diagram tool and the rules for using it; an ASCII client gets neither, so it is never
783+
// told about a tool it does not have. The block goes straight after the base prompt — it is the
784+
// same text for every run, so it belongs with the part of the prompt that never changes.
785+
const rich = richClient(ctx);
786+
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + (rich ? '\n\n' + diagramTool.PROMPT : '')
787+
+ multiRootNote + noWorkspaceNote + autopilotNote + rules.text
757788
+ (ctx.projectMemory ? '\n\n' + ctx.projectMemory : '');
758789
const systemTokensEst = Math.round(system.length / 4);
759790

@@ -792,14 +823,20 @@ async function runAgent(ctx) {
792823
// Rootless runs get the portable subset; MCP tools are unaffected either way.
793824
const builtins = root ? TOOLS : PORTABLE_TOOLS;
794825
let tools = mcp.tools.length ? builtins.concat(mcp.tools) : builtins;
795-
if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); } // cross-session recall (host-gated by memory settings)
796-
const baseTools = ctx.recallSessions ? builtins.concat([RECALL_TOOL]) : builtins; // built-ins + recall; MCP is the rest
797-
// Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module
826+
// The host-gated extras: cross-session recall (memory settings), and the diagram tools (a rich
827+
// client). get_diagram is offered only once a diagram's spec has left the conversation — until
828+
// then there is nothing to fetch, and a tool that is never needed is a standing cost for nothing.
829+
const extras = [];
830+
if (ctx.recallSessions) { extras.push(RECALL_TOOL); }
831+
if (rich) { extras.push(diagramTool.RENDER_DIAGRAM); if (ctx.diagramsStubbed) { extras.push(diagramTool.GET_DIAGRAM); } }
832+
if (extras.length) { tools = tools.concat(extras); }
833+
const baseTools = extras.length ? builtins.concat(extras) : builtins; // built-ins + extras; MCP is the rest
834+
// Recomputed only when MCP or a host-gated extra actually contributed tools, so the plain path keeps the module
798835
// constant and pays nothing for a feature it isn't using — but there are now TWO plain paths, and the
799836
// constant has to match the list that was actually sent. Reporting the full cost for a rootless run
800837
// was the same mistake as leaving baseTools on TOOLS, one line further down.
801838
const builtinsTokensEst = root ? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;
802-
const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;
839+
const toolsTokensEst = (mcp.tools.length || extras.length) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;
803840
// The MCP SHARE of that, reported separately so the context popover can show what these servers cost
804841
// (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the
805842
// window rather than a one-off — and until it has its own segment, that cost is invisible.
@@ -812,6 +849,9 @@ async function runAgent(ctx) {
812849
: 0;
813850

814851
const messages = ctx.messages;
852+
if (ctx.diagrams) { ctx.diagrams.beginRun(); } // nothing owed from an earlier run; repair passes reset
853+
// tool_use ids whose placeholder already carries its title (the spec streams; the title arrives early)
854+
const diagramTitled = new Set();
815855
let step = 0;
816856
let reason = 'done';
817857
let nudges = 0;
@@ -898,12 +938,26 @@ async function runAgent(ctx) {
898938
apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
899939
messages, tools: tools, signal: ctx.signal,
900940
onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); },
901-
onToolStart: (name) => {
941+
onToolStart: (name, id) => {
902942
dbg('tool.start', { name });
903943
const verb = name === 'edit_file' || name === 'write_file' ? 'preparing edit (' + name + ')…'
904944
: name === 'delete_file' ? 'deleting a file…'
905-
: name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…';
945+
: name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…'
946+
: name === diagramTool.RENDER_DIAGRAM.name ? 'drawing a diagram…' : 'running ' + name + '…';
906947
ctx.post({ type: 'agentStatus', text: verb });
948+
// A diagram's place in the answer is held from the moment the model starts writing it.
949+
if (rich && id && name === diagramTool.RENDER_DIAGRAM.name) { ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: '' }); }
950+
},
951+
// The spec streams in as JSON. Its title is near the front, so the placeholder can say what
952+
// is being drawn long before the last node arrives. Posted once per call.
953+
onToolInput: (id, name, json) => {
954+
if (!rich || !id || name !== diagramTool.RENDER_DIAGRAM.name || diagramTitled.has(id)) { return; }
955+
const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(json);
956+
if (!m) { return; }
957+
diagramTitled.add(id);
958+
let title = m[1];
959+
try { title = JSON.parse('"' + m[1] + '"'); } catch (e) { /* show it as written */ }
960+
ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: diagramRepair.cleanText(title).slice(0, 120) });
907961
},
908962
// A transient upstream 5xx (502/503/504) is retried once before it can fail the run — surface it
909963
// as a status rather than a mystery pause, and log it. Nothing has streamed yet when this fires.
@@ -975,7 +1029,22 @@ async function runAgent(ctx) {
9751029
let cancelled = false;
9761030
for (const tu of toolUses) {
9771031
if (cancelled || ctx.signal.aborted) { cancelled = true; dbg('tool.cancelled', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'Cancelled by the user.' }); continue; }
978-
if (turn.malformed && turn.malformed.has(tu.id)) { dbg('tool.malformed', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); continue; }
1032+
if (turn.malformed && turn.malformed.has(tu.id)) {
1033+
dbg('tool.malformed', { name: tu.name });
1034+
// A diagram spec is the one input worth a second look: JSON with a trailing comma or a
1035+
// comment is a spec, and the lenient parser reads it. A spec that simply STOPS is not —
1036+
// that is the model hitting its length limit, and it is re-requested, never repaired.
1037+
if (tu.name === diagramTool.RENDER_DIAGRAM.name && rich) {
1038+
const rawArgs = turn.raw && turn.raw.get(tu.id);
1039+
const parsed = (turn.stop_reason !== 'max_tokens' && typeof rawArgs === 'string') ? diagramRepair.parseLenient(rawArgs) : null;
1040+
const out = (parsed && parsed.ok) ? ctx.diagrams.render(rawArgs, { key: tu.id, model: ctx.model }) : ctx.diagrams.truncated({ key: tu.id, model: ctx.model });
1041+
for (const m of out.post) { ctx.post(m); }
1042+
results.push({ type: 'tool_result', tool_use_id: tu.id, content: out.result });
1043+
continue;
1044+
}
1045+
results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' });
1046+
continue;
1047+
}
9791048
dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) });
9801049
const out = await runTool(tu, ctx);
9811050
dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') });
@@ -995,6 +1064,7 @@ async function runAgent(ctx) {
9951064

9961065
// No tool calls this turn.
9971066
const text = turn.content.filter((c) => c.type === 'text').map((c) => c.text).join('');
1067+
if (rich && text.trim()) { ctx.diagrams.noteAnswer(text); } // "ASCII leaks": counted, never acted on
9981068
if (turn.stop_reason === 'max_tokens') {
9991069
// The turn was pure prose cut off at the token cap. Ask the model to CONTINUE exactly where it
10001070
// left off (not switch strategies) so the full answer streams out across turns instead of being
@@ -1059,6 +1129,11 @@ async function runAgent(ctx) {
10591129
}
10601130
else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; }
10611131
} finally {
1132+
// A diagram sent back for repair that never came back right still owes the user a picture:
1133+
// draw what can be drawn of it now, before the run is declared over. Never a blank placeholder.
1134+
if (ctx.diagrams) {
1135+
try { for (const m of ctx.diagrams.endRun()) { ctx.post(m); } } catch (e) { dbg('diagram.settle.error', { msg: String((e && e.message) || e) }); }
1136+
}
10621137
dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null });
10631138
// [LevelCode] Gateway runs now carry real money: costMicros = what THIS run cost, credits = the
10641139
// remaining balance — both RETAIL micro-$. BYOK runs send neither (null/0) and the bar omits them.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI · rich diagrams · the webview's copy (docs/RICH-DIAGRAMS.md, "Architecture")
3+
*
4+
* The chat renders diagrams itself — it has the real font to measure text with and the live theme
5+
* to paint with — using the SAME modules the extension host validates with. There is one copy of
6+
* that code, in this folder; this file hands it to the webview as source text, which extension.js
7+
* inlines into chat.html under the page's own script nonce.
8+
*
9+
* Inlined, not linked: every document this extension serves is self-contained (webviewCsp() allows
10+
* no remote anything), and one nonce'd inline block keeps it that way — no extra origin in the CSP,
11+
* no file to fetch, and the page behaves identically in the test harness.
12+
*
13+
* Host-only. Never shipped to the webview itself: tool.js (what the model is told), service.js
14+
* (conversation state), stats.js and this file stay on the host side.
15+
*--------------------------------------------------------------------------------------------*/
16+
// @ts-check
17+
'use strict';
18+
19+
const fs = require('fs');
20+
const path = require('path');
21+
const theme = require('./theme');
22+
23+
/** In dependency order: each module's factory reads the ones before it off `LCDiagram`. */
24+
const FILES = ['theme.js', 'schema.js', 'validate.js', 'repair.js', 'layout.js', 'scene.js', 'text.js', 'ascii.js'];
25+
26+
/**
27+
* A script block ends at the first "</script" the HTML parser sees, and "<!--" changes how the rest
28+
* is tokenised — whatever JavaScript thinks they are. None of these files contains either; if one
29+
* ever does, refuse to build the page rather than ship a script that ends early.
30+
*/
31+
const BREAKS_SCRIPT = /<\/script|<!--|<script/i;
32+
33+
let cached = null;
34+
/** The modules, concatenated, ready to sit inside one <script> element. */
35+
function webviewSource() {
36+
if (cached !== null) { return cached; }
37+
const parts = FILES.map((f) => {
38+
const src = fs.readFileSync(path.join(__dirname, f), 'utf8');
39+
if (BREAKS_SCRIPT.test(src)) { throw new Error('diagram/' + f + ' contains a sequence that would end an inline <script> block'); }
40+
return '// ---- diagram/' + f + '\n' + src;
41+
});
42+
cached = parts.join('\n;\n');
43+
return cached;
44+
}
45+
/** The stylesheet for the live view: every rule reads an editor theme token. */
46+
function webviewCss() { return theme.css(); }
47+
48+
/**
49+
* Put both into the page. chat.html carries two placeholders; a function replacer is used so no "$"
50+
* in the source is read as a replacement pattern.
51+
* @param {string} html
52+
*/
53+
function inject(html) {
54+
return String(html)
55+
.replace('/*__LCD_CSS__*/', () => webviewCss())
56+
.replace('/*__LCD_JS__*/', () => webviewSource());
57+
}
58+
59+
module.exports = { FILES, webviewSource, webviewCss, inject, BREAKS_SCRIPT };
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI · rich diagrams · what may be written to disk (docs/RICH-DIAGRAMS.md, FR-7 +
3+
* "Safe rendering")
4+
*
5+
* An exported SVG or PNG is produced in the webview — that is where the real font and the live
6+
* theme are, and it is the only way "exports match the rendered view" can be true. So the bytes
7+
* cross the webview boundary, and the host does not take them on trust: before anything is written,
8+
* an SVG must be made of nothing but the painter's own elements and attributes, and a PNG must
9+
* actually be a PNG.
10+
*
11+
* This is an allow-list over structure, the same lists the painter draws from — not a search for
12+
* known-bad strings.
13+
*
14+
* Host-only, pure.
15+
*--------------------------------------------------------------------------------------------*/
16+
// @ts-check
17+
'use strict';
18+
19+
const scene = require('./scene');
20+
21+
const MAX_SVG = 2 * 1024 * 1024;
22+
const MAX_PNG = 20 * 1024 * 1024;
23+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
24+
25+
/**
26+
* Is this an SVG the painter could have produced?
27+
* @param {any} svg
28+
* @returns {{ ok: true } | { ok: false, reason: string }}
29+
*/
30+
function checkSvg(svg) {
31+
if (typeof svg !== 'string') { return { ok: false, reason: 'not text' }; }
32+
if (svg.length > MAX_SVG) { return { ok: false, reason: 'too large' }; }
33+
const src = svg.trim();
34+
if (!/^<svg[\s>]/.test(src) || !/<\/svg>$/.test(src)) { return { ok: false, reason: 'not an SVG document' }; }
35+
// One stylesheet at most, in CDATA, and it may not reach outside the file.
36+
const styles = src.match(/<style>[\s\S]*?<\/style>/g) || [];
37+
if (styles.length > 1) { return { ok: false, reason: 'more than one stylesheet' }; }
38+
for (const st of styles) {
39+
const css = st.replace(/^<style><!\[CDATA\[/, '').replace(/\]\]><\/style>$/, '');
40+
if (css === st || /<|\]\]>|@import|url\s*\(|expression\s*\(|javascript:|\\/i.test(css)) { return { ok: false, reason: 'stylesheet is not the painter\'s' }; }
41+
}
42+
const body = src.replace(/<style>[\s\S]*?<\/style>/g, '<style/>');
43+
if (/<!|<\?|\]\]>/.test(body)) { return { ok: false, reason: 'declarations and comments are not allowed' }; }
44+
const stack = [];
45+
for (const m of body.matchAll(/<[^>]*>/g)) {
46+
const tag = m[0];
47+
const end = /^<\/([A-Za-z][\w:-]*)>$/.exec(tag);
48+
if (end) { if (stack.pop() !== end[1]) { return { ok: false, reason: 'tags do not nest' }; } continue; }
49+
const start = /^<([A-Za-z][\w:-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/.exec(tag);
50+
if (!start) { return { ok: false, reason: 'malformed tag' }; }
51+
if (!scene.TAGS.has(start[1])) { return { ok: false, reason: 'element <' + start[1] + '> is not allowed' }; }
52+
for (const a of (start[2].match(/[\w:-]+(?==")/g) || [])) { if (!scene.ATTRS.has(a)) { return { ok: false, reason: 'attribute "' + a + '" is not allowed' }; } }
53+
if (!start[3]) { stack.push(start[1]); }
54+
}
55+
if (stack.length) { return { ok: false, reason: 'unclosed element' }; }
56+
if (/<[^>]*$/.test(body)) { return { ok: false, reason: 'unclosed tag' }; }
57+
return { ok: true };
58+
}
59+
60+
/**
61+
* Decode a base64 PNG, checking it is one.
62+
* @param {any} base64
63+
* @returns {{ ok: true, bytes: Buffer } | { ok: false, reason: string }}
64+
*/
65+
function checkPng(base64) {
66+
if (typeof base64 !== 'string' || !base64) { return { ok: false, reason: 'no image data' }; }
67+
const b64 = base64.replace(/^data:image\/png;base64,/, '');
68+
if (b64.length > MAX_PNG * 1.4) { return { ok: false, reason: 'too large' }; }
69+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) { return { ok: false, reason: 'not base64' }; }
70+
const bytes = Buffer.from(b64, 'base64');
71+
if (bytes.length > MAX_PNG) { return { ok: false, reason: 'too large' }; }
72+
if (bytes.length < 60 || PNG_MAGIC.some((v, i) => bytes[i] !== v)) { return { ok: false, reason: 'not a PNG image' }; }
73+
return { ok: true, bytes };
74+
}
75+
76+
/** A file name for a diagram, from its title: lowercase words joined by hyphens, never empty. */
77+
function fileStem(title) {
78+
return String(title == null ? '' : title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '') || 'diagram';
79+
}
80+
81+
module.exports = { checkSvg, checkPng, fileStem, MAX_SVG, MAX_PNG };

0 commit comments

Comments
 (0)