-
Notifications
You must be signed in to change notification settings - Fork 4
canvas: an SVG is attached by drawing it, not by refusing it #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = | ||
| '<svg width="200" height="100" xmlns="http://www.w3.org/2000/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 = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/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 = '<svg width="100%" height="100%" viewBox="0 0 40 10"/>'; | ||
| expect(rasterSize(svg, unmeasured)).toEqual({ w: 1024, h: 256 }); | ||
| }); | ||
|
|
||
| it("reads the root's own size, not a child's", () => { | ||
| const svg = '<svg viewBox="0 0 10 20"><rect width="999" height="1"/></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("<svg/>", { w: 64, h: 32 })).toEqual({ w: 1024, h: 512 }); | ||
| }); | ||
|
|
||
| it("gives an unparseable file a square rather than a zero-sized canvas", () => { | ||
| expect(rasterSize("<svg", unmeasured)).toEqual({ w: 1024, h: 1024 }); | ||
| }); | ||
|
|
||
| it("keeps a pixel of a sliver too thin to round to one", () => { | ||
| // 5000:1 scales to 0.2 of a pixel. Rounded it is nothing, and a canvas with a zero side | ||
| // hands toBlob back no blob at all, which rasterizeSvg then reports as a failed drawing. | ||
| const svg = | ||
| '<svg viewBox="0 0 5000 1" xmlns="http://www.w3.org/2000/svg"/>'; | ||
| expect(rasterSize(svg, unmeasured)).toEqual({ w: 1024, h: 1 }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| /** | ||
| * An SVG on its way into the composer, drawn into a PNG. | ||
| * | ||
| * Two reasons, and either alone would be enough. The CLIs read png, jpeg, gif and webp and | ||
| * nothing else, so a vector handed over whole is a picture the agent cannot look at — it would | ||
| * travel the whole pipe to be refused at the far end. And an SVG is a document rather than an | ||
| * image, which is why IMAGE_TYPES (agents.ts) does not carry one: the server serves an | ||
| * attachment back on its own origin. | ||
| * | ||
| * The drawing goes through an `<img>`, and that is what makes this 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 is stored, sent or served back, so the allowlist stays as it is. | ||
| */ | ||
|
|
||
| /** | ||
| * The long edge of the PNG. A vector has no pixels of its own to be scaled up past — unlike the | ||
| * photographs in an image row, which App.tsx is careful never to draw larger than they are — so | ||
| * every one is drawn at this size rather than at whatever it claims: a 24px icon attached at 24px | ||
| * is a picture the agent can see nothing in. 1024² of PNG is a few hundred KB against the 24 MB | ||
| * the tray holds. | ||
| */ | ||
| const RASTER_EDGE = 1024; | ||
|
|
||
| /** | ||
| * What to draw the vector at, from its own markup: the `width`/`height` pair if it states one in | ||
| * absolute units, else the `viewBox`, else whatever the browser made of it. | ||
| * | ||
| * Parsed from the text rather than read off the loaded `<img>` because 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. | ||
| */ | ||
| export function rasterSize( | ||
| markup: string, | ||
| natural: { w: number; h: number }, | ||
| ): { w: number; h: number } { | ||
| // 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, | ||
|
|
||
| "image/svg+xml", | ||
| ).documentElement; | ||
| const px = (raw: string | null) => { | ||
| const n = Number.parseFloat(raw ?? ""); | ||
| // A percentage or an em is a size relative to a box this has no business inventing. | ||
| return Number.isFinite(n) && n > 0 && !/%|e[mx]\s*$/i.test(raw ?? "") | ||
| ? n | ||
| : 0; | ||
| }; | ||
| const box = (root.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Preserve valid Useful? React with 👍 / 👎. |
||
| const side = (attr: string, i: number, fallback: number) => | ||
| px(root.getAttribute(attr)) || | ||
| (Number.isFinite(box[i]) && box[i]! > 0 ? box[i]! : 0) || | ||
| fallback; | ||
| const w = side("width", 2, natural.w); | ||
| const h = side("height", 3, natural.h); | ||
| // A vector that states no size anywhere and that the browser measured at nothing. Square is the | ||
| // only guess left, and it is better than a zero-sized canvas, which throws. | ||
| if (!(w > 0) || !(h > 0)) return { w: RASTER_EDGE, h: RASTER_EDGE }; | ||
| const k = RASTER_EDGE / Math.max(w, h); | ||
| // Rounded up off zero, not just rounded. Past about 2048:1 — a hairline rule, a wide divider — | ||
| // the short edge lands under half a pixel and rounds away, and a canvas with a zero side draws | ||
| // nothing for toBlob to hand back. One pixel of a sliver is the sliver; none of it throws. | ||
| const edge = (side: number) => Math.max(1, Math.round(side * k)); | ||
| return { w: edge(w), h: edge(h) }; | ||
| } | ||
|
|
||
| /** The same picture as a PNG, named for the file it came from. Rejects if it will not draw. */ | ||
| export async function rasterizeSvg(file: File): Promise<File> { | ||
| const markup = await file.text(); | ||
| // An object URL rather than the text as a data URL: same origin either way, so the canvas is | ||
| // not tainted and toBlob is allowed, and a large icon is not carried twice through base64. | ||
| const url = URL.createObjectURL(file); | ||
| try { | ||
| const img = new Image(); | ||
| await new Promise<void>((drawn, fail) => { | ||
| img.onload = () => drawn(); | ||
| img.onerror = () => fail(new Error(`${file.name} is not a picture`)); | ||
| img.src = url; | ||
| }); | ||
| const { w, h } = rasterSize(markup, { | ||
| w: img.naturalWidth, | ||
| h: img.naturalHeight, | ||
| }); | ||
| const surface = document.createElement("canvas"); | ||
| surface.width = w; | ||
| surface.height = h; | ||
| const pen = surface.getContext("2d"); | ||
| if (!pen) throw new Error("this browser draws no 2d canvas"); | ||
| // Left transparent where the vector is transparent. Painting a background in would be a | ||
| // guess at the page it is meant to sit on, and would bury a logo drawn in white. | ||
| pen.drawImage(img, 0, 0, w, h); | ||
| const png = await new Promise<Blob | null>((done) => | ||
| surface.toBlob(done, "image/png"), | ||
| ); | ||
| if (!png) throw new Error(`${file.name} could not be drawn`); | ||
| return new File([png], `${file.name.replace(/\.svg$/i, "")}.png`, { | ||
| type: "image/png", | ||
| }); | ||
| } finally { | ||
| URL.revokeObjectURL(url); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Capture the selection range synchronously when an image is pasted.
addImagesreads the file and may rasterize it asynchronously beforeinsertAtCaretconsults 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 👍 / 👎.