@documonster/mcp 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +170 -51
  2. package/dist/capabilities.d.ts.map +1 -1
  3. package/dist/capabilities.js +28 -0
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +10 -2
  8. package/dist/config.js.map +1 -1
  9. package/dist/tools/diagram-inspect.d.ts +20 -0
  10. package/dist/tools/diagram-inspect.d.ts.map +1 -0
  11. package/dist/tools/diagram-inspect.js +91 -0
  12. package/dist/tools/diagram-inspect.js.map +1 -0
  13. package/dist/tools/diagram-markdown.d.ts +45 -0
  14. package/dist/tools/diagram-markdown.d.ts.map +1 -0
  15. package/dist/tools/diagram-markdown.js +127 -0
  16. package/dist/tools/diagram-markdown.js.map +1 -0
  17. package/dist/tools/diagram-render.d.ts +14 -0
  18. package/dist/tools/diagram-render.d.ts.map +1 -0
  19. package/dist/tools/diagram-render.js +117 -0
  20. package/dist/tools/diagram-render.js.map +1 -0
  21. package/dist/tools/diagram.d.ts +217 -0
  22. package/dist/tools/diagram.d.ts.map +1 -0
  23. package/dist/tools/diagram.js +760 -0
  24. package/dist/tools/diagram.js.map +1 -0
  25. package/dist/tools/doc-convert.d.ts.map +1 -1
  26. package/dist/tools/doc-convert.js +26 -4
  27. package/dist/tools/doc-convert.js.map +1 -1
  28. package/dist/tools/doc-read.d.ts.map +1 -1
  29. package/dist/tools/doc-read.js +32 -4
  30. package/dist/tools/doc-read.js.map +1 -1
  31. package/dist/tools/doc-write.d.ts.map +1 -1
  32. package/dist/tools/doc-write.js +21 -3
  33. package/dist/tools/doc-write.js.map +1 -1
  34. package/dist/tools/document.d.ts +1 -1
  35. package/dist/tools/document.d.ts.map +1 -1
  36. package/dist/tools/document.js +13 -1
  37. package/dist/tools/document.js.map +1 -1
  38. package/dist/tools/help.d.ts +263 -15
  39. package/dist/tools/help.d.ts.map +1 -1
  40. package/dist/tools/help.js +263 -15
  41. package/dist/tools/help.js.map +1 -1
  42. package/dist/tools/image.d.ts +147 -0
  43. package/dist/tools/image.d.ts.map +1 -0
  44. package/dist/tools/image.js +749 -0
  45. package/dist/tools/image.js.map +1 -0
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +4 -0
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/inspect.d.ts.map +1 -1
  50. package/dist/tools/inspect.js +98 -6
  51. package/dist/tools/inspect.js.map +1 -1
  52. package/dist/tools/pdf-edit.d.ts +13 -6
  53. package/dist/tools/pdf-edit.d.ts.map +1 -1
  54. package/dist/tools/pdf-edit.js +93 -8
  55. package/dist/tools/pdf-edit.js.map +1 -1
  56. package/dist/tools/sheet-edit.d.ts.map +1 -1
  57. package/dist/tools/sheet-edit.js +37 -2
  58. package/dist/tools/sheet-edit.js.map +1 -1
  59. package/dist/tools/sheet-image.d.ts +58 -0
  60. package/dist/tools/sheet-image.d.ts.map +1 -0
  61. package/dist/tools/sheet-image.js +156 -0
  62. package/dist/tools/sheet-image.js.map +1 -0
  63. package/dist/tools/sheet-read.d.ts.map +1 -1
  64. package/dist/tools/sheet-read.js +18 -1
  65. package/dist/tools/sheet-read.js.map +1 -1
  66. package/dist/tools/sheet-write.d.ts.map +1 -1
  67. package/dist/tools/sheet-write.js +41 -4
  68. package/dist/tools/sheet-write.js.map +1 -1
  69. package/dist/tools/template.d.ts +19 -0
  70. package/dist/tools/template.d.ts.map +1 -1
  71. package/dist/tools/template.js +436 -11
  72. package/dist/tools/template.js.map +1 -1
  73. package/package.json +3 -3
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Mermaid diagram helpers shared by `diagram_render`, `diagram_inspect` and the
3
+ * Markdown fence rendering in `doc_write` / `doc_convert`.
4
+ *
5
+ * Three things live here that no tool should re-derive:
6
+ *
7
+ * 1. **Where the source comes from.** A model supplies a diagram inline, or names
8
+ * a `.mmd` file, or names a Markdown file with several ` ```mermaid ` fences in
9
+ * it. All three resolve to the same thing, and fence positions are needed again
10
+ * later to splice rendered images back into the Markdown.
11
+ * 2. **What "render" means per format.** The diagram is converted to a display
12
+ * list *once*; SVG, PNG and PDF are three readings of that one list. Doing it
13
+ * any other way is how three backends come to disagree about one picture.
14
+ * 3. **What the parser actually saw.** A model cannot look at the output, so the
15
+ * only way it can verify a diagram is a structural read-back. That is
16
+ * {@link describeDiagram}, and it is the reason `diagram_inspect` exists.
17
+ *
18
+ * Theme presets are deliberately *this package's* own, not a claim of parity with
19
+ * Mermaid's named themes: only `default` is token-for-token Mermaid (the library
20
+ * reproduces Mermaid's `base` exactly). `themeOverrides` exposes every colour
21
+ * token, so nothing is reachable through the library that is not reachable here.
22
+ */
23
+ import { readFile } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { encodePng } from "documonster/archive";
26
+ import { cssColour, rasterizeToRgba, renderDrawList, toSvg } from "documonster/draw";
27
+ import { MermaidSyntaxError, mermaidToDrawList, parseMermaid } from "documonster/mermaid";
28
+ import { Pdf, createPdfDrawSurface } from "documonster/pdf";
29
+ import { z } from "zod";
30
+ import { toolError } from "../errors.js";
31
+ import { resolveInRoot } from "../sandbox.js";
32
+ import { assertReadableSize } from "./fs-helpers.js";
33
+ import { escapeTableCell } from "./result.js";
34
+ const DIAGRAM_EXTENSIONS = {
35
+ ".svg": "svg",
36
+ ".png": "png",
37
+ ".pdf": "pdf"
38
+ };
39
+ /** Extensions whose whole content is one diagram. */
40
+ const MERMAID_EXTENSIONS = [".mmd", ".mermaid"];
41
+ /** Extensions whose content is Markdown that may contain mermaid fences. */
42
+ const MARKDOWN_EXTENSIONS = [".md", ".markdown"];
43
+ /**
44
+ * Ceiling on a raster diagram's pixel count.
45
+ *
46
+ * The rasteriser has its own limit and throws a plain `Error`; this one exists so
47
+ * the refusal arrives as a `too_large` tool error with a hint, before any work is
48
+ * done, rather than as an `internal` after it.
49
+ */
50
+ const MAX_RASTER_PIXELS = 40_000_000;
51
+ /** EMU per PDF point — a display list's unit is a point, as the PDF backend proves. */
52
+ export const EMU_PER_POINT = 12700;
53
+ /** Format a diagram output path denotes, or a tool error naming what is possible. */
54
+ export function requireDiagramFormat(filePath, field) {
55
+ const format = DIAGRAM_EXTENSIONS[path.extname(filePath).toLowerCase()];
56
+ if (format === undefined) {
57
+ throw toolError.invalidInput(`cannot tell the diagram format of ${field} from its extension: ${JSON.stringify(filePath)}`, "Use .svg (crisp, editable, smallest), .png (pastes anywhere) or .pdf (one page sized to the diagram).");
58
+ }
59
+ return format;
60
+ }
61
+ export const THEME_PRESETS = {
62
+ default: {},
63
+ dark: {
64
+ background: "#1e222a",
65
+ nodeFill: "#3b4252",
66
+ nodeStroke: "#88c0d0",
67
+ nodeText: "#eceff4",
68
+ edge: "#d8dee9",
69
+ edgeText: "#eceff4",
70
+ edgeLabelBackground: "#434c5e",
71
+ groupFill: "#2e3440",
72
+ groupStroke: "#5e81ac",
73
+ title: "#eceff4",
74
+ paletteText: "#2e3440",
75
+ palette: [
76
+ "#88c0d0",
77
+ "#bf616a",
78
+ "#a3be8c",
79
+ "#ebcb8b",
80
+ "#b48ead",
81
+ "#d08770",
82
+ "#8fbcbb",
83
+ "#81a1c1",
84
+ "#e5e9f0",
85
+ "#5e81ac",
86
+ "#4c566a",
87
+ "#d8dee9"
88
+ ]
89
+ },
90
+ neutral: {
91
+ background: "#ffffff",
92
+ nodeFill: "#eeeeee",
93
+ nodeStroke: "#999999",
94
+ nodeText: "#111111",
95
+ edge: "#555555",
96
+ edgeText: "#111111",
97
+ edgeLabelBackground: "#f4f4f4",
98
+ groupFill: "#fafafa",
99
+ groupStroke: "#cccccc",
100
+ title: "#111111",
101
+ paletteText: "#ffffff",
102
+ palette: [
103
+ "#555555",
104
+ "#777777",
105
+ "#999999",
106
+ "#bbbbbb",
107
+ "#444444",
108
+ "#666666",
109
+ "#888888",
110
+ "#aaaaaa",
111
+ "#333333",
112
+ "#5f5f5f",
113
+ "#7f7f7f",
114
+ "#9f9f9f"
115
+ ]
116
+ }
117
+ };
118
+ const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS);
119
+ /** Colour token names a caller may override individually. */
120
+ const themeOverridesSchema = z
121
+ .object({
122
+ background: z.string().optional(),
123
+ nodeFill: z.string().optional(),
124
+ nodeStroke: z.string().optional(),
125
+ nodeText: z.string().optional(),
126
+ edge: z.string().optional(),
127
+ edgeText: z.string().optional(),
128
+ edgeLabelBackground: z.string().optional(),
129
+ groupFill: z.string().optional(),
130
+ groupStroke: z.string().optional(),
131
+ title: z.string().optional(),
132
+ paletteText: z.string().optional(),
133
+ palette: z.array(z.string()).min(1).max(24).optional()
134
+ })
135
+ .describe("Individual colour overrides applied on top of `theme`. CSS colours. `palette` colours the slices/series of pie, xychart, radar, sankey, journey and quadrant diagrams.");
136
+ /**
137
+ * The rendering fields both the render tool and the fence renderer accept.
138
+ *
139
+ * Exported as a raw shape so `diagram_render` can spread it into its own schema
140
+ * without the two drifting apart.
141
+ */
142
+ export const diagramStyleShape = {
143
+ theme: z
144
+ .enum(THEME_PRESET_NAMES)
145
+ .optional()
146
+ .describe("Colour set. `default` reproduces Mermaid's own base theme; `dark` and `neutral` are this server's, not Mermaid's named themes. Defaults to `default`."),
147
+ themeOverrides: themeOverridesSchema.optional(),
148
+ background: z
149
+ .string()
150
+ .optional()
151
+ .describe('Page background as a CSS colour, or "transparent". Defaults to white — a transparent PNG is unreadable in a dark viewer, which is a failure the model cannot see.'),
152
+ fontSize: z.number().min(6).max(72).optional().describe("Label font size. Defaults to 14."),
153
+ fontFamily: z.string().optional().describe('Label font family. Defaults to "Arial".'),
154
+ rankGap: z
155
+ .number()
156
+ .min(0)
157
+ .max(400)
158
+ .optional()
159
+ .describe("Graph diagrams: gap between ranks, along the flow. Defaults to 56."),
160
+ nodeGap: z
161
+ .number()
162
+ .min(0)
163
+ .max(400)
164
+ .optional()
165
+ .describe("Graph diagrams: gap between siblings within a rank. Defaults to 34."),
166
+ maxLabelWidth: z
167
+ .number()
168
+ .min(20)
169
+ .max(2000)
170
+ .optional()
171
+ .describe("Longest label line before it wraps. Defaults to 220."),
172
+ padding: z
173
+ .number()
174
+ .min(0)
175
+ .max(200)
176
+ .optional()
177
+ .describe("Margin between the drawing and the edge of the image. Defaults to 16.")
178
+ };
179
+ /**
180
+ * Turn tool arguments into library render options.
181
+ *
182
+ * The background is resolved here rather than left to the library because the
183
+ * library's default is `"transparent"` — right for a caller compositing the list
184
+ * onto something else, wrong for a file a human opens.
185
+ */
186
+ export function toRenderOptions(args) {
187
+ const preset = THEME_PRESETS[args.theme ?? "default"];
188
+ // Every override is validated, not just the background. `cssColour` falls back to
189
+ // black for anything it cannot read, so a misspelled `nodeFill` produced a diagram
190
+ // with black boxes — and since nothing can look at the picture, the caller's only
191
+ // clue was that it silently stopped matching the theme it asked for.
192
+ const overrides = requireColours(args.themeOverrides ?? {});
193
+ const background = requireColour(args.background ?? overrides.background ?? preset.background ?? "#ffffff", "background");
194
+ const theme = { ...preset, ...overrides, background };
195
+ return {
196
+ theme,
197
+ background,
198
+ ...(args.fontSize === undefined ? {} : { fontSize: args.fontSize }),
199
+ ...(args.fontFamily === undefined ? {} : { fontFamily: args.fontFamily }),
200
+ ...(args.rankGap === undefined ? {} : { rankGap: args.rankGap }),
201
+ ...(args.nodeGap === undefined ? {} : { nodeGap: args.nodeGap }),
202
+ ...(args.maxLabelWidth === undefined ? {} : { maxLabelWidth: args.maxLabelWidth }),
203
+ ...(args.padding === undefined ? {} : { padding: args.padding })
204
+ };
205
+ }
206
+ /** Validate every colour in a set of overrides, including the palette. */
207
+ function requireColours(overrides) {
208
+ const checked = {};
209
+ for (const [key, value] of Object.entries(overrides)) {
210
+ if (value === undefined) {
211
+ continue;
212
+ }
213
+ checked[key] =
214
+ key === "palette"
215
+ ? value.map((entry, index) => requireColour(entry, `themeOverrides.palette[${index}]`))
216
+ : requireColour(value, `themeOverrides.${key}`);
217
+ }
218
+ return checked;
219
+ }
220
+ /**
221
+ * Reject a colour token the renderer cannot read.
222
+ *
223
+ * `cssColour` falls back to black for anything it fails to parse, so a misspelled
224
+ * `background` would silently produce a black rectangle — an outcome a model has
225
+ * no way to detect, since it never sees the image. Detecting the fallback needs
226
+ * the "was it actually black?" question asked separately, which is what the
227
+ * pattern below is for.
228
+ */
229
+ function requireColour(token, field) {
230
+ if (token === "transparent") {
231
+ return token;
232
+ }
233
+ const normalised = token.trim().toLowerCase();
234
+ const parsed = cssColour(normalised);
235
+ const isBlack = parsed.r === 0 && parsed.g === 0 && parsed.b === 0 && parsed.a === 1;
236
+ const spelledBlack = normalised === "black" ||
237
+ /^#0{3,8}$/.test(normalised) ||
238
+ /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*(,\s*1(\.0+)?\s*)?\)$/.test(normalised);
239
+ if (isBlack && !spelledBlack) {
240
+ throw toolError.invalidInput(`${field} is not a colour the renderer can read: ${JSON.stringify(token)}`, 'Use a hex value like "#ffffff", an rgb()/rgba() function, a CSS colour name, or "transparent".');
241
+ }
242
+ return normalised;
243
+ }
244
+ /**
245
+ * Find every mermaid fence in a Markdown document.
246
+ *
247
+ * Scanned line by line rather than with one regular expression because a fence
248
+ * may be opened with backticks or tildes, of any length from three up, and must
249
+ * be closed by at least as many of the same character — a rule a single pattern
250
+ * expresses badly and an unterminated fence at the end of a file breaks outright.
251
+ * An unterminated fence is treated as running to the end of the document, which
252
+ * is what every Markdown renderer does.
253
+ */
254
+ export function findMermaidFences(markdown) {
255
+ const fences = [];
256
+ const lines = markdown.split("\n");
257
+ // Offset of the start of each line, so a fence can report its own span. Computed
258
+ // from the raw line lengths, so a `\r` retained by the split is counted and the
259
+ // offsets stay valid against the original string.
260
+ const offsets = [];
261
+ let cursor = 0;
262
+ for (const line of lines) {
263
+ offsets.push(cursor);
264
+ cursor += line.length + 1;
265
+ }
266
+ let index = 0;
267
+ while (index < lines.length) {
268
+ const opening = OPENING_FENCE.exec(lines[index] ?? "");
269
+ if (opening === null) {
270
+ index += 1;
271
+ continue;
272
+ }
273
+ const marker = opening[1] ?? "";
274
+ const info = (opening[2] ?? "").trim().toLowerCase();
275
+ const closer = closingFence(marker);
276
+ let end = index + 1;
277
+ while (end < lines.length && !closer.test(lines[end] ?? "")) {
278
+ end += 1;
279
+ }
280
+ // Any fence at all opens a block, and only its own closer ends one. A
281
+ // ```mermaid inside a ````markdown block is therefore *content* — an example of
282
+ // a diagram, not a diagram. Treating it as real rendered a picture nobody asked
283
+ // for, left the substituted reference sitting inside a code block where nothing
284
+ // consumed it, and still reported the diagram as embedded.
285
+ if (info === "mermaid") {
286
+ const start = offsets[index] ?? 0;
287
+ // An unterminated fence runs to the end of the document, as every Markdown
288
+ // renderer does; a terminated one ends past its closing line.
289
+ const afterClosing = end < lines.length
290
+ ? Math.min(markdown.length, (offsets[end] ?? 0) + (lines[end] ?? "").length + 1)
291
+ : markdown.length;
292
+ fences.push({
293
+ ordinal: fences.length + 1,
294
+ line: index + 1,
295
+ // `\r` is stripped from the diagram source: the Mermaid parser matches its
296
+ // keywords against whole lines, and a trailing carriage return makes every
297
+ // one of them fail.
298
+ source: lines
299
+ .slice(index + 1, end)
300
+ .map(line => line.replace(/\r$/, ""))
301
+ .join("\n"),
302
+ start,
303
+ end: afterClosing
304
+ });
305
+ }
306
+ index = end + 1;
307
+ }
308
+ return fences;
309
+ }
310
+ /**
311
+ * A fence opener: three or more backticks or tildes, then an optional info string.
312
+ *
313
+ * The trailing `\r?` is what makes a CRLF document work at all. `split("\n")` leaves
314
+ * the carriage return on every line, and a pattern anchored with `$` after
315
+ * `[ \t]*` matched none of them — so a Windows-authored Markdown file reported zero
316
+ * mermaid fences, and `doc_write` silently embedded the diagram as a code block.
317
+ */
318
+ const OPENING_FENCE = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*([^\r\n]*?)[ \t]*\r?$/;
319
+ /** The matching closer: at least as many of the same character, and nothing else. */
320
+ function closingFence(marker) {
321
+ return new RegExp(`^[ \\t]{0,3}${marker[0] === "~" ? "~" : "`"}{${marker.length},}[ \\t]*\\r?$`);
322
+ }
323
+ /**
324
+ * Strip a fence a model wrapped its own answer in.
325
+ *
326
+ * A model asked for "mermaid source" very often produces a fenced block, because
327
+ * that is how it has seen mermaid written everywhere. Passing that through to the
328
+ * parser fails on the first line with "unsupported diagram type '```mermaid'",
329
+ * which is a confusing report of the model's own formatting habit rather than of
330
+ * anything wrong with the diagram.
331
+ */
332
+ export function unwrapFence(source) {
333
+ const trimmed = source.trim();
334
+ const fences = findMermaidFences(trimmed);
335
+ if (fences.length === 1 && fences[0]?.start === 0 && fences[0].end >= trimmed.length) {
336
+ return fences[0].source;
337
+ }
338
+ // A bare fence with no language tag, which is the other habit.
339
+ const bare = /^(`{3,}|~{3,})[ \t]*\n([\s\S]*?)\n?\1[ \t]*$/.exec(trimmed);
340
+ return bare?.[2] ?? source;
341
+ }
342
+ /**
343
+ * Resolve the diagram text from `source` or `from`.
344
+ *
345
+ * @throws {McpToolError} `invalid_input` when neither or both were given, or when
346
+ * a Markdown file holds no mermaid fence, or `index` names one that is not there.
347
+ */
348
+ export async function resolveDiagramSource(config, args) {
349
+ const hasSource = typeof args.source === "string" && args.source.trim().length > 0;
350
+ const hasFrom = typeof args.from === "string" && args.from.trim().length > 0;
351
+ if (hasSource === hasFrom) {
352
+ throw toolError.invalidInput(hasSource
353
+ ? "pass either `source` or `from`, not both"
354
+ : "no diagram source: pass `source` with the Mermaid text, or `from` with a path", "`source` is Mermaid text you write yourself. `from` reads a .mmd file, or picks a ```mermaid fence out of a .md file.");
355
+ }
356
+ if (hasSource) {
357
+ return { source: unwrapFence(args.source), origin: "inline", fences: [] };
358
+ }
359
+ const display = args.from;
360
+ const resolved = await resolveInRoot(config, display, { mustExist: true });
361
+ await assertReadableSize(config, resolved, display);
362
+ const text = await readFile(resolved, "utf8");
363
+ const extension = path.extname(resolved).toLowerCase();
364
+ if (MERMAID_EXTENSIONS.includes(extension)) {
365
+ return { source: unwrapFence(text), origin: display, fences: [] };
366
+ }
367
+ const fences = findMermaidFences(text);
368
+ if (fences.length === 0) {
369
+ if (MARKDOWN_EXTENSIONS.includes(extension)) {
370
+ throw toolError.invalidInput(`${display} contains no \`\`\`mermaid fence`, "Add one, or pass the diagram directly as `source`.");
371
+ }
372
+ // Not Markdown and not .mmd: treat the whole file as one diagram rather than
373
+ // refusing over an extension, since the parser will say so if it is not.
374
+ return { source: unwrapFence(text), origin: display, fences: [] };
375
+ }
376
+ const ordinal = args.index ?? 1;
377
+ const chosen = fences.find(fence => fence.ordinal === ordinal);
378
+ if (chosen === undefined) {
379
+ throw toolError.invalidInput(`${display} has ${fences.length} mermaid fence(s); there is no fence ${ordinal}`, `Pass index between 1 and ${fences.length}, or call diagram_inspect on the file to list them.`);
380
+ }
381
+ return { source: chosen.source, origin: display, fences, selected: chosen.ordinal };
382
+ }
383
+ /** Parse mermaid text, reporting a syntax error as a model-facing tool error. */
384
+ export function parseDiagram(source) {
385
+ try {
386
+ return parseMermaid(source);
387
+ }
388
+ catch (cause) {
389
+ if (cause instanceof MermaidSyntaxError) {
390
+ throw toolError.invalidInput(`the diagram could not be parsed: ${cause.message}`, DIAGRAM_HINT, {
391
+ cause
392
+ });
393
+ }
394
+ throw cause;
395
+ }
396
+ }
397
+ /** Build the display list, mapping a syntax error the same way. */
398
+ export function buildDrawList(source, options) {
399
+ try {
400
+ return mermaidToDrawList(source, options);
401
+ }
402
+ catch (cause) {
403
+ if (cause instanceof MermaidSyntaxError) {
404
+ throw toolError.invalidInput(`the diagram could not be drawn: ${cause.message}`, DIAGRAM_HINT, {
405
+ cause
406
+ });
407
+ }
408
+ throw cause;
409
+ }
410
+ }
411
+ const DIAGRAM_HINT = 'The first non-empty line names the diagram type, e.g. "flowchart TD" or "sequenceDiagram". Call documonster_help({ topic: "diagrams" }) for the list of supported types.';
412
+ /**
413
+ * Render one display list to one format.
414
+ *
415
+ * The list is built once by the caller and read here — which is the whole claim
416
+ * the drawing engine makes, tested from outside the library by this very function.
417
+ */
418
+ export async function renderDiagram(list, format, size, background) {
419
+ const width = size.width ?? list.width;
420
+ const height = size.height ?? list.height;
421
+ if (format === "svg") {
422
+ const svg = toSvg(list, {
423
+ ...(size.width === undefined ? {} : { width }),
424
+ ...(size.height === undefined ? {} : { height }),
425
+ ...(background === "transparent" ? {} : { background })
426
+ });
427
+ return { bytes: new TextEncoder().encode(svg), width, height };
428
+ }
429
+ if (format === "png") {
430
+ const scale = size.scale ?? 2;
431
+ const pixels = Math.round(width * scale) * Math.round(height * scale);
432
+ if (pixels > MAX_RASTER_PIXELS) {
433
+ throw toolError.tooLarge(`a ${Math.round(width * scale)}x${Math.round(height * scale)} PNG is ${Math.round(pixels / 1e6)}M pixels, over the ${Math.round(MAX_RASTER_PIXELS / 1e6)}M limit`, "Lower `scale`, or render SVG instead — it is resolution-independent and much smaller.");
434
+ }
435
+ const image = rasterizeToRgba(list, {
436
+ scale,
437
+ ...(size.width === undefined ? {} : { width }),
438
+ ...(size.height === undefined ? {} : { height })
439
+ });
440
+ return {
441
+ // A display list's unit is a point, so `scale` pixels per point is
442
+ // `72 * scale` dots per inch. Word, Excel and print pipelines read this
443
+ // instead of assuming 96, and getting it wrong resizes the picture.
444
+ bytes: encodePng(image.data, image.width, image.height, { dpi: Math.round(72 * scale) }),
445
+ width: image.width,
446
+ height: image.height
447
+ };
448
+ }
449
+ const builder = new Pdf.Builder();
450
+ const page = builder.addPage({ width, height });
451
+ // Letterbox: fit the list into the page uniformly, so an explicit page size
452
+ // produces a correct picture rather than a stretched one.
453
+ const fit = Math.min(width / list.width, height / list.height);
454
+ const drawnWidth = list.width * fit;
455
+ const drawnHeight = list.height * fit;
456
+ if (background !== "transparent") {
457
+ // The list's own backdrop covers the drawing only; with letterboxing that
458
+ // leaves the margin unpainted, and a PDF's unpainted area is not white.
459
+ page.drawRect({ x: 0, y: 0, width, height, fill: toPdfColour(background) });
460
+ }
461
+ renderDrawList(list, createPdfDrawSurface(page, {
462
+ x: (width - drawnWidth) / 2,
463
+ y: (height - drawnHeight) / 2,
464
+ width: drawnWidth,
465
+ height: drawnHeight
466
+ }, fit));
467
+ return { bytes: await builder.build(), width, height };
468
+ }
469
+ function toPdfColour(token) {
470
+ const colour = cssColour(token);
471
+ return colour.a >= 1
472
+ ? { r: colour.r, g: colour.g, b: colour.b }
473
+ : { r: colour.r, g: colour.g, b: colour.b, a: colour.a };
474
+ }
475
+ // ---------------------------------------------------------------------------
476
+ // Structural read-back
477
+ // ---------------------------------------------------------------------------
478
+ /** Human name for each diagram kind, and the Mermaid keyword that selects it. */
479
+ const KIND_NAMES = {
480
+ flowchart: "flowchart",
481
+ state: "stateDiagram",
482
+ class: "classDiagram",
483
+ er: "erDiagram",
484
+ sequence: "sequenceDiagram",
485
+ requirement: "requirementDiagram",
486
+ c4: "C4",
487
+ architecture: "architecture",
488
+ gantt: "gantt",
489
+ timeline: "timeline",
490
+ journey: "journey",
491
+ kanban: "kanban",
492
+ mindmap: "mindmap",
493
+ git: "gitGraph",
494
+ quadrant: "quadrantChart",
495
+ xy: "xychart",
496
+ radar: "radar",
497
+ sankey: "sankey",
498
+ packet: "packet",
499
+ block: "block",
500
+ pie: "pie"
501
+ };
502
+ /** Every diagram type the parser accepts, for help text and error hints. */
503
+ export const SUPPORTED_DIAGRAM_KEYWORDS = Object.values(KIND_NAMES);
504
+ /** How many labels a structural summary lists before it stops. */
505
+ const MAX_LISTED = 40;
506
+ function diagramDetail(diagram) {
507
+ switch (diagram.kind) {
508
+ case "flowchart":
509
+ return {
510
+ counts: `${diagram.nodes.length} node(s), ${diagram.edges.length} edge(s), ${diagram.subgraphs.length} subgraph(s)`,
511
+ extra: [
512
+ `- direction: ${diagram.direction}`,
513
+ ...list("nodes", diagram.nodes.map(node => node.text === node.id ? node.id : `${node.id} (${node.text})`)),
514
+ ...list("edges", diagram.edges.map(edge => `${edge.from} ${edge.label === undefined ? "->" : `-[${edge.label}]->`} ${edge.to}`))
515
+ ]
516
+ };
517
+ case "state":
518
+ return {
519
+ counts: `${diagram.states.length} state(s), ${diagram.transitions.length} transition(s), ${diagram.composites.length} composite(s)`,
520
+ extra: [
521
+ `- direction: ${diagram.direction}`,
522
+ ...list("states", diagram.states.map(state => (state.text === state.id ? state.id : state.text))),
523
+ ...list("transitions", diagram.transitions.map(transition => `${transition.from} ${transition.label === undefined ? "->" : `-[${transition.label}]->`} ${transition.to}`))
524
+ ]
525
+ };
526
+ case "class":
527
+ return {
528
+ counts: `${diagram.classes.length} class(es), ${diagram.links.length} relation(s)`,
529
+ extra: [
530
+ ...list("classes", diagram.classes.map(box => `${box.name} (${box.members.length} member(s))`)),
531
+ // Which classes relate, and how: inheritance drawn as composition is a
532
+ // wrong diagram, and the count cannot show it.
533
+ ...list("relations", diagram.links.map(link => `${link.from} ${link.relation} ${link.to}`))
534
+ ]
535
+ };
536
+ case "er":
537
+ return {
538
+ counts: `${diagram.entities.length} entit(y/ies), ${diagram.relations.length} relation(s)`,
539
+ extra: [
540
+ ...list("entities", diagram.entities.map(entity => `${entity.name} (${entity.attributes.length} attribute(s))`)),
541
+ ...list("relations", diagram.relations.map(relation => `${relation.from} ${relation.fromCardinality}–${relation.toCardinality} ${relation.to}${relation.label === undefined ? "" : ` (${relation.label})`}`))
542
+ ]
543
+ };
544
+ case "sequence":
545
+ return {
546
+ counts: `${diagram.participants.length} participant(s), ${diagram.messages.length} message(s)`,
547
+ extra: [
548
+ `- autonumber: ${diagram.autonumber}`,
549
+ ...list("participants", diagram.participants.map(participant => participant.text)),
550
+ // The messages *are* the diagram. A count cannot distinguish two
551
+ // sequences that say opposite things, which defeats a read-back.
552
+ ...list("messages", diagram.messages.map(message => `${message.from} → ${message.to}: ${message.text}`))
553
+ ]
554
+ };
555
+ case "requirement":
556
+ return {
557
+ counts: `${diagram.requirements.length} requirement(s), ${diagram.elements.length} element(s), ${diagram.links.length} link(s)`,
558
+ extra: [
559
+ ...list("requirements", diagram.requirements.map(requirement => requirement.name)),
560
+ ...list("links", diagram.links.map(link => `${link.from} ${link.verb} ${link.to}`))
561
+ ]
562
+ };
563
+ case "c4":
564
+ return {
565
+ counts: `${diagram.elements.length} element(s), ${diagram.boundaries.length} boundar(y/ies), ${diagram.relations.length} relation(s)`,
566
+ extra: [
567
+ ...list("elements", diagram.elements.map(element => element.label)),
568
+ ...list("relations", diagram.relations.map(relation => `${relation.from} → ${relation.to}${relation.label === undefined ? "" : `: ${relation.label}`}`))
569
+ ]
570
+ };
571
+ case "architecture":
572
+ return {
573
+ counts: `${diagram.nodes.length} node(s), ${diagram.edges.length} edge(s)`,
574
+ extra: [
575
+ ...list("nodes", diagram.nodes.map(node => (node.isGroup ? `${node.label} (group)` : node.label))),
576
+ ...list("edges", diagram.edges.map(edge => `${edge.from} → ${edge.to}`))
577
+ ]
578
+ };
579
+ case "gantt":
580
+ return {
581
+ counts: `${diagram.tasks.length} task(s) in ${diagram.sections.length} section(s)`,
582
+ extra: list("tasks", diagram.tasks.map(task =>
583
+ // Dates, not the epoch milliseconds they are stored as: a bar in the
584
+ // wrong place is not an ugly chart, it is a wrong one, and this is the
585
+ // only way the model can check the date arithmetic it asked for.
586
+ `${task.label} ${isoDay(task.start)}→${isoDay(task.end)}${task.milestone ? " (milestone)" : ""}`))
587
+ };
588
+ case "timeline":
589
+ return {
590
+ counts: `${diagram.periods.length} period(s) in ${diagram.sections.length} section(s)`,
591
+ extra: list("periods", diagram.periods.map(period => `${period.label} (${period.events.length} event(s))`))
592
+ };
593
+ case "journey":
594
+ return {
595
+ counts: `${diagram.tasks.length} task(s) in ${diagram.sections.length} section(s)`,
596
+ extra: list("tasks", diagram.tasks.map(task => `${task.label} — score ${task.score}`))
597
+ };
598
+ case "kanban":
599
+ return {
600
+ counts: `${diagram.columns.length} column(s), ${diagram.columns.reduce((sum, column) => sum + column.cards.length, 0)} card(s)`,
601
+ extra: list("columns", diagram.columns.map(column => `${column.title} (${column.cards.length})`))
602
+ };
603
+ case "mindmap":
604
+ return {
605
+ counts: diagram.root === undefined
606
+ ? "**empty**: no root node was recognised"
607
+ : `${countMindNodes(diagram.root)} node(s)`,
608
+ extra: diagram.root === undefined
609
+ ? []
610
+ : [
611
+ `- root: ${JSON.stringify(diagram.root.text)}`,
612
+ ...list("nodes", mindNodeLabels(diagram.root).slice(1))
613
+ ]
614
+ };
615
+ case "git":
616
+ return {
617
+ counts: `${diagram.commits.length} commit(s) on ${diagram.branches.length} branch(es)`,
618
+ extra: [
619
+ ...list("branches", diagram.branches),
620
+ ...list("commits", diagram.commits.map(commit => `${commit.kind === "merge" ? "merge " : ""}${commit.id}@${commit.branch}${commit.tag === undefined ? "" : ` (${commit.tag})`}`))
621
+ ]
622
+ };
623
+ case "quadrant":
624
+ return {
625
+ counts: `${diagram.points.length} point(s)`,
626
+ extra: [
627
+ `- quadrants: ${diagram.quadrants.map(label => JSON.stringify(label)).join(", ")}`,
628
+ ...list("points", diagram.points.map(point => `${point.label} (${point.x}, ${point.y})`))
629
+ ]
630
+ };
631
+ case "xy":
632
+ return {
633
+ counts: `${diagram.series.length} series over ${diagram.categories.length} categor(y/ies)`,
634
+ extra: [
635
+ `- orientation: ${diagram.horizontal ? "horizontal" : "vertical"}`,
636
+ ...list("series", diagram.series.map(series => `${series.type} (${series.values.length} value(s))`))
637
+ ]
638
+ };
639
+ case "radar":
640
+ return {
641
+ counts: `${diagram.series.length} series over ${diagram.axes.length} ax(is/es)`,
642
+ extra: list("axes", diagram.axes)
643
+ };
644
+ case "sankey":
645
+ return {
646
+ counts: `${diagram.links.length} link(s)`,
647
+ extra: list("links", diagram.links.map(link => `${link.from} → ${link.to} (${link.value})`))
648
+ };
649
+ case "packet":
650
+ return {
651
+ counts: `${diagram.fields.length} field(s), ${diagram.bitsPerRow} bits per row`,
652
+ extra: list("fields", diagram.fields.map(field => `${field.start}-${field.end}: ${field.label}`))
653
+ };
654
+ case "block":
655
+ return {
656
+ counts: `${diagram.cells.length} cell(s) in ${diagram.columns} column(s), ${diagram.edges.length} edge(s)`,
657
+ extra: [
658
+ ...list("cells", diagram.cells.filter(cell => !cell.spacer).map(cell => cell.label)),
659
+ ...list("edges", diagram.edges.map(edge => `${edge.from} → ${edge.to}`))
660
+ ]
661
+ };
662
+ case "pie":
663
+ return {
664
+ counts: `${diagram.slices.length} slice(s), values ${diagram.showData ? "shown" : "hidden"}`,
665
+ extra: list("slices", diagram.slices.map(slice => `${slice.label}: ${slice.value}`))
666
+ };
667
+ }
668
+ }
669
+ /**
670
+ * Describe what the parser made of a diagram, in full.
671
+ *
672
+ * This is the tool surface's answer to a hard problem: the model cannot see the
673
+ * picture, so "it rendered" is not evidence the diagram says what was meant. The
674
+ * parser implements a subset of Mermaid, and the way a subset fails is by
675
+ * *silently omitting* what it did not recognise — a mistyped arrow simply produces
676
+ * one fewer edge. Reporting counts and labels is what lets that be caught.
677
+ */
678
+ export function describeDiagram(diagram) {
679
+ const detail = diagramDetail(diagram);
680
+ return [
681
+ `- type: **${KIND_NAMES[diagram.kind]}** (\`kind: "${diagram.kind}"\`)`,
682
+ ...(diagram.title === undefined || diagram.title.length === 0
683
+ ? []
684
+ : [`- title: ${JSON.stringify(diagram.title)}`]),
685
+ `- ${detail.counts}`,
686
+ ...detail.extra
687
+ ];
688
+ }
689
+ /**
690
+ * The same facts on one line, for a report that has no room for a list.
691
+ *
692
+ * Used by `pdf_edit`, where a diagram is one entry in a numbered list of
693
+ * operations and the alternative — saying only "drew a diagram" — would leave the
694
+ * model with no way to check what it drew short of another tool call.
695
+ */
696
+ export function summariseDiagram(diagram) {
697
+ const detail = diagramDetail(diagram);
698
+ return `${KIND_NAMES[diagram.kind]}${diagram.title === undefined || diagram.title.length === 0 ? "" : ` ${JSON.stringify(diagram.title)}`} — ${detail.counts}`;
699
+ }
700
+ /**
701
+ * List the mermaid fences in a Markdown document, for a reader that will not
702
+ * render them.
703
+ *
704
+ * `doc_inspect` and `doc_read` both hand back Markdown as text, so a fence
705
+ * arrives at the model as source code. Without this note the model's next move is
706
+ * to copy that source into its own output and pass it back as `source` — spending
707
+ * tokens on data the server already has, which is the one habit this server's
708
+ * whole design tries to break. Naming the index makes `{ from, index }` the
709
+ * obvious call instead.
710
+ */
711
+ export function describeFences(markdown, options = {}) {
712
+ const fences = findMermaidFences(markdown);
713
+ if (fences.length === 0) {
714
+ return [];
715
+ }
716
+ return [
717
+ `## ${fences.length} mermaid diagram(s)${options.sampled === true ? " in the first 64 KiB" : ""}`,
718
+ "",
719
+ "| index | line | first line |",
720
+ "| --- | --- | --- |",
721
+ ...fences.map(fence => `| ${fence.ordinal} | ${fence.line} | ${escapeTableCell(fence.source.split("\n")[0] ?? "", 60)} |`),
722
+ "",
723
+ "Draw one with `diagram_render({ from, index, to })`, or read its structure with",
724
+ "`diagram_inspect({ from, index })`. Do not copy the diagram source into your own",
725
+ "output in order to render it — name the file and the index instead."
726
+ ];
727
+ }
728
+ /**
729
+ * Render a capped list of what the parser found.
730
+ *
731
+ * Omitted entirely when empty, because the counts line above already carries the
732
+ * quantity. An emphatic "none recognised" beside a diagram that legitimately
733
+ * declares no edges reads as a failure, which is the opposite of the point: the
734
+ * signal for something dropped is a zero in the counts, and duplicating it here in
735
+ * stronger language made a correct block diagram look broken.
736
+ */
737
+ function list(label, entries) {
738
+ if (entries.length === 0) {
739
+ return [];
740
+ }
741
+ const shown = entries.slice(0, MAX_LISTED);
742
+ const suffix = entries.length > shown.length ? `, … ${entries.length - shown.length} more` : "";
743
+ return [`- ${label}: ${shown.map(entry => `\`${entry}\``).join(", ")}${suffix}`];
744
+ }
745
+ function countMindNodes(node) {
746
+ return (1 +
747
+ node.children.reduce((sum, child) => sum + countMindNodes(child), 0));
748
+ }
749
+ /** Every label in a mind map, depth first, so the tree itself can be checked. */
750
+ function mindNodeLabels(node) {
751
+ return [
752
+ node.text,
753
+ ...node.children.flatMap(mindNodeLabels)
754
+ ];
755
+ }
756
+ /** A Gantt bound as a plain date. Epoch milliseconds prove nothing to a reader. */
757
+ function isoDay(time) {
758
+ return Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : "?";
759
+ }
760
+ //# sourceMappingURL=diagram.js.map