Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Keep the rendering in the browser: let Raphaël draw into a visible element, let html2canvas turn that element into a bitmap canvas, then either download the image or send it to a Sinatra POST route. Sinatra serves the HTML, JavaScript and CSS; it does not need to render the page itself.

How the pieces fit together

These libraries have different jobs:

  • Raphaël is a browser-side vector-graphics library. It creates SVG content inside a DOM container.
  • html2canvas reconstructs the selected DOM element and CSS in the browser and resolves a Promise with a raster canvas. It is not a pixel-perfect browser screenshot engine and it does not run in Node.js.
  • Sinatra serves the page and assets and can receive the exported image through a route.

The practical pipeline is:

  1. Sinatra serves html2canvas, raphael.min.js and your application JavaScript from public/.
  2. Raphaël draws into a wrapper such as #capture.
  3. After fonts and images are ready, html2canvas captures that wrapper.
  4. The resulting canvas is downloaded or converted to a Blob and uploaded to Sinatra.

Build a minimal Sinatra application

Project layout

Sinatra serves the public/ directory as static files by default. A small application can therefore look like this:

raphael-sinatra/
├── app.rb
├── views/
│   └── index.erb
└── public/
    ├── js/
    │   ├── html2canvas.min.js
    │   ├── raphael.min.js
    │   └── app.js
    └── css/
        └── app.css

Use the browser builds of both libraries in public/js/. Raphaël’s repository supplies browser-loadable UMD distributions, so a normal script tag is sufficient.

Sinatra routes

require 'sinatra'
require 'securerandom'

set :public_folder, File.join(__dir__, 'public')
set :captures_dir, File.join(__dir__, 'captures')

get '/' do
  erb :index
end

post '/captures' do
  upload = params['image']
  halt 400, 'image is required' unless upload

  tempfile = upload[:tempfile]
  filename = upload[:filename].to_s
  content_type = upload[:type].to_s

  # Keep the accepted media types explicit in production.
  halt 415, 'PNG or JPEG required' unless ['image/png', 'image/jpeg'].include?(content_type)
  halt 400, 'invalid upload' unless tempfile && !filename.empty?

  Dir.mkdir(settings.captures_dir) unless Dir.exist?(settings.captures_dir)
  extension = content_type == 'image/jpeg' ? 'jpg' : 'png'
  stored_name = "#{SecureRandom.hex(16)}.#{extension}"
  destination = File.join(settings.captures_dir, stored_name)

  File.open(destination, 'wb') { |file| file.write(tempfile.read) }
  content_type 'text/plain'
  "stored as #{stored_name}"
end

get '/captures/:name' do
  name = File.basename(params['name'])
  path = File.join(settings.captures_dir, name)
  halt 404 unless File.file?(path)
  send_file path
end

The route reads Sinatra’s multipart parameter, validates the type, chooses its own filename and persists the temporary file. Never use an uploaded filename directly as a filesystem path. If you later need to return a stored image, send_file provides that response.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
#1 Best Overall

Draw a Raphaël graphic and capture it

HTML and CSS

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Raphaël capture</title>
  <link rel="stylesheet" href="/css/app.css">
</head>
<body>
  <main>
    <h1>Quarterly sales</h1>
    <div id="capture" aria-label="Sales chart"></div>
    <button id="download" type="button">Download PNG</button>
    <button id="upload" type="button">Upload to Sinatra</button>
    <p id="status" role="status"></p>
  </main>
  <script src="/js/raphael.min.js"></script>
  <script src="/js/html2canvas.min.js"></script>
  <script src="/js/app.js"></script>
</body>
</html>
#capture {
  width: 720px;
  min-height: 420px;
  padding: 24px;
  box-sizing: border-box;
  background: #ffffff;
}

Give the wrapper a real width, height and background. A transparent background is also possible, but an explicit white background makes exported charts predictable.

Raphaël drawing and html2canvas export

const wrapper = document.querySelector('#capture');
const status = document.querySelector('#status');
const paper = Raphael(wrapper, 672, 340);

paper.rect(0, 0, 672, 340, 12).attr({
  fill: '#f7f9fc',
  stroke: '#d7deea'
});
paper.text(336, 35, 'Quarterly sales').attr({
  'font-size': 24,
  'font-family': 'Arial, sans-serif',
  fill: '#1f2937'
});
paper.rect(90, 220, 70, 80).attr({ fill: '#4f46e5', stroke: 'none' });
paper.rect(210, 170, 70, 130).attr({ fill: '#0ea5e9', stroke: 'none' });
paper.rect(330, 115, 70, 185).attr({ fill: '#10b981', stroke: 'none' });
paper.rect(450, 75, 70, 225).attr({ fill: '#f59e0b', stroke: 'none' });

function waitForImages(root) {
  const images = [...root.querySelectorAll('img')];
  return Promise.all(images.map((image) => {
    if (image.complete) return Promise.resolve();
    return new Promise((resolve) => {
      image.addEventListener('load', resolve, { once: true });
      image.addEventListener('error', resolve, { once: true });
    });
  }));
}

async function renderCanvas() {
  await document.fonts.ready;
  await waitForImages(wrapper);
  return html2canvas(wrapper, {
    backgroundColor: '#ffffff',
    scale: window.devicePixelRatio,
    useCORS: true
  });
}

document.querySelector('#download').addEventListener('click', async () => {
  status.textContent = 'Rendering…';
  try {
    const canvas = await renderCanvas();
    const link = document.createElement('a');
    link.download = 'raphael-capture.png';
    link.href = canvas.toDataURL('image/png');
    link.click();
    status.textContent = 'Downloaded.';
  } catch (error) {
    console.error(error);
    status.textContent = 'The image could not be exported.';
  }
});

document.querySelector('#upload').addEventListener('click', async () => {
  status.textContent = 'Rendering…';
  try {
    const canvas = await renderCanvas();
    canvas.toBlob(async (blob) => {
      if (!blob) throw new Error('Canvas conversion failed');
      const body = new FormData();
      body.append('image', blob, 'raphael-capture.png');
      const response = await fetch('/captures', { method: 'POST', body });
      if (!response.ok) throw new Error(await response.text());
      status.textContent = await response.text();
    }, 'image/png');
  } catch (error) {
    console.error(error);
    status.textContent = 'The upload failed.';
  }
});

toDataURL() is convenient for a local download. toBlob() avoids embedding a large base64 string in the request and works naturally with FormData. Do not set a Content-Type header yourself for that request; the browser must add the multipart boundary.

Choose capture scope, size and output

Capture only the drawing or a larger region

Passing document.querySelector('#capture') captures the wrapper and its descendants. To capture a larger page area, select that larger element or use html2canvas’s positional options such as x, y, width and height. Keep the capture region explicit so unrelated controls are not included.

Resolution and memory

scale: window.devicePixelRatio produces a sharper image on retina displays, but the canvas becomes wider and taller in pixels. A four-times increase in linear dimensions requires roughly sixteen times as many pixels. Very large dimensions consume memory and can hit browser canvas limits, resulting in a blank or truncated output. If that happens, reduce the element size or scale, or set windowWidth and windowHeight to the element’s scroll dimensions before capturing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Background and transparency

Use backgroundColor: '#ffffff' for an opaque image. Set backgroundColor: null when transparency is required and the rest of your CSS supports it. A PNG preserves transparency; JPEG does not.

Images, fonts and cross-origin content

Same-origin images are simplest. For an image hosted elsewhere, the image server must send an appropriate Access-Control-Allow-Origin response and the capture must use useCORS: true. If you cannot control that server, proxy the image through Sinatra so it is served from your origin. Otherwise the canvas can become tainted, and browser security will block toDataURL() and toBlob().

Wait for document.fonts.ready and image load completion before rendering. html2canvas reconstructs supported DOM and CSS; unsupported properties, cross-origin iframes and other browser-controlled content may be omitted. Test the exact CSS used by the chart wrapper rather than assuming every visual effect will transfer.

Upload and serve captures safely

Keep uploads private until you have an access policy. Validate the declared media type, impose an application-level size limit, and consider checking the file signature before storage. Generate a random server-side name and store files outside any executable directory. If captures are public, authorize access to /captures/:name and send a restrictive content type. The example route demonstrates the essential flow but is not an authentication system.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For a one-off download, you do not need Sinatra at all: create an object URL from the Blob, click an anchor, and revoke the URL afterward. Use the POST route when the server must retain, process or deliver the image later.

Common failures and precise fixes

Symptom Likely cause Fix
Blank or truncated canvas Capture dimensions or scale exceed browser limits. Reduce the region or scale; set windowWidth/windowHeight to the element’s scroll dimensions.
External images are missing The image response lacks CORS permission. Serve the image with Access-Control-Allow-Origin and use useCORS: true, or proxy it through Sinatra.
SecurityError while exporting Cross-origin content tainted the canvas. Fix the resource origin and CORS policy before calling toDataURL() or toBlob().
Styles look different html2canvas does not implement every CSS property. Simplify or adjust the wrapper’s CSS and test the rendered result in the target browsers.
Raphaël drawing is absent Capture started before Raphaël finished inserting SVG. Call the capture handler after the drawing code has run; delay until asynchronous data and fonts are ready.
Sinatra returns 400 or 415 The request has no multipart field named image, or the type is not accepted. Send FormData with body.append('image', blob, ...) and keep the Blob type aligned with the route’s allow-list.
Downloaded image cannot be edited as vectors html2canvas rasterized the visible SVG. Save the original Raphaël/SVG representation separately when later editing is required.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

For a server-generated website image, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP or PDF. The API can remove cookie-consent banners, newsletter popups and chat widgets before capture; bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. It also provides an MCP server for Claude, Cursor and other MCP clients, with take_screenshot, get_page_info and capture_pdf tools.

Use the API documentation at https://screenshotneo.com/docs/ for the full option list.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo includes full-page capture with lazy-image loading, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF paper and page controls, custom CSS and JavaScript, click and wait actions, ad/tracker/request blocking, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed image links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, an OpenAPI specification and compatibility with parameter names used by other screenshot APIs.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is available on every plan. Create a free ScreenshotNeo account to start without a card.

FAQ

Does html2canvas preserve Raphaël’s SVG for later editing?

No. The visible SVG is rasterized into a bitmap canvas. Keep the Raphaël/SVG source if you need to change paths, text or colors later.

Can an iframe from another site be captured?

Not reliably. Browser origin and iframe security rules prevent html2canvas from reading many cross-origin iframe contents; capture content served from your origin or provide a server-side alternative.

Frequently Asked Questions

Does html2canvas preserve Raphaël’s SVG for later editing?

No. The visible SVG is rasterized into a bitmap canvas. Keep the Raphaël/SVG source if you need to change paths, text or colors later.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can an iframe from another site be captured?

Not reliably. Browser origin and iframe security rules prevent html2canvas from reading many cross-origin iframe contents; capture content served from your origin or provide a server-side alternative.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.