jspdf-md-renderer started as a utility. Someone needed a report exported as a
PDF, the content was already Markdown, and jsPDF was already in the bundle. A
day of work, at most.
Four major versions and about 7,800 downloads a month later, I can tell you what it actually is: a small, badly-behaved browser. It has a layout engine. It has a pagination model. It fetches remote resources. It executes untrusted input.
Every one of those was a surprise. Here is what each one taught me.
The bug that was actually an architecture problem
The version before v4 had a category of bug I could not close.
A heading that sat perfectly on page one collided with a table on page four. Fix the heading, break the list. Fix the list, break the blockquote inside the list. Each fix was correct in isolation and made the whole worse.
That pattern — local correctness, global failure — is a reliable signal that a decision is being made in the wrong place.
The decision, in this case, was how much vertical space does this take, and does it fit? And it was being made independently by the paragraph renderer, the list renderer, the blockquote renderer, and the heading renderer. Four components with four opinions about line height, and the last one to write to the cursor won.
A bold run inside a list item inside a table involves all four. There is no "correct" fix, because the components genuinely disagree, and nothing was adjudicating.
One door
The v4 change was not an algorithm. It was a rule, enforced by making all other paths impossible:
/**
* THE single entry point for rendering any mixed inline content.
* All paragraph, heading, list item, and blockquote text must go through here.
*/
export const renderInlineContent = (
doc: jsPDF,
elements: ParsedElement[],
x: number,
y: number,
maxWidth: number,
store: RenderStore,
opts: LayoutOptions = {},
): number => {
const words = flattenToWords(doc, elements, store);
if (words.length === 0) return y;
const lines = breakIntoLines(doc, words, maxWidth, store);
// ...
};Three stages, each with one job:
flattenToWords walks the parsed Markdown tree and produces a flat list of
words, each carrying its own resolved style and measured width. This is where
nesting is destroyed on purpose — a bold word inside a list item inside a
blockquote emerges as a word with style: "bold" and a number. The tree
mattered for determining the style. It does not matter for placing the text.
breakIntoLines consumes words and a width, and returns lines. It knows
nothing about pages. It cannot know about pages, because it does not have them.
renderLine draws one line at one position and makes no layout decisions
whatsoever.
The measurement step is fussier than it looks, because measuring text requires mutating the document to the font you are measuring in:
export const measureStyledWidth = (
doc: jsPDF,
text: string,
style: TextStyle,
store: RenderStore,
): number => {
const savedFont = doc.getFont();
const savedSize = doc.getFontSize();
applyStyleToDoc(doc, style, store);
const charSpace = doc.getCharSpace?.() ?? 0;
const width = doc.getTextWidth(text) + text.length * charSpace;
doc.setFont(savedFont.fontName, savedFont.fontStyle);
doc.setFontSize(savedSize);
return width;
};Save, mutate, measure, restore. Skip the restore and every subsequent
measurement in the document is taken in the wrong font — and the failure is not
an exception, it is a document that is subtly, unfixably misaligned. The
charSpace term is there because jsPDF applies character spacing at draw time
but does not include it in getTextWidth, so text measured without it wraps
about one character too late on every line.
Style resolution is where nesting actually gets handled:
export const resolveStyle = (type: string, parentStyle?: TextStyle): TextStyle => {
switch (type) {
case "strong":
return parentStyle === "italic" ? "bolditalic" : "bold";
case "em":
return parentStyle === "bold" ? "bolditalic" : "italic";
case "codespan":
return "codespan";
default:
return parentStyle ?? "normal";
}
};Bold inside italic and italic inside bold both have to arrive at bolditalic,
because Markdown authors write both and a PDF has one font slot. Whichever
wrapper is outermost, the answer is the same — which is only true because this
function is the only place that decides.
Pagination is a different kind of decision
Line breaking is pure. Given words and a width, the answer is a function of the inputs.
Page breaking is not. A page boundary changes the available geometry for everything that follows, which means it is a decision with consequences for code that has not run yet. Distributing that across components is what made the original bugs impossible to isolate.
So every page break in the codebase goes through one module:
export const HandlePageBreaks = (doc: jsPDF, store: RenderStore) => {
if (typeof store.options.pageBreakHandler === "function") {
store.options.pageBreakHandler(doc);
} else {
doc.addPage(store.options.page?.format, store.options.page?.orientation);
}
// Reset the cursor for the new page.
store.updateY(store.options.page.topmargin);
store.updateX(store.options.page.xpading);
};
export const willOverflow = (store: RenderStore, height: number): boolean =>
store.Y + height > store.options.page.maxContentHeight;
export const ensureSpace = (doc: jsPDF, store: RenderStore, minHeight: number): void => {
if (store.options.page.maxContentHeight - store.Y < minHeight) {
HandlePageBreaks(doc, store);
}
};ensureSpace is the one that earns its place. It is how a heading avoids being
orphaned at the bottom of a page: before drawing, reserve enough room for the
heading and the first line of what follows. Without a shared helper, that logic
gets reimplemented in five components with five different definitions of
"enough", and the sixth component forgets entirely.
The practical payoff of centralizing all of this is testability. With breaking isolated, a page-break bug is reproducible from a layout fixture — you feed the breaker a list of lines and a page height and assert where it splits. No PDF is generated. No fonts load. The test runs in milliseconds and fails for exactly one reason.
When breaking was distributed, reproducing the same bug meant rendering a full document and looking at it.
The part I did not plan for
Here is the thing I want people to take from this post, because it is the non-obvious one.
A Markdown renderer executes untrusted input. If your application lets users write Markdown — comments, reports, templates, notes — and you export it to PDF, then this library is parsing text an attacker controls.
And Markdown has images.
To render that, something has to fetch it. If the renderer runs server-side — generating invoices in a Node process, say — then an attacker who can write Markdown can make your server issue HTTP requests to URLs of their choosing. That is server-side request forgery, and the classic target is not a website:
That address is the cloud instance metadata endpoint. On an unhardened instance it returns credentials. My "render some Markdown as a PDF" utility was, without anyone intending it, a request proxy running inside the trust boundary.
So v4 has a security layer. It is opt-in, because turning it on by default would break every existing user's document rendering, and a silent behaviour change in a patch is its own kind of harm:
const options = {
security: {
enabled: true,
violationMode: "skip", // 'skip' | 'throw' | 'placeholder'
// SSRF controls
blockLocalhost: true,
blockPrivateIPs: true,
blockLinkLocalIPs: true,
blockMetadataIPs: true,
// Link and image surface
allowedLinkProtocols: ["https:", "mailto:"],
allowedImageDomains: ["cdn.example.com"],
allowSvgImages: false,
// Resource limits
maxImageCount: 200,
maxImageSizeBytes: 10 * 1024 * 1024,
renderTimeoutMs: 30_000,
onSecurityViolation: (violation) => log.warn(violation),
},
};Three details worth stealing if you build something similar.
violationMode is a policy, not a constant. A public document generator
wants skip — render what is safe, drop the rest, never fail the request. An
internal pipeline wants throw, because a blocked resource means the input is
wrong and someone should know. A CMS preview wants placeholder, so the author
can see where their image was rejected. Same enforcement, three different
correct behaviours, and picking one for everybody would be picking wrong twice.
allowedImageDomains distinguishes empty from absent. undefined allows
all domains; [] denies all. Collapsing those — treating an empty array as "no
restriction" — turns a maximally restrictive config into a maximally permissive
one, which is the worst possible direction for a mistake in a security option to
fail.
Some limits cannot be turned off. Regardless of security.enabled, input
over 2 MB and structures nested deeper than 300 levels are rejected before
parsing begins:
> > > > > > (repeat three hundred more times)That is not a policy question. Deeply nested Markdown blows the parser's stack,
and a library that lets a config flag turn a text input into a process crash has
made a config flag responsible for availability. Those limits throw a distinct
MarkdownParsingLimitError so callers can tell "this input is hostile" apart
from "this input is invalid".
There is a caveat I state plainly in the README rather than burying: IP-level SSRF checks are best-effort in a browser, because there is no DNS resolution API to check what a hostname actually points at. A domain allowlist is enforceable in a browser; a private-IP block fundamentally is not. If you need a strict policy, fetch remote images through a server-side proxy you control.
Security options that overstate what they guarantee are worse than absent ones, because they end an investigation that should have continued.
What v4 costs
Every architectural choice trades something away, and naming the trade is what separates a build log from a changelog.
The single-entry-point rule makes the common path clean and makes anything that
wants to influence its own pagination genuinely awkward. "Keep this table with
its caption" or "start this section on an odd page" both mean a component
reaching back into a decision the design deliberately took away from it. The
escape hatch is pageBreakHandler, and it is an escape hatch rather than a
feature.
I would make the trade again. Four components negotiating pagination is the system I already had, and it was unfixable. One component owning it is a system with a known limitation. A known limitation is a much better place to be than an emergent one — but it is still a limitation, and pretending otherwise would be the dishonest version of this post.
jspdf-md-renderer is MIT, on
npm and
GitHub. It supports headings,
lists, task lists, tables, images with sizing attributes, code blocks,
blockquotes, links, and inline styles, with configurable typography, headers and
footers, and page numbers.
If you are rendering user-authored Markdown on a server, turn the security layer on.