Build Report:
I’ve been building Sheeto, a web app for creating printable worksheets through a chat-based editor.
One of the hardest features has been PDF export.
At first, I used an existing PDF plugin. It worked for simple pages, but it became unreliable as the editor grew more complex.
I needed the exported PDF to preserve:
Exact worksheet dimensions
User-selected paper sizes and margins
Images and web fonts
Model answers and handwriting-style text
Object-level page breaks
Multi-page worksheet layouts
Separate answer sheets
The plugin could not reliably reproduce everything shown in the editor, so I started building my own PDF system.
Before replacing the PDF exporter, I built a custom page-break system inside the editor.
Each worksheet consists of multiple objects, such as headings, questions, images, answer fields and writing areas.
The editor measures these objects and determines which object should begin the next page. This means page breaks happen between meaningful worksheet objects instead of cutting through their contents.
The resulting page-break positions are saved and reused during PDF generation.
This became important later because calculating page breaks independently in two different browser environments produced inconsistent results.
The editor is now the source of truth for worksheet pagination.
My first custom implementation sent the worksheet’s generated HTML to a Cloud Run service.
The service opened the HTML in Puppeteer and converted it into a PDF.
The basic pipeline worked, but the actual worksheet did not render consistently.
The exported result could differ because the new browser environment did not automatically inherit everything from the original editor:
Loaded CSS
Web fonts
Images
Runtime DOM changes
Computed layout
Dynamically rendered answers
I realized that “sending the HTML” was not the same as “reproducing the editor.”
I temporarily abandoned the Puppeteer approach and considered using the browser’s native print function instead.
Before moving on, I tried one more approach.
Instead of sending isolated HTML to Puppeteer, the Cloud Run service now opens the actual Sheeto editor in an authorized session.
Inside that real application page, it waits for the worksheet to finish rendering and then runs the same export logic used by the editor.
The new flow is roughly:
User requests a PDF ↓ A backend workflow starts the job ↓ Cloud Run launches Puppeteer ↓ Puppeteer opens the actual editor ↓ The editor restores the requested worksheet view ↓ Dynamic content, fonts and images finish loading ↓ The editor builds a PDF-only DOM clone ↓ Saved page-break information is applied ↓ Puppeteer generates the PDF ↓ The completed file is downloaded automatically
This avoided rebuilding the application’s visual state from raw HTML.
The existing editor became part of the rendering engine.
Another issue was that a page could appear loaded while parts of the worksheet were still being generated.
For example, the first PDF attempt sometimes omitted:
Correct-answer circles
Writing lines
Model answers
Parsed fill-in-the-blank answers
Running the export a second time often worked, which showed that this was a timing problem.
I added explicit readiness checks for dynamically generated content. PDF generation now waits for the required answer elements, fonts and layout calculations to finish before cloning the page.
One useful lesson was:
“The page has loaded” and “the page is ready to print” are not the same state.
The worksheet and answer sheet use different layouts.
The worksheet follows page breaks selected in the editor. The answer sheet arranges answers more compactly and can place multiple answer objects on the same row.
I therefore added a separate answer-sheet pagination process that:
Measures the rendered answer objects
Groups objects that share the same visual row
Keeps section headings with their content
Calculates page breaks using the selected paper size
Creates a PDF clone specifically for the answer sheet
The Cloud Run service can now generate either view using the same endpoint.
PDF generation can take several seconds, especially when Cloud Run starts a new instance.
To make the delay feel intentional, I added a progress UI in Bubble. The progress slows down near completion instead of moving at a perfectly fixed rate, but it never claims the file is complete until the backend actually finishes.
The current experience is:
Click PDF
See which document is being prepared
Watch the progress indicator
Automatically download the finished file
Worksheet PDFs and answer-sheet PDFs are stored separately, and an older generated file is removed only after its replacement has been saved successfully.
The custom pipeline now supports:
Cloud Run + Puppeteer PDF generation
Object-aware page breaks
A4 and Letter paper sizes
Configurable worksheet margins
Dedicated answer-sheet margins
Images, backgrounds and web fonts
Dynamically generated model answers
Separate worksheet and answer-sheet files
Automatic download
Progress feedback
Previous-file cleanup
There are still occasional layout inconsistencies in the answer-sheet export. For now, I’ve accepted that as a known limitation.
In the longer term, I may restructure the answer sheet so that it uses exactly the same page-based rendering model as the worksheet. That should make both exports even more consistent.
The biggest lesson was that the first failed approach was not proof that Puppeteer could not work.
The real problem was the boundary I had chosen.
Trying to reconstruct the editor from exported HTML was fragile. Opening the real application and reusing its existing rendering logic was much more reliable.
I also learned that duplicating layout calculations across environments creates unnecessary instability. Having one source of truth for pagination made the system easier to reason about.
This feature took several iterations and, at one point, I had almost given up on fully automated PDF generation.
It is now working well enough to become a real part of Sheeto.
The next step is to separate the staging and production PDF services so I can continue improving the renderer without affecting live users.