
Why use one language when you can use two?
I've been experimenting with FastAPI and Nim lately, and I decided to bridge them together to build a simple PDF merger.
The goal? Offload the possible heavy binary lifting of PDF generation to Nim (which compiles to C) while keeping the web layer comfy with Python.
Nim process:
import nimpdf
import nimpdf/image
import os
proc makePDF(outputPath: string, files: seq[string]): bool =
let factorX = 20.0
let factorY = 200.0
var pdf = newPDF()
for file in files:
discard pdf.addPage("A4")
let img = loadImage(file)
if img == nil:
echo "Warning: Could not load ", file
continue
pdf.drawImage(factorX, factorY, img)
return pdf.writePDF(outputPath)
when isMainModule:
if paramCount() < 2:
quit "Usage: pdf_merger <output.pdf> <images...>", QuitFailure
let outputPath = paramStr(1)
var images: seq[string] = @[]
for i in 2 .. paramCount():
images.add paramStr(i)
if makePDF(outputPath, images):
echo "Success: ", outputPath
quit QuitSuccess
else:
quit "Failed to write PDF", QuitFailure
The endpoint:
@app.post("/upload-multiple-files/")
async def create_upload_files(files: List[UploadFile] = File(...)):
job_id = str(uuid.uuid4())
output_filename = os.path.join(OUTPUT_DIR, f"{job_id}.pdf")
uploaded_paths = []
for file in files:
unique_img_name = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(UPLOAD_DIR, unique_img_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
uploaded_paths.append(file_path)
def run_nim():
return subprocess.run(
[NIM_EXE_PATH, output_filename] + uploaded_paths,
capture_output=True,
text=True
)
result = await anyio.to_thread.run_sync(run_nim)
if result.returncode == 0:
return FileResponse(
path=output_filename,
filename="merged_images.pdf",
media_type="application/pdf"
)
else:
return {"status": "error", "error": result.stderr}
The "Dirty" Details:
• The Bridge: FastAPI handles the multi-part file uploads, saves them, and then kicks off a Nim subprocess to "plaster" the images onto an A4 page.
• The Windows Trap: If you've ever tried spawning subprocesses in asyncio on Windows, you've likely met the NotImplementedError. I bypassed this using anyio.to_thread. It's a "sync-in-async" hack that keeps the server non-blocking.
• Performance: nimpdf is impressively lightweight. It makes the "binary crunching" part of the stack feel almost instant.
Why do this? Mostly curiosity. I wanted to see how much friction there was in passing data into a systems-level binary from a high-level web framework. Turns out, once you get the subprocess logic right, it's a pretty powerful pattern.
Note: From what I've read, while the subprocess approach worked, a more "pro" move would be to compile Nim to a .so file.
Instead of Python calling a program, Python becomes the program, executing Nim code natively via C-bindings. This removes the OS overhead of spawning processes and keeps everything in-memory.
Take a peek. Beware, it's not validated, this was just a proof of concept.
Top comments (0)