diff --git a/next.config.js b/next.config.js index 787b353..6dfa9df 100644 --- a/next.config.js +++ b/next.config.js @@ -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({ diff --git a/src/lib/reports/pdf/chart-svg.test.ts b/src/lib/reports/pdf/chart-svg.test.ts index e557ad3..2781d4a 100644 --- a/src/lib/reports/pdf/chart-svg.test.ts +++ b/src/lib/reports/pdf/chart-svg.test.ts @@ -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(""); 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([]); + }); }); diff --git a/src/lib/reports/pdf/chart-svg.ts b/src/lib/reports/pdf/chart-svg.ts index 6674539..43ed682 100644 --- a/src/lib/reports/pdf/chart-svg.ts +++ b/src/lib/reports/pdf/chart-svg.ts @@ -289,6 +289,29 @@ function esc(s: string): string { return s.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 { `${esc(tick.label)}`, ); } - // 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( ``, ); - 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( - `${esc(label)}`, + `${esc(cat.label)}`, ); }