DEV Community

Cover image for Saving a tweet as a PDF is harder than it looks (CORS, tokens, and Unicode)
Simran Kaur
Simran Kaur

Posted on

Saving a tweet as a PDF is harder than it looks (CORS, tokens, and Unicode)

I wanted a simple thing: paste a Twitter/X post link, get a clean PDF of it. Text, author, images, the whole card. No screenshots stitched together.

It sounded like a one-afternoon project. It was not. Here are the four walls I hit, and how I got past each one. If you ever try to read tweet data from the browser, this will save you a day.

Wall 1: you cannot read a tweet from the browser

The obvious first attempt: fetch the tweet from the syndication endpoint that X's own embed widgets use.

const id = "1719049883789729890";
const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${token}`;
const res = await fetch(url); // TypeError: Failed to fetch
Enter fullscreen mode Exit fullscreen mode

Dead on arrival. The response carries this header:

Access-Control-Allow-Origin: https://platform.twitter.com
Enter fullscreen mode Exit fullscreen mode

So the endpoint only allows requests from X's own embed origin. No other website can call it from the browser, CORS blocks it. The old conversation endpoints (/timeline/conversation/<id>.json) now return 200 with an empty body, so those are gone too. And the official API needs a paid key.

There is no pure client-side path. If you want tweet data on your own site, something on your server has to fetch it.

The fix: a tiny server-side proxy

CORS is a browser rule. Server to server, it does not apply. So the browser asks my server, and my server asks X. My stack is WordPress, so this is a small REST endpoint (any backend works the same way).

register_rest_route('myplugin/v1', '/tweet', [
  'methods'  => 'GET',
  'permission_callback' => '__return_true',
  'callback' => function ($req) {
    $id    = preg_replace('/[^0-9]/', '', $req->get_param('id'));
    $token = preg_replace('/[^A-Za-z0-9]/', '', $req->get_param('token'));
    $url = "https://cdn.syndication.twimg.com/tweet-result?id=$id&lang=en&token=$token";

    $r = wp_remote_get($url, [
      'timeout' => 15,
      'headers' => ['User-Agent' => 'Mozilla/5.0 ... Chrome/124 Safari/537.36'],
    ]);
    // ...normalize and return JSON
  },
]);
Enter fullscreen mode Exit fullscreen mode

One caveat worth knowing: send a real browser User-Agent. Without it the endpoint gets moody.

Wall 2: the mysterious token

That endpoint needs a token query param. It is not an API key. It is a value derived from the tweet ID, and X's own embed code computes it client-side. The formula floating around the web (used by Vercel's react-tweet) is:

function token(id) {
  return ((Number(id) / 1e15) * Math.PI)
    .toString(36)
    .replace(/(0+|\.)/g, "");
}
Enter fullscreen mode Exit fullscreen mode

I compute this in the browser (it is trivial and exact there) and pass it to my endpoint, so the PHP side never has to reimplement JavaScript's float-to-base36. Clean division of labor.

Now the browser flow is:

const id = parseIdFromUrl(url);
const r  = await fetch(`/wp-json/myplugin/v1/tweet?id=${id}&token=${token(id)}`);
const post = await r.json(); // { name, handle, avatar, text, images, likes, replies, quoted }
Enter fullscreen mode Exit fullscreen mode

Wall 3: images taint the canvas

To put images into a PDF, most tools rasterize through a <canvas>. But drawing a cross-origin image onto a canvas taints it, and toDataURL() throws a security error. Twitter's pbs.twimg.com sometimes cooperates with crossOrigin, sometimes not.

Simplest reliable fix: let the server fetch the image bytes too and hand them back as base64 data URLs. Data URLs are same-origin by definition, so no taint, ever.

$bin = wp_remote_retrieve_body(wp_remote_get($imgUrl, [...]));
$dataUrl = 'data:' . $mime . ';base64,' . base64_encode($bin);
Enter fullscreen mode Exit fullscreen mode

Yes, base64 is about 33% bigger than the binary. For a handful of tweet images, who cares. It just works, on every browser, every time.

Wall 4: jsPDF breaks on Hindi and emoji

This one bit hardest. I drew the whole tweet card with jsPDF, which is great, until you feed it anything outside Latin-1. The built-in fonts (Helvetica and friends) have no glyphs for Devanagari, Arabic, CJK, or emoji. Hindi text came out as boxes and broken characters.

You can embed a full Unicode TTF, but a single font that covers every script plus color emoji is enormous and still incomplete.

The trick that actually works: render the text with the browser's canvas (which already has the system fonts for every script and emoji), then drop that as a crisp image into the PDF.

function textImage(text, maxWpt, fontPt, color) {
  const scale = 3;                    // render at 3x for sharp text
  const cv = document.createElement("canvas");
  const ctx = cv.getContext("2d");
  ctx.font = `${fontPt * scale}px Inter, "Noto Sans", "Noto Sans Devanagari", "Apple Color Emoji", sans-serif`;
  const lines = wrap(ctx, text, maxWpt * scale); // your own word-wrap
  cv.width = maxWpt * scale;
  cv.height = lines.length * fontPt * scale * 1.4;
  ctx.font = ...; ctx.fillStyle = color; ctx.textBaseline = "top";
  lines.forEach((l, i) => ctx.fillText(l, 0, i * fontPt * scale * 1.4));
  return { url: cv.toDataURL("image/png"), wPt: maxWpt, hPt: cv.height / scale };
}

// then in jsPDF:
doc.addImage(img.url, "PNG", x, y, img.wPt, img.hPt);
Enter fullscreen mode Exit fullscreen mode

Tradeoff: that text is now an image, so it is not selectable. But it renders correctly in every language, which was the whole point. I keep the ASCII bits (handles, dates, counts) as real jsPDF text so those stay selectable.

Putting the card together

With data in hand, the layout is ordinary jsPDF drawing:

  • A centered card on a white page (no heavy border, just the content).
  • Circular avatar via a clip path (saveGraphicsState + circle + clip + addImage).
  • The verified badge is a blue filled circle with a white check drawn from two lines.
  • Images in a 2x2 grid, capped at 4 per page (that is Twitter's own max per post), with pagination when a thread runs long.
  • Quote tweets render as a nested bordered box, same building blocks, smaller.
  • A footer link using doc.textWithLink(...).

Engagement counts are worth a note: the public endpoint returns real favorite_count (likes) and conversation_count (replies), but not retweets or views. Rather than show a fake number or an empty dash, I only render the metrics that actually exist. Honest and cleaner.

Bonus: PNG export for free

Once the card renders nicely in the DOM (the on-page preview), exporting a shareable image is one library away. html2canvas snapshots the preview element, and because the images are already base64 data URLs, there is no CORS drama.

const canvas = await html2canvas(cardEl, { backgroundColor: "#fff", scale: 2, useCORS: true });
canvas.toBlob(blob => download(blob, "post.png"), "image/png");
Enter fullscreen mode Exit fullscreen mode

Lessons

  1. If a third party locks CORS to their own origin, no client-side trick beats it. Proxy through your server or stop.
  2. Compute fiddly values (like that base36 token) on whichever side has the right primitives, then pass them along. Do not port float math across languages for no reason.
  3. Base64 data URLs are the boring, reliable answer to canvas tainting.
  4. jsPDF is Latin-only out of the box. For real i18n, render text on a canvas and embed it as an image.

The finished thing does what I wanted: paste a link, get a clean PDF or image, threads and quote tweets included, every language intact, all in the browser with a thin server helper.

If you want to see it in action: Twitter/X to PDF.

Have you fought the syndication endpoint or jsPDF's font limits before? I would love to hear how you handled it.

Top comments (0)