diff --git a/apps/site/app/(home)/blog/[slug]/page.tsx b/apps/site/app/(home)/blog/[slug]/page.tsx index 5fee294c9..fe5512485 100644 --- a/apps/site/app/(home)/blog/[slug]/page.tsx +++ b/apps/site/app/(home)/blog/[slug]/page.tsx @@ -3,6 +3,12 @@ import { notFound } from 'next/navigation'; import { Mdx } from '@/components/mdx'; import { OverviewTimeline } from '@/components/overview-timeline'; import { PostCover } from '@/components/post-cover'; +import { + GlyphLab, + LineBoxLab, + LineBreakLab, + RunsStrip, +} from '@/components/text-lab'; import { blogSource } from '@/lib/blog-source'; const formatDate = (value: string) => @@ -47,7 +53,16 @@ export default async function BlogPost(props: {
- +
); diff --git a/apps/site/components/text-lab/breaks.ts b/apps/site/components/text-lab/breaks.ts new file mode 100644 index 000000000..4819ff6f1 --- /dev/null +++ b/apps/site/components/text-lab/breaks.ts @@ -0,0 +1,44 @@ +/** + * First-fit line breaking: the algorithm react-pdf does *not* use, kept around + * so the lab can put it next to Knuth & Plass. + */ +export const greedyLines = ( + text: string, + maxWidth: number, + measure: (line: string) => number, +) => { + const tokens = text.split(/( +)/).filter(Boolean); + const lines: string[] = []; + + let current = ''; + + for (const token of tokens) { + if (token.trim() === '') { + if (current) current += token; + continue; + } + + const candidate = current + token; + + if (current.trim() && measure(candidate.trimEnd()) > maxWidth) { + lines.push(current.trimEnd()); + current = token; + } else { + current = candidate; + } + } + + if (current.trim()) lines.push(current.trimEnd()); + + return lines; +}; + +/** + * Sum of squared leftovers, ignoring the last line. Squaring is what makes one + * badly short line cost more than several slightly short ones, which is the + * whole reason optimal breaking looks better. + */ +export const raggedness = (widths: number[], maxWidth: number) => + widths + .slice(0, -1) + .reduce((total, width) => total + (maxWidth - width) ** 2, 0); diff --git a/apps/site/components/text-lab/font.ts b/apps/site/components/text-lab/font.ts new file mode 100644 index 000000000..c8610265b --- /dev/null +++ b/apps/site/components/text-lab/font.ts @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import * as fontkit from 'fontkit'; + +export const ROBOTO = '/fonts/Roboto-Regular.ttf'; + +export type Glyph = { + id: number; + codePoints: number[]; + advanceWidth: number; + path: { toSVG: () => string }; +}; + +export type Position = { + xAdvance: number; + yAdvance: number; + xOffset: number; + yOffset: number; +}; + +export type GlyphRun = { + glyphs: Glyph[]; + positions: Position[]; + advanceWidth: number; +}; + +export type Font = { + unitsPerEm: number; + ascent: number; + descent: number; + lineGap: number; + layout: ( + string: string, + features?: Record | string[], + ) => GlyphRun; +}; + +const pending = new Map>(); + +const load = (src: string) => { + let request = pending.get(src); + + if (!request) { + request = fetch(src) + .then((response) => response.arrayBuffer()) + .then((buffer) => fontkit.create(new Uint8Array(buffer)) as Font); + pending.set(src, request); + } + + return request; +}; + +export const useFont = (src: string = ROBOTO) => { + const [font, setFont] = useState(null); + + useEffect(() => { + let live = true; + load(src).then((loaded) => { + if (live) setFont(loaded); + }); + return () => { + live = false; + }; + }, [src]); + + return font; +}; + +export const isSpace = (glyph: Glyph) => glyph.codePoints.includes(0x20); + +/** Natural line height, the value textkit uses when no lineHeight is set. */ +export const naturalHeight = (font: Font, fontSize: number) => + ((font.lineGap + font.ascent - font.descent) / font.unitsPerEm) * fontSize; diff --git a/apps/site/components/text-lab/fontkit.d.ts b/apps/site/components/text-lab/fontkit.d.ts new file mode 100644 index 000000000..b906c796f --- /dev/null +++ b/apps/site/components/text-lab/fontkit.d.ts @@ -0,0 +1,3 @@ +declare module 'fontkit' { + export function create(data: Uint8Array, postscriptName?: string): unknown; +} diff --git a/apps/site/components/text-lab/glyph-lab.tsx b/apps/site/components/text-lab/glyph-lab.tsx new file mode 100644 index 000000000..f61c79a73 --- /dev/null +++ b/apps/site/components/text-lab/glyph-lab.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { useFont } from './font'; +import { Lab, TextInput, Toggle } from './ui'; + +const DEFAULT_TEXT = 'office fjord AVA To'; + +const useShaping = (text: string, kern: boolean, liga: boolean) => { + const font = useFont(); + + return useMemo(() => { + if (!font) return null; + + // fontkit mutates the feature object it is handed, so it gets a fresh one + const run = font.layout(text || ' ', { kern, liga }); + + let x = 0; + + const cells = run.glyphs.map((glyph, index) => { + const position = run.positions[index]; + const chars = String.fromCodePoint(...glyph.codePoints); + const cell = { + key: index, + id: glyph.id, + chars, + x, + offset: position.xOffset, + advance: position.xAdvance, + natural: glyph.advanceWidth, + kerning: position.xAdvance - glyph.advanceWidth, + outline: glyph.path.toSVG(), + }; + + x += position.xAdvance; + + return cell; + }); + + const chars = cells.flatMap((cell) => + [...cell.chars].map((char) => ({ char, cell })), + ); + + return { + cells, + chars, + content: x, + // a short string would otherwise give the svg a portrait aspect ratio and, + // at width:100%, a height several times the width of the article + width: Math.max(x, 7 * font.unitsPerEm), + em: font.unitsPerEm, + ascent: font.ascent, + descent: font.descent, + kerned: cells.filter((cell) => cell.kerning !== 0).length, + ligatures: cells.filter((cell) => cell.chars.length > 1).length, + }; + }, [font, text, kern, liga]); +}; + +const plural = (count: number, noun: string) => + `${count} ${noun}${count === 1 ? '' : 's'}`; + +const caption = (model: NonNullable>) => { + const { chars, cells, ligatures, kerned } = model; + + return [ + `${plural(chars.length, 'character')} shape into ${plural(cells.length, 'glyph')}.`, + ligatures > 0 && + `${ligatures} of them ${ligatures === 1 ? 'covers' : 'cover'} more than one character.`, + kerned > 0 + ? `${plural(kerned, 'pair')} ${kerned === 1 ? 'is' : 'are'} kerned, which is why the character row above is evenly spaced and the glyph row below is not.` + : 'Nothing here is kerned, so both rows line up.', + ] + .filter(Boolean) + .join(' '); +}; + +export function GlyphLab() { + const [text, setText] = useState(DEFAULT_TEXT); + const [kern, setKern] = useState(true); + const [liga, setLiga] = useState(true); + + const model = useShaping(text, kern, liga); + + const controls = ( + <> + +
+ + +
+ + ); + + if (!model) { + return ( + +
+ + ); + } + + const { em, ascent, descent, cells, chars, width, content } = model; + const origin = (width - content) / 2; + + const chipHeight = 0.5 * em; + const gap = 0.55 * em; + const labelRow = 0.5 * em; + const pad = 0.14 * em; + + const bandHeight = ascent - descent; + const baseline = ascent; + const chipY = -(gap + chipHeight); + + const chipWidth = Math.min(0.8 * em, width / Math.max(chars.length, 1)); + const chipX0 = (width - chipWidth * chars.length) / 2; + + const viewBox = [ + -pad, + chipY - pad, + width + pad * 2, + chipHeight + gap + bandHeight + labelRow + pad * 2, + ].join(' '); + + if (!text) { + return ( + +
+ Type something to shape it +
+
+ ); + } + + return ( + + + + {cells.map((cell, index) => ( + + 1 + ? 'fill-fd-primary/10 stroke-fd-primary/30' + : index % 2 + ? 'fill-fd-muted/70 stroke-fd-border' + : 'fill-transparent stroke-fd-border' + } + strokeWidth={1} + vectorEffect="non-scaling-stroke" + /> + {cell.outline && ( + + )} + {cell.kerning !== 0 && ( + + )} + {cell.advance > 0.3 * em && ( + + {cell.id} + + )} + + ))} + + + + + {chars.map((entry, index) => { + const x = chipX0 + index * chipWidth; + const cx = x + chipWidth / 2; + const target = origin + entry.cell.x + entry.cell.advance / 2; + + return ( + + 1 + ? 'stroke-fd-primary/60' + : 'stroke-fd-border' + } + strokeWidth={1} + vectorEffect="non-scaling-stroke" + /> + + + {entry.char === ' ' ? '␣' : entry.char} + + + ); + })} + + + ); +} diff --git a/apps/site/components/text-lab/index.tsx b/apps/site/components/text-lab/index.tsx new file mode 100644 index 000000000..40692115c --- /dev/null +++ b/apps/site/components/text-lab/index.tsx @@ -0,0 +1,24 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import type { FC } from 'react'; + +import { LabSkeleton } from './ui'; + +/** fontkit and textkit only load once a reader scrolls into one of these. */ +export const GlyphLab = dynamic( + () => import('./glyph-lab').then((m) => m.GlyphLab), + { ssr: false, loading: () => }, +) as FC; + +export const LineBreakLab = dynamic( + () => import('./line-break-lab').then((m) => m.LineBreakLab), + { ssr: false, loading: () => }, +) as FC; + +export const LineBoxLab = dynamic( + () => import('./line-box-lab').then((m) => m.LineBoxLab), + { ssr: false, loading: () => }, +) as FC; + +export { RunsStrip } from './runs-strip'; diff --git a/apps/site/components/text-lab/line-box-lab.tsx b/apps/site/components/text-lab/line-box-lab.tsx new file mode 100644 index 000000000..c811b0b75 --- /dev/null +++ b/apps/site/components/text-lab/line-box-lab.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { naturalHeight, useFont } from './font'; +import { Lab, Legend, Slider } from './ui'; + +const TEXT = 'Typography is the craft of'; +const SECOND = 'endowing human language'; + +const PAD = 18; +const LINES = 2; +const ZOOM = 2.6; // css pixels per point, so the box grows as fontSize does + +export function LineBoxLab() { + const font = useFont(); + const [fontSize, setFontSize] = useState(18); + const [multiplier, setMultiplier] = useState(0); + + const model = useMemo(() => { + if (!font) return null; + + const scale = fontSize / font.unitsPerEm; + const ascent = font.ascent * scale; + const descent = font.descent * scale; + const lineGap = font.lineGap * scale; + const natural = naturalHeight(font, fontSize); + const height = multiplier ? multiplier * fontSize : natural; + + const shape = (text: string) => { + const run = font.layout(text); + const glyphs: { d: string; x: number }[] = []; + let x = 0; + + run.glyphs.forEach((glyph, index) => { + const outline = glyph.path.toSVG(); + if (outline) glyphs.push({ d: outline, x }); + x += run.positions[index].xAdvance * scale; + }); + + return { glyphs, width: run.advanceWidth * scale }; + }; + + const lines = [shape(TEXT), shape(SECOND)]; + + return { + scale, + ascent, + descent, + lineGap, + natural, + height, + lines, + // the box hugs the widest line, so growing fontSize can never overflow it + column: Math.max(...lines.map((line) => line.width)) + fontSize, + }; + }, [font, fontSize, multiplier]); + + const controls = ( + <> + `${value} pt`} + /> + (value ? value.toFixed(2) : 'auto')} + /> + + ); + + if (!model) { + return ( + +
+ + ); + } + + const { ascent, descent, height, natural, lines, scale, column } = model; + const boxHeight = Math.max(height, natural); + const total = boxHeight * LINES; + + const viewBox = [-PAD, -PAD, column + PAD * 2, total + PAD * 2].join(' '); + + return ( + + + + {multiplier + ? `height = lineHeight ${multiplier.toFixed(2)} × fontSize ${fontSize} = ${boxHeight.toFixed(2)} pt` + : `height = lineGap ${model.lineGap.toFixed(2)} + ascent ${ascent.toFixed(2)} − descent (${descent.toFixed(2)}) = ${natural.toFixed(2)} pt`} + , baseline {ascent.toFixed(2)} pt below the top + +
+ } + > +
+ + {lines.map((line, index) => { + const top = index * boxHeight; + const baseline = top + ascent; + + return ( + + + + + + + {line.glyphs.map((glyph, glyphIndex) => ( + + ))} + + ); + })} + +
+
+ ); +} diff --git a/apps/site/components/text-lab/line-break-lab.tsx b/apps/site/components/text-lab/line-break-lab.tsx new file mode 100644 index 000000000..0452bd769 --- /dev/null +++ b/apps/site/components/text-lab/line-break-lab.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import layoutEngine, { + bidi, + linebreaker, + justification, + textDecoration, + scriptItemizer, + wordHyphenation, + fontSubstitution, + fromFragments, +} from '@react-pdf/textkit'; + +import { greedyLines, raggedness } from './breaks'; +import { + isSpace, + naturalHeight, + useFont, + type Font, + type GlyphRun, +} from './font'; +import { Lab, Legend, Segmented, Slider, Toggle } from './ui'; + +const ENGINE = layoutEngine({ + bidi, + linebreaker, + justification, + textDecoration, + scriptItemizer, + wordHyphenation, + fontSubstitution, +}); + +const TEXT = + 'Knuth and Plass read a paragraph the way a compiler reads a program: not one line at a time, but all of it at once. Every space can stretch or shrink a little, every hyphenation point costs something, and the winning set of breaks is the one with the lowest total badness.'; + +const FONT_SIZE = 11; +const MIN_WIDTH = 150; +const MAX_WIDTH = 380; +const SLOTS = 10; // the tallest either algorithm gets, at MIN_WIDTH +const PAD = 16; + +type DrawLine = { + glyphs: { d: string; x: number }[]; + gaps: { x: number; width: number; grew: boolean }[]; + right: number; +}; + +/** + * @param posScale points per position unit — 1 for textkit runs, which are + * already scaled, and fontSize/unitsPerEm for raw fontkit output + * @param unitScale points per font unit, always fontSize/unitsPerEm + */ +const collect = ( + run: Pick, + posScale: number, + unitScale: number, + startX: number, + stretch: number, +): DrawLine => { + const glyphs: DrawLine['glyphs'] = []; + const gaps: DrawLine['gaps'] = []; + + let x = startX; + let right = startX; + + run.glyphs.forEach((glyph, index) => { + const position = run.positions[index]; + const advance = + position.xAdvance * posScale + (isSpace(glyph) ? stretch : 0); + + if (isSpace(glyph)) { + const natural = glyph.advanceWidth * unitScale; + const extra = advance - natural; + if (Math.abs(extra) > 0.15) { + gaps.push({ + x: x + Math.min(natural, advance), + width: Math.abs(extra), + grew: extra > 0, + }); + } + } else { + const outline = glyph.path.toSVG(); + if (outline) + glyphs.push({ d: outline, x: x + position.xOffset * posScale }); + right = x + advance; + } + + x += advance; + }); + + return { glyphs, gaps, right }; +}; + +const useKnuthPlass = (font: Font | null, width: number, justify: boolean) => + useMemo(() => { + if (!font) return null; + + const attributes = { + font: [font], + fontSize: FONT_SIZE, + align: justify ? 'justify' : 'left', + }; + + const string = fromFragments([ + { string: TEXT, attributes }, + ] as unknown as Parameters[0]); + + const container = { x: 0, y: 0, width, height: Infinity }; + const blocks = ENGINE(string, container, {}); + + // textkit already scaled every position into points, and its justification + // engine already moved the extra width into the space advances, so nothing + // here rescales or stretches anything. + return blocks.flat().map((line) => + collect( + { + glyphs: line.runs.flatMap((run) => run.glyphs ?? []), + positions: line.runs.flatMap((run) => run.positions ?? []), + } as unknown as GlyphRun, + 1, + FONT_SIZE / font.unitsPerEm, + line.box?.x ?? 0, + 0, + ), + ); + }, [font, width, justify]); + +const useGreedy = (font: Font | null, width: number, justify: boolean) => + useMemo(() => { + if (!font) return null; + + const scale = FONT_SIZE / font.unitsPerEm; + const measure = (line: string) => font.layout(line).advanceWidth * scale; + const lines = greedyLines(TEXT, width, measure); + + return lines.map((line, index) => { + const run = font.layout(line); + const spaces = run.glyphs.filter(isSpace).length; + const slack = width - run.advanceWidth * scale; + const stretchable = justify && index < lines.length - 1 && spaces > 0; + + return collect(run, scale, scale, 0, stretchable ? slack / spaces : 0); + }); + }, [font, width, justify]); + +const score = (lines: DrawLine[] | null, width: number) => + lines + ? raggedness( + lines.map((line) => line.right), + width, + ) + : 0; + +export function LineBreakLab() { + const font = useFont(); + const [width, setWidth] = useState(260); + const [mode, setMode] = useState<'knuth' | 'greedy'>('knuth'); + const [justify, setJustify] = useState(false); + + const knuth = useKnuthPlass(font, width, justify); + const greedy = useGreedy(font, width, justify); + + const lines = mode === 'knuth' ? knuth : greedy; + const lineHeight = font ? naturalHeight(font, FONT_SIZE) : 13; + const ascent = font ? (font.ascent / font.unitsPerEm) * FONT_SIZE : 10; + const scale = font ? FONT_SIZE / font.unitsPerEm : 1; + + const controls = ( + <> + `${value} pt`} + /> + + + + ); + + // the box is sized for the worst case so dragging the slider never resizes it, + // and short paragraphs are centred in it rather than pinned to the top + const offset = Math.max(0, (SLOTS - (lines?.length ?? 0)) / 2); + + const viewBox = [ + -PAD, + -PAD, + MAX_WIDTH + PAD * 2, + SLOTS * lineHeight + PAD * 2, + ].join(' '); + + return ( + + + {/* every justified line is flush by definition, so the leftovers only + mean something with justify off */} + + {justify ? 'lines' : 'raggedness'}{' '} + + K&P{' '} + {justify + ? (knuth?.length ?? 0) + : Math.round(score(knuth, width)).toLocaleString('en-US')} + {' '} + ·{' '} + + greedy{' '} + {justify + ? (greedy?.length ?? 0) + : Math.round(score(greedy, width)).toLocaleString('en-US')} + + +
+ } + > + + + + + {lines?.map((line, index) => { + const top = (index + offset) * lineHeight; + const baseline = top + ascent; + + return ( + + {line.right < width - 0.5 && ( + + )} + {line.gaps.map((gap, gapIndex) => ( + + ))} + {line.glyphs.map((glyph, glyphIndex) => ( + + ))} + + ); + })} + +
+ ); +} diff --git a/apps/site/components/text-lab/runs-strip.tsx b/apps/site/components/text-lab/runs-strip.tsx new file mode 100644 index 000000000..b251053df --- /dev/null +++ b/apps/site/components/text-lab/runs-strip.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; + +type Run = { + text: string; + label: string; + /** how the run actually looks once its attributes are applied */ + style: string; + /** the underline that marks where this run starts and ends */ + edge: string; + attributes: [string, string][]; +}; + +const BASE: [string, string][] = [ + ['font', '[Roboto, Helvetica]'], + ['fontSize', '12'], + ['color', "'black'"], + ['align', "'left'"], +]; + +const RUNS: Run[] = [ + { + text: 'A ', + label: 'run 0', + style: '', + edge: 'decoration-fd-muted-foreground/60', + attributes: BASE, + }, + { + text: 'run', + label: 'run 1', + style: 'font-bold', + edge: 'decoration-fd-primary', + attributes: [['font', '[Roboto Bold, Helvetica]'], ...BASE.slice(1)], + }, + { + text: ' is the longest slice of the string that ', + label: 'run 2', + style: '', + edge: 'decoration-fd-muted-foreground/25', + attributes: BASE, + }, + { + text: 'agrees on everything', + label: 'run 3', + style: 'text-blue-700 dark:text-blue-400', + edge: 'decoration-blue-500', + attributes: [ + ...BASE.slice(0, 2), + ['color', "'blue'"], + ['underline', 'true'], + ['underlineColor', "'blue'"], + ['link', "'https://react-pdf.org'"], + ], + }, + { + text: '. Nothing else survives the flattening.', + label: 'run 4', + style: '', + edge: 'decoration-fd-muted-foreground/60', + attributes: BASE, + }, +]; + +export function RunsStrip() { + const [active, setActive] = useState(1); + const run = RUNS[active]; + + let offset = 0; + const bounds = RUNS.map((entry) => { + const start = offset; + offset += entry.text.length; + return [start, offset] as const; + }); + + return ( +
+
+

+ {RUNS.map((entry, index) => ( + + ))} +

+
+ +
+
+ {run.label} · characters {bounds[active][0]}–{bounds[active][1]} +
+
+ {run.attributes.map(([key, value]) => ( + + {key}: + {value} + + ))} +
+
+
+ ); +} diff --git a/apps/site/components/text-lab/ui.tsx b/apps/site/components/text-lab/ui.tsx new file mode 100644 index 000000000..b9c497b55 --- /dev/null +++ b/apps/site/components/text-lab/ui.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useId, type ReactNode } from 'react'; + +export function Lab({ + children, + controls, + caption, +}: { + children: ReactNode; + controls: ReactNode; + caption?: ReactNode; +}) { + return ( +
+
{children}
+
+
+ {controls} +
+ {caption && ( +
+ {caption} +
+ )} +
+
+ ); +} + +export function Slider({ + label, + value, + min, + max, + step = 1, + onChange, + format, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + format?: (value: number) => string; +}) { + const id = useId(); + + return ( +
+ + onChange(Number(event.target.value))} + className="accent-fd-primary h-1 min-w-0 flex-1 cursor-pointer" + /> + + {format ? format(value) : value} + +
+ ); +} + +const chip = + 'focus-visible:ring-fd-ring rounded-md px-2.5 py-1 text-[0.75rem] transition-colors outline-none focus-visible:ring-2'; + +export function Segmented({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { + return ( +
+ {label} +
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +export function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +export function TextInput({ + label, + value, + onChange, + maxLength = 40, +}: { + label: string; + value: string; + onChange: (value: string) => void; + maxLength?: number; +}) { + const id = useId(); + + return ( +
+ + onChange(event.target.value)} + className="border-fd-border bg-fd-background focus-visible:ring-fd-ring min-w-0 flex-1 rounded-md border px-2.5 py-1 font-mono text-[0.75rem] outline-none focus-visible:ring-2" + /> +
+ ); +} + +export function Legend({ + items, +}: { + items: { color: string; label: string }[]; +}) { + return ( +
+ {items.map((item) => ( + + + {item.label} + + ))} +
+ ); +} + +export function LabSkeleton({ height }: { height: string }) { + return ( +
+
+
+ ); +} diff --git a/apps/site/content/blog/dropping-the-pdfkit-fork.mdx b/apps/site/content/blog/dropping-the-pdfkit-fork.mdx new file mode 100644 index 000000000..ac1118bb2 --- /dev/null +++ b/apps/site/content/blog/dropping-the-pdfkit-fork.mdx @@ -0,0 +1,64 @@ +--- +title: Dropping the pdfkit fork +date: '2026-08-23' +description: React-pdf shipped its own copy of pdfkit for eight years. The story of why that happened, and how we finally got rid of it. +--- + +React-pdf no longer ships its own copy of pdfkit. The `@react-pdf/pdfkit` package is gone, [about 12,000 lines across 97 files deleted](https://github.com/diegomura/react-pdf/pull/3509), and both the renderer and the font package now depend on plain [`pdfkit`](https://github.com/foliojs/pdfkit) from npm like everybody else. + +That fork had been around since 2018. Killing it took two years of on and off work, and it's probably the most valuable thing I've done for this project in a long while, even though nobody using react-pdf will notice a single thing. + +## The two month detour + +The [very first commit](https://github.com/diegomura/react-pdf/commit/272212a6847ad737be8241c64dbca7ad5a95ae8e), back in October 2016, already had pdfkit as a dependency. I had no real idea what generating a PDF involved, and pdfkit did all the hard parts for me while I played with the reconciler. + +A few months later @jbovenschen opened [#24](https://github.com/diegomura/react-pdf/issues/24) with a list of ideas for the project. One of them was getting rid of pdfkit, because its own text and layout logic was in the way of doing flexbox with Yoga. It made sense to me at the time, so I did it: [#26](https://github.com/diegomura/react-pdf/pull/26) brought in Yoga as the layout engine and a hand rolled PDF writer next to it. + +That lasted two months. Writing the PDF bytes yourself is genuinely good fun while the only thing you support is a rectangle. Then someone asks for a JPEG. Then for an embedded font, and you find yourself reading about CMaps and glyph widths at one in the morning, slowly realising you have volunteered to reimplement a seven hundred page ISO specification in your spare time, for free, so that a `` component can be bold. In May 2017 I opened [#79](https://github.com/diegomura/react-pdf/issues/79), politely titled "Migrating back to pdfkit", and [#85](https://github.com/diegomura/react-pdf/pull/85) landed two weeks later. + +## Why I forked it + +The trouble started when react-pdf grew its own text layout engine. + +pdfkit has a good text API, but react-pdf can't use it. We need to know where every single line breaks before we can decide where the _page_ breaks, so text layout has to happen up front and separately, in textkit. By the time pdfkit gets involved, the only thing left to say is "draw exactly these glyphs at exactly these coordinates", and back then there was no way to say that. + +I did try upstream first. [foliojs/pdfkit#798](https://github.com/foliojs/pdfkit/pull/798) is my March 2018 attempt at teaching `_fragment` to render pre laid out glyph runs. It sat for a year, and was closed with "Not mergeable anymore. Also lots of unrelated changes", which by then was entirely true. + +So in February 2018 I clicked fork. In April `@react-pdf/pdfkit` went up on npm and react-pdf [switched over to it](https://github.com/diegomura/react-pdf/pull/219). It felt like the pragmatic call and honestly, at that moment, it was. + +## What it cost + +Every upstream release meant sitting down with a diff and deciding, file by file, what to take. This was years before you could hand a model a thousand lines of drift and ask it what changed, so the only place that knowledge existed was my head. I knew that codebase by heart. Every mixin, every file, which line in `text.js` was mine and which was Devon's, because there was no other way to get a merge done. I never wrote any of it down either, which was fine right up until it wasn't. + +And the changes kept coming. Some were react-pdf specific: user units on pages, page mode and layout, a different `embedImage`, all the textkit rendering work. But the ones that really pushed the fork away from upstream were about running in the browser. pdfkit assumed Node, so it wanted `fs`, `zlib`, `stream` and `Buffer`, and asking every react-pdf user to configure four polyfills in their bundler was not something I was willing to do. Each of those got ripped out and replaced. Each one made the fork harder to sync. + +The dumbest part is that I was solving all of this alone, in a copy of a library, for problems that were obviously not unique to react-pdf. + +## Getting back + +In February 2024 I opened [#2613](https://github.com/diegomura/react-pdf/issues/2613) as an umbrella issue: catalogue every difference between the fork and upstream, then close them one at a time. "It will be a slow path", I wrote. It was. + +The work went in two directions at once. Pulling upstream's changes into the fork, file by file, until the diff was small enough to reason about. And pushing react-pdf's changes into pdfkit, which is the half that actually mattered: + +- [Use fflate instead of zlib](https://github.com/foliojs/pdfkit/pull/1760) in the browser build +- [Drop the `fs` dependency](https://github.com/foliojs/pdfkit/pull/1763) from browser builds +- [Use `Uint8Array` instead of `Buffer`](https://github.com/foliojs/pdfkit/pull/1764) +- [Drop the `stream` dependency](https://github.com/foliojs/pdfkit/pull/1766) from browser builds +- [Accept pre-parsed fontkit `Font` instances](https://github.com/foliojs/pdfkit/pull/1776) in `doc.font()` +- [Add a real Node ESM build](https://github.com/foliojs/pdfkit/pull/1778) + +Those last two were the actual blockers, and once they landed in 0.20 there was nothing left to justify the fork. Deleting it was a one line change to two `package.json` files and a very satisfying `rm -rf`. + +What I like about this list is that none of it is react-pdf specific. A pdfkit browser bundle with zero Node builtins in it is something plenty of people wanted and nobody had time to build. Doing it upstream means it exists for everyone, and it means I stop paying for it every release. Eight years late, but the incentives were finally pointing the same way. + +## What changes for you + +Nothing. Same API, same output, and the visual regression suite passes byte for byte. + +One caveat worth being honest about: `pdfkit` is pinned to exactly `0.20.1`, not a range, because our renderer still reaches into a few pdfkit internals that aren't public API. That pin is a smell and I know it. Turning those into real APIs upstream is the next chunk of this work, and when it's done the pin can relax. + +## Thanks + +To [@devongovett](https://github.com/devongovett), for pdfkit and for fontkit, which between them are most of what makes this library possible. React-pdf has been standing on that work since its first commit, including the years I was standing on a copy of it. + +And to the foliojs maintainers who reviewed a long string of PRs from someone showing up to remove `Buffer` from their codebase. Thanks for the patience. diff --git a/apps/site/content/blog/large-documents-in-the-browser.mdx b/apps/site/content/blog/large-documents-in-the-browser.mdx new file mode 100644 index 000000000..d96402999 --- /dev/null +++ b/apps/site/content/blog/large-documents-in-the-browser.mdx @@ -0,0 +1,60 @@ +--- +title: 'Large documents in the browser' +date: '2026-08-25' +description: Why a big document locks up your UI, and how to move the whole render into a web worker so it doesn't. +--- + +Every so often someone opens an issue that goes roughly like this: react-pdf works beautifully in development, then a customer with a two hundred page report clicks download and the tab freezes. Sometimes Chrome offers to kill the page, which is an alarming thing for your users to be asked about an invoice. + +Nothing is broken. It's doing exactly what you asked, on exactly the wrong thread. + +## Why it happens + +Generating a PDF is not I/O bound work that politely yields while it waits. It's a long stretch of computation: resolving styles, turning characters into glyphs, breaking every paragraph into lines, then deciding where every page ends. I wrote about [those steps in detail](/blog/rendering-process), but the part that matters here is that all of them run synchronously on whatever thread called react-pdf. + +In Node that's fine. In the browser, that thread is the main one, the same thread responsible for painting, scrolling, and responding to clicks. While a document is being computed, none of that happens. + +For a handful of pages nobody notices. The cost scales with content, and page breaking in particular gets more expensive as there is more of it to break, so somewhere past a few dozen pages the freeze becomes long enough to be a bug report. + +## Move it off the main thread + +The fix isn't to make the render incremental or to chunk it across frames. It's to run it somewhere that isn't the main thread at all. Web workers are exactly this: a separate thread, with no access to the DOM, which is not a limitation here because react-pdf never touches the DOM anyway. + +The important design constraint is that you can't hand a React element to a worker. `postMessage` uses structured cloning, and functions and elements don't survive it. So the document component has to live inside the worker, and what you send across is plain data. + +```jsx +// pdf.worker.jsx +import { pdf, Font } from '@react-pdf/renderer'; +import Invoice from './Invoice'; + +Font.register({ family: 'Roboto', src: '/fonts/roboto.ttf' }); + +self.onmessage = async (event) => { + const blob = await pdf().toBlob(); + self.postMessage(blob); +}; +``` + +Note that fonts are registered inside the worker. `Font.register` populates a store in the module scope of whichever thread runs it, and the worker has its own module scope, so registering on the main thread does nothing for it. + +On the other side you send props and get a blob back. Blobs are structured cloneable, so nothing special is needed to return one. + +```js +const worker = new Worker(new URL('./pdf.worker.jsx', import.meta.url), { + type: 'module', +}); + +worker.onmessage = (event) => { + setUrl(URL.createObjectURL(event.data)); +}; + +worker.postMessage({ invoiceId: 42, lines }); +``` + +The document takes exactly as long to generate as it did before. The difference is that your interface spends that time responding to the user instead of ignoring them, and you can show real progress rather than a spinner that has already stopped animating. + +Simon Hessel wrote [a fuller walkthrough of this setup](https://dev.to/simonhessel/creating-pdf-files-without-slowing-down-your-app-a42), including the bundler wiring, which varies more than the react-pdf part does. + +## When not to bother + +If your documents are small, skip all of this. A worker adds a build step, a message protocol, and a second place your document code has to be reachable from, in exchange for solving a problem you don't have. Reach for it when you can measure the freeze, not before. diff --git a/apps/site/content/blog/rendering-text.mdx b/apps/site/content/blog/rendering-text.mdx new file mode 100644 index 000000000..c6647195d --- /dev/null +++ b/apps/site/content/blog/rendering-text.mdx @@ -0,0 +1,129 @@ +--- +title: 'How react-pdf renders text' +date: '2026-08-26' +description: Six steps sit between a element and a glyph on the page, and none of them are the ones you would guess. A visual tour of the textkit pipeline. +--- + +The [pipeline post](/blog/rendering-process) gives text layout one paragraph out of six. That is honest as an overview and badly misleading as an estimate: text is where most of the code lives, and where most of the bug reports come from. + +There is no browser here, so nothing below is delegated. Six passes, in a fixed order, before a single glyph is written to the file. + + + +## 1. Flatten + +A `` can contain other `` elements, images, links. The first thing that happens is that all of it collapses into a single flat string plus a list of **runs**, where a run is a range of characters that agree on every attribute. + +```jsx + + A run is the longest slice of the + string that agrees on everything. + +``` + +Hover the sentence below to see what that turns into. + + + +Two things worth noticing. The tree is gone: after this step there is no parent, no children, only offsets into one string. And the attributes are fully resolved, so `fontFamily: 'Roboto'` has already become a parsed font object, `textDecoration: 'underline'` has become `underline: true` plus an `underlineColor`, and anything you did not set has picked up its default. Helvetica is appended to every font list as the last resort, whether you asked for it or not. + +Two smaller things happen in the same pass. Text in Indic and Southeast Asian scripts is NFD decomposed, because fontkit's shaped output for those scripts only maps back onto the original string reliably in decomposed form. And the string is cut at every `\n` into separate paragraphs. Everything from here until the lines are stacked runs once per paragraph, which is why a newline is a hard break and why the line breaker never optimises across one. + +## 2. Split + +Three engines now cut those runs finer than you wrote them, and none of them are optional: + +- **script itemization** cuts at writing system boundaries, because a single shaping pass cannot handle Latin and Devanagari at once +- **bidi** cuts at direction changes and stamps every piece with its embedding level +- **font substitution** cuts wherever the current font has no glyph for the next character, and moves on to the next family in your list + +The three sets of boundaries are then merged, so what comes out is the finest subdivision all three agree on. A `` that drops an Arabic phrase into an English sentence leaves this step as several runs. You wrote one. + +One more thing happens here, and one deliberately does not. Mirrored characters, parentheses and brackets and their friends, get swapped for their mirror image inside right to left runs. But the runs are **not** reordered for display yet. Visual order depends on where each line ends, and no lines exist yet, so reordering waits until step 5. + +## 3. Shape + +First, every word goes through the hyphenation engine and comes back as a list of syllables, with soft hyphens stripped out. Nothing is hyphenated at this point. This is only the set of places where a break would be legal, and step 4 decides whether any of them is worth the cost. + +Then each run goes to [fontkit](https://github.com/foliojs/fontkit), which turns characters into positioned glyphs. This is the step people skip when they reason about text, and it is the step that makes the counting stop working. + + + +Type in it. Turn off the `liga` toggle and watch `ffi` fall apart into three glyphs. Turn it back on and they collapse into one, with a single id, a single outline and a single advance width, covering three characters of the string. Any code that assumes "one character, one glyph" is already wrong at `office`. + +Turn `kern` off and the row spreads out. Kerning does not change which glyphs you get, it changes the advance of the glyph on the left. `AV` in Roboto pulls in by 87 font units, `To` by 99. That is why the character chips along the top stay evenly spaced while the glyph boxes underneath do not line up with them. + +What comes out is a list of glyph ids and a list of positions: `xAdvance`, `yAdvance`, `xOffset`, `yOffset`, each scaled from font units into points by `fontSize / unitsPerEm`. From here on the string is documentation. The positions are the truth. + +Two small passes finish the job. `verticalAlign: 'super'` and `'sub'` shift every glyph in the run by +0.4 em and -0.2 em, without resizing anything. And an `` inside a `` becomes a single U+FFFC character carrying the picture as an attachment, so it takes up exactly one glyph slot and gets measured like one. + +## 4. Break + +Before anything breaks, textkit asks the container which rectangles a line is allowed to occupy. + +With no exclusions that is one rectangle, the full width, and the breaker is simply told every line may be this wide. With exclusions, which is what `float` compiles down to, the container is sliced into bands one line tall, each band is cut around the shapes that intrude into it, and the breaker is handed an array instead: one available width per line, in order. `textIndent` shrinks the first entry. That array is the only thing the line breaker ever learns about the shape of the page. + +Then the actual breaking, and this is my favourite part of the library. React-pdf does not fill lines greedily. It runs [Knuth and Plass](https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap#Minimum_raggedness), the algorithm TeX uses, over the whole paragraph at once, as three kinds of node: + +- **boxes** are words, with a fixed width +- **glue** is whitespace, with a natural width plus how far it will stretch and how far it will shrink +- **penalties** are the hyphenation points from step 3, each carrying a cost for breaking there + +It then searches for the set of breakpoints with the lowest total badness for the paragraph as a whole, which is why a word on the last line can move where the first line breaks. + + + +Drag the column width and watch the grey leftovers on the right. Then switch to greedy and drag it again. Greedy is not wrong, it is just local: it takes as much as it can on every line and lets the last one absorb whatever remains. Knuth and Plass will deliberately end a line early so the next three come out even, and the raggedness number in the caption is the sum of the squared leftovers, which is roughly what the algorithm is minimising. + +The constants explain most of the behaviour you actually see. Glue stretches by half its natural width and shrinks by a third of it. A hyphen is assumed to be 5 points wide, a hardcoded guess that does not scale with your font size, and it carries a penalty of **600** in ragged text but only **100** in justified text, because justified lines need hyphens to avoid rivers of whitespace and ragged ones mostly do not. Flip the justify toggle around a 320 point column and the paragraph rebreaks entirely: same text, same font, a different price on hyphens. + +Tolerance starts at 4. If no solution exists within it, react-pdf raises it by 5 and tries again, up to 50, and if it still cannot find one it gives up and runs a plain best-fit pass, because a slightly ugly paragraph beats no paragraph. If you have ever seen one paragraph in a long document break noticeably worse than its neighbours, that is what happened. + +## 5. Stack + +Each line now gets a box, and the height of that box is not the height of the letters. + + + +With `lineHeight` unset the height is `lineGap + ascent - descent`, all three read from the font's own metrics. Roboto reports an ascent of 1900 and a descent of -500 against 2048 units per em, so its natural line height is 1.17 times the font size. Nothing in your styles produced that 17%. The font did. + +Set `lineHeight` and the height becomes exactly `lineHeight × fontSize`. Note where the extra space goes. The baseline stays at `ascent` below the top of the box, so everything you add lands underneath the line, not split half above and half below the way CSS does it. This is the single most common source of "why is my text sitting too high in its background colour". A line also takes the **maximum** height and ascent across all its runs, so one 24pt word both makes the whole line taller and pushes its baseline down. + +Stacking those boxes is the typesetter's job, and it is the first step that cares about the container rather than the text. It walks the paragraphs in order, cropping the remaining height as it goes, and stops once the next one does not fit. `maxLines` cuts it short, `textOverflow: 'ellipsis'` truncates whatever survived, and a paragraph that only partly fits is sliced at the exact height available. A line that does not fit the rectangle it was assigned moves to the next one, which is how text flows past a float. + +Only now that lines exist can bidi finish. Within each line the runs are reordered into visual order, highest embedding level first, and right to left runs have their glyphs reversed inside themselves. + +Then one last pass per line: drop a trailing newline, push leading and trailing whitespace outside the box so alignment ignores it, apply the alignment or hand the leftover width to the justification engine, compute the rectangles for underlines and strikethroughs, and record the final ascent, descent and height. That whitespace trick is why a centred line with a trailing space still looks centred, and the justification engine is why the coloured slivers appear under the spaces when you turn justify on above. + +## 6. Draw + +By the time the render package sees any of this, there is nothing left to decide. Here is a `To Vary` on a 200 by 100 point page, straight out of the content stream with a couple of empty save/restore pairs removed: + +``` +1 0 0 1 20 20 cm % translate to the text node's box +q +1 0 0 1 0 12.988281 cm % drop to the baseline: ascent at 14pt +/DeviceRGB cs +0 0 0 scn +q +1 0 0 -1 0 100 cm % flip: PDF y grows upward, layout y grows down +BT +1 0 0 1 0 100 Tm % text matrix +/F2 14 Tf % font and size +[<0001> 48.339844 <000200030004> 22.460938 <00050006> -8.789062 <0007> 0] TJ +ET +Q +1 0 0 1 46.819336 0 cm % advance by the run's total width +``` + +The `TJ` array is the whole article in one line. `<0001>` through `<0007>` are glyph ids in the embedded subset, not characters and not the ids fontkit reported, because only the seven glyphs actually used got written into the file. The bare numbers between them are position adjustments in thousandths of an em, with the sign inverted, so a positive number moves the next glyph left. + +`48.339844` is the kerning between `T` and `o`. Back in step 3 that pair measured 99 font units of overlap, and 99 / 2048 × 1000 is 48.34. `22.460938` is `V` and `a`. `-8.789062` is `r` and `y`, which Roboto pushes apart rather than pulling together. The same three numbers you can read off the glyph lab above, having survived four steps unchanged, written into a file. + +## Why the order is fixed + +None of this can be reordered. You cannot measure a word without the shaped glyphs, you cannot break a line without measured words, you cannot know a paragraph's height without its lines, and pagination cannot decide anything until it knows how tall things are. Text layout is the reason the [outer pipeline](/blog/rendering-process) has the shape it has. + +It also explains the two pieces of advice I give most often. Register your fonts before rendering, because a missing font quietly falls back to Helvetica and every measurement downstream changes. And if your text is breaking badly, reach for `hyphenationCallback` before you reach for manual line breaks, because manual breaks are the one thing this whole machine cannot reason about. diff --git a/apps/site/content/docs/v4/addons/mermaid.mdx b/apps/site/content/docs/v4/addons/mermaid.mdx new file mode 100644 index 000000000..478b8e12c --- /dev/null +++ b/apps/site/content/docs/v4/addons/mermaid.mdx @@ -0,0 +1,96 @@ +--- +title: "Mermaid" +--- + +React-pdf renders [Mermaid](https://mermaid.js.org/) diagrams via the `@react-pdf/mermaid` package. Definitions are turned into vector graphics at layout time, so flowcharts, sequence diagrams and the rest are drawn as real PDF shapes and text rather than embedded as a bitmap. + +## Installation + +```bash +npm install @react-pdf/mermaid +``` + +## Usage + +```jsx +import { Document, Page, View } from '@react-pdf/renderer'; +import { Mermaid } from '@react-pdf/mermaid'; + +const MyDocument = () => ( + + + + + {`graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Ship it] + B -->|No| D[Back to the drawing board]`} + + + + +); +``` + +Both `width` and `height` are optional. Left out, the diagram takes the size of its own viewBox; given one of the two, the other follows the aspect ratio. + +## Supported diagrams + +| Diagram | Keyword | +|---------|---------| +| Flowchart | `graph TD`, `graph LR` | +| Sequence | `sequenceDiagram` | +| State | `stateDiagram-v2` | +| Class | `classDiagram` | +| Entity relationship | `erDiagram` | +| XY chart | `xychart-beta` | + +```jsx + + {`classDiagram + Document <|-- Page + Page <|-- View + View <|-- Text`} + +``` + +## Colors + +Colors can be set one by one, or picked up from a built-in theme: + +```jsx + + {`graph LR + A --> B --> C`} + +``` + +Individual color props override the theme, so a theme can be used as a starting +point and adjusted from there: + +```jsx + + {`graph LR + A --> B --> C`} + +``` + +Available themes are `tokyo-night`, `tokyo-night-storm`, `tokyo-night-light`, `catppuccin-mocha`, `catppuccin-latte`, `nord`, `nord-light`, `dracula`, `github-dark`, `github-light`, `solarized-dark`, `solarized-light`, `one-dark`, `zinc-dark` and `zinc-light`. + +## Valid props + +| Prop name | Description | Type | Default | +|-----------|:--------------------------------------------------------------------------:|-------------------:|------------:| +| children | Mermaid diagram definition | _String_ | _undefined_ | +| width | Width of the rendered diagram. Derived from the viewBox aspect ratio if omitted | _Number_, _String_ | _undefined_ | +| height | Height of the rendered diagram. Derived from the viewBox aspect ratio if omitted | _Number_, _String_ | _undefined_ | +| theme | Built-in theme name | _String_ | _undefined_ | +| color | Foreground and text color | _String_ | _"black"_ | +| bg | Background color of the diagram | _String_ | _undefined_ | +| accent | Accent color for arrowheads and highlights | _String_ | _undefined_ | +| line | Edge and connector stroke color | _String_ | _undefined_ | +| muted | Secondary text and label color | _String_ | _undefined_ | +| surface | Node fill color | _String_ | _undefined_ | +| border | Node stroke color | _String_ | _undefined_ | +| transparent | Use a transparent background | _Boolean_ | _false_ | +| debug | Enables debug mode showing a border around the diagram | _Boolean_ | _false_ | diff --git a/apps/site/content/docs/v4/meta.json b/apps/site/content/docs/v4/meta.json index 3e6a5b2ae..2633d36ef 100644 --- a/apps/site/content/docs/v4/meta.json +++ b/apps/site/content/docs/v4/meta.json @@ -63,6 +63,7 @@ "advanced/express", "---Addons---", "addons/math", + "addons/mermaid", "---API---", "node", "---AI---", diff --git a/apps/site/package.json b/apps/site/package.json index 6925e26e8..64d813ddc 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -21,9 +21,11 @@ "@react-pdf/mermaid": "^5.0.1", "@react-pdf/renderer": "^4.8.0", "@react-pdf/tailwind": "^0.1.0", + "@react-pdf/textkit": "^7.0.1", "@react-pdf/ui": "^0.1.0", "@uiw/react-codemirror": "^4.25.11", "codemirror": "^6.0.2", + "fontkit": "^2.0.4", "fumadocs-core": "^16.15.1", "fumadocs-mdx": "^15.3.1", "fumadocs-ui": "^16.15.1",