A PDF is a slide deck LinkedIn will swipe through for you
Shipped
ghostwriter writes LinkedIn posts in my voice and publishes them after I approve. v0.3.0 baked a sourced LinkedIn reach playbook into config the skill reads on every draft, instead of advice I had to remember, and added end-to-end carousel support: a render step that screenshots HTML slides and stitches them into a PDF, plus a publish path that uploads that PDF through LinkedIn’s document API. Two rules from the playbook explain why this was worth building. LinkedIn’s own engineering team has published that dwell time, how long someone actually stays on a post, is a first-class ranking signal measured on every view rather than just on clicks, and a multi-page document earns meaningfully more of it than a static image (LinkedIn Engineering: understanding feed dwell time). And a link in the post body sends people off the platform, which gets it deprioritized (LinkedIn: do links lower post reach). The part worth building is turning a folder of styled HTML slides into a single PDF a reader can actually verify, then shipping that PDF through an upload flow that looks nothing like a normal image attach.
Setting up the render pipeline
The render step drives a real Chromium instance headlessly, so it needs Playwright and a browser binary, not just the Python package.
python3 -m venv .venv
.venv/bin/pip install playwright
.venv/bin/playwright install chromium
That second install step is easy to skip and the failure is not obvious: importing playwright succeeds, and the process only breaks when you actually launch a browser, with an error about a missing executable.
Turning HTML slides into one PDF
The one constraint that shapes everything else: LinkedIn’s document posts accept a PDF and render each page as one swipeable slide. So the job is “one HTML file with several slide-sized sections in it” in, “one multi-page PDF, one page per slide” out.
Author each slide as a fixed-size block, all in the same HTML document, side by side rather than stacked into separate files:
<!-- slides.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { margin: 0; padding: 0; }
.slide {
width: 1200px;
height: 1200px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font: 700 64px system-ui, sans-serif;
color: #fff;
}
.slide.cover { background: #111; }
.slide.point { background: #1d3557; }
</style>
</head>
<body>
<div class="slide cover">Slide one: the cover</div>
<div class="slide point">Slide two: a point</div>
<div class="slide point">Slide three: the close</div>
</body>
</html>
The render itself is a two-stage trick. First, screenshot each .slide element on the page individually, at screen media (so colors and layout are exactly what you designed, no print-stylesheet surprises). Second, build a brand-new page containing nothing but those screenshots as full-bleed <img> tags, one per PDF page via @page sizing and a forced page break after each image, and export that with page.pdf(). Screenshotting per-element means you never hand-compute pixel offsets to clip a big page into slides; Playwright’s locator API finds each .slide’s own bounding box for you.
# render.py
import base64
from pathlib import Path
from playwright.sync_api import sync_playwright
SLIDE_PX = 1200
def render(src: Path, out_pdf: Path) -> int:
html = src.read_text(encoding="utf-8")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(
viewport={"width": SLIDE_PX, "height": SLIDE_PX},
device_scale_factor=2, # render at 2x so text stays crisp
)
page.set_content(html, wait_until="load")
slides = page.locator(".slide")
count = slides.count()
if count == 0:
browser.close()
raise SystemExit("no .slide elements found")
# Stage 1: one screenshot per slide, screen media, exact colors.
png_bytes = [slides.nth(i).screenshot() for i in range(count)]
# Stage 2: a fresh page of nothing but those images, one per PDF page.
imgs = "".join(
f'<img src="data:image/png;base64,{base64.b64encode(b).decode()}" />'
for b in png_bytes
)
stitched = f"""<!doctype html><html><head><style>
@page {{ size: {SLIDE_PX}px {SLIDE_PX}px; margin: 0; }}
html, body {{ margin: 0; padding: 0; }}
img {{ display: block; width: {SLIDE_PX}px; height: {SLIDE_PX}px;
break-after: page; page-break-after: always; }}
img:last-child {{ break-after: auto; page-break-after: auto; }}
</style></head><body>{imgs}</body></html>"""
pdf_page = browser.new_page()
pdf_page.set_content(stitched, wait_until="load")
pdf_page.pdf(
path=str(out_pdf),
width=f"{SLIDE_PX}px",
height=f"{SLIDE_PX}px",
print_background=True, # otherwise printed output drops backgrounds
margin={"top": "0", "bottom": "0", "left": "0", "right": "0"},
)
browser.close()
return count
if __name__ == "__main__":
n = render(Path("slides.html"), Path("carousel.pdf"))
print(f"Rendered {n}-slide carousel -> carousel.pdf")
@page is a print-only CSS rule; it has no effect on screen rendering and only applies inside page.pdf()/print output, which is exactly the boundary this script relies on (MDN: @page).
Shipping the PDF through a document upload
A carousel is a document post, not an image post, and LinkedIn’s Documents API is a three-step handshake rather than a single upload call: register the upload to get a one-time URL and a document URN, PUT the raw bytes to that URL, then attach the returned URN to a post (LinkedIn: Documents API). Every request needs the Linkedin-Version header as a bare YYYYMM string and X-Restli-Protocol-Version: 2.0.0; both are mandatory on every call, not just the first one.
# publish.py
import json
import urllib.request
API = "https://api.linkedin.com/rest"
def _headers(token: str) -> dict:
return {
"Authorization": f"Bearer {token}",
"LinkedIn-Version": "202606",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
}
def _post_json(url: str, token: str, payload: dict) -> dict:
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), headers=_headers(token), method="POST"
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def publish_carousel(token: str, author_urn: str, pdf_bytes: bytes, title: str) -> str:
# 1) Register the upload; get back a one-time URL and the document's URN.
init = _post_json(
f"{API}/documents?action=initializeUpload",
token,
{"initializeUploadRequest": {"owner": author_urn}},
)["value"]
upload_url, doc_urn = init["uploadUrl"], init["document"]
# 2) PUT the raw PDF bytes. No JSON wrapper, just the file.
put_req = urllib.request.Request(
upload_url,
data=pdf_bytes,
headers={"Authorization": f"Bearer {token}"},
method="PUT",
)
urllib.request.urlopen(put_req)
# 3) Attach the document URN to a post. This is what turns it into a
# swipeable carousel in the feed instead of a bare file attachment.
_post_json(f"{API}/posts", token, {
"author": author_urn,
"commentary": "",
"visibility": "PUBLIC",
"distribution": {"feedDistribution": "MAIN_FEED"},
"content": {"media": {"id": doc_urn, "title": title}},
"lifecycleState": "PUBLISHED",
})
return doc_urn
The document URN is reusable once uploaded; the same URN can back more than one post without re-uploading the bytes.
Verify it before you trust it
The two things that actually break a carousel are silent: a render that drops or duplicates a slide gives the wrong page count, and a page-size mismatch letterboxes the deck in the feed. Both are visible in the finished PDF, so check the PDF, not just that the script exited zero. Running the render script above against the three-slide slides.html:
$ .venv/bin/python render.py
slide 1/3 captured (42195 bytes)
slide 2/3 captured (42448 bytes)
slide 3/3 captured (42608 bytes)
Rendered 3-slide carousel -> carousel.pdf
Then check the actual PDF structure, which is the same check a publish gate should run before ever calling the upload API:
$ python3 -c "
import re
data = open('carousel.pdf', 'rb').read()
pages = re.findall(rb'/Type\s*/Page[^s]', data)
sizes = re.findall(rb'/MediaBox\s*\[([^\]]+)\]', data)
print('pages:', len(pages))
print('sizes:', sizes[:1])
"
pages: 3
sizes: [b'0 0 900 900']
Three slides in, three pages out, one consistent page size, which is the whole check: page count matches slide count, and every MediaBox matches.
Gotchas
The page size in points is not the pixel number you wrote. The CSS above sets size: 1200px 1200px, but the MediaBox above reads 900 900, not 1200 1200. Chromium’s PDF export treats CSS pixels at 96 DPI and PDF points are 1/72 inch, so 1200px becomes 1200 * 72 / 96 = 900pt. If you hardcode an expected page size for a downstream check, derive it from that conversion instead of the CSS number, or the check will fail on a perfectly correct PDF.
Backgrounds vanish if you skip print_background. Chromium’s PDF export defaults to “modified colors for printing,” which strips backgrounds and dims colors unless you opt out (Playwright: Page.pdf()). The two-stage screenshot-then-stitch approach above only needs this on the final stitching page (the individual slide screenshots already captured exact colors via screen media), but forgetting it there still turns a full-bleed image into a page with white gutters.
A 403 on initializeUpload doesn’t mean your token is bad. The Documents API lives under LinkedIn’s Community Management API product, which is a separate approval from a plain “Share on LinkedIn” app; a token that already has w_member_social will still get rejected until that product is added to the app and approved (LinkedIn Developer: Community Management API). Check the app’s Products tab before debugging the token.
Dropping either version header breaks every call, not just the first one. Linkedin-Version and X-Restli-Protocol-Version are required on the initialize call, the post-attach call, and any follow-up GET, not only the upload step; it’s easy to add them once and forget them on a helper function that builds a second request.
Sources
- LinkedIn Engineering: understanding feed dwell time — why dwell time is a ranking signal and how the skip-probability model uses it.
- LinkedIn: Documents API — the initialize-upload, PUT-bytes, attach-URN flow and the mandatory version headers.
- LinkedIn Developer: Community Management API — the separate product/approval the document and post endpoints sit under.
- MDN: @page — the print-only at-rule that controls PDF page size and margins.
- Playwright: Page.pdf() —
print_backgroundand why colors get stripped by default. - LinkedIn: do links lower post reach — external links in a post body reduce reach; the platform is optimized to keep attention on it.
Changelog
- feat(ghostwriter): reach optimization, flow diagrams & carousels (v0.3.0) (1cf7e86)