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:
42
src/lib/pdf/brand-kit/Badge.tsx
Normal file
42
src/lib/pdf/brand-kit/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/lib/pdf/brand-kit/DataTable.tsx
Normal file
165
src/lib/pdf/brand-kit/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/lib/pdf/brand-kit/DocumentShell.tsx
Normal file
61
src/lib/pdf/brand-kit/DocumentShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/lib/pdf/brand-kit/Footer.tsx
Normal file
52
src/lib/pdf/brand-kit/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/lib/pdf/brand-kit/Header.tsx
Normal file
72
src/lib/pdf/brand-kit/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/lib/pdf/brand-kit/KeyValueGrid.tsx
Normal file
92
src/lib/pdf/brand-kit/KeyValueGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/lib/pdf/brand-kit/Section.tsx
Normal file
40
src/lib/pdf/brand-kit/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/lib/pdf/brand-kit/charts/BarChart.tsx
Normal file
155
src/lib/pdf/brand-kit/charts/BarChart.tsx
Normal 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();
|
||||
}
|
||||
91
src/lib/pdf/brand-kit/charts/FunnelChart.tsx
Normal file
91
src/lib/pdf/brand-kit/charts/FunnelChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/lib/pdf/brand-kit/charts/LineChart.tsx
Normal file
146
src/lib/pdf/brand-kit/charts/LineChart.tsx
Normal 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();
|
||||
}
|
||||
140
src/lib/pdf/brand-kit/charts/PieChart.tsx
Normal file
140
src/lib/pdf/brand-kit/charts/PieChart.tsx
Normal 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(' ');
|
||||
}
|
||||
4
src/lib/pdf/brand-kit/charts/index.ts
Normal file
4
src/lib/pdf/brand-kit/charts/index.ts
Normal 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';
|
||||
20
src/lib/pdf/brand-kit/index.ts
Normal file
20
src/lib/pdf/brand-kit/index.ts
Normal 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';
|
||||
57
src/lib/pdf/brand-kit/logo.ts
Normal file
57
src/lib/pdf/brand-kit/logo.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
50
src/lib/pdf/brand-kit/svg-primitives.tsx
Normal file
50
src/lib/pdf/brand-kit/svg-primitives.tsx
Normal 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>;
|
||||
}
|
||||
51
src/lib/pdf/brand-kit/tokens.ts
Normal file
51
src/lib/pdf/brand-kit/tokens.ts
Normal 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
32
src/lib/pdf/render.ts
Normal 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
8
src/types/react-pdf-augment.d.ts
vendored
Normal 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 {};
|
||||
Reference in New Issue
Block a user