feat(pdf): brand kit foundation for @react-pdf/renderer
Phase 1 / commit 1 of 14 — installs deps and lays down the brand-kit
primitives used by every internal-only PDF. No callers wired yet.
Adds:
@react-pdf/renderer 4.5.1 one engine for internal exports
unpdf 1.6.2 reserved for berth-PDF parser tier-2
react-image-crop 11.0.10 admin logo crop UI (commit 2)
svgo 4.0.1 SVG sanitization on logo upload (commit 2)
brand-kit/
tokens.ts single source of truth for colors/fonts/spacing
logo.ts resolvePortLogo() — cached, soft-fallback
DocumentShell <Document><Page> + fixed Header + fixed Footer
Header dark band, logo slot (letterboxed) + text fallback
Footer page N of M + generated-at + confidential tag
Section heading + bottom border
KeyValueGrid 2-col (default) or stacked label/value
DataTable zebra rows + sticky header + totals row + empty state
Badge 5 tone pills
charts/
BarChart pure SVG, 4-tick y-axis, optional value labels
LineChart pure SVG, line + markers + grid
PieChart pure SVG, donut-or-pie + side legend
FunnelChart pure SVG, slope-cut slices for pipeline stages
render.ts renderToBuffer + renderToStream wrappers, typed
svg-primitives.tsx <SvgLabel> wraps react-pdf SVG <Text> to bridge
missing TS declarations for fontSize/fontFamily
Smoke test renders a kitchen-sink Document including every primitive
and every chart, plus an empty-data path. 1293+4 vitest tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
tests/unit/pdf-brand-kit.test.tsx
Normal file
125
tests/unit/pdf-brand-kit.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Page, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { renderPdf } from '@/lib/pdf/render';
|
||||
import {
|
||||
Badge,
|
||||
BarChart,
|
||||
DataTable,
|
||||
DocumentShell,
|
||||
FunnelChart,
|
||||
KeyValueGrid,
|
||||
LineChart,
|
||||
PDF_TOKENS,
|
||||
PieChart,
|
||||
Section,
|
||||
} from '@/lib/pdf/brand-kit';
|
||||
|
||||
describe('pdf brand kit', () => {
|
||||
it('exposes canonical design tokens', () => {
|
||||
expect(PDF_TOKENS.colors.headerBand).toBe('#0f172a');
|
||||
expect(PDF_TOKENS.sizes.body).toBe(10);
|
||||
expect(PDF_TOKENS.spacing.logoMaxWidth).toBe(200);
|
||||
});
|
||||
|
||||
it('renders a kitchen-sink PDF without throwing', async () => {
|
||||
const tree = (
|
||||
<DocumentShell
|
||||
portName="Port Test"
|
||||
docTitle="Smoke Report"
|
||||
docMeta="2026-05-12"
|
||||
logoBuffer={null}
|
||||
>
|
||||
<Section title="Summary">
|
||||
<KeyValueGrid
|
||||
rows={[
|
||||
{ label: 'Total', value: 247 },
|
||||
{ label: 'Active', value: 'Yes' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Charts">
|
||||
<BarChart
|
||||
data={[
|
||||
{ label: 'Mon', value: 10 },
|
||||
{ label: 'Tue', value: 20 },
|
||||
]}
|
||||
/>
|
||||
<LineChart
|
||||
data={[
|
||||
{ label: 'Jan', value: 5 },
|
||||
{ label: 'Feb', value: 8 },
|
||||
]}
|
||||
/>
|
||||
<PieChart
|
||||
data={[
|
||||
{ label: 'A', value: 30 },
|
||||
{ label: 'B', value: 70 },
|
||||
]}
|
||||
/>
|
||||
<FunnelChart
|
||||
data={[
|
||||
{ label: 'Lead', value: 100 },
|
||||
{ label: 'Closed', value: 25 },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Table">
|
||||
<Badge text="Active" tone="success" />
|
||||
<DataTable
|
||||
columns={[
|
||||
{ header: 'Name', render: (r: { name: string }) => r.name },
|
||||
{
|
||||
header: 'Score',
|
||||
align: 'right',
|
||||
render: (r: { score: number }) => String(r.score),
|
||||
},
|
||||
]}
|
||||
rows={[
|
||||
{ name: 'Alpha', score: 1 },
|
||||
{ name: 'Beta', score: 2 },
|
||||
]}
|
||||
totals={['Total', '3']}
|
||||
/>
|
||||
</Section>
|
||||
</DocumentShell>
|
||||
);
|
||||
const bytes = await renderPdf(tree);
|
||||
expect(bytes).toBeInstanceOf(Buffer);
|
||||
expect(bytes.length).toBeGreaterThan(1000);
|
||||
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
|
||||
it('falls back gracefully when no chart data is provided', async () => {
|
||||
const tree = (
|
||||
<DocumentShell portName="Port Empty" docTitle="Empty" logoBuffer={null}>
|
||||
<BarChart data={[]} />
|
||||
<LineChart data={[]} />
|
||||
<PieChart data={[]} />
|
||||
<FunnelChart data={[]} />
|
||||
<DataTable columns={[{ header: 'X', render: () => '' }]} rows={[]} />
|
||||
</DocumentShell>
|
||||
);
|
||||
const bytes = await renderPdf(tree);
|
||||
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// Belt-and-suspenders: a minimal direct Page render to confirm @react-pdf/renderer
|
||||
// is actually installed and produces a real PDF stream, not just our tree.
|
||||
describe('react-pdf renderer wiring', () => {
|
||||
it('renders a bare <Page> to bytes', async () => {
|
||||
const { Document } = await import('@react-pdf/renderer');
|
||||
const tree = (
|
||||
<Document>
|
||||
<Page size="A4">
|
||||
<Text>hello</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
const bytes = await renderPdf(tree);
|
||||
expect(bytes.length).toBeGreaterThan(500);
|
||||
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user