fix(reports): externalize pdfmake's font stack, thin dense chart labels

Found live: rendering a report PDF 500'd with "ENOENT ... data.trie".
pdfmake's server PdfPrinter pulls in pdfkit -> @foliojs-fork/fontkit,
which loads font-shaping tables from a path relative to its own
installed location at runtime; webpack-bundled into the route's chunk,
that lookup can't find them. serverExternalPackages leaves the whole
chain on native Node `require`, where the real node_modules layout
resolves them — the same reason Next already ships `sharp` and
`@react-pdf/renderer` externalized by default.

Also found live: a bar chart with ~30 categories (fuel by vehicle)
printed completely overlapping x-axis labels. categoryLabelStride
thins them the way recharts' minTickGap does, keeping only as many
labels as the plot width can fit without collision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-27 09:33:14 +02:00
parent 2f613181ac
commit 64d8fe1be2
3 changed files with 81 additions and 5 deletions

View file

@ -22,6 +22,15 @@ const nextConfig = {
allowedOrigins: ["*"],
},
},
// pdfmake's server-side PdfPrinter pulls in pdfkit -> @foliojs-fork/fontkit,
// which loads font-shaping tables (data.trie etc.) from a path relative to
// its own installed location at runtime. Webpack-bundled into a Server
// Component/Route Handler chunk, that relative lookup 404s (ENOENT) because
// the .trie files never get copied alongside the bundle. Left as native
// Node `require`, the real node_modules layout resolves them correctly —
// the same reason `sharp` and `@react-pdf/renderer` ship in Next's own
// default external-packages list.
serverExternalPackages: ["pdfmake", "pdfkit", "@foliojs-fork/fontkit"],
};
const withPWA = require("@ducanh2912/next-pwa").default({

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ChartSpec } from "@/lib/reports/chart-spec";
import { buildChartPlan, planToSvg } from "./chart-svg";
import { buildChartPlan, categoryLabelStride, planToSvg } from "./chart-svg";
const barSpec: ChartSpec = {
id: "fuel-by-vehicle",
@ -99,4 +99,46 @@ describe("planToSvg", () => {
expect(svg).not.toContain("<A&B>");
expect(svg).toContain("&lt;A&amp;B&gt;");
});
it("thins x-axis labels for a chart with many narrow categories, instead of printing overlapping text", () => {
const many: ChartSpec = {
...barSpec,
rows: Array.from({ length: 30 }, (_, i) => ({
vehicle: `Vehicle number ${i + 1}`,
litres: 100 + i,
cost: 100 + i,
})),
};
const plan = buildChartPlan(many, 400, 220);
const svg = planToSvg(plan);
const categoryLabelCount = (svg.match(/text-anchor="middle">/g) ?? []).length;
expect(categoryLabelCount).toBeGreaterThan(0);
expect(categoryLabelCount).toBeLessThan(plan.categories.length);
});
});
describe("categoryLabelStride", () => {
it("keeps every label when the slots are wide enough", () => {
const categories = [
{ x: 0, width: 100, label: "A" },
{ x: 100, width: 100, label: "B" },
{ x: 200, width: 100, label: "C" },
];
expect(categoryLabelStride(categories).map((c) => c.label)).toEqual(["A", "B", "C"]);
});
it("drops every other label (or more) when slots are too narrow for the widest one", () => {
const categories = Array.from({ length: 10 }, (_, i) => ({
x: i * 20,
width: 20,
label: `Long vehicle name ${i}`,
}));
const kept = categoryLabelStride(categories);
expect(kept.length).toBeLessThan(categories.length);
expect(kept[0].label).toBe(categories[0].label.slice(0, 11) + "…");
});
it("returns nothing for no categories", () => {
expect(categoryLabelStride([])).toEqual([]);
});
});

View file

@ -289,6 +289,29 @@ function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const LABEL_MAX_CHARS = 12;
const LABEL_CHAR_PX = 5.2;
const LABEL_GAP_PX = 6;
function truncateLabel(label: string): string {
return label.length > LABEL_MAX_CHARS ? `${label.slice(0, LABEL_MAX_CHARS - 1)}…` : label;
}
/**
* Every category's slot, truncated to its label text, but only every
* Nth one when the slots are too narrow for the widest label to fit
* without colliding with its neighbour — the SVG equivalent of
* recharts' `minTickGap`.
*/
export function categoryLabelStride(categories: Category[]): Category[] {
if (categories.length === 0) return [];
const truncated = categories.map((c) => ({ ...c, label: truncateLabel(c.label) }));
const widestPx = Math.max(...truncated.map((c) => c.label.length * LABEL_CHAR_PX));
const slotWidth = truncated[0].width || 1;
const step = Math.max(1, Math.ceil((widestPx + LABEL_GAP_PX) / slotWidth));
return truncated.filter((_, i) => i % step === 0);
}
function pointsToPath(points: Point[]): string {
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
}
@ -321,15 +344,17 @@ export function planToSvg(plan: ChartPlan): string {
`<text x="${plan.plot.left - 8}" y="${tick.y + 3}" font-size="9" fill="${CHART_AXIS}" text-anchor="end">${esc(tick.label)}</text>`,
);
}
// X axis baseline + category labels.
// X axis baseline + category labels, thinned (recharts' minTickGap
// idea) so a chart with many categories never prints overlapping
// garbage — a category label is only ever this dense on a rich
// dataset, and a "how would this look" test would never catch it.
const baseline = plan.plot.top + plan.plot.height;
parts.push(
`<line x1="${plan.plot.left}" y1="${baseline}" x2="${plan.plot.left + plan.plot.width}" y2="${baseline}" stroke="${CHART_AXIS}" stroke-width="1"/>`,
);
for (const cat of plan.categories) {
const label = cat.label.length > 12 ? `${cat.label.slice(0, 11)}…` : cat.label;
for (const cat of categoryLabelStride(plan.categories)) {
parts.push(
`<text x="${cat.x + cat.width / 2}" y="${baseline + 14}" font-size="9" fill="${CHART_AXIS}" text-anchor="middle">${esc(label)}</text>`,
`<text x="${cat.x + cat.width / 2}" y="${baseline + 14}" font-size="9" fill="${CHART_AXIS}" text-anchor="middle">${esc(cat.label)}</text>`,
);
}