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:
2026-05-12 20:45:28 +02:00
parent 81a98c6695
commit 73184c51e0
22 changed files with 1758 additions and 1 deletions

View File

@@ -0,0 +1,42 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger';
const toneStyles: Record<BadgeTone, { background: string; foreground: string }> = {
neutral: { background: PDF_TOKENS.colors.border, foreground: PDF_TOKENS.colors.text },
accent: { background: PDF_TOKENS.colors.accentBlue, foreground: '#ffffff' },
success: { background: PDF_TOKENS.colors.success, foreground: '#ffffff' },
warning: { background: PDF_TOKENS.colors.warning, foreground: '#ffffff' },
danger: { background: PDF_TOKENS.colors.danger, foreground: '#ffffff' },
};
const styles = StyleSheet.create({
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
alignSelf: 'flex-start',
},
label: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.caption,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
});
export interface BadgeProps {
text: string;
tone?: BadgeTone;
}
export function Badge({ text, tone = 'neutral' }: BadgeProps) {
const t = toneStyles[tone];
return (
<View style={[styles.pill, { backgroundColor: t.background }]}>
<Text style={[styles.label, { color: t.foreground }]}>{text}</Text>
</View>
);
}

View File

@@ -0,0 +1,165 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
wrap: {
flexDirection: 'column',
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.border,
},
headerRow: {
flexDirection: 'row',
backgroundColor: PDF_TOKENS.colors.headerBand,
paddingVertical: 6,
paddingHorizontal: 8,
},
bodyRow: {
flexDirection: 'row',
paddingVertical: 6,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.border,
},
zebraRow: {
backgroundColor: PDF_TOKENS.colors.zebra,
},
totalsRow: {
flexDirection: 'row',
paddingVertical: 6,
paddingHorizontal: 8,
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.text,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.text,
},
headerCell: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.headerText,
textTransform: 'uppercase',
letterSpacing: 0.4,
},
bodyCell: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
bodyCellMuted: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
},
totalsCell: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
});
export type Align = 'left' | 'right' | 'center';
export interface TableColumn<Row> {
header: string;
/** flex-grow weight (default 1) — controls column width proportions. */
flex?: number;
align?: Align;
render: (row: Row, rowIndex: number) => ReactNode;
}
export interface DataTableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
/** Render a bold totals row beneath the body. Cell value-or-null per column. */
totals?: (string | null)[];
/** Apply zebra background to alternate rows (default true). */
zebra?: boolean;
/** Empty-state text shown when rows.length === 0. */
emptyMessage?: string;
}
function cellStyle(align: Align | undefined, base: Record<string, unknown>) {
return {
flex: 1,
paddingHorizontal: 4,
textAlign: align ?? 'left',
...base,
} as const;
}
export function DataTable<Row>({
columns,
rows,
totals,
zebra = true,
emptyMessage = 'No entries',
}: DataTableProps<Row>) {
return (
<View style={styles.wrap}>
<View style={styles.headerRow} fixed>
{columns.map((c, i) => (
<Text
key={i}
style={{
flex: c.flex ?? 1,
paddingHorizontal: 4,
textAlign: c.align ?? 'left',
...styles.headerCell,
}}
>
{c.header}
</Text>
))}
</View>
{rows.length === 0 ? (
<View style={styles.bodyRow}>
<Text style={{ ...styles.bodyCellMuted, flex: 1, textAlign: 'center' }}>
{emptyMessage}
</Text>
</View>
) : (
rows.map((row, i) => (
<View
key={i}
style={[styles.bodyRow, zebra && i % 2 === 1 ? styles.zebraRow : {}]}
wrap={false}
>
{columns.map((c, ci) => (
<View
key={ci}
style={{
flex: c.flex ?? 1,
paddingHorizontal: 4,
alignItems:
c.align === 'right'
? 'flex-end'
: c.align === 'center'
? 'center'
: 'flex-start',
}}
>
{(() => {
const node = c.render(row, i);
if (typeof node === 'string' || typeof node === 'number') {
return <Text style={styles.bodyCell}>{node}</Text>;
}
return node;
})()}
</View>
))}
</View>
))
)}
{totals ? (
<View style={styles.totalsRow}>
{columns.map((c, i) => (
<Text key={i} style={cellStyle(c.align, styles.totalsCell)}>
{totals[i] ?? ''}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@@ -0,0 +1,61 @@
import { Document, Page, StyleSheet, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { Footer } from './Footer';
import { Header } from './Header';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
page: {
fontFamily: PDF_TOKENS.fonts.sans,
color: PDF_TOKENS.colors.text,
fontSize: PDF_TOKENS.sizes.body,
backgroundColor: PDF_TOKENS.colors.surface,
paddingBottom: PDF_TOKENS.spacing.pagePaddingBottom,
},
body: {
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingTop: PDF_TOKENS.spacing.sectionGap,
flexDirection: 'column',
gap: PDF_TOKENS.spacing.sectionGap,
},
});
export interface DocumentShellProps {
portName: string;
docTitle: string;
docMeta?: string;
logoBuffer: Buffer | null;
/** ISO timestamp shown in footer. Defaults to render time. */
generatedAt?: Date;
/** Title shown in the PDF metadata (cmd+I in Preview). */
pdfTitle?: string;
/** Author shown in the PDF metadata. Defaults to port name. */
pdfAuthor?: string;
children: ReactNode;
}
export function DocumentShell({
portName,
docTitle,
docMeta,
logoBuffer,
generatedAt,
pdfTitle,
pdfAuthor,
children,
}: DocumentShellProps) {
return (
<Document
title={pdfTitle ?? docTitle}
author={pdfAuthor ?? portName}
producer="Port Nimara CRM"
>
<Page size="A4" style={styles.page}>
<Header portName={portName} docTitle={docTitle} meta={docMeta} logoBuffer={logoBuffer} />
<View style={styles.body}>{children}</View>
<Footer portName={portName} generatedAt={generatedAt} />
</Page>
</Document>
);
}

View File

@@ -0,0 +1,52 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
band: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.border,
minHeight: PDF_TOKENS.spacing.footerHeight,
},
left: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.caption,
color: PDF_TOKENS.colors.textMuted,
},
right: {
marginLeft: 'auto',
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.caption,
color: PDF_TOKENS.colors.textMuted,
},
});
export interface FooterProps {
portName: string;
generatedAt?: Date;
confidential?: boolean;
}
export function Footer({ portName, generatedAt, confidential = true }: FooterProps) {
const tag = confidential ? `${portName} · Confidential` : portName;
const stamp = (generatedAt ?? new Date()).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
return (
<View style={styles.band} fixed>
<Text style={styles.left}>
{tag} · Generated {stamp}
</Text>
<Text
style={styles.right}
render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`}
/>
</View>
);
}

View File

@@ -0,0 +1,72 @@
import { Image, StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
band: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: PDF_TOKENS.colors.headerBand,
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingVertical: 16,
minHeight: PDF_TOKENS.spacing.headerHeight,
},
logoSlot: {
width: PDF_TOKENS.spacing.logoMaxWidth,
height: PDF_TOKENS.spacing.logoMaxHeight,
justifyContent: 'center',
},
logoImage: {
maxWidth: PDF_TOKENS.spacing.logoMaxWidth,
maxHeight: PDF_TOKENS.spacing.logoMaxHeight,
objectFit: 'contain',
objectPositionX: 0,
},
portNameFallback: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.docTitle,
color: PDF_TOKENS.colors.headerText,
},
rightBlock: {
marginLeft: 'auto',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 4,
},
docTitle: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.docTitle,
color: PDF_TOKENS.colors.headerText,
},
meta: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.headerText,
opacity: 0.85,
},
});
export interface HeaderProps {
portName: string;
docTitle: string;
meta?: string;
logoBuffer: Buffer | null;
}
export function Header({ portName, docTitle, meta, logoBuffer }: HeaderProps) {
return (
<View style={styles.band} fixed>
<View style={styles.logoSlot}>
{logoBuffer ? (
<Image src={logoBuffer} style={styles.logoImage} />
) : (
<Text style={styles.portNameFallback}>{portName}</Text>
)}
</View>
<View style={styles.rightBlock}>
<Text style={styles.docTitle}>{docTitle}</Text>
{meta ? <Text style={styles.meta}>{meta}</Text> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,92 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
grid: {
flexDirection: 'column',
gap: PDF_TOKENS.spacing.rowGap,
},
twoCol: {
flexDirection: 'row',
gap: PDF_TOKENS.spacing.sectionGap,
},
cell: {
flexBasis: '50%',
flexDirection: 'column',
gap: 2,
},
fullRow: {
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
},
label: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.4,
},
value: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
});
export type KvRow = { label: string; value: string | number | null | undefined };
export interface KeyValueGridProps {
rows: KvRow[];
/** Render as two-column layout (default) or stacked. */
layout?: 'two-col' | 'stacked';
}
function fmt(v: string | number | null | undefined): string {
if (v === null || v === undefined || v === '') return '—';
return typeof v === 'number' ? String(v) : v;
}
export function KeyValueGrid({ rows, layout = 'two-col' }: KeyValueGridProps) {
if (layout === 'stacked') {
return (
<View style={styles.grid}>
{rows.map((r, i) => (
<View key={i} style={styles.cell}>
<Text style={styles.label}>{r.label}</Text>
<Text style={styles.value}>{fmt(r.value)}</Text>
</View>
))}
</View>
);
}
const pairs: KvRow[][] = [];
for (let i = 0; i < rows.length; i += 2) {
pairs.push([rows[i]!, rows[i + 1] ?? { label: '', value: '' }]);
}
return (
<View style={styles.grid}>
{pairs.map((pair, i) => (
<View key={i} style={styles.twoCol}>
<View style={styles.cell}>
{pair[0]?.label ? (
<>
<Text style={styles.label}>{pair[0].label}</Text>
<Text style={styles.value}>{fmt(pair[0].value)}</Text>
</>
) : null}
</View>
<View style={styles.cell}>
{pair[1]?.label ? (
<>
<Text style={styles.label}>{pair[1].label}</Text>
<Text style={styles.value}>{fmt(pair[1].value)}</Text>
</>
) : null}
</View>
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,40 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
wrap: {
flexDirection: 'column',
gap: PDF_TOKENS.spacing.rowGap,
},
heading: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.sectionH,
color: PDF_TOKENS.colors.text,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.border,
paddingBottom: 4,
},
subhead: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
},
});
export interface SectionProps {
title: string;
subtitle?: string;
children: ReactNode;
}
export function Section({ title, subtitle, children }: SectionProps) {
return (
<View style={styles.wrap} wrap={false}>
<Text style={styles.heading}>{title}</Text>
{subtitle ? <Text style={styles.subhead}>{subtitle}</Text> : null}
{children}
</View>
);
}

View File

@@ -0,0 +1,155 @@
import { Line, Rect, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface BarDatum {
label: string;
value: number;
/** Optional override color per bar. */
color?: string;
}
export interface BarChartProps {
data: BarDatum[];
width?: number;
height?: number;
/** Bar fill when not overridden per-datum. */
color?: string;
/** Render value labels on top of each bar. */
showValues?: boolean;
/** Optional y-axis label rendered vertically on the left. */
yLabel?: string;
}
const MARGIN_LEFT = 44;
const MARGIN_RIGHT = 12;
const MARGIN_TOP = 18;
const MARGIN_BOTTOM = 32;
export function BarChart({
data,
width = 480,
height = 200,
color = PDF_TOKENS.colors.accentBlue,
showValues = false,
yLabel,
}: BarChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = Math.max(...data.map((d) => d.value));
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
const barW = chartW / data.length;
const yTicks = 4;
return (
<Svg width={width} height={height}>
{/* y-axis tick labels + horizontal grid */}
{Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (max / yTicks) * (yTicks - i);
const y = MARGIN_TOP + (chartH / yTicks) * i;
return (
<Svg key={`t${i}`}>
<Line
x1={MARGIN_LEFT}
y1={y}
x2={width - MARGIN_RIGHT}
y2={y}
strokeWidth={0.5}
stroke={PDF_TOKENS.colors.border}
/>
<SvgLabel
x={MARGIN_LEFT - 6}
y={y + 3}
textAnchor="end"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{formatTick(v)}
</SvgLabel>
</Svg>
);
})}
{/* axes */}
<Line
x1={MARGIN_LEFT}
y1={MARGIN_TOP}
x2={MARGIN_LEFT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
<Line
x1={MARGIN_LEFT}
y1={height - MARGIN_BOTTOM}
x2={width - MARGIN_RIGHT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
{/* bars + labels */}
{data.map((d, i) => {
const h = max === 0 ? 0 : (d.value / max) * chartH;
const x = MARGIN_LEFT + i * barW + 4;
const y = height - MARGIN_BOTTOM - h;
const w = barW - 8;
return (
<Svg key={i}>
<Rect x={x} y={y} width={w} height={h} fill={d.color ?? color} />
<SvgLabel
x={x + w / 2}
y={height - MARGIN_BOTTOM + 14}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{d.label}
</SvgLabel>
{showValues ? (
<SvgLabel
x={x + w / 2}
y={y - 3}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.text}
>
{formatTick(d.value)}
</SvgLabel>
) : null}
</Svg>
);
})}
{yLabel ? (
<SvgLabel
x={10}
y={MARGIN_TOP + chartH / 2}
textAnchor="middle"
fontSize={8}
fill={PDF_TOKENS.colors.textMuted}
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
>
{yLabel}
</SvgLabel>
) : null}
</Svg>
);
}
function formatTick(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toLocaleString();
}

View File

@@ -0,0 +1,91 @@
import { Path, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface FunnelDatum {
label: string;
value: number;
color?: string;
}
export interface FunnelChartProps {
data: FunnelDatum[];
width?: number;
height?: number;
}
const FUNNEL_PALETTE = [
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
'#bfdbfe',
'#dbeafe',
];
export function FunnelChart({ data, width = 480, height = 240 }: FunnelChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = Math.max(...data.map((d) => d.value), 1);
const sliceH = (height - 16) / data.length;
const centerX = width / 2;
return (
<Svg width={width} height={height}>
{data.map((d, i) => {
const topWidth = (data[i]!.value / max) * (width * 0.7);
const next = data[i + 1];
const bottomWidth = next ? (next.value / max) * (width * 0.7) : topWidth * 0.85;
const y0 = 8 + i * sliceH;
const y1 = y0 + sliceH;
const x0Top = centerX - topWidth / 2;
const x1Top = centerX + topWidth / 2;
const x0Bot = centerX - bottomWidth / 2;
const x1Bot = centerX + bottomWidth / 2;
const color =
d.color ?? FUNNEL_PALETTE[i % FUNNEL_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
return (
<Svg key={i}>
<Path
d={`M ${x0Top} ${y0} L ${x1Top} ${y0} L ${x1Bot} ${y1} L ${x0Bot} ${y1} Z`}
fill={color}
/>
<SvgLabel
x={centerX}
y={y0 + sliceH / 2 + 2}
textAnchor="middle"
fontSize={8}
fill="#ffffff"
fontWeight="bold"
>
{d.label}
</SvgLabel>
<SvgLabel
x={centerX}
y={y0 + sliceH / 2 + 12}
textAnchor="middle"
fontSize={7}
fill="#ffffff"
>
{d.value.toLocaleString()}
</SvgLabel>
</Svg>
);
})}
</Svg>
);
}

View File

@@ -0,0 +1,146 @@
import { Circle, Line, Path, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface LineDatum {
label: string;
value: number;
}
export interface LineChartProps {
data: LineDatum[];
width?: number;
height?: number;
color?: string;
yLabel?: string;
/** Render circle markers at each point (default true). */
markers?: boolean;
/** Optional fixed y-axis max — used when value-space should not auto-zoom. */
yMax?: number;
}
const MARGIN_LEFT = 44;
const MARGIN_RIGHT = 12;
const MARGIN_TOP = 18;
const MARGIN_BOTTOM = 32;
export function LineChart({
data,
width = 480,
height = 200,
color = PDF_TOKENS.colors.accentBlue,
yLabel,
markers = true,
yMax,
}: LineChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = yMax ?? Math.max(...data.map((d) => d.value));
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0;
const yTicks = 4;
const points = data.map((d, i) => {
const x = MARGIN_LEFT + i * stepX;
const y = MARGIN_TOP + chartH - (max === 0 ? 0 : (d.value / max) * chartH);
return { x, y, d };
});
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
return (
<Svg width={width} height={height}>
{/* gridlines + y-axis ticks */}
{Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (max / yTicks) * (yTicks - i);
const y = MARGIN_TOP + (chartH / yTicks) * i;
return (
<Svg key={`t${i}`}>
<Line
x1={MARGIN_LEFT}
y1={y}
x2={width - MARGIN_RIGHT}
y2={y}
strokeWidth={0.5}
stroke={PDF_TOKENS.colors.border}
/>
<SvgLabel
x={MARGIN_LEFT - 6}
y={y + 3}
textAnchor="end"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{formatTick(v)}
</SvgLabel>
</Svg>
);
})}
{/* axes */}
<Line
x1={MARGIN_LEFT}
y1={MARGIN_TOP}
x2={MARGIN_LEFT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
<Line
x1={MARGIN_LEFT}
y1={height - MARGIN_BOTTOM}
x2={width - MARGIN_RIGHT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
{/* line */}
<Path d={path} stroke={color} strokeWidth={1.5} fill="none" />
{/* markers + x-axis labels */}
{points.map((p, i) => (
<Svg key={i}>
{markers ? <Circle cx={p.x} cy={p.y} r={2.5} fill={color} /> : null}
<SvgLabel
x={p.x}
y={height - MARGIN_BOTTOM + 14}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{p.d.label}
</SvgLabel>
</Svg>
))}
{yLabel ? (
<SvgLabel
x={10}
y={MARGIN_TOP + chartH / 2}
textAnchor="middle"
fontSize={8}
fill={PDF_TOKENS.colors.textMuted}
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
>
{yLabel}
</SvgLabel>
) : null}
</Svg>
);
}
function formatTick(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
if (v % 1 !== 0) return v.toFixed(1);
return v.toLocaleString();
}

View File

@@ -0,0 +1,140 @@
import { Path, Rect, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface PieDatum {
label: string;
value: number;
color?: string;
}
export interface PieChartProps {
data: PieDatum[];
width?: number;
height?: number;
/** Inner radius as fraction of outer radius. 0 = solid pie, 0.6 = donut. */
innerRadiusRatio?: number;
}
const DEFAULT_PALETTE = [
PDF_TOKENS.colors.accentBlue,
PDF_TOKENS.colors.success,
PDF_TOKENS.colors.warning,
PDF_TOKENS.colors.danger,
PDF_TOKENS.colors.accentSlate,
'#7c3aed',
'#0891b2',
'#db2777',
];
export function PieChart({ data, width = 240, height = 200, innerRadiusRatio = 0 }: PieChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
All zero
</SvgLabel>
</Svg>
);
}
const chartSize = Math.min(width * 0.55, height - 30);
const r = chartSize / 2;
const cx = r + 12;
const cy = height / 2;
const ir = r * innerRadiusRatio;
let angle = -Math.PI / 2;
const slices = data.map((d, i) => {
const slice = (d.value / total) * Math.PI * 2;
const startAngle = angle;
const endAngle = angle + slice;
angle = endAngle;
const color =
d.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
return { d, color, startAngle, endAngle, slice };
});
const legendX = cx + r + 24;
const legendStartY = cy - (data.length * 12) / 2;
return (
<Svg width={width} height={height}>
{slices.map((s, i) => (
<Path key={i} d={arcPath(cx, cy, r, ir, s.startAngle, s.endAngle)} fill={s.color} />
))}
{slices.map((s, i) => (
<Svg key={`l${i}`}>
<Rect x={legendX} y={legendStartY + i * 12 - 5} width={8} height={8} fill={s.color} />
<SvgLabel
x={legendX + 12}
y={legendStartY + i * 12 + 1}
fontSize={8}
fill={PDF_TOKENS.colors.text}
>
{s.d.label}
</SvgLabel>
<SvgLabel
x={legendX + 12}
y={legendStartY + i * 12 + 10}
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{Math.round((s.d.value / total) * 100)}% · {s.d.value.toLocaleString()}
</SvgLabel>
</Svg>
))}
</Svg>
);
}
function arcPath(
cx: number,
cy: number,
outerR: number,
innerR: number,
startAngle: number,
endAngle: number,
): string {
const x1 = cx + outerR * Math.cos(startAngle);
const y1 = cy + outerR * Math.sin(startAngle);
const x2 = cx + outerR * Math.cos(endAngle);
const y2 = cy + outerR * Math.sin(endAngle);
const large = endAngle - startAngle > Math.PI ? 1 : 0;
if (innerR === 0) {
return `M ${cx} ${cy} L ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} Z`;
}
const x3 = cx + innerR * Math.cos(endAngle);
const y3 = cy + innerR * Math.sin(endAngle);
const x4 = cx + innerR * Math.cos(startAngle);
const y4 = cy + innerR * Math.sin(startAngle);
return [
`M ${x1} ${y1}`,
`A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4}`,
`Z`,
].join(' ');
}

View File

@@ -0,0 +1,4 @@
export { BarChart, type BarChartProps, type BarDatum } from './BarChart';
export { LineChart, type LineChartProps, type LineDatum } from './LineChart';
export { PieChart, type PieChartProps, type PieDatum } from './PieChart';
export { FunnelChart, type FunnelChartProps, type FunnelDatum } from './FunnelChart';

View File

@@ -0,0 +1,20 @@
export { PDF_TOKENS, type PdfTokens } from './tokens';
export { resolvePortLogo, PORT_LOGO_SETTING_KEY, type ResolvedLogo } from './logo';
export { DocumentShell, type DocumentShellProps } from './DocumentShell';
export { Header, type HeaderProps } from './Header';
export { Footer, type FooterProps } from './Footer';
export { Section, type SectionProps } from './Section';
export { KeyValueGrid, type KeyValueGridProps, type KvRow } from './KeyValueGrid';
export { DataTable, type DataTableProps, type TableColumn, type Align } from './DataTable';
export { Badge, type BadgeProps, type BadgeTone } from './Badge';
export { BarChart, LineChart, PieChart, FunnelChart } from './charts';
export type {
BarChartProps,
BarDatum,
LineChartProps,
LineDatum,
PieChartProps,
PieDatum,
FunnelChartProps,
FunnelDatum,
} from './charts';

View File

@@ -0,0 +1,57 @@
import { cache } from 'react';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files } from '@/lib/db/schema/documents';
import { systemSettings } from '@/lib/db/schema/system';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
export const PORT_LOGO_SETTING_KEY = 'port_logo_file_id';
export type ResolvedLogo =
| { source: 'logo'; buffer: Buffer; mimeType: 'image/png' }
| { source: 'fallback'; buffer: null; mimeType: null };
async function readLogoSetting(portId: string): Promise<string | null> {
const [row] = await db
.select()
.from(systemSettings)
.where(and(eq(systemSettings.key, PORT_LOGO_SETTING_KEY), eq(systemSettings.portId, portId)));
if (!row) return null;
const value = row.value;
if (typeof value !== 'string') return null;
return value;
}
/**
* Resolves the port-level logo for PDF rendering. Returns `source: 'fallback'`
* when the setting is unset, the file row is missing, or the storage backend
* errors. Renderers should fall back to text-only headers in that case.
*
* Cached per request via React `cache()` so multi-page PDFs only fetch once.
*/
export const resolvePortLogo = cache(async (portId: string): Promise<ResolvedLogo> => {
const fileId = await readLogoSetting(portId);
if (!fileId) {
return { source: 'fallback', buffer: null, mimeType: null };
}
const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
if (!file) {
logger.warn({ portId, fileId }, 'port_logo_file_id points at missing file');
return { source: 'fallback', buffer: null, mimeType: null };
}
try {
const backend = await getStorageBackend();
const stream = await backend.get(file.storagePath);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
return { source: 'logo', buffer, mimeType: 'image/png' };
} catch (err) {
logger.warn({ err, portId, fileId, storagePath: file.storagePath }, 'logo fetch failed');
return { source: 'fallback', buffer: null, mimeType: null };
}
});

View File

@@ -0,0 +1,50 @@
import { Text as PdfText } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
/**
* Brand-kit wrapper around @react-pdf/renderer's SVG `<Text>`. Exists because
* the renderer accepts `fontSize`/`fontFamily`/`fontWeight` as presentation
* attributes at runtime but the published types only declare the SVG-spec
* subset. Wrapping here keeps the casts isolated to one file.
*/
export interface SvgLabelProps {
x: number;
y: number;
fontSize?: number;
fontFamily?: string;
fontWeight?: string | number;
fill?: string;
textAnchor?: 'start' | 'middle' | 'end';
transform?: string;
opacity?: number | string;
children: ReactNode;
}
export function SvgLabel({
x,
y,
fontSize = PDF_TOKENS.sizes.caption,
fontFamily = PDF_TOKENS.fonts.sans,
fontWeight,
fill = PDF_TOKENS.colors.text,
textAnchor = 'start',
transform,
opacity,
children,
}: SvgLabelProps) {
// Runtime accepts these as presentation attrs; types omit them. Cast scoped here.
const props = {
x,
y,
fontSize,
fontFamily,
fontWeight,
fill,
textAnchor,
transform,
opacity,
} as unknown as { x: number; y: number; fill: string };
return <PdfText {...props}>{children}</PdfText>;
}

View File

@@ -0,0 +1,51 @@
/**
* Design tokens shared by every internally-generated PDF surface. Edit here
* to re-skin every report/export/expense PDF.
*
* Coordinates are in PDF points (1/72 inch). 36pt = 0.5in = standard margin.
*/
export const PDF_TOKENS = {
colors: {
text: '#111111',
textMuted: '#666666',
border: '#e5e7eb',
headerBand: '#0f172a',
headerText: '#ffffff',
accentBlue: '#1d4ed8',
accentSlate: '#334155',
zebra: '#f9fafb',
success: '#16a34a',
warning: '#d97706',
danger: '#dc2626',
surface: '#ffffff',
},
fonts: {
sans: 'Helvetica',
sansBold: 'Helvetica-Bold',
mono: 'Courier',
},
sizes: {
docTitle: 18,
sectionH: 13,
body: 10,
small: 8,
caption: 7,
},
spacing: {
pagePadding: 36,
pagePaddingTop: 72,
pagePaddingBottom: 54,
sectionGap: 18,
rowGap: 6,
headerHeight: 72,
footerHeight: 36,
logoMaxWidth: 200,
logoMaxHeight: 60,
},
page: {
width: 595,
height: 842,
},
} as const;
export type PdfTokens = typeof PDF_TOKENS;

32
src/lib/pdf/render.ts Normal file
View File

@@ -0,0 +1,32 @@
import { renderToBuffer, renderToStream, type DocumentProps } from '@react-pdf/renderer';
import type { ReactElement } from 'react';
import { logger } from '@/lib/logger';
type DocumentElement = ReactElement<DocumentProps>;
/**
* Render a react-pdf element tree to PDF bytes. Use for one-shot PDFs that
* fit in memory comfortably (reports, record exports, parent-company exports).
*
* For photo-heavy or hundreds-of-entries PDFs, see `renderPdfStream` or use
* `expense-pdf.service.ts` (pdfkit streaming).
*/
export async function renderPdf(element: DocumentElement): Promise<Buffer> {
try {
const buf = await renderToBuffer(element);
return buf;
} catch (err) {
logger.error({ err }, 'PDF render failed');
throw new Error('Failed to render PDF');
}
}
/**
* Stream-render a react-pdf element tree. Pages are emitted incrementally so
* memory peaks are bounded. Caller should pipe the stream to the response
* (or convert via `Readable.toWeb` for a Web Response).
*/
export async function renderPdfStream(element: DocumentElement): Promise<NodeJS.ReadableStream> {
return renderToStream(element);
}

8
src/types/react-pdf-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* Marker file kept to anchor the project's TS configuration. SVG font
* presentation attributes (`fontSize`, `fontFamily`, etc.) for @react-pdf's
* SVG <Text> are accepted at runtime but not declared in the renderer's
* namespace types. Brand-kit `<SvgLabel>` is the single place that bridges
* the gap with a typed wrapper, so we don't litter chart code with casts.
*/
export {};