CogniPrep generates four PDF reports with @react-pdf/renderer: one per practice session, one master analysis across sessions, one per mock interview, and one per assessment centre exercise. I have written before about why the engine is imported on click rather than at module load and about the one download helper all four share.
This post is about the part nobody thinks about until a document gets long: where the pages break.
On screen, a card that is too tall just scrolls. In a PDF it has to be cut somewhere, and the default somewhere is wherever the content happens to reach the bottom margin. That produces the two classic artefacts: a score card sliced through the middle of a progress bar, and a section heading sitting alone on the last line of a page with its content on the next one.
react-pdf gives you three props to fix that, and they do different jobs.
wrap={false}: this block is atomic
<View style={styles.scoreCard} wrap={false}>
<Text style={styles.scoreHeader}>Decision Score</Text>
<View style={styles.scoreRow}>
<Text style={styles.scoreNumber}>{Math.round(report.rawScore)}</Text>
<Text style={styles.scoreOutOf}>/100</Text>
</View>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${Math.round(report.rawScore)}%` }]} />
</View>
</View>
wrap={false} means: if this will not fit in the space left, move the whole thing to the next page. It is the right answer for anything with a fixed, known, small height. In the exercise report that is the score card, each per-competency bar, each decision card, and each bullet point. A bullet whose text wrapped across a page boundary, leaving the dot on one page and the sentence on the next, is the single ugliest thing a generated report can do.
It is the wrong answer for anything unbounded. A candidate's written response can run to a couple of hundred words, and wrap={false} on a block taller than a page does not make it fit, it just leaves react-pdf to break it anyway after wasting the rest of the previous page. So the written response cards are deliberately left wrappable, and the page they live on makes room for the fixed footer instead:
// A written response runs to a couple of hundred words, so its card is
// allowed to break across pages and the page leaves room for the fixed
// footer, as the session report does.
page: {
paddingBottom: 64,
},
break: start here, on a fresh page
<View
style={styles.sectionCard}
break={report.hasMcq && report.decisions.length > 0}
>
<Text style={styles.sectionTitle}>Written Responses</Text>
break is a forced page break before the element, and the condition is the interesting bit. The written section only forces a new page when there is a decision section above it. For an exercise with no decisions, that same prop would push an empty first page in front of the document.
A boolean expression in a layout prop looks like a smell until you say what it means in prose: start on a fresh page, unless you are already at the top of one.
minPresenceAhead: do not leave me stranded
<View style={exerciseStyles.decisionHeaderRow} minPresenceAhead={60}>
<Text style={exerciseStyles.decisionLabel}>{w.label}</Text>
...
</View>
This is the orphan control. It tells the renderer that this element needs at least 60 units of its following content on the same page, otherwise it should move the element itself to the next page. It is how you stop a heading from being the last thing on a page.
wrap={false} could not do this job: the header row fits on its own perfectly well, which is exactly the problem. The constraint is not about the element's own height, it is about what comes after it.
fixed: the footer that is on every page
<View style={styles.footer} fixed>
<Text style={styles.footerText}>Generated by CogniPrep • cogniprep.app</Text>
<Text
style={styles.footerText}
render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`}
/>
</View>
fixed repeats the element on every page. render is react-pdf's way of handing you per-page state, and page numbering is the reason it exists: the component cannot know its own page number, because the page it lands on is decided by the layout engine after the tree is built. The footer style is absolutely positioned at bottom: 30, which is why the page needed that paddingBottom: 64 above. Padding and fixed positioning do not negotiate with each other, so the room for the footer has to be reserved by hand.
Two smaller things that caught me out
The built-in fonts have no tick. This was going to be a check mark next to the best answer:
{/* Written out rather than a tick: the built-in Helvetica has no
check mark glyph. */}
<Text style={exerciseStyles.decisionText}>
You chose: {d.chosen ?? 'No answer'}
{d.gotBest ? ' (best action)' : ''}
</Text>
react-pdf's default Helvetica is one of the PDF base 14 fonts, and U+2713 is not in it. You get a blank or a fallback box, with no error. Registering a font that has the glyph would mean shipping a font file for one character, so the document says "(best action)" in words.
A colon in a title becomes an underscore in a filename. The browser rewrites characters it does not like in a download attribute, silently:
const slug = data.report.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
Exercise titles look like "E-Tray: Product Launch", so without that slug you get cogniprep-exercise-e-tray_-product-launch.pdf.
Which styles are allowed to be shared
One last structural rule that is not about pagination but is about keeping four documents sane. There is a shared pdf-styles.ts, and its header states the entry criterion:
Shared black & white PDF styles used by two or more report documents (session, master, interview, exercise). Styles used by only a single document live alongside that document's module instead, so a report surface that lazy-loads its own module doesn't pull in style objects it never renders.
So the page, header, title card, score card, trait bars, bullets and footer are shared, because at least two documents render them. Decision cards, written-answer boxes and criterion comments live in the exercise document's own StyleSheet.create call, because only it has those. A shared stylesheet that accumulates every document's private styles is a shared stylesheet that every document pays for.
The reports themselves are behind a sign-in, but what they are generated from is public: the assessment centre exercises, the mock interview practice, and the test formats that produce the session and master reports.
Top comments (0)