2 Commits

Author SHA1 Message Date
soconnor b582b6c88e update pdf generation to flow better 2026-04-27 14:15:06 -04:00
soconnor 00e066ca4e fix: register frutiger-bold as pdf font 2026-04-27 13:33:40 -04:00
+86 -191
View File
@@ -25,6 +25,11 @@ Font.register({
], ],
}); });
Font.register({
family: "Frutiger-Bold",
src: "/fonts/frutiger/Frutiger_bold.ttf",
});
// Fallback download function for better browser compatibility // Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void { function downloadBlob(blob: Blob, filename: string): void {
try { try {
@@ -582,206 +587,86 @@ const getStatusStyle = (status: string) => {
} }
}; };
// Helper function to estimate text height based on content and width function pageContentBudget(isFirstPage: boolean, hasNotes: boolean): number {
function estimateTextHeight( // 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
text: string, let h = 672;
maxWidth: number, h -= isFirstPage ? 285 : 50; // dense vs abridged header
fontSize = 10, h -= hasNotes ? 185 : 130; // totals box (+ notes section if present)
lineHeight = 1.3, h -= 28; // table header row
return h;
}
function estimateRowHeight(
item: NonNullable<NonNullable<InvoiceData["items"]>[0]>,
showRate: boolean,
): number { ): number {
if (!text) return fontSize * lineHeight; // 532pt usable width (612 - 80pt horizontal padding); description takes 40% or 48%
const descColWidth = 532 * (showRate ? 0.4 : 0.48);
// Rough character width estimation for Frutiger at given font size // Frutiger at 10pt: 0.45em gives ~47 chars/line, matching real wrap behaviour
const avgCharWidth = fontSize * 0.6; const charsPerLine = Math.max(1, Math.floor(descColWidth / (10 * 0.45)));
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth); const lines = Math.ceil((item.description.length || 1) / charsPerLine);
// row paddingVertical:6 (×2=12) + cell paddingVertical:4 (×2=8) = 20pt overhead,
if (maxCharsPerLine <= 0) return fontSize * lineHeight; // but react-pdf measures the line box at slightly under full lineHeight, so 16pt in practice
return lines * 10 * 1.4 + 16;
const lines = Math.ceil(text.length / maxCharsPerLine);
return lines * fontSize * lineHeight;
} }
// Calculate estimated height for a table row based on actual content
function calculateRowHeight(
item: NonNullable<InvoiceData["items"]>[0],
): number {
if (!item) return 18; // fallback
const basePadding = 8; // Row padding
const fontSize = 10;
const lineHeight = 1.3;
// Description column is 40% of table width
// Table width is roughly 512 points (letter width - margins)
const descriptionWidth = 512 * 0.4;
const descriptionHeight = estimateTextHeight(
item.description,
descriptionWidth,
fontSize,
lineHeight,
);
// Minimum row height for other columns
const minRowHeight = fontSize * lineHeight;
// Row height is the maximum of description height and minimum height, plus padding
// Ensure minimum row height of 24 points for readability
return Math.max(descriptionHeight, minRowHeight, 24) + basePadding;
}
// Dynamic pagination calculation based on actual content
function calculateItemsForPage(
items: NonNullable<InvoiceData["items"]>,
startIndex: number,
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Calculate how many items can fit based on actual row heights
let usedHeight = 0;
let itemCount = 0;
for (let i = startIndex; i < items.length; i++) {
const item = items[i];
if (!item) continue;
const rowHeight = calculateRowHeight(item);
if (usedHeight + rowHeight > availableHeight) {
break; // This item won't fit
}
usedHeight += rowHeight;
itemCount++;
}
return Math.max(1, itemCount); // Always return at least 1 item
}
// Fallback function for backward compatibility
function calculateItemsPerPage(
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Conservative estimate using average row height
const avgRowHeight = 24; // Increased from 18 to account for potential wrapping
return Math.max(1, Math.floor(availableHeight / avgRowHeight));
}
// Dynamic pagination function
function paginateItems( function paginateItems(
items: NonNullable<InvoiceData["items"]>, items: NonNullable<InvoiceData["items"]>,
hasNotes = false, hasNotes = false,
showRate = true,
) { ) {
const validItems = items.filter(Boolean); const validItems = items.filter(Boolean) as NonNullable<typeof items[0]>[];
const pages: Array<typeof validItems> = []; if (validItems.length === 0) return [[]];
if (validItems.length === 0) { const rowHeights = validItems.map((item) => estimateRowHeight(item, showRate));
return [[]];
function pack(startIdx: number, budget: number): number {
let used = 0, count = 0;
for (let i = startIdx; i < validItems.length; i++) {
if (used + rowHeights[i]! > budget) break;
used += rowHeights[i]!;
count++;
}
return Math.max(1, count);
} }
let currentIndex = 0; const pages: (typeof validItems)[] = [];
let pageIndex = 0; let idx = 0;
while (currentIndex < validItems.length) { while (idx < validItems.length) {
const isFirstPage = pageIndex === 0; const isFirst = pages.length === 0;
const remainingItems = validItems.length - currentIndex; const countFull = pack(idx, pageContentBudget(isFirst, false));
// Determine if this could be the last page with simple calculation if (idx + countFull >= validItems.length) {
const maxPossibleItems = calculateItemsPerPage(isFirstPage, false); // All remaining items fit — if there are notes, verify they also fit with the notes reservation
const wouldBeLastPage = if (hasNotes) {
currentIndex + maxPossibleItems >= validItems.length; const countWithNotes = pack(idx, pageContentBudget(isFirst, true));
if (idx + countWithNotes >= validItems.length) {
// Calculate items per page, accounting for notes space if this is likely the last page pages.push(validItems.slice(idx));
let itemsPerPage = calculateItemsForPage( break;
validItems,
currentIndex,
isFirstPage,
wouldBeLastPage && hasNotes,
);
// Fallback to conservative calculation if dynamic fails
if (itemsPerPage === 0) {
itemsPerPage = calculateItemsPerPage(
isFirstPage,
wouldBeLastPage && hasNotes,
);
} }
// Notes don't fit alongside all items — push what fits, notes go on next page
// Ensure we don't have tiny orphan pages pages.push(validItems.slice(idx, idx + countWithNotes));
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 2) { idx += countWithNotes;
itemsPerPage = Math.max(1, itemsPerPage - 1); } else {
pages.push(validItems.slice(idx));
break;
}
} else {
pages.push(validItems.slice(idx, idx + countFull));
idx += countFull;
} }
// Never take more items than we have
itemsPerPage = Math.min(itemsPerPage, remainingItems);
const pageItems = validItems.slice(
currentIndex,
currentIndex + itemsPerPage,
);
pages.push(pageItems);
currentIndex += itemsPerPage;
pageIndex++;
} }
return pages; return pages;
} }
function getColumnWidths(showRate: boolean) {
return showRate
? { date: "15%", description: "40%", hours: "12%", rate: "15%", amount: "18%" }
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
}
// Dense header component (first page) // Dense header component (first page)
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
<View style={styles.denseHeader}> <View style={styles.denseHeader}>
@@ -902,19 +787,24 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
); );
// Table header component // Table header component
const TableHeader: React.FC = () => ( const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => {
const cols = getColumnWidths(showRate);
return (
<View style={styles.tableHeader}> <View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text> <Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}> <Text style={[styles.tableHeaderCell, { width: cols.description }]}>
Description Description
</Text> </Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text> <Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text>
{showRate && (
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text> <Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}> )}
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}>
Amount Amount
</Text> </Text>
</View> </View>
); );
};
// Footer component // Footer component
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
@@ -1022,8 +912,10 @@ const TotalsSection: React.FC<{
// Main PDF component // Main PDF component
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const items = invoice.items?.filter(Boolean) ?? []; const items = invoice.items?.filter(Boolean) ?? [];
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
const paginatedItems = paginateItems(items, Boolean(invoice.notes), showRate);
return ( return (
<Document> <Document>
@@ -1044,7 +936,7 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{/* Table */} {/* Table */}
{hasItems && ( {hasItems && (
<View style={styles.tableContainer}> <View style={styles.tableContainer}>
<TableHeader /> <TableHeader showRate={showRate} />
{pageItems.map( {pageItems.map(
(item, index) => (item, index) =>
item && ( item && (
@@ -1055,25 +947,28 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
index % 2 === 0 ? styles.tableRowAlt : {}, index % 2 === 0 ? styles.tableRowAlt : {},
]} ]}
> >
<Text style={[styles.tableCell, styles.tableCellDate]}> <Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
{formatDate(item.date)} {formatDate(item.date)}
</Text> </Text>
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
styles.tableCellDescription, styles.tableCellDescription,
{ width: cols.description },
]} ]}
> >
{item.description} {item.description}
</Text> </Text>
<Text style={[styles.tableCell, styles.tableCellHours]}> <Text style={[styles.tableCell, styles.tableCellHours, { width: cols.hours }]}>
{item.hours} {item.hours}
</Text> </Text>
{showRate && (
<Text style={[styles.tableCell, styles.tableCellRate]}> <Text style={[styles.tableCell, styles.tableCellRate]}>
{formatCurrency(item.rate, currency)} {formatCurrency(item.rate, currency)}
</Text> </Text>
)}
<Text <Text
style={[styles.tableCell, styles.tableCellAmount]} style={[styles.tableCell, styles.tableCellAmount, { width: cols.amount }]}
> >
{formatCurrency(item.amount, currency)} {formatCurrency(item.amount, currency)}
</Text> </Text>