Polish flight-finder PDF layout
This commit is contained in:
@@ -48,7 +48,26 @@ function formatDateWindow(leg: FlightLegWindow): string {
|
|||||||
return "Not provided";
|
return "Not provided";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
|
||||||
|
const bottomLimit = doc.page.height - doc.page.margins.bottom;
|
||||||
|
if (doc.y + requiredHeight > bottomLimit) {
|
||||||
|
doc.addPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
|
||||||
|
|
||||||
|
export function formatArtifactsRoot(value: string): string {
|
||||||
|
const trimmed = String(value || "").trim();
|
||||||
|
if (!trimmed) return "Not provided";
|
||||||
|
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
|
||||||
|
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
|
||||||
|
}
|
||||||
|
return path.basename(trimmed) || trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||||
|
ensurePageSpace(doc, 28);
|
||||||
const x = doc.page.margins.left;
|
const x = doc.page.margins.left;
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
@@ -68,6 +87,11 @@ function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
|||||||
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
|
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
|
||||||
const lines = bulletLines(items, fallback);
|
const lines = bulletLines(items, fallback);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
const lineHeight = doc.heightOfString(line, {
|
||||||
|
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||||
|
lineGap: 2
|
||||||
|
});
|
||||||
|
ensurePageSpace(doc, lineHeight + 12);
|
||||||
const startY = doc.y;
|
const startY = doc.y;
|
||||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
|
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
|
||||||
doc
|
doc
|
||||||
@@ -108,6 +132,14 @@ export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[
|
|||||||
|
|
||||||
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
|
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
|
||||||
for (const linkItem of links) {
|
for (const linkItem of links) {
|
||||||
|
const lineHeight = Math.max(
|
||||||
|
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
|
||||||
|
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
|
||||||
|
width: width - 132,
|
||||||
|
lineGap: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
ensurePageSpace(doc, lineHeight + 8);
|
||||||
const startY = doc.y;
|
const startY = doc.y;
|
||||||
doc
|
doc
|
||||||
.fillColor("#1E2329")
|
.fillColor("#1E2329")
|
||||||
@@ -134,10 +166,11 @@ function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]
|
|||||||
const valueWidth = totalWidth - keyWidth;
|
const valueWidth = totalWidth - keyWidth;
|
||||||
|
|
||||||
for (const [key, value] of rows) {
|
for (const [key, value] of rows) {
|
||||||
const startY = doc.y;
|
|
||||||
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||||
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||||
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||||
|
ensurePageSpace(doc, rowHeight + 8);
|
||||||
|
const startY = doc.y;
|
||||||
|
|
||||||
doc.save();
|
doc.save();
|
||||||
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||||
@@ -197,8 +230,7 @@ function drawItineraryCard(
|
|||||||
): void {
|
): void {
|
||||||
const left = doc.page.margins.left;
|
const left = doc.page.margins.left;
|
||||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
const top = doc.y;
|
const badge = option.backup ? "Backup option" : "Primary recommendation";
|
||||||
const badge = option.backup ? "Backup" : "Primary";
|
|
||||||
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
|
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
|
||||||
const quote = quotesById.get(quoteId);
|
const quote = quotesById.get(quoteId);
|
||||||
if (!quote) {
|
if (!quote) {
|
||||||
@@ -227,79 +259,39 @@ function drawItineraryCard(
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const estimateHeight = quoteBlocks.reduce((sum, block) => {
|
ensurePageSpace(doc, 28);
|
||||||
const headingHeight = doc.heightOfString(block.heading, { width: width - 20, lineGap: 2 });
|
const top = doc.y;
|
||||||
const detailsHeight = block.details.reduce(
|
|
||||||
(acc, line) => acc + doc.heightOfString(line, { width: width - 44, lineGap: 2 }) + 4,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const linksHeight = block.links.reduce(
|
|
||||||
(acc, link) =>
|
|
||||||
acc +
|
|
||||||
Math.max(
|
|
||||||
doc.heightOfString(`${link.label}:`, { width: 130 }),
|
|
||||||
doc.heightOfString(link.text.replace(`${link.label}: `, ""), { width: width - 152, lineGap: 1 })
|
|
||||||
) +
|
|
||||||
4,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return sum + headingHeight + detailsHeight + linksHeight + 18;
|
|
||||||
}, 0);
|
|
||||||
const rationaleHeight = doc.heightOfString(`Why it ranked here: ${option.rationale}`, {
|
|
||||||
width: width - 20,
|
|
||||||
lineGap: 2
|
|
||||||
});
|
|
||||||
const bodyHeight = rationaleHeight + estimateHeight + 24;
|
|
||||||
|
|
||||||
doc.save();
|
doc.save();
|
||||||
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
||||||
doc
|
doc
|
||||||
.fillColor("white")
|
.fillColor("white")
|
||||||
.font("Helvetica-Bold")
|
.font("Helvetica-Bold")
|
||||||
.fontSize(11)
|
.fontSize(10.5)
|
||||||
.text(`${badge}: ${option.title} (${formatUsd(option.totalPriceUsd)})`, left + 10, top + 7, {
|
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
|
||||||
width: width - 20
|
doc.restore();
|
||||||
});
|
doc.y = top + 28;
|
||||||
doc
|
|
||||||
.roundedRect(left, top + 24, width, bodyHeight, 4)
|
drawKeyValueTable(doc, [
|
||||||
.fillAndStroke("#F8FAFC", "#C7D0D9");
|
["Recommendation total", formatUsd(option.totalPriceUsd)],
|
||||||
doc
|
["Why it ranked here", option.rationale]
|
||||||
.fillColor("#1E2329")
|
]);
|
||||||
.font("Helvetica")
|
|
||||||
.fontSize(10)
|
|
||||||
.text(`Why it ranked here: ${option.rationale}`, left + 10, top + 34, {
|
|
||||||
width: width - 20,
|
|
||||||
lineGap: 2
|
|
||||||
});
|
|
||||||
doc.y = top + 34 + rationaleHeight + 10;
|
|
||||||
for (const block of quoteBlocks) {
|
for (const block of quoteBlocks) {
|
||||||
|
ensurePageSpace(doc, 22);
|
||||||
doc
|
doc
|
||||||
.fillColor("#10384E")
|
.fillColor("#10384E")
|
||||||
.font("Helvetica-Bold")
|
.font("Helvetica-Bold")
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.text(block.heading, left + 10, doc.y, {
|
.text(block.heading, left, doc.y, {
|
||||||
width: width - 20,
|
width,
|
||||||
lineGap: 2
|
lineGap: 2
|
||||||
});
|
});
|
||||||
doc.moveDown(0.2);
|
doc.moveDown(0.2);
|
||||||
for (const line of block.details) {
|
drawBulletList(doc, block.details);
|
||||||
const startY = doc.y;
|
drawQuoteBookingLinks(doc, block.links, left, width);
|
||||||
doc.circle(left + 16, startY + 6, 1.4).fill("#10384E");
|
doc.moveDown(0.5);
|
||||||
doc
|
}
|
||||||
.fillColor("#1E2329")
|
|
||||||
.font("Helvetica")
|
|
||||||
.fontSize(9.5)
|
|
||||||
.text(line, left + 24, startY, {
|
|
||||||
width: width - 44,
|
|
||||||
lineGap: 2
|
|
||||||
});
|
|
||||||
doc.moveDown(0.2);
|
doc.moveDown(0.2);
|
||||||
}
|
|
||||||
drawQuoteBookingLinks(doc, block.links, left + 10, width - 20);
|
|
||||||
doc.moveDown(0.35);
|
|
||||||
}
|
|
||||||
doc.restore();
|
|
||||||
doc.y = top + 24 + bodyHeight + 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderFlightReportPdf(
|
export async function renderFlightReportPdf(
|
||||||
@@ -349,7 +341,7 @@ export async function renderFlightReportPdf(
|
|||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.text(
|
.text(
|
||||||
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search: ${payload.searchExecution.completedAt}`
|
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
|
||||||
);
|
);
|
||||||
doc.moveDown();
|
doc.moveDown();
|
||||||
|
|
||||||
@@ -363,7 +355,7 @@ export async function renderFlightReportPdf(
|
|||||||
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
|
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
|
||||||
].join(" | ")],
|
].join(" | ")],
|
||||||
["Market-country mode", request.preferences.marketCountry || "Off"],
|
["Market-country mode", request.preferences.marketCountry || "Off"],
|
||||||
["Search artifacts", payload.searchExecution.artifactsRoot]
|
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
drawSectionHeader(doc, "Legs And Travelers");
|
drawSectionHeader(doc, "Legs And Travelers");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fs from "node:fs";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
describeQuoteBookingLinks,
|
describeQuoteBookingLinks,
|
||||||
|
formatArtifactsRoot,
|
||||||
renderFlightReportPdf,
|
renderFlightReportPdf,
|
||||||
ReportValidationError
|
ReportValidationError
|
||||||
} from "../src/report-pdf.js";
|
} from "../src/report-pdf.js";
|
||||||
@@ -63,6 +64,10 @@ function samplePayload(): FlightReportPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countPdfPages(outputPath: string): number {
|
||||||
|
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
||||||
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||||
await renderFlightReportPdf(samplePayload(), outputPath);
|
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||||
@@ -111,6 +116,81 @@ test("renderFlightReportPdf embeds booking links and only includes direct-airlin
|
|||||||
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
|
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
|
||||||
|
const payload = samplePayload();
|
||||||
|
payload.searchExecution.artifactsRoot =
|
||||||
|
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
|
||||||
|
payload.quotes = [
|
||||||
|
{
|
||||||
|
...payload.quotes[0],
|
||||||
|
id: "quote-1",
|
||||||
|
notes: [
|
||||||
|
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
|
||||||
|
"Skyscanner TH/USD corroborated the same Madrid pattern.",
|
||||||
|
"Expedia also found a nearby fare band."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quote-2",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "return-pair",
|
||||||
|
passengerGroupIds: ["pair"],
|
||||||
|
bookingLink: "https://example.com/quote-2",
|
||||||
|
itinerarySummary: "BLQ -> DFW via LHR",
|
||||||
|
totalPriceUsd: 1316,
|
||||||
|
displayPriceUsd: "$1,316 total ($658 pp x 2)",
|
||||||
|
notes: [
|
||||||
|
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
|
||||||
|
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
|
||||||
|
"Expedia hit a bot challenge on the Jun 8 paired-return check."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quote-3",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "return-solo",
|
||||||
|
passengerGroupIds: ["solo"],
|
||||||
|
bookingLink: "https://example.com/quote-3",
|
||||||
|
itinerarySummary: "BLQ -> DFW via MAD",
|
||||||
|
totalPriceUsd: 722,
|
||||||
|
displayPriceUsd: "$722 total",
|
||||||
|
notes: [
|
||||||
|
"Lowest valid solo-return fare in the fresh Jul window capture.",
|
||||||
|
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
|
||||||
|
"Expedia surfaced a cheaper Turkish option, but it was excluded."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
payload.rankedOptions = [
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
title: "Best overall itinerary",
|
||||||
|
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||||
|
totalPriceUsd: 4420,
|
||||||
|
rationale: "Best price-to-convenience tradeoff."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "backup",
|
||||||
|
title: "Backup itinerary",
|
||||||
|
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||||
|
totalPriceUsd: 4515,
|
||||||
|
rationale: "Slightly higher price but still viable."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
payload.executiveSummary = [
|
||||||
|
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
|
||||||
|
"Lowest valid total observed was $4,420.",
|
||||||
|
"Cross-source corroboration is strongest for the outbound and solo return."
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderFlightReportPdf(payload, outputPath);
|
||||||
|
|
||||||
|
const pdfContents = fs.readFileSync(outputPath, "latin1");
|
||||||
|
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
|
||||||
|
assert.ok(countPdfPages(outputPath) <= 4);
|
||||||
|
});
|
||||||
|
|
||||||
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -183,3 +263,11 @@ test("describeQuoteBookingLinks calls out missing direct-airline links explicitl
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("formatArtifactsRoot only shortens known workspace report paths", () => {
|
||||||
|
assert.equal(
|
||||||
|
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
|
||||||
|
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
|
||||||
|
);
|
||||||
|
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user