Travel Itinerary Example
A six-day Kyoto & Osaka itinerary that checks five things in two rounds. The first render is six pages. Patch 1 fixes the map and a long note, dropping it to five. Patch 2 fixes the booking summary and the page numbers, dropping it to four.
Covers: five spatial assertions, two dependent patches, a day index, a page total, and a two-page layout cascade.
What the document contains
- A cover page.
- A trip-at-a-glance page with a route map, stats, and a six-day “In this itinerary” index.
- Days 3–4, including the standalone Nara route map.
- Days 5–6, travel notes, a good-to-know line, and the booking summary.
What breaks
| Render | Pages | What happens |
|---|---|---|
| Broken | 6 | Day 4 map alone on page 4; Day 3 note overflows; booking split pages 5–6; index and page total stale. |
| After patch 1 | 5 | Map and note fixed; booking still split pages 4–5; index and page total still stale. |
| After patch 2 | 4 | Booking intact page 4; day index 2,2,3,3,4,4; page total 4. |
The first render is six pages: Day 4’s Nara map is pushed onto its own page, a long Day 3 note runs off the card, the booking summary splits across pages 5–6, and the page 2 day index and page total are stale.
The five checks
| Check | What it reads | Patch |
|---|---|---|
| Map + Day 4 | day4.pages.length and map.pages[0] |
compactDays |
| Activity fits | #day-3-highlight width vs the Day 3 card |
fitText |
| Booking intact | booking.pages.length and #booking-total page |
compactBooking |
| Day index | Each #index-day-N vs #day-N.pages[0] + 1 |
dayIndex |
| Page total | .page-total text |
pageTotal |
The order matters: patch 1 changes pagination. The day index and page total have to be measured after that patch, not before it.
The test case
This is the exact script sent to the API. It runs against the rendered PDF DOM; it does not use a PDF parser or OCR.
const day3 = doc.querySelector('#day-3');
const day4 = doc.querySelector('#day-4');
const map = doc.querySelector('#day-4-map');
const note = doc.querySelector('#day-3-highlight');
const booking = doc.querySelector('#booking-summary');
const total = doc.querySelector('#booking-total');
if (!day3 || !day4 || !map || !note || !booking || !total) {
return { success: false, reason: 'Required itinerary elements are missing' };
}
function textOf(node) {
if (!node) return '';
if (node.innerText) return node.innerText;
if (node.textChild && node.textChild.text_content) return node.textChild.text_content;
return node.text_content || '';
}
function buildIndex() {
const out = [];
for (let i = 1; i <= 6; i++) {
const day = doc.querySelector('#day-' + i);
if (!day || day.pages.length !== 1) return null;
out.push({ day: i, page: day.pages[0] + 1 });
}
return out;
}
// ROUND 1: layout + text.
const mapSplit = day4.pages.length > 1 || map.pages[0] !== day4.pages[0];
const textOverflow = (note.geometry.x + note.geometry.width) > (day3.geometry.x + day3.geometry.width - 2);
if (mapSplit || textOverflow) {
return {
success: false,
reason: mapSplit ? 'Day 4 map split from its day' : 'Day 3 highlight overflows',
patch: { compactDays: true, fitText: true }
};
}
// ROUND 2: booking integrity.
const bookingIntact = booking.pages.length === 1 &&
booking.pages[0] === 3 &&
total.pages[0] === booking.pages[0];
if (!bookingIntact) {
const index = buildIndex();
if (!index) return { success: false, reason: 'A day card still spans pages' };
return {
success: false,
reason: 'Booking summary split across pages',
patch: { compactBooking: true, dayIndex: index, pageTotal: 4 }
};
}
// ROUND 2: page-number metadata.
const index = buildIndex();
if (!index) return { success: false, reason: 'A day card spans pages' };
let indexStale = false;
for (let i = 1; i <= 6; i++) {
const shown = doc.querySelector('#index-day-' + i);
if (!shown) return { success: false, reason: 'Day index is missing' };
if (textOf(shown).trim() !== String(index[i - 1].page)) indexStale = true;
}
const totalNode = doc.querySelector('.page-total');
const totalStale = totalNode && textOf(totalNode).trim() !== '4';
if (indexStale || totalStale) {
return {
success: false,
reason: 'Page numbers are stale',
patch: { compactBooking: true, dayIndex: index, pageTotal: 4 }
};
}
function box(node) {
const g = node.geometry;
return {
page: node.pages[0] + 1,
x: Math.round(g.x * 100) / 100,
y: Math.round(g.y * 100) / 100,
width: Math.round(g.width * 100) / 100,
height: Math.round(g.height * 100) / 100
};
}
return {
success: true,
pages: booking.pages[0] + 1,
pageTotal: 4,
dayIndex: index,
day4: box(day4),
map: box(map),
booking: box(booking)
};
Patch 1 — layout and text
The first patch fixes the map and the long note. The document drops to five pages, but the booking summary is still split and the page numbers are still stale.
{
"patch": {
"compactDays": true,
"fitText": true
}
}
Before patch 1: the Day 3 note runs off the card, and Day 4’s map is alone on page 4.
Patch 2 — booking and page numbers
Patch 2 compacts the booking, measures the actual day pages, and sets the page total. The final document is four pages after three attempts.
{
"patch": {
"compactBooking": true,
"dayIndex": [2, 2, 3, 3, 4, 4],
"pageTotal": 4
}
}
After patch 1: the map is back with Day 4. The booking summary is still split, so patch 2 is needed.
After patch 2: the booking is intact on page 4.
Result
The successful response includes the final page count, the day index, the page total, and the page + coordinates of the elements.
{
"success": true,
"pages": 4,
"pageTotal": 4,
"dayIndex": [
{ "day": 1, "page": 2 },
{ "day": 2, "page": 2 },
{ "day": 3, "page": 3 },
{ "day": 4, "page": 3 },
{ "day": 5, "page": 4 },
{ "day": 6, "page": 4 }
],
"day4": {
"page": 3,
"x": 41.98,
"y": 224.12,
"width": 510.56,
"height": 266.9
},
"map": {
"page": 3,
"x": 41.98,
"y": 50.18,
"width": 510.56,
"height": 142.45
},
"booking": {
"page": 4,
"x": 41.98,
"y": 53.18,
"width": 510.56,
"height": 122.95
},
"total_attempts": 3
}
Output
Day index: before and after patch 2. The rows are fixed height, so filling them in does not change pagination.
Why the order matters
Patch 1 changes pagination. If you measure the day index or page total before the layout settles, the numbers will be wrong. This example measures them after patch 1, then returns patch 2. The API allows two internal patches; this example uses both.
Accessibility semantics
The template keeps its original layout, but exposes structure in the source: the cover facts, glance stats, day index, and booking totals use ARIA table / row / rowheader / cell roles; the route map is a labelled image; the decorative cover artwork is hidden from assistive technology; and headings, lists, and emphasis use native HTML. The spatial map records those attributes, so a post-processor can emit real PDF table and figure tags without guessing from coordinates.
The same source semantics are the basis for the experimental PDF/UA-2 generation path. That output is a machine-validated candidate, not a completed human accessibility audit.
Small accent text uses a darker text-only accent (#A64733) that meets WCAG AA contrast on the paper, card, and map backgrounds; decorative accent fills keep the original colour.
Gotchas
pagesis 0-indexed. Add 1 for the TOC/page-number value.- The day map is a standalone block after the Day 4 card so it can move as a unit.
- The day index rows and page-total text are fixed height; filling them in does not change pagination.
- The long note is a
spaninside the note row, so its geometry is the text width, not the card width. - The second patch is only valid after the first patch has settled the layout.
Download the example
Download the full source (.zip)
Generated PDFs: healed (4 pages) · patch 1 (5 pages) · broken (6 pages)
Run it locally
# from the unzipped example folder
npm install
cp .env.example .env
# add your API key and project ID
npm start
The source script writes output/itinerary-broken.pdf (6 pages), output/itinerary-patch1.pdf (5 pages), output/itinerary-healed.pdf (4 pages), output/results.json, and the spatial maps.