Every renderer funneled the PDF through a JS string and re-encoded it as
latin1. Three separate problems, only the first of which was ever real:
1. `pdf(el).toBuffer()` is NOT the buffer API — it resolves to the PDFKit
*stream* (react-pdf carries a TODO to rename it `toStream`), and under
jsdom it resolves to a bare PDFDocument handle. That is the jsdom
breakage an old comment described, but it applies to that method, not
to the module-level `renderToBuffer()` helper.
2. `toString()` renders with `compress: false`. react-pdf's own source
says why: "when rendering to string if compress=true the document is
blank". The method is flagged upstream as buggy (#2112, #2095).
3. The string round-trip mangles binary streams. Text is ASCII in the
content stream so it survives; a raster image is a compressed blob
that does not — the image object lands in the file but is never
painted. Poppler was already reporting "Bad FCHECK in flate stream".
It stayed latent because the only image in these documents is the letterhead
logo, and that logo is an SVG. SVG is text, so it round-tripped perfectly and
hid the whole thing. `0xFD` in the old comments is the fingerprint: latin1's
truncation of U+FFFD, the Unicode replacement char.
`renderToBuffer()` fixes all three in a single pass and works under jsdom, so
no fallback is needed. Note you cannot "try toBuffer, fall back to toString" —
calling `toBuffer()` first consumes the render and a later `toString()` on the
same instance returns a document with the content missing.
Three more instances of the same pattern lived outside render.tsx and are fixed
here too: the print-bundle, delivery-note-print and picking-slip routes.
`render-image-pipeline.test.ts` is the regression guard. Its size assertion is
expressed against the old uncompressed baseline rather than a round number
picked when the document was smaller — an absolute byte count is a tripwire
that fires on every feature addition, which is how a real regression gets
tuned away. Guard verified by reintroducing the old path: 2 tests fail.
188 lines
No EOL
7.1 KiB
TypeScript
188 lines
No EOL
7.1 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { getAdminPocketBase } from "./server/pocketbase-admin";
|
|
import type { BaseRecord } from "./types";
|
|
|
|
/**
|
|
* Letterhead — server-side read model for a row in `letterhead_settings`.
|
|
*
|
|
* Each company / brand has exactly one row. The PDF generator fetches
|
|
* this row fresh on every render so edits in the PB admin UI (logo
|
|
* swap, bank detail fix, terms tweak, accent color change) propagate
|
|
* to the next quote / DN / PO without a code deploy.
|
|
*/
|
|
export type LetterheadCode = "CNL" | "ISHERWOOD" | "WESTSCOTT GROUP OF COMPANIES";
|
|
|
|
export const LETTERHEAD_CODES: readonly LetterheadCode[] = [
|
|
"CNL",
|
|
"ISHERWOOD",
|
|
"WESTSCOTT GROUP OF COMPANIES",
|
|
] as const;
|
|
|
|
/**
|
|
* Canonical letterhead for ALL delivery-note PDFs, regardless of
|
|
* which subsidiary the customer belongs to. The subsidiary name
|
|
* still appears in the PDF meta block (so the printed document
|
|
* tells the driver which subsidiary the delivery is for) but the
|
|
* letterhead/header is always the Westcott Group one.
|
|
*
|
|
* Centralised here so the delivery-notes service and the invoicing
|
|
* API (which both render delivery-note PDFs) can't drift apart
|
|
* again. If the brand ever needs to change, this is the only
|
|
* constant to touch.
|
|
*/
|
|
export const DELIVERY_NOTE_LETTERHEAD_CODE: LetterheadCode =
|
|
"WESTSCOTT GROUP OF COMPANIES";
|
|
|
|
/**
|
|
* Helper that returns the canonical delivery-note letterhead code.
|
|
* Use this anywhere a delivery-note PDF is being rendered so the
|
|
* three-letter `?? "CNL"` fallbacks that used to live in the
|
|
* invoicing API routes stay gone.
|
|
*/
|
|
export function deliveryNoteLetterheadCode(): LetterheadCode {
|
|
return DELIVERY_NOTE_LETTERHEAD_CODE;
|
|
}
|
|
|
|
/**
|
|
* Letterhead for Repair Request PDFs (the "send to supplier for
|
|
* analysis" document). Always CNL — a repair request is a paper
|
|
* document the office hands to the supplier with the machine, and
|
|
* it identifies the *sender* (the office), not the recipient.
|
|
* Centralised so the service layer + the (future) any-other
|
|
* Repair Request renderer can't drift apart.
|
|
*/
|
|
export const REPAIR_REQUEST_LETTERHEAD_CODE: LetterheadCode = "CNL";
|
|
|
|
/**
|
|
* Helper that returns the canonical Repair Request letterhead code.
|
|
* Mirror of `deliveryNoteLetterheadCode()`.
|
|
*/
|
|
export function repairRequestLetterheadCode(): LetterheadCode {
|
|
return REPAIR_REQUEST_LETTERHEAD_CODE;
|
|
}
|
|
|
|
/**
|
|
* Pick the letterhead code that matches a given company name. The
|
|
* invoice / delivery-note flows don't always carry an explicit
|
|
* letterhead (the operator just hits "Send"), so we infer it from
|
|
* the order's billing company — "ISHERWOOD" or "CNL" in the name
|
|
* wins, otherwise we fall back to `null` and the caller decides
|
|
* (typically defaulting to CNL).
|
|
*/
|
|
export function letterheadForCompany(companyName: string | undefined | null): LetterheadCode | null {
|
|
const upper = (companyName ?? "").trim().toUpperCase();
|
|
if (!upper) return null;
|
|
if (upper.includes("ISHERWOOD")) return "ISHERWOOD";
|
|
if (upper.includes("CNL")) return "CNL";
|
|
return null;
|
|
}
|
|
|
|
export interface Letterhead extends BaseRecord {
|
|
display_name: string;
|
|
address: string;
|
|
phone: string;
|
|
email: string;
|
|
vat_number: string;
|
|
registration_number: string;
|
|
bank_name: string;
|
|
bank_account_number: string;
|
|
bank_branch_code: string;
|
|
terms: string;
|
|
default_markup_percent: number;
|
|
footer_text: string;
|
|
/** PB file token (filename) — combine with `letterheadLogoUrl` for a usable URL. */
|
|
logo: string;
|
|
is_active: boolean;
|
|
company: string;
|
|
/** Hex like "#738224". Set per-company so the PDF table header + accents differ. */
|
|
accent_color: string;
|
|
/** VAT rate the company charges. 15 for VAT-registered, 0 for exempt / below threshold. */
|
|
vat_percent: number;
|
|
}
|
|
|
|
/**
|
|
* Look up the letterhead row for a given company code.
|
|
*
|
|
* Matches on the `display_name` field (e.g. "CNL Mining Supplies" matches
|
|
* the `CNL` code). Throws if no row matches — that's a config bug, not a
|
|
* user error, so it surfaces as a 500 in the API.
|
|
*/
|
|
export async function fetchLetterhead(code: LetterheadCode): Promise<Letterhead> {
|
|
if (!LETTERHEAD_CODES.includes(code)) {
|
|
throw new Error(`Unknown letterhead code: ${code}`);
|
|
}
|
|
const pb = await getAdminPocketBase();
|
|
const rows = await pb.collection("letterhead_settings").getFullList<Letterhead>({
|
|
filter: `display_name ~ "${code}"`,
|
|
});
|
|
if (rows.length === 0) {
|
|
throw new Error(
|
|
`No letterhead_settings row matching code ${code}. ` +
|
|
`Seed one in the PB admin UI (Settings → Collections → letterhead_settings).`
|
|
);
|
|
}
|
|
return rows[0];
|
|
}
|
|
|
|
/**
|
|
* Public file URL for the logo. PB's `/api/files/{collection}/{record}/{filename}`
|
|
* endpoint serves without auth, so the PDF renderer can fetch this directly
|
|
* — though we still inline as data URI in the API route to avoid extra
|
|
* round trips during render and to keep the document component pure.
|
|
*/
|
|
export function letterheadLogoUrl(row: Letterhead): string | null {
|
|
if (!row.logo) return null;
|
|
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
|
return `${base}/api/files/${row.collectionId}/${row.id}/${row.logo}`;
|
|
}
|
|
|
|
/** Default accent for a code if the row has no `accent_color` set. */
|
|
export function defaultAccentFor(code: LetterheadCode): string {
|
|
return code === "CNL" ? "#738224" : "#7ed957";
|
|
}
|
|
|
|
/**
|
|
* Load the company logo SVG from `public/letterhead/<code>.svg` and
|
|
* return it as a `data:image/svg+xml;base64,...` URI suitable for
|
|
* `<Image src={...}>` in @react-pdf.
|
|
*
|
|
* The logo is embedded as SVG because `public/letterhead/<code>.svg`
|
|
* IS an SVG source file — @react-pdf parses the XML and embeds vector
|
|
* primitives. (An earlier version of this comment blamed a
|
|
* "@react-pdf byte-corruption bug that replaces every byte ≥ 0x80 with
|
|
* 0xFD" and used SVG as a workaround. The corruption was real but the
|
|
* culprit was this app's own render pipeline — `pdf().toString()`
|
|
* re-encodes the PDF as latin1, so U+FFFD replacement characters
|
|
* truncated to 0xFD. Fixed by using `renderToBuffer()`; see the note
|
|
* at the top of `lib/pdf/render.tsx`. Raster logos work again.)
|
|
*
|
|
* **Base64, not URL-encoded.** The bundled PDFKit only matches
|
|
* `^data:.+?;base64,(.*)$` when decoding inline data URIs (see
|
|
* `PDFImage.open` in `@react-pdf/pdfkit/lib/pdfkit.js`). A
|
|
* `;charset=utf-8,<urlencoded>` URI is treated as a filesystem path
|
|
* and silently dropped. Base64 it is — yes, the data URI is ~33%
|
|
* bigger than URL-encoded, but for a 1x-per-quote render that's
|
|
* a non-issue.
|
|
*
|
|
* Returns `null` if the file is missing or unreadable — that's a
|
|
* non-fatal degradation (the PDF still renders, just without a logo),
|
|
* so callers should treat it as best-effort.
|
|
*/
|
|
export async function loadLetterheadLogoDataUri(
|
|
code: LetterheadCode
|
|
): Promise<string | null> {
|
|
try {
|
|
const filePath = path.join(
|
|
process.cwd(),
|
|
"public",
|
|
"letterhead",
|
|
`${code.toLowerCase()}.svg`
|
|
);
|
|
const raw = await readFile(filePath);
|
|
return `data:image/svg+xml;base64,${raw.toString("base64")}`;
|
|
} catch (err) {
|
|
console.warn(`[letterhead] could not load logo for ${code}:`, err);
|
|
return null;
|
|
}
|
|
} |