Skip to content

canvas: an SVG is attached by drawing it, not by refusing it - #99

Merged
Jing-yilin merged 3 commits into
mainfrom
fix/svg-attachments
Sep 19, 2026
Merged

Jing-yilin merged 3 commits into
mainfrom
fix/svg-attachments

Conversation

@Jing-yilin

Copy link
Copy Markdown
Contributor

The bug

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".

Why not just widen IMAGE_TYPES

Two independent reasons, and either alone settles it.

An SVG is a document that can carry a script. That is why IMAGE_TYPES is what
it is, and why the server serves an attachment back under
content-security-policy: sandbox (#98). 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.

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 canvas
shape. canvas/src/svgRaster.ts is the whole of it.

The drawing goes through an <img>, and that 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, 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_TYPES is 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 naturalWidth then reports (the CSS default 300×150 in some, the viewBox in
others). 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 paste
is 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:

input result
perplexity-ios/assets/logo-symbol.svg, viewBox 1920×1080, no width/height 1024×576 PNG, 17,775 bytes
inline 120×40 SVG 1024×341 PNG
+ on logo-clearspace.svg #1 logo-clearspace.png in the tray, tile visibly painted
paste into the composer tile above and #1 pasted.png at the caret

bunx tsc --noEmit silent, bunx oxlint silent, bunx vitest run 19 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

Jing-yilin and others added 2 commits September 18, 2026 15:55
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Deploying super-prototyping with  Cloudflare Pages  Cloudflare Pages

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

View logs

Comment thread canvas/src/svgRaster.ts Fixed
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>
Comment thread canvas/src/svgRaster.ts
// 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,

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread canvas/src/svgRaster.ts
? n
: 0;
};
const box = (root.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread canvas/src/ChatPanel.tsx
Comment on lines 1055 to +1058
onPaste={(e) => {
e.preventDefault();
if (e.clipboardData.files.length)
return void addImages(e.clipboardData.files);
return void addImages(e.clipboardData.files, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Jing-yilin
Jing-yilin merged commit cbaad9b into main Sep 19, 2026
7 of 8 checks passed
Jing-yilin added a commit that referenced this pull request Sep 19, 2026
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>
@Jing-yilin Jing-yilin mentioned this pull request Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants