@mrfylke/nsr-barcode-generator 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -4
- package/dist/api.d.ts +16 -10
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +90 -85
- package/dist/api.js.map +1 -1
- package/dist/cli.js +29 -4
- package/dist/cli.js.map +1 -1
- package/dist/utils/assets.js +4 -4
- package/dist/utils/assets.js.map +1 -1
- package/dist/utils/enturApi.d.ts +14 -0
- package/dist/utils/enturApi.d.ts.map +1 -1
- package/dist/utils/enturApi.js +32 -13
- package/dist/utils/enturApi.js.map +1 -1
- package/dist/utils/fileReader.js +4 -4
- package/dist/utils/fileReader.js.map +1 -1
- package/dist/utils/fontLoader.js +2 -2
- package/dist/utils/fontLoader.js.map +1 -1
- package/dist/utils/nsrId.js +1 -1
- package/dist/utils/nsrId.js.map +1 -1
- package/dist/utils/pdfGenerator.d.ts +18 -2
- package/dist/utils/pdfGenerator.d.ts.map +1 -1
- package/dist/utils/pdfGenerator.js +355 -327
- package/dist/utils/pdfGenerator.js.map +1 -1
- package/package.json +8 -2
|
@@ -37,14 +37,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.generatePdfsForStopPlaces = generatePdfsForStopPlaces;
|
|
40
|
+
const node_fs_1 = require("node:fs");
|
|
41
|
+
const node_path_1 = require("node:path");
|
|
40
42
|
const pdfkit_1 = __importDefault(require("pdfkit"));
|
|
41
|
-
const fs_1 = require("fs");
|
|
42
|
-
const path_1 = require("path");
|
|
43
|
-
const fs_2 = require("fs");
|
|
44
43
|
const QRCode = __importStar(require("qrcode"));
|
|
44
|
+
const assets_1 = require("./assets");
|
|
45
45
|
const enturApi_1 = require("./enturApi");
|
|
46
46
|
const fontLoader_1 = require("./fontLoader");
|
|
47
|
-
const assets_1 = require("./assets");
|
|
48
47
|
async function generatePdfsForStopPlaces(requests, options) {
|
|
49
48
|
const { outputDirectory, overwrite = false, onProgress } = options;
|
|
50
49
|
await ensureDirectoryExists(outputDirectory);
|
|
@@ -67,7 +66,26 @@ async function generatePdfsForStopPlaces(requests, options) {
|
|
|
67
66
|
let fetchedInfos = [];
|
|
68
67
|
if (idsNeedingFetch.length > 0) {
|
|
69
68
|
const fetchStopPlaces = options.stopPlaceFetcher ??
|
|
70
|
-
((idsToFetch) => enturApi_1.enturApi.getMultipleStopPlaces(idsToFetch
|
|
69
|
+
((idsToFetch) => enturApi_1.enturApi.getMultipleStopPlaces(idsToFetch, {
|
|
70
|
+
...(onProgress
|
|
71
|
+
? {
|
|
72
|
+
onProgress: (event) => onProgress({
|
|
73
|
+
type: "data-fetching",
|
|
74
|
+
current: event.current,
|
|
75
|
+
total: event.total,
|
|
76
|
+
nsrId: event.nsrId,
|
|
77
|
+
status: event.status,
|
|
78
|
+
...(event.error ? { error: event.error } : {}),
|
|
79
|
+
}),
|
|
80
|
+
onBatchComplete: (event) => onProgress({
|
|
81
|
+
type: "data-fetching-batch",
|
|
82
|
+
batchNumber: event.batchNumber,
|
|
83
|
+
totalBatches: event.totalBatches,
|
|
84
|
+
delayMs: event.delayMs,
|
|
85
|
+
}),
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
}));
|
|
71
89
|
fetchedInfos = await fetchStopPlaces(idsNeedingFetch);
|
|
72
90
|
}
|
|
73
91
|
const fetchedById = new Map(idsNeedingFetch.map((id, index) => [id, fetchedInfos[index] ?? null]));
|
|
@@ -95,10 +113,11 @@ async function generatePdfsForStopPlaces(requests, options) {
|
|
|
95
113
|
throw new Error(`Failed to resolve stop place metadata for "${id}"`);
|
|
96
114
|
}
|
|
97
115
|
const filename = generateSafeFilename(id, stopPlaceInfo.name);
|
|
98
|
-
const outputPath = (0,
|
|
116
|
+
const outputPath = (0, node_path_1.join)(outputDirectory, `${filename}.pdf`);
|
|
99
117
|
if (!overwrite && (await pathExists(outputPath))) {
|
|
100
118
|
skipped.push({ nsrId: id, outputPath });
|
|
101
119
|
onProgress?.({
|
|
120
|
+
type: "generation",
|
|
102
121
|
current,
|
|
103
122
|
total,
|
|
104
123
|
nsrId: id,
|
|
@@ -110,6 +129,7 @@ async function generatePdfsForStopPlaces(requests, options) {
|
|
|
110
129
|
await generateSinglePdf(id, outputPath, stopPlaceInfo, options);
|
|
111
130
|
generated.push({ nsrId: id, outputPath });
|
|
112
131
|
onProgress?.({
|
|
132
|
+
type: "generation",
|
|
113
133
|
current,
|
|
114
134
|
total,
|
|
115
135
|
nsrId: id,
|
|
@@ -120,13 +140,20 @@ async function generatePdfsForStopPlaces(requests, options) {
|
|
|
120
140
|
catch (error) {
|
|
121
141
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
122
142
|
failed.push({ nsrId: id, error: message });
|
|
123
|
-
onProgress?.({
|
|
143
|
+
onProgress?.({
|
|
144
|
+
type: "generation",
|
|
145
|
+
current,
|
|
146
|
+
total,
|
|
147
|
+
nsrId: id,
|
|
148
|
+
status: "error",
|
|
149
|
+
error: message,
|
|
150
|
+
});
|
|
124
151
|
}
|
|
125
152
|
}
|
|
126
153
|
return {
|
|
127
154
|
generatedFiles: generated.map((item) => item.outputPath),
|
|
128
155
|
totalGenerated: generated.length,
|
|
129
|
-
outputDirectory: (0,
|
|
156
|
+
outputDirectory: (0, node_path_1.resolve)(outputDirectory),
|
|
130
157
|
generated,
|
|
131
158
|
skipped,
|
|
132
159
|
failed,
|
|
@@ -145,337 +172,338 @@ function isValidAbsoluteHttpUrl(value) {
|
|
|
145
172
|
}
|
|
146
173
|
}
|
|
147
174
|
async function generateSinglePdf(id, outputPath, stopPlaceInfo, options) {
|
|
148
|
-
|
|
175
|
+
let stream;
|
|
176
|
+
try {
|
|
177
|
+
const fonts = await fontLoader_1.fontLoader.loadPoppins(options?.assetsDirectory);
|
|
178
|
+
const orientation = options?.orientation || "landscape";
|
|
179
|
+
const format = options?.format || "A4";
|
|
180
|
+
const doc = new pdfkit_1.default({
|
|
181
|
+
size: format,
|
|
182
|
+
layout: orientation,
|
|
183
|
+
margins: {
|
|
184
|
+
top: 50,
|
|
185
|
+
bottom: 50,
|
|
186
|
+
left: 50,
|
|
187
|
+
right: 50,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
let poppinsRegular = "Helvetica";
|
|
191
|
+
let poppinsBold = "Helvetica-Bold";
|
|
149
192
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const doc = new pdfkit_1.default({
|
|
154
|
-
size: format,
|
|
155
|
-
layout: orientation,
|
|
156
|
-
margins: {
|
|
157
|
-
top: 50,
|
|
158
|
-
bottom: 50,
|
|
159
|
-
left: 50,
|
|
160
|
-
right: 50,
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
let poppinsRegular = "Helvetica";
|
|
164
|
-
let poppinsBold = "Helvetica-Bold";
|
|
165
|
-
try {
|
|
166
|
-
if (fonts.regular !== "Helvetica") {
|
|
167
|
-
doc.registerFont("Poppins-Regular", fonts.regular);
|
|
168
|
-
poppinsRegular = "Poppins-Regular";
|
|
169
|
-
}
|
|
170
|
-
if (fonts.bold !== "Helvetica-Bold") {
|
|
171
|
-
doc.registerFont("Poppins-Bold", fonts.bold);
|
|
172
|
-
poppinsBold = "Poppins-Bold";
|
|
173
|
-
}
|
|
193
|
+
if (fonts.regular !== "Helvetica") {
|
|
194
|
+
doc.registerFont("Poppins-Regular", fonts.regular);
|
|
195
|
+
poppinsRegular = "Poppins-Regular";
|
|
174
196
|
}
|
|
175
|
-
|
|
176
|
-
|
|
197
|
+
if (fonts.bold !== "Helvetica-Bold") {
|
|
198
|
+
doc.registerFont("Poppins-Bold", fonts.bold);
|
|
199
|
+
poppinsBold = "Poppins-Bold";
|
|
177
200
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
else if (modeString.includes("bus")) {
|
|
224
|
-
iconFileName = "Bus.png";
|
|
225
|
-
}
|
|
226
|
-
const iconPath = (0, assets_1.resolveAssetPath)(options?.assetsDirectory, "images", iconFileName);
|
|
227
|
-
const iconBuffer = await fs_1.promises.readFile(iconPath);
|
|
228
|
-
doc
|
|
229
|
-
.lineWidth(3)
|
|
230
|
-
.circle(iconX, iconY, iconSize / 2)
|
|
231
|
-
.stroke(white)
|
|
232
|
-
.circle(iconX, iconY, iconSize / 2)
|
|
233
|
-
.stroke(white);
|
|
234
|
-
const iconImageSize = iconSize * 0.6;
|
|
235
|
-
const iconImageX = iconX - iconImageSize / 2;
|
|
236
|
-
const iconImageY = iconY - iconImageSize / 2;
|
|
237
|
-
doc.image(iconBuffer, iconImageX, iconImageY, {
|
|
238
|
-
width: iconImageSize,
|
|
239
|
-
height: iconImageSize,
|
|
240
|
-
});
|
|
201
|
+
}
|
|
202
|
+
catch (fontError) {
|
|
203
|
+
console.warn("Failed to register Poppins fonts, using fallback:", fontError);
|
|
204
|
+
}
|
|
205
|
+
stream = (0, node_fs_1.createWriteStream)(outputPath);
|
|
206
|
+
doc.pipe(stream);
|
|
207
|
+
const pageWidth = doc.page.width;
|
|
208
|
+
const pageHeight = doc.page.height;
|
|
209
|
+
const styleConfig = options?.style || {};
|
|
210
|
+
const headerFooterColor = styleConfig.headerFooterColor || "#1A4D75";
|
|
211
|
+
const logoPath = styleConfig.logoPath ||
|
|
212
|
+
(0, assets_1.resolveAssetPath)(options?.assetsDirectory, "images", "fram_mor_fylkeskommune_dark.png");
|
|
213
|
+
const logoWidth = styleConfig.logoWidth || 105;
|
|
214
|
+
const fallbackLogoText = styleConfig.fallbackLogoText || "FRAM";
|
|
215
|
+
const fallbackLogoSubtext = styleConfig.fallbackLogoSubtext || "Møre og Romsdal fylkeskommune";
|
|
216
|
+
const white = "#FFFFFF";
|
|
217
|
+
const darkGray = "#333333";
|
|
218
|
+
const borderRadius = 15;
|
|
219
|
+
const borderMargin = 20;
|
|
220
|
+
const boxWidth = pageWidth - borderMargin * 2;
|
|
221
|
+
const boxHeight = pageHeight - borderMargin * 2;
|
|
222
|
+
doc
|
|
223
|
+
.lineWidth(0.25)
|
|
224
|
+
.roundedRect(borderMargin, borderMargin, boxWidth, boxHeight, borderRadius)
|
|
225
|
+
.stroke("#000000");
|
|
226
|
+
const headerHeight = 80;
|
|
227
|
+
doc
|
|
228
|
+
.save()
|
|
229
|
+
.roundedRect(borderMargin, borderMargin, boxWidth, boxHeight, borderRadius)
|
|
230
|
+
.clip()
|
|
231
|
+
.rect(borderMargin, borderMargin, boxWidth, headerHeight)
|
|
232
|
+
.fill(headerFooterColor)
|
|
233
|
+
.restore();
|
|
234
|
+
const iconX = 80;
|
|
235
|
+
const iconY = 60;
|
|
236
|
+
const iconSize = 50;
|
|
237
|
+
try {
|
|
238
|
+
const transportModes = stopPlaceInfo.transportMode || [];
|
|
239
|
+
let iconFileName = "Bus.png";
|
|
240
|
+
const modes = Array.isArray(transportModes)
|
|
241
|
+
? transportModes
|
|
242
|
+
: [transportModes];
|
|
243
|
+
const modeString = modes.join(" ").toLowerCase();
|
|
244
|
+
if (modeString.includes("water") || modeString.includes("boat")) {
|
|
245
|
+
iconFileName = "Boat.png";
|
|
241
246
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
.stroke(white);
|
|
248
|
-
doc
|
|
249
|
-
.fillColor(white)
|
|
250
|
-
.fontSize(20)
|
|
251
|
-
.font(poppinsBold)
|
|
252
|
-
.text("x", iconX - 10, iconY - 10);
|
|
247
|
+
else if (modeString.includes("ferry")) {
|
|
248
|
+
iconFileName = "Ferry.png";
|
|
249
|
+
}
|
|
250
|
+
else if (modeString.includes("bus")) {
|
|
251
|
+
iconFileName = "Bus.png";
|
|
253
252
|
}
|
|
254
|
-
const
|
|
253
|
+
const iconPath = (0, assets_1.resolveAssetPath)(options?.assetsDirectory, "images", iconFileName);
|
|
254
|
+
const iconBuffer = await node_fs_1.promises.readFile(iconPath);
|
|
255
|
+
doc
|
|
256
|
+
.lineWidth(3)
|
|
257
|
+
.circle(iconX, iconY, iconSize / 2)
|
|
258
|
+
.stroke(white)
|
|
259
|
+
.circle(iconX, iconY, iconSize / 2)
|
|
260
|
+
.stroke(white);
|
|
261
|
+
const iconImageSize = iconSize * 0.6;
|
|
262
|
+
const iconImageX = iconX - iconImageSize / 2;
|
|
263
|
+
const iconImageY = iconY - iconImageSize / 2;
|
|
264
|
+
doc.image(iconBuffer, iconImageX, iconImageY, {
|
|
265
|
+
width: iconImageSize,
|
|
266
|
+
height: iconImageSize,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
console.warn("Could not load transport mode icon:", error);
|
|
271
|
+
doc
|
|
272
|
+
.lineWidth(3)
|
|
273
|
+
.circle(iconX, iconY, iconSize / 2)
|
|
274
|
+
.stroke(white);
|
|
255
275
|
doc
|
|
256
276
|
.fillColor(white)
|
|
257
|
-
.fontSize(
|
|
277
|
+
.fontSize(20)
|
|
258
278
|
.font(poppinsBold)
|
|
259
|
-
.text(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
width: 200,
|
|
284
|
-
margin: 1,
|
|
285
|
-
color: {
|
|
286
|
-
dark: "#000000",
|
|
287
|
-
light: "#FFFFFF",
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
const base64Data = qrCodeDataURL.split(",")[1];
|
|
291
|
-
let qrCodeBuffer = null;
|
|
292
|
-
if (base64Data) {
|
|
293
|
-
qrCodeBuffer = Buffer.from(base64Data, "base64");
|
|
294
|
-
}
|
|
295
|
-
if (orientation === "portrait") {
|
|
296
|
-
const qrSize = 120;
|
|
297
|
-
const topMargin = headerHeight + 20;
|
|
298
|
-
const qrX = iconX - iconSize / 2;
|
|
299
|
-
const qrY = topMargin;
|
|
300
|
-
if (qrCodeBuffer) {
|
|
301
|
-
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
|
302
|
-
}
|
|
303
|
-
const rightColumnX = pageWidth - 350;
|
|
304
|
-
const rightColumnStartY = topMargin;
|
|
305
|
-
doc
|
|
306
|
-
.fillColor(darkGray)
|
|
307
|
-
.fontSize(24)
|
|
308
|
-
.font(poppinsBold)
|
|
309
|
-
.text("Når kjem bussen?", rightColumnX, rightColumnStartY);
|
|
310
|
-
doc
|
|
311
|
-
.fillColor(darkGray)
|
|
312
|
-
.fontSize(14)
|
|
313
|
-
.font(poppinsRegular)
|
|
314
|
-
.text("Opne mobilkameraet ditt og hald", rightColumnX, rightColumnStartY + 40)
|
|
315
|
-
.text("kameralinsa over QR-koden. Lenka", rightColumnX, rightColumnStartY + 60)
|
|
316
|
-
.text("fører deg til reiseplanleggaren, og viser", rightColumnX, rightColumnStartY + 80)
|
|
317
|
-
.text("busslinjer og avgangar frå haldeplassen", rightColumnX, rightColumnStartY + 100)
|
|
318
|
-
.text("du står på.", rightColumnX, rightColumnStartY + 120);
|
|
319
|
-
const englishStartY = rightColumnStartY + 160;
|
|
320
|
-
doc
|
|
321
|
-
.fillColor(darkGray)
|
|
322
|
-
.fontSize(24)
|
|
323
|
-
.font(poppinsBold)
|
|
324
|
-
.text("When will the bus", rightColumnX, englishStartY)
|
|
325
|
-
.text("arrive?", rightColumnX, englishStartY + 25);
|
|
326
|
-
doc
|
|
327
|
-
.fillColor(darkGray)
|
|
328
|
-
.fontSize(14)
|
|
329
|
-
.font(poppinsRegular)
|
|
330
|
-
.text("Open your mobile camera and hold", rightColumnX, englishStartY + 60)
|
|
331
|
-
.text("camera lens over the QR code. The link", rightColumnX, englishStartY + 80)
|
|
332
|
-
.text("takes you to the travel planner, and", rightColumnX, englishStartY + 100)
|
|
333
|
-
.text("shows bus lines and departures from the", rightColumnX, englishStartY + 120)
|
|
334
|
-
.text("stop you are at.", rightColumnX, englishStartY + 140);
|
|
335
|
-
const infoStartY = englishStartY + 180;
|
|
336
|
-
doc
|
|
337
|
-
.fillColor(darkGray)
|
|
338
|
-
.fontSize(14)
|
|
339
|
-
.font(poppinsBold)
|
|
340
|
-
.text("Informasjon om bussavgangar", rightColumnX, infoStartY)
|
|
341
|
-
.text("finn du också:", rightColumnX, infoStartY + 20);
|
|
342
|
-
doc
|
|
343
|
-
.fillColor(darkGray)
|
|
344
|
-
.fontSize(14)
|
|
345
|
-
.font(poppinsRegular)
|
|
346
|
-
.text("Information about bus departures", rightColumnX, infoStartY + 40)
|
|
347
|
-
.text("can also be found:", rightColumnX, infoStartY + 60);
|
|
348
|
-
doc
|
|
349
|
-
.fillColor(darkGray)
|
|
350
|
-
.fontSize(14)
|
|
351
|
-
.font(poppinsRegular)
|
|
352
|
-
.text("• I appane / in the apps:", rightColumnX, infoStartY + 100);
|
|
353
|
-
doc
|
|
354
|
-
.font(poppinsBold)
|
|
355
|
-
.text("FRAM / Entur", rightColumnX + 10, infoStartY + 120);
|
|
356
|
-
doc
|
|
357
|
-
.font(poppinsRegular)
|
|
358
|
-
.text("• På nettsidene / on the websites:", rightColumnX, infoStartY + 155);
|
|
359
|
-
doc
|
|
360
|
-
.font(poppinsBold)
|
|
361
|
-
.text("frammr.no / entur.no", rightColumnX + 10, infoStartY + 175);
|
|
279
|
+
.text("x", iconX - 10, iconY - 10);
|
|
280
|
+
}
|
|
281
|
+
const stopName = stopPlaceInfo.name || id;
|
|
282
|
+
doc
|
|
283
|
+
.fillColor(white)
|
|
284
|
+
.fontSize(36)
|
|
285
|
+
.font(poppinsBold)
|
|
286
|
+
.text(stopName, 130, 35, {
|
|
287
|
+
width: pageWidth - 200,
|
|
288
|
+
align: "left",
|
|
289
|
+
});
|
|
290
|
+
const footerHeight = 80;
|
|
291
|
+
const availableHeight = pageHeight - headerHeight - footerHeight;
|
|
292
|
+
const contentHeight = orientation === "portrait" ? 400 : 310;
|
|
293
|
+
const contentY = headerHeight + (availableHeight - contentHeight) / 2;
|
|
294
|
+
let qrUrl;
|
|
295
|
+
if (options?.generateQrUrl) {
|
|
296
|
+
const qrContext = {
|
|
297
|
+
id,
|
|
298
|
+
name: stopPlaceInfo.name ?? id,
|
|
299
|
+
};
|
|
300
|
+
const candidate = options.generateQrUrl(qrContext);
|
|
301
|
+
if (!isValidAbsoluteHttpUrl(candidate)) {
|
|
302
|
+
throw new Error(`generateQrUrl returned an invalid URL for "${id}": ${JSON.stringify(candidate)}. Expected a non-empty absolute http(s) URL.`);
|
|
362
303
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
.fontSize(24)
|
|
389
|
-
.font(poppinsBold)
|
|
390
|
-
.text("When will the bus", textStartX, contentY + 160)
|
|
391
|
-
.text("arrive?", textStartX, contentY + 185);
|
|
392
|
-
doc
|
|
393
|
-
.fillColor(darkGray)
|
|
394
|
-
.fontSize(14)
|
|
395
|
-
.font(poppinsRegular)
|
|
396
|
-
.text("Open your mobile camera and hold", textStartX, contentY + 220)
|
|
397
|
-
.text("camera lens over the QR code. The link", textStartX, contentY + 240)
|
|
398
|
-
.text("takes you to the travel planner, and", textStartX, contentY + 260)
|
|
399
|
-
.text("shows bus lines and departures from the", textStartX, contentY + 280)
|
|
400
|
-
.text("stop you are at.", textStartX, contentY + 300);
|
|
401
|
-
const separatorX = rightColumnX - 22;
|
|
402
|
-
doc
|
|
403
|
-
.lineWidth(0.25)
|
|
404
|
-
.moveTo(separatorX, contentY)
|
|
405
|
-
.lineTo(separatorX, contentY + 320)
|
|
406
|
-
.stroke("#000000");
|
|
407
|
-
const rightColumnStartY = contentY + 40;
|
|
408
|
-
doc
|
|
409
|
-
.fillColor(darkGray)
|
|
410
|
-
.fontSize(14)
|
|
411
|
-
.font(poppinsBold)
|
|
412
|
-
.text("Informasjon om bussavgangar", rightColumnX, rightColumnStartY)
|
|
413
|
-
.text("finn du også:", rightColumnX, rightColumnStartY + 20);
|
|
414
|
-
doc
|
|
415
|
-
.fillColor(darkGray)
|
|
416
|
-
.fontSize(14)
|
|
417
|
-
.font(poppinsRegular)
|
|
418
|
-
.text("Information about bus departures", rightColumnX, rightColumnStartY + 40)
|
|
419
|
-
.text("can also be found:", rightColumnX, rightColumnStartY + 60);
|
|
420
|
-
doc
|
|
421
|
-
.fillColor(darkGray)
|
|
422
|
-
.fontSize(14)
|
|
423
|
-
.font(poppinsRegular)
|
|
424
|
-
.text("• I appane / in the apps:", rightColumnX, rightColumnStartY + 100);
|
|
425
|
-
doc
|
|
426
|
-
.font(poppinsBold)
|
|
427
|
-
.text("FRAM / Entur", rightColumnX + 10, rightColumnStartY + 120);
|
|
428
|
-
doc
|
|
429
|
-
.font(poppinsRegular)
|
|
430
|
-
.text("• På nettsidene / on the websites:", rightColumnX, rightColumnStartY + 155);
|
|
431
|
-
doc
|
|
432
|
-
.font(poppinsBold)
|
|
433
|
-
.text("frammr.no / entur.no", rightColumnX + 10, rightColumnStartY + 175);
|
|
304
|
+
qrUrl = candidate;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
qrUrl = `https://reise.frammr.no/departures/${encodeURIComponent(id)}?qr`;
|
|
308
|
+
}
|
|
309
|
+
const qrCodeDataURL = await QRCode.toDataURL(qrUrl, {
|
|
310
|
+
width: 200,
|
|
311
|
+
margin: 1,
|
|
312
|
+
color: {
|
|
313
|
+
dark: "#000000",
|
|
314
|
+
light: "#FFFFFF",
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
const base64Data = qrCodeDataURL.split(",")[1];
|
|
318
|
+
let qrCodeBuffer = null;
|
|
319
|
+
if (base64Data) {
|
|
320
|
+
qrCodeBuffer = Buffer.from(base64Data, "base64");
|
|
321
|
+
}
|
|
322
|
+
if (orientation === "portrait") {
|
|
323
|
+
const qrSize = 120;
|
|
324
|
+
const topMargin = headerHeight + 20;
|
|
325
|
+
const qrX = iconX - iconSize / 2;
|
|
326
|
+
const qrY = topMargin;
|
|
327
|
+
if (qrCodeBuffer) {
|
|
328
|
+
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
|
434
329
|
}
|
|
435
|
-
const
|
|
330
|
+
const rightColumnX = pageWidth - 350;
|
|
331
|
+
const rightColumnStartY = topMargin;
|
|
436
332
|
doc
|
|
437
|
-
.
|
|
438
|
-
.
|
|
439
|
-
.
|
|
440
|
-
.
|
|
441
|
-
|
|
442
|
-
.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
333
|
+
.fillColor(darkGray)
|
|
334
|
+
.fontSize(24)
|
|
335
|
+
.font(poppinsBold)
|
|
336
|
+
.text("Når kjem bussen?", rightColumnX, rightColumnStartY);
|
|
337
|
+
doc
|
|
338
|
+
.fillColor(darkGray)
|
|
339
|
+
.fontSize(14)
|
|
340
|
+
.font(poppinsRegular)
|
|
341
|
+
.text("Opne mobilkameraet ditt og hald", rightColumnX, rightColumnStartY + 40)
|
|
342
|
+
.text("kameralinsa over QR-koden. Lenka", rightColumnX, rightColumnStartY + 60)
|
|
343
|
+
.text("fører deg til reiseplanleggaren, og viser", rightColumnX, rightColumnStartY + 80)
|
|
344
|
+
.text("busslinjer og avgangar frå haldeplassen", rightColumnX, rightColumnStartY + 100)
|
|
345
|
+
.text("du står på.", rightColumnX, rightColumnStartY + 120);
|
|
346
|
+
const englishStartY = rightColumnStartY + 160;
|
|
347
|
+
doc
|
|
348
|
+
.fillColor(darkGray)
|
|
349
|
+
.fontSize(24)
|
|
350
|
+
.font(poppinsBold)
|
|
351
|
+
.text("When will the bus", rightColumnX, englishStartY)
|
|
352
|
+
.text("arrive?", rightColumnX, englishStartY + 25);
|
|
353
|
+
doc
|
|
354
|
+
.fillColor(darkGray)
|
|
355
|
+
.fontSize(14)
|
|
356
|
+
.font(poppinsRegular)
|
|
357
|
+
.text("Open your mobile camera and hold", rightColumnX, englishStartY + 60)
|
|
358
|
+
.text("camera lens over the QR code. The link", rightColumnX, englishStartY + 80)
|
|
359
|
+
.text("takes you to the travel planner, and", rightColumnX, englishStartY + 100)
|
|
360
|
+
.text("shows bus lines and departures from the", rightColumnX, englishStartY + 120)
|
|
361
|
+
.text("stop you are at.", rightColumnX, englishStartY + 140);
|
|
362
|
+
const infoStartY = englishStartY + 180;
|
|
363
|
+
doc
|
|
364
|
+
.fillColor(darkGray)
|
|
365
|
+
.fontSize(14)
|
|
366
|
+
.font(poppinsBold)
|
|
367
|
+
.text("Informasjon om bussavgangar", rightColumnX, infoStartY)
|
|
368
|
+
.text("finn du också:", rightColumnX, infoStartY + 20);
|
|
369
|
+
doc
|
|
370
|
+
.fillColor(darkGray)
|
|
371
|
+
.fontSize(14)
|
|
372
|
+
.font(poppinsRegular)
|
|
373
|
+
.text("Information about bus departures", rightColumnX, infoStartY + 40)
|
|
374
|
+
.text("can also be found:", rightColumnX, infoStartY + 60);
|
|
375
|
+
doc
|
|
376
|
+
.fillColor(darkGray)
|
|
377
|
+
.fontSize(14)
|
|
378
|
+
.font(poppinsRegular)
|
|
379
|
+
.text("• I appane / in the apps:", rightColumnX, infoStartY + 100);
|
|
380
|
+
doc
|
|
381
|
+
.font(poppinsBold)
|
|
382
|
+
.text("FRAM / Entur", rightColumnX + 10, infoStartY + 120);
|
|
383
|
+
doc
|
|
384
|
+
.font(poppinsRegular)
|
|
385
|
+
.text("• På nettsidene / on the websites:", rightColumnX, infoStartY + 155);
|
|
386
|
+
doc
|
|
387
|
+
.font(poppinsBold)
|
|
388
|
+
.text("frammr.no / entur.no", rightColumnX + 10, infoStartY + 175);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const qrSize = 120;
|
|
392
|
+
const qrX = iconX - iconSize / 2;
|
|
393
|
+
const qrY = contentY;
|
|
394
|
+
if (qrCodeBuffer) {
|
|
395
|
+
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
|
467
396
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
397
|
+
const textStartX = qrX + qrSize + 20;
|
|
398
|
+
const rightColumnX = pageWidth - 320;
|
|
399
|
+
doc
|
|
400
|
+
.fillColor(darkGray)
|
|
401
|
+
.fontSize(24)
|
|
402
|
+
.font(poppinsBold)
|
|
403
|
+
.text("Når kjem bussen?", textStartX, contentY);
|
|
404
|
+
doc
|
|
405
|
+
.fillColor(darkGray)
|
|
406
|
+
.fontSize(14)
|
|
407
|
+
.font(poppinsRegular)
|
|
408
|
+
.text("Opne mobilkameraet ditt og hald", textStartX, contentY + 40)
|
|
409
|
+
.text("kameralinsa over QR-koden. Lenka", textStartX, contentY + 60)
|
|
410
|
+
.text("fører deg til reiseplanleggaren, og viser", textStartX, contentY + 80)
|
|
411
|
+
.text("busslinjer og avgangar frå haldeplassen", textStartX, contentY + 100)
|
|
412
|
+
.text("du står på.", textStartX, contentY + 120);
|
|
413
|
+
doc
|
|
414
|
+
.fillColor(darkGray)
|
|
415
|
+
.fontSize(24)
|
|
416
|
+
.font(poppinsBold)
|
|
417
|
+
.text("When will the bus", textStartX, contentY + 160)
|
|
418
|
+
.text("arrive?", textStartX, contentY + 185);
|
|
419
|
+
doc
|
|
420
|
+
.fillColor(darkGray)
|
|
421
|
+
.fontSize(14)
|
|
422
|
+
.font(poppinsRegular)
|
|
423
|
+
.text("Open your mobile camera and hold", textStartX, contentY + 220)
|
|
424
|
+
.text("camera lens over the QR code. The link", textStartX, contentY + 240)
|
|
425
|
+
.text("takes you to the travel planner, and", textStartX, contentY + 260)
|
|
426
|
+
.text("shows bus lines and departures from the", textStartX, contentY + 280)
|
|
427
|
+
.text("stop you are at.", textStartX, contentY + 300);
|
|
428
|
+
const separatorX = rightColumnX - 22;
|
|
429
|
+
doc
|
|
430
|
+
.lineWidth(0.25)
|
|
431
|
+
.moveTo(separatorX, contentY)
|
|
432
|
+
.lineTo(separatorX, contentY + 320)
|
|
433
|
+
.stroke("#000000");
|
|
434
|
+
const rightColumnStartY = contentY + 40;
|
|
435
|
+
doc
|
|
436
|
+
.fillColor(darkGray)
|
|
437
|
+
.fontSize(14)
|
|
438
|
+
.font(poppinsBold)
|
|
439
|
+
.text("Informasjon om bussavgangar", rightColumnX, rightColumnStartY)
|
|
440
|
+
.text("finn du også:", rightColumnX, rightColumnStartY + 20);
|
|
441
|
+
doc
|
|
442
|
+
.fillColor(darkGray)
|
|
443
|
+
.fontSize(14)
|
|
444
|
+
.font(poppinsRegular)
|
|
445
|
+
.text("Information about bus departures", rightColumnX, rightColumnStartY + 40)
|
|
446
|
+
.text("can also be found:", rightColumnX, rightColumnStartY + 60);
|
|
447
|
+
doc
|
|
448
|
+
.fillColor(darkGray)
|
|
449
|
+
.fontSize(14)
|
|
450
|
+
.font(poppinsRegular)
|
|
451
|
+
.text("• I appane / in the apps:", rightColumnX, rightColumnStartY + 100);
|
|
452
|
+
doc
|
|
453
|
+
.font(poppinsBold)
|
|
454
|
+
.text("FRAM / Entur", rightColumnX + 10, rightColumnStartY + 120);
|
|
455
|
+
doc
|
|
456
|
+
.font(poppinsRegular)
|
|
457
|
+
.text("• På nettsidene / on the websites:", rightColumnX, rightColumnStartY + 155);
|
|
458
|
+
doc
|
|
459
|
+
.font(poppinsBold)
|
|
460
|
+
.text("frammr.no / entur.no", rightColumnX + 10, rightColumnStartY + 175);
|
|
461
|
+
}
|
|
462
|
+
const footerY = pageHeight - footerHeight;
|
|
463
|
+
doc
|
|
464
|
+
.save()
|
|
465
|
+
.roundedRect(borderMargin, borderMargin, boxWidth, boxHeight, borderRadius)
|
|
466
|
+
.clip()
|
|
467
|
+
.rect(borderMargin, footerY, boxWidth, footerHeight)
|
|
468
|
+
.fill(headerFooterColor)
|
|
469
|
+
.restore();
|
|
470
|
+
try {
|
|
471
|
+
const logoBuffer = await node_fs_1.promises.readFile(logoPath);
|
|
472
|
+
const logoHeight = Math.round(logoWidth * 0.27);
|
|
473
|
+
const logoX = pageWidth - borderMargin - logoWidth - 20;
|
|
474
|
+
const logoY = footerY + footerHeight - logoHeight - 35;
|
|
475
|
+
doc.image(logoBuffer, logoX, logoY, {
|
|
476
|
+
width: logoWidth,
|
|
477
|
+
height: logoHeight,
|
|
474
478
|
});
|
|
475
479
|
}
|
|
476
480
|
catch (error) {
|
|
477
|
-
|
|
481
|
+
console.warn("Could not load logo image:", error);
|
|
482
|
+
doc
|
|
483
|
+
.fillColor(white)
|
|
484
|
+
.fontSize(24)
|
|
485
|
+
.font(poppinsBold)
|
|
486
|
+
.text(fallbackLogoText, pageWidth - 150, footerY + 25);
|
|
487
|
+
if (fallbackLogoSubtext) {
|
|
488
|
+
doc
|
|
489
|
+
.fillColor(white)
|
|
490
|
+
.fontSize(10)
|
|
491
|
+
.font(poppinsRegular)
|
|
492
|
+
.text(fallbackLogoSubtext, pageWidth - 220, footerY + 55);
|
|
493
|
+
}
|
|
478
494
|
}
|
|
495
|
+
doc.end();
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
throw new Error(`PDF generation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
499
|
+
}
|
|
500
|
+
await new Promise((resolve, reject) => {
|
|
501
|
+
stream.on("finish", () => {
|
|
502
|
+
resolve();
|
|
503
|
+
});
|
|
504
|
+
stream.on("error", (error) => {
|
|
505
|
+
reject(new Error(`Failed to write PDF: ${error.message}`));
|
|
506
|
+
});
|
|
479
507
|
});
|
|
480
508
|
}
|
|
481
509
|
function slugify(text) {
|
|
@@ -498,7 +526,7 @@ function slugify(text) {
|
|
|
498
526
|
}
|
|
499
527
|
function generateSafeFilename(id, stopPlaceName) {
|
|
500
528
|
const safeId = id.replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
501
|
-
if (stopPlaceName
|
|
529
|
+
if (stopPlaceName?.trim()) {
|
|
502
530
|
const slugifiedName = slugify(stopPlaceName);
|
|
503
531
|
if (slugifiedName) {
|
|
504
532
|
return `${safeId}-${slugifiedName}`;
|
|
@@ -508,15 +536,15 @@ function generateSafeFilename(id, stopPlaceName) {
|
|
|
508
536
|
}
|
|
509
537
|
async function ensureDirectoryExists(dirPath) {
|
|
510
538
|
try {
|
|
511
|
-
await
|
|
539
|
+
await node_fs_1.promises.access(dirPath);
|
|
512
540
|
}
|
|
513
541
|
catch {
|
|
514
|
-
await
|
|
542
|
+
await node_fs_1.promises.mkdir(dirPath, { recursive: true });
|
|
515
543
|
}
|
|
516
544
|
}
|
|
517
545
|
async function pathExists(path) {
|
|
518
546
|
try {
|
|
519
|
-
await
|
|
547
|
+
await node_fs_1.promises.access(path);
|
|
520
548
|
return true;
|
|
521
549
|
}
|
|
522
550
|
catch {
|