Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/smoke/verify/pdf-text-position.test.ts
12925 views
1
/*
2
* pdf-text-position.test.ts
3
*
4
* Tests for the ensurePdfTextPositions verify predicate.
5
* Renders a fixture document and runs various assertions including expected failures.
6
*
7
* Copyright (C) 2020-2025 Posit Software, PBC
8
*/
9
10
import { testQuartoCmd } from "../../test.ts";
11
import { ensurePdfTextPositions, PdfTextPositionAssertion } from "../../verify-pdf-text-position.ts";
12
import { assert, AssertionError } from "testing/asserts";
13
import { join } from "../../../src/deno_ral/path.ts";
14
import { safeRemoveSync, safeExistsSync } from "../../../src/core/path.ts";
15
16
const fixtureDir = "docs/verify/pdf-text-position";
17
const fixtureQmd = join(fixtureDir, "fixture.qmd");
18
const fixturePdf = join(fixtureDir, "fixture.pdf");
19
20
/**
21
* Helper to assert that a function throws with error message matching a pattern
22
*/
23
async function assertThrowsWithPattern(
24
fn: () => Promise<void>,
25
pattern: RegExp,
26
description: string,
27
) {
28
let threw = false;
29
let errorMessage = "";
30
try {
31
await fn();
32
} catch (e) {
33
threw = true;
34
errorMessage = e instanceof Error ? e.message : String(e);
35
}
36
37
assert(threw, `Expected to throw for: ${description}`);
38
assert(
39
pattern.test(errorMessage),
40
`Error message "${errorMessage}" did not match pattern ${pattern} for: ${description}`,
41
);
42
}
43
44
// Test: Render fixture and run assertions
45
testQuartoCmd("render", [fixtureQmd, "--to", "typst"], [], {
46
teardown: async () => {
47
// Run the test assertions after render completes
48
await runPositiveTests();
49
await runExpectedFailureTests();
50
await runSemanticTagTests();
51
await runPageRoleTests();
52
await runEdgeOverrideTests();
53
await runDistanceConstraintTests();
54
await runDistanceConstraintErrorTests();
55
await runPageRoleWithEdgeTests();
56
await runCenterEdgeTests();
57
58
// Cleanup
59
if (safeExistsSync(fixturePdf)) {
60
safeRemoveSync(fixturePdf);
61
}
62
},
63
});
64
65
/**
66
* Test positive assertions that should pass
67
*/
68
async function runPositiveTests() {
69
// Test 1: Basic vertical ordering (header < title < h1 < body < footer)
70
// Note: Headers and footers are page decorations without MCIDs, use role: "Decoration"
71
const verticalOrdering = ensurePdfTextPositions(fixturePdf, [
72
{
73
subject: { text: "FIXTURE_HEADER_TEXT", role: "Decoration" },
74
relation: "above",
75
object: "FIXTURE_TITLE_TEXT",
76
},
77
{ subject: "FIXTURE_TITLE_TEXT", relation: "above", object: "FIXTURE_H1_TEXT" },
78
{ subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_BODY_P1_TEXT" },
79
{
80
subject: "FIXTURE_BODY_P1_TEXT",
81
relation: "above",
82
object: { text: "FIXTURE_FOOTER_TEXT", role: "Decoration" },
83
},
84
]);
85
await verticalOrdering.verify([]);
86
87
// Test 2: Margin positioning - use topAligned since semantic bbox may span full width
88
// Note: rightOf may not work with semantic bboxes because body paragraph's bbox
89
// may include the full content width
90
const marginPositioning = ensurePdfTextPositions(fixturePdf, [
91
{ subject: "FIXTURE_MARGIN_TEXT", relation: "topAligned", object: "FIXTURE_BODY_P2_TEXT" },
92
]);
93
await marginPositioning.verify([]);
94
95
// Test 3: Heading hierarchy
96
const headingHierarchy = ensurePdfTextPositions(fixturePdf, [
97
{ subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_H2_TEXT" },
98
{ subject: "FIXTURE_H2_TEXT", relation: "above", object: "FIXTURE_H3_TEXT" },
99
]);
100
await headingHierarchy.verify([]);
101
}
102
103
/**
104
* Test expected failures - each should throw with specific error messages
105
*/
106
async function runExpectedFailureTests() {
107
// Error 1: Text not found
108
await assertThrowsWithPattern(
109
async () => {
110
const predicate = ensurePdfTextPositions(fixturePdf, [
111
{ subject: "NONEXISTENT_TEXT_12345", relation: "above", object: "FIXTURE_BODY_P1_TEXT" },
112
]);
113
await predicate.verify([]);
114
},
115
/Text not found.*NONEXISTENT_TEXT_12345/,
116
"Text not found error",
117
);
118
119
// Error 1b: Ambiguous text (appears multiple times)
120
await assertThrowsWithPattern(
121
async () => {
122
const predicate = ensurePdfTextPositions(fixturePdf, [
123
// "paragraph" appears in multiple places in the fixture
124
{ subject: "paragraph", relation: "above", object: "FIXTURE_BODY_P1_TEXT" },
125
]);
126
await predicate.verify([]);
127
},
128
/paragraph.*ambiguous.*matches/i,
129
"Ambiguous text error",
130
);
131
132
// Error 2: Unknown relation (Zod validation error)
133
await assertThrowsWithPattern(
134
async () => {
135
const predicate = ensurePdfTextPositions(fixturePdf, [
136
// Use type assertion for intentionally invalid relation to test error handling
137
{ subject: "FIXTURE_H1_TEXT", relation: "invalidRelation", object: "FIXTURE_BODY_P1_TEXT" } as PdfTextPositionAssertion,
138
]);
139
await predicate.verify([]);
140
},
141
/Assertion.*is invalid/,
142
"Unknown relation error",
143
);
144
145
// Error 3: Different pages - comparing items on different pages should fail
146
await assertThrowsWithPattern(
147
async () => {
148
const predicate = ensurePdfTextPositions(fixturePdf, [
149
{ subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_PAGE2_BODY_TEXT" },
150
]);
151
await predicate.verify([]);
152
},
153
/Cannot compare positions.*page \d+.*page \d+/,
154
"Different pages error",
155
);
156
157
// Error 4: Position assertion failed (wrong relation)
158
await assertThrowsWithPattern(
159
async () => {
160
const predicate = ensurePdfTextPositions(fixturePdf, [
161
// Footer is BELOW header, not above (both are Decorations)
162
{
163
subject: { text: "FIXTURE_FOOTER_TEXT", role: "Decoration" },
164
relation: "above",
165
object: { text: "FIXTURE_HEADER_TEXT", role: "Decoration" },
166
},
167
]);
168
await predicate.verify([]);
169
},
170
/Position assertion failed.*FIXTURE_FOOTER_TEXT.*NOT.*above/,
171
"Position assertion failed error",
172
);
173
174
// Error 5: Negative assertion unexpectedly true
175
await assertThrowsWithPattern(
176
async () => {
177
const predicate = ensurePdfTextPositions(
178
fixturePdf,
179
[], // No positive assertions
180
[
181
// This IS true, so negative assertion should fail
182
{ subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_H2_TEXT" },
183
],
184
);
185
await predicate.verify([]);
186
},
187
/Negative assertion failed.*FIXTURE_H1_TEXT.*IS.*above/,
188
"Negative assertion unexpectedly true error",
189
);
190
191
// Error 6: Role mismatch (wrong semantic role)
192
await assertThrowsWithPattern(
193
async () => {
194
const predicate = ensurePdfTextPositions(fixturePdf, [
195
// H1 is not a Figure
196
{ subject: { text: "FIXTURE_H1_TEXT", role: "Figure" }, relation: "above", object: "FIXTURE_BODY_P1_TEXT" },
197
]);
198
await predicate.verify([]);
199
},
200
/Role mismatch.*FIXTURE_H1_TEXT.*expected Figure.*got H1/,
201
"Role mismatch error",
202
);
203
}
204
205
/**
206
* Test semantic role assertions
207
*/
208
async function runSemanticTagTests() {
209
// Test: Correct semantic roles should pass
210
const correctRoles = ensurePdfTextPositions(fixturePdf, [
211
{
212
subject: { text: "FIXTURE_H1_TEXT", role: "H1" },
213
relation: "above",
214
object: { text: "FIXTURE_BODY_P1_TEXT", role: "P" },
215
},
216
{
217
subject: { text: "FIXTURE_H2_TEXT", role: "H2" },
218
relation: "above",
219
object: { text: "FIXTURE_H3_TEXT", role: "H3" },
220
},
221
]);
222
await correctRoles.verify([]);
223
}
224
225
/**
226
* Test Page role - represents entire page bounds
227
* Page intersects all content on that page, so directional relations should fail
228
*/
229
async function runPageRoleTests() {
230
// Test: Page role should NOT be above/below/leftOf/rightOf any content on same page
231
// because Page covers the entire page and thus intersects everything
232
const pageNotDirectional = ensurePdfTextPositions(
233
fixturePdf,
234
[], // No positive assertions
235
[
236
// Page 1 is NOT above body text (it contains it)
237
{
238
subject: { role: "Page", page: 1 },
239
relation: "above",
240
object: "FIXTURE_BODY_P1_TEXT",
241
},
242
// Page 1 is NOT below anything on page 1
243
{
244
subject: { role: "Page", page: 1 },
245
relation: "below",
246
object: "FIXTURE_TITLE_TEXT",
247
},
248
// Page 1 is NOT leftOf anything on page 1
249
{
250
subject: { role: "Page", page: 1 },
251
relation: "leftOf",
252
object: "FIXTURE_BODY_P1_TEXT",
253
},
254
// Page 1 is NOT rightOf anything on page 1
255
{
256
subject: { role: "Page", page: 1 },
257
relation: "rightOf",
258
object: "FIXTURE_BODY_P1_TEXT",
259
},
260
],
261
);
262
await pageNotDirectional.verify([]);
263
264
// Test: Two Page selectors for same page should be aligned (both at origin 0,0)
265
const pageAlignment = ensurePdfTextPositions(fixturePdf, [
266
{
267
subject: { role: "Page", page: 1 },
268
relation: "topAligned",
269
object: { role: "Page", page: 1 },
270
},
271
{
272
subject: { role: "Page", page: 1 },
273
relation: "leftAligned",
274
object: { role: "Page", page: 1 },
275
},
276
]);
277
await pageAlignment.verify([]);
278
}
279
280
/**
281
* Test edge override functionality for directional and alignment relations
282
*/
283
async function runEdgeOverrideTests() {
284
// Test: Edge override for directional relation - compare same edges
285
// H1's top edge should be above H2's top edge (both top edges)
286
const edgeOverrideDirectional = ensurePdfTextPositions(fixturePdf, [
287
{
288
subject: { text: "FIXTURE_H1_TEXT", edge: "top" },
289
relation: "above",
290
object: { text: "FIXTURE_H2_TEXT", edge: "top" },
291
},
292
]);
293
await edgeOverrideDirectional.verify([]);
294
295
// Test: Edge override for alignment relation - align different edges
296
// This tests that we can align one element's edge with another's different edge
297
// Header's bottom should NOT align with body's top (they're spaced apart)
298
// But we can verify header.bottom < body.top by checking header.bottom is above body.top
299
const edgeOverrideAlignment = ensurePdfTextPositions(fixturePdf, [
300
{
301
subject: { text: "FIXTURE_H1_TEXT", edge: "bottom" },
302
relation: "above",
303
object: { text: "FIXTURE_BODY_P1_TEXT", edge: "top" },
304
},
305
]);
306
await edgeOverrideAlignment.verify([]);
307
308
// Test: rightOf with edge overrides
309
// We know margin text is to the right of body text
310
// Margin's left edge should be rightOf body's right edge
311
const rightOfEdgeOverride = ensurePdfTextPositions(fixturePdf, [
312
{
313
subject: { text: "FIXTURE_MARGIN_TEXT", edge: "left" },
314
relation: "rightOf",
315
object: { text: "FIXTURE_BODY_P2_TEXT", edge: "right" },
316
},
317
]);
318
await rightOfEdgeOverride.verify([]);
319
320
// Test: below with edge overrides
321
// Body P1's top should be below H1's bottom
322
const belowEdgeOverride = ensurePdfTextPositions(fixturePdf, [
323
{
324
subject: { text: "FIXTURE_BODY_P1_TEXT", edge: "top" },
325
relation: "below",
326
object: { text: "FIXTURE_H1_TEXT", edge: "bottom" },
327
},
328
]);
329
await belowEdgeOverride.verify([]);
330
331
// Test: leftAligned with object edge override
332
// We can check if header's left aligns with page's left using edge override
333
const leftAlignedEdgeOverride = ensurePdfTextPositions(fixturePdf, [
334
{
335
subject: { text: "FIXTURE_H1_TEXT" },
336
relation: "leftAligned",
337
object: { text: "FIXTURE_H2_TEXT" },
338
tolerance: 5, // Allow some tolerance for heading indentation
339
},
340
]);
341
await leftAlignedEdgeOverride.verify([]);
342
}
343
344
/**
345
* Test byMin/byMax distance constraint functionality
346
*/
347
async function runDistanceConstraintTests() {
348
// Test: byMin constraint - H1 should be at least 1pt above H2
349
const byMinTest = ensurePdfTextPositions(fixturePdf, [
350
{
351
subject: "FIXTURE_H1_TEXT",
352
relation: "above",
353
object: "FIXTURE_H2_TEXT",
354
byMin: 1,
355
},
356
]);
357
await byMinTest.verify([]);
358
359
// Test: byMax constraint - header decorations shouldn't be too far from title
360
// Using a generous max to ensure it passes
361
const byMaxTest = ensurePdfTextPositions(fixturePdf, [
362
{
363
subject: { text: "FIXTURE_HEADER_TEXT", role: "Decoration" },
364
relation: "above",
365
object: "FIXTURE_TITLE_TEXT",
366
byMax: 500, // Generous max distance
367
},
368
]);
369
await byMaxTest.verify([]);
370
371
// Test: byMin and byMax together - range constraint
372
const byRangeTest = ensurePdfTextPositions(fixturePdf, [
373
{
374
subject: "FIXTURE_H1_TEXT",
375
relation: "above",
376
object: "FIXTURE_H2_TEXT",
377
byMin: 1,
378
byMax: 500, // Generous range
379
},
380
]);
381
await byRangeTest.verify([]);
382
383
// Test: Negative byMin (allows overlap) should work
384
// This tests that negative values are accepted
385
const negativeByMinTest = ensurePdfTextPositions(fixturePdf, [
386
{
387
subject: "FIXTURE_H1_TEXT",
388
relation: "above",
389
object: "FIXTURE_H2_TEXT",
390
byMin: -100, // Negative allows overlap
391
},
392
]);
393
await negativeByMinTest.verify([]);
394
395
// Test: rightOf with byMin - margin should be at least some distance right of body
396
const rightOfByMinTest = ensurePdfTextPositions(fixturePdf, [
397
{
398
subject: { text: "FIXTURE_MARGIN_TEXT", edge: "left" },
399
relation: "rightOf",
400
object: { text: "FIXTURE_BODY_P2_TEXT", edge: "right" },
401
byMin: 1, // At least 1pt gap
402
},
403
]);
404
await rightOfByMinTest.verify([]);
405
406
// Test: below with distance constraints
407
const belowByMinTest = ensurePdfTextPositions(fixturePdf, [
408
{
409
subject: "FIXTURE_H2_TEXT",
410
relation: "below",
411
object: "FIXTURE_H1_TEXT",
412
byMin: 1,
413
},
414
]);
415
await belowByMinTest.verify([]);
416
}
417
418
/**
419
* Test error cases for distance constraints
420
*/
421
async function runDistanceConstraintErrorTests() {
422
// Error: byMin/byMax with alignment relation should error (Zod .strict() catches extra keys)
423
// Use type assertion to test runtime error handling for invalid YAML input
424
await assertThrowsWithPattern(
425
async () => {
426
const predicate = ensurePdfTextPositions(fixturePdf, [
427
{
428
subject: "FIXTURE_H1_TEXT",
429
relation: "topAligned",
430
object: "FIXTURE_H2_TEXT",
431
byMin: 10,
432
} as PdfTextPositionAssertion,
433
]);
434
await predicate.verify([]);
435
},
436
/Assertion.*is invalid/,
437
"byMin with alignment relation error",
438
);
439
440
// Error: byMax with alignment relation should error (Zod .strict() catches extra keys)
441
// Use type assertion to test runtime error handling for invalid YAML input
442
await assertThrowsWithPattern(
443
async () => {
444
const predicate = ensurePdfTextPositions(fixturePdf, [
445
{
446
subject: "FIXTURE_H1_TEXT",
447
relation: "leftAligned",
448
object: "FIXTURE_H2_TEXT",
449
byMax: 10,
450
} as PdfTextPositionAssertion,
451
]);
452
await predicate.verify([]);
453
},
454
/Assertion.*is invalid/,
455
"byMax with alignment relation error",
456
);
457
458
// Error: byMin > byMax should error (caught by Zod .refine())
459
await assertThrowsWithPattern(
460
async () => {
461
const predicate = ensurePdfTextPositions(fixturePdf, [
462
{
463
subject: "FIXTURE_H1_TEXT",
464
relation: "above",
465
object: "FIXTURE_H2_TEXT",
466
byMin: 100,
467
byMax: 10, // Invalid: byMin > byMax
468
},
469
]);
470
await predicate.verify([]);
471
},
472
/byMin must be <= byMax/i,
473
"byMin > byMax error",
474
);
475
476
// Error: byMin constraint not satisfied (too close)
477
await assertThrowsWithPattern(
478
async () => {
479
const predicate = ensurePdfTextPositions(fixturePdf, [
480
{
481
subject: "FIXTURE_H1_TEXT",
482
relation: "above",
483
object: "FIXTURE_H2_TEXT",
484
byMin: 10000, // Unreasonably large min distance
485
},
486
]);
487
await predicate.verify([]);
488
},
489
/Position assertion failed.*distance.*byMin/i,
490
"byMin constraint not satisfied error",
491
);
492
493
// Error: byMax constraint not satisfied (too far)
494
await assertThrowsWithPattern(
495
async () => {
496
const predicate = ensurePdfTextPositions(fixturePdf, [
497
{
498
subject: { text: "FIXTURE_HEADER_TEXT", role: "Decoration" },
499
relation: "above",
500
object: { text: "FIXTURE_FOOTER_TEXT", role: "Decoration" },
501
byMax: 1, // Unreasonably small max distance
502
},
503
]);
504
await predicate.verify([]);
505
},
506
/Position assertion failed.*distance.*byMax/i,
507
"byMax constraint not satisfied error",
508
);
509
}
510
511
/**
512
* Test Page role with edge override functionality
513
*/
514
async function runPageRoleWithEdgeTests() {
515
// Test: Page's left edge should be at x=0, content should be rightOf that
516
// This verifies edge overrides work with Page role
517
const pageLeftEdgeTest = ensurePdfTextPositions(fixturePdf, [
518
{
519
subject: "FIXTURE_BODY_P1_TEXT",
520
relation: "rightOf",
521
object: { role: "Page", page: 1, edge: "left" },
522
},
523
]);
524
await pageLeftEdgeTest.verify([]);
525
526
// Test: Content should be below Page's top edge
527
const pageTopEdgeTest = ensurePdfTextPositions(fixturePdf, [
528
{
529
subject: "FIXTURE_BODY_P1_TEXT",
530
relation: "below",
531
object: { role: "Page", page: 1, edge: "top" },
532
},
533
]);
534
await pageTopEdgeTest.verify([]);
535
536
// Test: Content should be above Page's bottom edge
537
const pageBottomEdgeTest = ensurePdfTextPositions(fixturePdf, [
538
{
539
subject: "FIXTURE_BODY_P1_TEXT",
540
relation: "above",
541
object: { role: "Page", page: 1, edge: "bottom" },
542
},
543
]);
544
await pageBottomEdgeTest.verify([]);
545
546
// Test: Content should be leftOf Page's right edge
547
const pageRightEdgeTest = ensurePdfTextPositions(fixturePdf, [
548
{
549
subject: "FIXTURE_BODY_P1_TEXT",
550
relation: "leftOf",
551
object: { role: "Page", page: 1, edge: "right" },
552
},
553
]);
554
await pageRightEdgeTest.verify([]);
555
556
// Test: Page edge with byMin - content should be at least some distance from page edges
557
const pageEdgeWithByMin = ensurePdfTextPositions(fixturePdf, [
558
{
559
subject: "FIXTURE_BODY_P1_TEXT",
560
relation: "rightOf",
561
object: { role: "Page", page: 1, edge: "left" },
562
byMin: 1, // At least 1pt from left edge
563
},
564
]);
565
await pageEdgeWithByMin.verify([]);
566
567
// Test: topAligned with Page using edge override
568
// Header decoration's top should be close to page top
569
const headerNearPageTop = ensurePdfTextPositions(fixturePdf, [
570
{
571
subject: { text: "FIXTURE_HEADER_TEXT", role: "Decoration" },
572
relation: "below",
573
object: { role: "Page", page: 1, edge: "top" },
574
byMax: 100, // Within 100pt of page top
575
},
576
]);
577
await headerNearPageTop.verify([]);
578
}
579
580
/**
581
* Test centerX and centerY edge functionality
582
*/
583
async function runCenterEdgeTests() {
584
// Test: centerX - title's horizontal centre should be near page's horizontal centre (inset tolerance for minor misalignment)
585
const centerXPageTest = ensurePdfTextPositions(fixturePdf, [
586
{
587
subject: { text: "FIXTURE_TITLE_TEXT", edge: "centerX" },
588
relation: "leftAligned",
589
object: { role: "Page", page: 1, edge: "centerX" },
590
tolerance: 35,
591
},
592
]);
593
await centerXPageTest.verify([]);
594
595
// Test: centerY directional - header decoration's centerY should be above title's centerY
596
const centerYDirectionalTest = ensurePdfTextPositions(fixturePdf, [
597
{
598
subject: { text: "FIXTURE_HEADER_TEXT", role: "Decoration", edge: "centerY" },
599
relation: "above",
600
object: { text: "FIXTURE_TITLE_TEXT", edge: "centerY" },
601
},
602
]);
603
await centerYDirectionalTest.verify([]);
604
605
// Test: centerX directional - a left-aligned heading's centerX should be leftOf page centerX
606
const centerXDirectionalTest = ensurePdfTextPositions(fixturePdf, [
607
{
608
subject: { text: "FIXTURE_H1_TEXT", edge: "centerX" },
609
relation: "leftOf",
610
object: { role: "Page", page: 1, edge: "centerX" },
611
},
612
]);
613
await centerXDirectionalTest.verify([]);
614
}
615
616