diff --git a/canvas/src/ChatPanel.tsx b/canvas/src/ChatPanel.tsx
index 5b3a9d17..a8110e94 100644
--- a/canvas/src/ChatPanel.tsx
+++ b/canvas/src/ChatPanel.tsx
@@ -47,9 +47,10 @@
import { Fragment, useContext, useEffect, useRef, useState } from "react";
import { useValue } from "tldraw";
import {
- IMAGE_TYPES,
+ ATTACH_TYPES,
MAX_IMAGE_BYTES,
MAX_IMAGES,
+ SVG_TYPE,
type AgentId,
type AgentModel,
} from "./agents";
@@ -63,6 +64,7 @@ import { ClaudeMark } from "./ClaudeMark";
import { CodexMark } from "./CodexMark";
import { Check, ClockRewind, Image, Plus } from "./geistIcons";
import { renderMarkdown } from "./markdown";
+import { rasterizeSvg } from "./svgRaster";
const RUNS_KEY = "sp-chat-runs";
const AGENT_KEY = "sp-chat-agent";
@@ -438,20 +440,28 @@ export function ChatPanel() {
(adds.current = adds.current
.then(async () => {
const given = [...(list ?? [])];
- const picked = given.filter((f) => IMAGE_TYPES.includes(f.type));
+ const taken = given.filter((f) => ATTACH_TYPES.includes(f.type));
// A file the agent could not look at is refused here rather than by the server, and said:
// the file dialog offers only these, but a drop, a paste and the canvas hand over anything.
setSendError(
- picked.length < given.length
- ? "only png, jpeg, gif and webp pictures can be attached"
+ taken.length < given.length
+ ? "only png, jpeg, gif, webp and svg pictures can be attached"
: null,
);
- if (picked.length === 0) return;
+ if (taken.length === 0) return;
// What cannot fit whatever the tray holds is refused before it is read: a read is the
- // whole file in memory, a third larger.
+ // whole file in memory, a third larger. Measured on the files as they arrived, since
+ // that is what a drawing has to hold in memory too.
const tooBig = `those are too large to attach; keep them under about ${MAX_IMAGE_BYTES / 1_000_000} MB together`;
- if (picked.reduce((n, f) => n + f.size, 0) > MAX_IMAGE_BYTES)
+ if (taken.reduce((n, f) => n + f.size, 0) > MAX_IMAGE_BYTES)
return setSendError(tooBig);
+ // A vector is drawn into a PNG here, at the one door every attachment comes through —
+ // the picker, a paste, a drop, and the + on a canvas shape, which hands its asset over
+ // with whatever type the asset has. Past this line everything is an IMAGE_TYPE, so the
+ // tray, the limits, the preview and the agent all go on seeing what they always saw.
+ const picked = await Promise.all(
+ taken.map((f) => (f.type === SVG_TYPE ? rasterizeSvg(f) : f)),
+ );
// Read first and numbered after, which is what lets the same picture keep the number it
// already has: pressing + on one twice is one picture said twice, not two — and not a
// twenty-first, so the limits are over what is new. Still numbered in the order they
@@ -492,10 +502,13 @@ export function ChatPanel() {
// rather than the one React has not rendered yet.
tray.current = [...tray.current, ...fresh].sort((x, y) => x.n - y.n);
setAttached(tray.current);
- // One picture, pointed at on the canvas: the sentence says which one without a second
- // click on the tile that has just appeared, and says it once however many times it is
- // pointed at. A pick, a paste or a drop is a handful at once, and which of them the
- // message is about is still to be said.
+ // Numbered into the sentence where the caret already is, so the picture the writer has
+ // just put there is named without a second click on the tile that has appeared above —
+ // and named once however many times it is added. Both ways in that land on one picture
+ // do this: the + on a canvas shape, and a paste, which is the screenshot in the
+ // clipboard going into the sentence being typed. A pick and a drop are a handful chosen
+ // at a distance from the caret, and which of them the message is about is still to be
+ // said.
const box = composer.current;
if (cite && box)
for (const image of said)
@@ -1042,7 +1055,7 @@ export function ChatPanel() {
onPaste={(e) => {
e.preventDefault();
if (e.clipboardData.files.length)
- return void addImages(e.clipboardData.files);
+ return void addImages(e.clipboardData.files, true);
// The text and not the markup that came with it: the box holds the chips it made
// itself and nothing else. execCommand because it is the only insert that native
// undo still knows about.
@@ -1177,7 +1190,7 @@ export function ChatPanel() {
{
diff --git a/canvas/src/agents.ts b/canvas/src/agents.ts
index 20b06729..a0279fd2 100644
--- a/canvas/src/agents.ts
+++ b/canvas/src/agents.ts
@@ -40,6 +40,14 @@ export const IMAGE_TYPES = [
"image/gif",
"image/webp",
];
+export const SVG_TYPE = "image/svg+xml";
+/**
+ * What the composer takes at the door, which is one more than what travels: an SVG is accepted
+ * and drawn into a PNG on the way in (svgRaster.ts), so the vector itself never reaches the tray,
+ * the server or the agent. The two lists differ by exactly that conversion — anything accepted
+ * here is an IMAGE_TYPE by the time it is numbered.
+ */
+export const ATTACH_TYPES = [...IMAGE_TYPES, SVG_TYPE];
export const MAX_IMAGES = 20;
export const MAX_IMAGE_BYTES = 24_000_000;
diff --git a/canvas/src/svgRaster.test.ts b/canvas/src/svgRaster.test.ts
new file mode 100644
index 00000000..2e20366d
--- /dev/null
+++ b/canvas/src/svgRaster.test.ts
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { rasterSize } from "./svgRaster";
+
+/** What the browser reports for a vector it could not measure, which is the interesting case. */
+const unmeasured = { w: 0, h: 0 };
+
+describe("rasterSize", () => {
+ it("scales a stated size so its long edge is the raster edge", () => {
+ const svg =
+ '';
+ expect(rasterSize(svg, unmeasured)).toEqual({ w: 1024, h: 512 });
+ });
+
+ it("keeps a viewBox-only icon square rather than taking the browser's 300x150", () => {
+ const svg = '';
+ expect(rasterSize(svg, { w: 300, h: 150 })).toEqual({ w: 1024, h: 1024 });
+ });
+
+ it("ignores a relative width and falls through to the viewBox", () => {
+ const svg = '';
+ expect(rasterSize(svg, unmeasured)).toEqual({ w: 1024, h: 256 });
+ });
+
+ it("reads the root's own size, not a child's", () => {
+ const svg = '';
+ expect(rasterSize(svg, unmeasured)).toEqual({ w: 512, h: 1024 });
+ });
+
+ it("falls back to what the browser measured when the markup states nothing", () => {
+ expect(rasterSize("", { w: 64, h: 32 })).toEqual({ w: 1024, h: 512 });
+ });
+
+ it("gives an unparseable file a square rather than a zero-sized canvas", () => {
+ expect(rasterSize("