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:
parent
2f613181ac
commit
64d8fe1be2
3 changed files with 81 additions and 5 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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("<A&B>");
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -289,6 +289,29 @@ function esc(s: string): string {
|
|||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue