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
Dead on arrival. The response carries this header:
Access-Control-Allow-Origin: https://platform.twitter.com
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
},
]);
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, "");
}
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 }
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);
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);
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");
Lessons
- If a third party locks CORS to their own origin, no client-side trick beats it. Proxy through your server or stop.
- 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.
- Base64 data URLs are the boring, reliable answer to canvas tainting.
- 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)