canvas: an SVG is attached by drawing it, not by refusing it - #99
Conversation
A vector on a board — every brand logo in notion-ios, perplexity-ios and chatgpt-ios is one — could not be attached to the chat. The + in its corner handed the asset over with `image/svg+xml` on it, and the composer dropped it with "only png, jpeg, gif and webp pictures can be attached". Widening IMAGE_TYPES would have been the wrong fix twice over. An SVG is a document that can carry a script, which is why that list is what it is and why the server serves an attachment back under `content-security-policy: sandbox`. And the CLIs read png, jpeg, gif and webp and nothing else, so a vector sent whole would travel the entire pipe to be refused at the far end — the picture still not looked at, only later and less clearly. So the vector is drawn into a PNG on the way in, at the one door every attachment comes through: the picker, a paste, a drop, and the + on a canvas shape. The drawing goes through an `<img>`, which is what makes it safe rather than merely convenient — an SVG loaded that way renders in the browser's secure static mode, where a script does not run and an external reference is not fetched. Nothing SVG-shaped reaches the tray, the server or the agent, so IMAGE_TYPES is untouched and ATTACH_TYPES, what the composer takes at the door, is the only list that grew. The size comes from the markup rather than from the loaded `<img>`: a viewBox-only SVG, which is most icons, has a ratio but no intrinsic size, and browsers disagree on what naturalWidth then reports — the CSS default 300×150 in some, the viewBox in others, and taking the wrong one is a squashed logo. Every vector is drawn at a 1024px long edge, since a vector has no pixels of its own to be scaled up past and a 24px icon attached at 24px is a picture with nothing in it. Checked against the real files, in a browser: perplexity-ios's logo-symbol.svg (viewBox 1920×1080, no width or height) comes out a 1024×576 PNG of 17.7 KB, and the + on logo-clearspace.svg puts "#1 logo-clearspace.png" in the tray. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pasting a screenshot into the composer put the tile above the box and left the sentence in it alone, so the picture had to be clicked a second time to be named. The + on a canvas shape has always written its number at the caret, and a paste is the same gesture — one picture, arriving where the caret already is — so it now does the same. A pick and a drop still do not. Those are a handful chosen at a distance from the caret, and which of them the message is about is still to be said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying super-prototyping with
|
| Latest commit: |
c57a944
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://7120c4a4.super-prototyping.pages.dev |
| Branch Preview URL: | https://fix-svg-attachments.super-prototyping.pages.dev |
rasterSize scales the long edge to 1024 and rounds both, so past about 2048:1 the short edge lands under half a pixel and rounds to nothing. A canvas with a zero side draws, but toBlob hands back no blob, and rasterizeSvg reports that as "could not be drawn" — a thrown error for a hairline rule or a wide divider, which are ordinary things to attach. Rounded up off zero instead. The picture is a sliver either way; one pixel of it is the sliver, and none of it is an error. Found while reading Telegram-iOS's SVG path, which guards the same end of the range from the other side: nanosvg refuses an image measuring under 1.0 outright (Svg.m:328) rather than carrying a degenerate size forward. Two hunks of unrelated reformatting rode along on the editor's formatter. The file is new in this branch, so it churns nothing already on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // Malformed markup parses to a <parsererror> document, whose attributes are all absent: that | ||
| // falls through to `natural` on its own, so there is nothing here to catch. | ||
| const root = new DOMParser().parseFromString( | ||
| markup, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c57a9443e5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ? n | ||
| : 0; | ||
| }; | ||
| const box = (root.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); |
There was a problem hiding this comment.
Trim the viewBox before splitting it
Preserve valid viewBox values with leading whitespace. For example, viewBox=" 0 0 24 24" produces an initial empty token, shifting the width to box[2] === 0 and the height to box[3] === 24; on a browser reporting the default 300×150 intrinsic size, this rasterizes a square icon at roughly 1024×82. Trim the attribute before splitting so ordinary formatting whitespace cannot distort attached SVGs.
Useful? React with 👍 / 👎.
| onPaste={(e) => { | ||
| e.preventDefault(); | ||
| if (e.clipboardData.files.length) | ||
| return void addImages(e.clipboardData.files); | ||
| return void addImages(e.clipboardData.files, true); |
There was a problem hiding this comment.
Preserve the paste range before reading the image
Capture the selection range synchronously when an image is pasted. addImages reads the file and may rasterize it asynchronously before insertAtCaret consults the current selection, so if the user keeps typing or moves the caret while that work runs, the new chip is inserted at the later caret rather than where the paste occurred. Because the native paste is prevented, nothing currently preserves the original insertion point.
Useful? React with 👍 / 👎.
Step one of the release flow is reading `## Unreleased` and reconstructing anything a PR left out, "the one part of a release that cannot be done well late". Grepping the section for image, attach, paste, svg, drag or drop found nothing: the whole picture feature area — fourteen commits across #97, #98 and #99 — reached `main` with no line for the person deciding whether to update. Five paragraphs, written from the log and checked against the code rather than from memory: attaching and numbering, tool images arriving in the transcript, the + on a canvas shape, vectors drawn into a PNG on the way in, and the limits. Placed with the rest of the chat panel material, before the Geist paragraphs, since that is the order the section already reads in. Two numbers are the code's, not the commit messages': MAX_IMAGE_BYTES is 24 MB (agents.ts:52), which an early commit called 32, and a mockup has one hover button now rather than the two it shipped with. No version is cut here. `scripts/bump-version.sh --check` still says all 7 files agree on 1.3.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bug
A vector on a board — every brand logo in
notion-ios,perplexity-iosandchatgpt-iosis one — could not be attached to the chat. The+in its cornerhanded the asset over with
image/svg+xmlon it, and the composer dropped itwith "only png, jpeg, gif and webp pictures can be attached".
Why not just widen
IMAGE_TYPESTwo independent reasons, and either alone settles it.
An SVG is a document that can carry a script. That is why
IMAGE_TYPESis whatit is, and why the server serves an attachment back under
content-security-policy: sandbox(#98). And the CLIs read png, jpeg, gif andwebp and nothing else, so a vector sent whole would travel the entire pipe to be
refused at the far end — the picture still not looked at, only later and less
clearly.
What this does instead
The vector is drawn into a PNG on the way in, at the one door every
attachment comes through: the picker, a paste, a drop, and the
+on a canvasshape.
canvas/src/svgRaster.tsis the whole of it.The drawing goes through an
<img>, and that is what makes it safe rather thanmerely convenient: an SVG loaded that way renders in the browser's secure
static mode, where a script does not run, an external reference is not fetched
and no other document is reachable. Pixels come out. Nothing SVG-shaped reaches
the tray, the server or the agent, so
IMAGE_TYPESis untouched —ATTACH_TYPES, what the composer takes at the door, is the only list that grew,and the two differ by exactly that conversion.
Size comes from the markup, not from the loaded
<img>: a viewBox-only SVG —which is most icons — has a ratio but no intrinsic size, and browsers disagree on
what
naturalWidththen reports (the CSS default 300×150 in some, the viewBox inothers). Taking the wrong one is a squashed logo, and a logo is the thing people
attach these for. Every vector is drawn at a 1024px long edge, since a vector has
no pixels of its own to be scaled up past and a 24px icon attached at 24px is a
picture with nothing in it.
Second commit: a pasted picture cites itself
Pasting a screenshot into the composer put the tile above the box and left the
sentence in it alone, so the picture had to be clicked a second time to be named.
The
+on a canvas shape has always written its number at the caret, and a pasteis the same gesture — one picture, arriving where the caret already is — so it
now does the same.
A pick and a drop deliberately still do not. Those are a handful chosen at a
distance from the caret, and which of them the message is about is still to be
said.
Evidence
Checked in a browser against the real files on the boards, not only in tests:
perplexity-ios/assets/logo-symbol.svg, viewBox 1920×1080, no width/height+onlogo-clearspace.svg#1 logo-clearspace.pngin the tray, tile visibly painted#1 pasted.pngat the caretbunx tsc --noEmitsilent,bunx oxlintsilent,bunx vitest run19 files /147 tests passing (was 141 — six new cases cover the sizing rules, including the
viewBox-only case the browsers disagree about).
One trade-off worth naming
A rasterized SVG keeps its transparency — no background is painted in, because
that would be a guess at the page it is meant to sit on and would bury a logo
drawn in white. So a white-on-transparent logo will look blank against a light
backdrop in the tray. Painting one in is the larger mistake; leaving it is
deliberate.
🤖 Generated with Claude Code