pdfrum-render 0.1.0

Rendering engine and the RenderDevice/RasterBackend seam
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
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
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
//! Text as filled glyph outlines.
//!
//! # Two paths, and which one a run takes
//!
//! The oracle draws small text and large text differently, and so does this.
//! Below `|char2device.a| + |char2device.b| > 50` it rasterizes a *glyph
//! bitmap* and blits it at a snapped origin; above, it fills the outline at
//! its true position through `DrawTextPath`. [`takes_bitmap_path`] is that
//! threshold and [`snaps_origins`] the three gates around it.
//!
//! This module lays glyphs out and decides which path each run takes. It does
//! not rasterize: the bitmap pipeline is [`crate::glyph`], which reproduces
//! all four of the oracle's stages — the 64-ppem grid fit, the 3×-wide LCD
//! rasterization, FreeType's FIR5 filter, and `kTextGammaAdjust` over the
//! averaged triples. A run that takes it carries a [`BitmapPlacement`]; one
//! that does not is drawn by filling [`PlacedGlyph::outline`].
//!
//! [`RenderOptions::subpixel_text_positioning`](crate::options::RenderOptions::subpixel_text_positioning)
//! turns the bitmap path off for a caller who wants text where the PDF puts
//! it rather than where a golden expects it.

use kurbo::{Affine, BezPath, Vec2};

use crate::options::{RenderOptions, TextAa};
use pdfrum_font::{CharItem, Font, GlyphCache, GlyphKey, cid_transform_to_float};
use pdfrum_page::{TextObject, TextRenderMode};

/// Which of fill, stroke and clip a text render mode asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextPaintKinds {
    /// Whether the glyphs are filled.
    pub fill: bool,
    /// Whether they are stroked.
    pub stroke: bool,
    /// Whether they contribute to the clip.
    pub clip: bool,
}

/// Resolve a text render mode into what it paints.
///
/// `has_face` is whether the font has real outlines: a stroke-only mode on a
/// font without them **falls back to a fill**, which is upstream's own
/// substitution and not a rounding of intent.
#[must_use]
pub fn paint_kinds(mode: TextRenderMode, has_face: bool) -> Option<TextPaintKinds> {
    let none = TextPaintKinds {
        fill: false,
        stroke: false,
        clip: false,
    };
    match mode {
        // Tr 3: nothing at all, not even a clip contribution.
        TextRenderMode::Invisible => None,
        // Tr 7: clip only, no paint. Returns early in the C++ *before* the
        // clip accumulation, so it paints nothing here either.
        TextRenderMode::Clip => Some(TextPaintKinds { clip: true, ..none }),
        TextRenderMode::Fill => Some(TextPaintKinds { fill: true, ..none }),
        TextRenderMode::FillClip => Some(TextPaintKinds {
            fill: true,
            clip: true,
            ..none
        }),
        TextRenderMode::Stroke => Some(if has_face {
            TextPaintKinds {
                stroke: true,
                ..none
            }
        } else {
            TextPaintKinds { fill: true, ..none }
        }),
        TextRenderMode::StrokeClip => Some(if has_face {
            TextPaintKinds {
                stroke: true,
                clip: true,
                ..none
            }
        } else {
            TextPaintKinds {
                fill: true,
                clip: true,
                ..none
            }
        }),
        TextRenderMode::FillStroke => Some(TextPaintKinds {
            fill: true,
            stroke: has_face,
            ..none
        }),
        TextRenderMode::FillStrokeClip => Some(TextPaintKinds {
            fill: true,
            stroke: has_face,
            clip: true,
        }),
    }
}

/// Where a snapped glyph's bitmap goes, and which of its three phases to
/// average.
///
/// Present exactly when the run takes the oracle's glyph-*bitmap* path, which
/// is what [`snaps_origins`] decides. A run that does not — display type, a
/// stroke, a caller who asked for fractional placement — carries `None` and is
/// drawn by filling [`PlacedGlyph::outline`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BitmapPlacement {
    /// The whole-pixel device origin the bitmap's own origin lands on.
    ///
    /// `floor(x)` and `round(y)`: the two halves of the oracle's snap, kept
    /// separate from the third-of-a-pixel remainder rather than folded into one
    /// number, because the bitmap is blitted at the integer and the remainder
    /// selects a *different bitmap* rather than moving this one.
    pub origin: kurbo::Point,
    /// Which third of a pixel the true origin sat in.
    pub phase: crate::glyph::SubpixelPhase,
}

/// One glyph, placed.
#[derive(Debug, Clone)]
pub struct PlacedGlyph {
    /// The outline in 1000-unit text space, straight from the cache.
    ///
    /// Shared rather than copied. A placement record cannot borrow the cache
    /// — placing the next glyph needs it mutably again — and copying instead
    /// clones the path once per glyph, on pages whose glyphs may all take the
    /// *bitmap* path and never read this field at all.
    pub outline: std::sync::Arc<BezPath>,
    /// Text space to device space for this glyph, font size included.
    ///
    /// For a snapped glyph this is the matrix *before* the snap: the snap is
    /// [`Self::bitmap`]'s integer origin, and folding it in here as well would
    /// apply it twice. For an unsnapped one it is simply where the glyph goes.
    pub matrix: Affine,
    /// The cache key the outline came back under, which the bitmap path needs
    /// to key its own cache by.
    pub key: GlyphKey,
    /// Where the bitmap goes, when this run takes the bitmap path.
    pub bitmap: Option<BitmapPlacement>,
}

impl PlacedGlyph {
    /// The outline in device space.
    #[must_use]
    pub fn device_path(&self) -> BezPath {
        self.matrix * (*self.outline).clone()
    }
}

/// The matrix one glyph is drawn under.
///
/// Three spaces compose. Outlines arrive scaled to **1000 units per em**, so
/// the font size divides by a thousand to reach text space. `pen` is the
/// glyph's origin in that same text space, where y grows *upward* and the
/// pen advances along whichever axis the writing mode names. And
/// `text_to_device` — which is
/// `pdfrum-page`'s `TextObject::matrix`, already carrying the CTM, the text
/// matrix and the horizontal scale, composed with the page-to-device
/// transform — takes it the rest of the way.
///
/// There is deliberately **no y flip here**: PDF text space and PDF user
/// space share their orientation, and the single flip that turns y-up into a
/// y-down device lives in the page matrix, where every object kind sees it.
/// Flipping again per glyph mirrors every letter about its own baseline.
#[must_use]
pub fn glyph_matrix(font_size: f32, pen: kurbo::Point, text_to_device: Affine) -> Affine {
    let s = f64::from(font_size) / 1000.0;
    text_to_device * Affine::translate((pen.x, pen.y)) * Affine::scale(s)
}

/// What a per-glyph correction does to one glyph.
///
/// Two separate things, which is why it is not simply a matrix: a shift of
/// the drawing **origin** in text space, and a reshaping **matrix** applied
/// to the outline inside its em box. Neither touches the advance, so the
/// pen walks the run as if no correction existed and only this one glyph
/// moves or changes shape.
///
/// Two corrections produce this record — the Adobe-Japan1 per-CID transform
/// ([`japan1_adjust`]) and the glyph-spacing correction
/// ([`glyph_spacing_adjust`]) — and a glyph can take both.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlyphAdjust {
    /// Added to the pen for this glyph only — never to the running pen.
    pub origin: Vec2,
    /// Post-multiplied onto the glyph matrix, so it acts in em space.
    pub matrix: Affine,
}

impl GlyphAdjust {
    /// No adjustment: the identity matrix and no shift.
    pub const NONE: Self = Self {
        origin: Vec2::new(0.0, 0.0),
        matrix: Affine::IDENTITY,
    };
}

impl Default for GlyphAdjust {
    fn default() -> Self {
        Self::NONE
    }
}

/// The Japan1 adjustment one decoded character takes.
///
/// A non-embedded Japanese font is substituted onto a face that has only
/// upright glyphs, so PDFium carries a hand-tuned 154-row table of per-CID
/// transforms and applies them itself. It is *not* only for vertical writing:
/// the gate is the charset and the absence of a font program, and a
/// horizontal CMap such as `/90pv-RKSJ-H` reaches the listed CIDs perfectly
/// well. `bug_1402.pdf` is exactly that file, and without this its three
/// ideographic full stops land 22 device pixels left and 27 up — right shape,
/// right ink, wrong side of the em box.
///
/// **The advance is untouched.** Upstream mutates a per-glyph `origin_`, not
/// the running pen, so a run's spacing is the same with the transform as
/// without it and only each glyph's own placement moves.
///
/// `is_vertical_glyph` suppresses it: a `GSUB` `vert` substitution has
/// already produced a rotated form, and rotating it again is the one way to
/// make this worse than not applying it.
///
/// The transform **replaces** any narrowing from [`glyph_spacing_adjust`]
/// rather than composing freely with it: upstream overwrites the whole
/// four-element adjust matrix here, carrying the spacing factor into only the
/// `a` and `b` columns. That is exactly right-multiplying by the horizontal
/// scale, which [`place_glyphs`] does when both fire.
#[must_use]
pub fn japan1_adjust(font: &Font, item: &CharItem, font_size: f32) -> GlyphAdjust {
    if item.vertical_glyph {
        return GlyphAdjust::NONE;
    }
    let Some(t) = font.japan1_transform(item.code) else {
        return GlyphAdjust::NONE;
    };
    let f = |b: u8| f64::from(cid_transform_to_float(b));
    GlyphAdjust {
        origin: Vec2::new(f(t.e) * f64::from(font_size), f(t.f) * f64::from(font_size)),
        // The packed order is the PDF matrix's own: `a b c d`.
        matrix: Affine::new([f(t.a), f(t.b), f(t.c), f(t.d), 0.0, 0.0]),
    }
}

/// The spacing correction one glyph takes when
/// [`Font::applies_glyph_spacing`] passed.
///
/// `declared` is the advance the PDF gives this character code and `face` the
/// advance the substituted face gives the glyph, both in 1000/em units. The
/// two disagree in either direction and the answers are deliberately **not**
/// symmetric:
///
/// - the document's advance is **wider** than the face's, by more than one
///   unit: the glyph is drawn at its natural width but *centred* in the
///   advance the document reserved, by shifting the origin right by half the
///   excess. The outline is not stretched — a letter set in a wide slot
///   should sit in the middle of it, not become a fat letter.
/// - the document's advance is **narrower**: the glyph is squeezed
///   horizontally to fit, by the ratio of the two, and its origin does not
///   move. Centring would not help here — the glyph would still overhang the
///   advance and collide with its neighbour.
///
/// The one-unit slack on the wider branch is upstream's and matters: a
/// rounding difference of a single 1000/em unit is not a design disagreement,
/// and shifting on it would jitter otherwise well-matched text.
///
/// A face advance of zero means the face could not answer, which disables
/// both branches — there is nothing to compare against. A declared width of
/// zero disables only the narrowing branch, since scaling a glyph to zero
/// width erases it.
#[must_use]
pub fn glyph_spacing_adjust(declared: i32, face: i32, font_size: f32) -> GlyphAdjust {
    if face != 0 && declared > face.saturating_add(1) {
        return GlyphAdjust {
            origin: Vec2::new(
                f64::from(declared.saturating_sub(face)) * f64::from(font_size) / 2000.0,
                0.0,
            ),
            matrix: Affine::IDENTITY,
        };
    }
    if declared != 0 && face != 0 && declared < face {
        return GlyphAdjust {
            origin: Vec2::ZERO,
            matrix: Affine::scale_non_uniform(f64::from(declared) / f64::from(face), 1.0),
        };
    }
    GlyphAdjust::NONE
}

/// The size threshold above which the oracle abandons glyph bitmaps for
/// outline fills.
///
/// `char2device` is the text-to-device matrix scaled by `(font_size,
/// -font_size)`, so `|a| + |b|` is roughly the em's device width. Above 50
/// device units `DrawNormalText` hands the run to `DrawTextPath`, which
/// places every glyph at its true fractional origin — so the integer snap is
/// a *small-text* rule and large display type is unaffected by it either way.
pub const BITMAP_PATH_MAX_EM: f64 = 50.0;

/// Whether a run of this size takes the oracle's glyph-*bitmap* path, and so
/// gets its origins snapped.
///
/// The threshold is `|a| + |b| <= 50` on the char-to-device matrix; above it
/// the outline is filled instead. A face with no outlines at all would also
/// leave the bitmap path, but in this engine that is a type-3 font and
/// type-3 text never reaches here.
#[must_use]
pub fn takes_bitmap_path(font_size: f32, text_to_device: Affine) -> bool {
    // char2device = text2device * Scale(font_size, -font_size); a column-major
    // `Affine` holds [a, b, c, d, e, f], and scaling post-multiplies, so
    // a' = a * font_size and b' = b * font_size.
    let [a, b, ..] = text_to_device.as_coeffs();
    let size = f64::from(font_size);
    (a * size).abs() + (b * size).abs() <= BITMAP_PATH_MAX_EM
}

/// Snap one glyph's device origin to the grid a blitted glyph bitmap sits on.
///
/// # The x grid is thirds of a pixel, not whole pixels
///
/// Reading only the snap itself is misleading:
///
/// ```cpp
/// glyph.origin_.x = anti_alias_is_lcd ? static_cast<int>(floor(x))
///                                     : FXSYS_roundf(x);
/// glyph.origin_.y = FXSYS_roundf(y);
/// ```
///
/// Under `kLcd` the integer `origin_.x` is only *half* of the horizontal
/// placement. The blit loop recovers the rest:
///
/// ```cpp
/// int x_subpixel = static_cast<int>(glyph.device_origin_.x * 3) % 3;
/// ```
///
/// and `DrawNormalTextHelper` shifts its window into the 3×-wide LCD bitmap
/// by that many subpixels before averaging the triples back down. So the
/// effective origin is `floor(x) + x_subpixel/3`, which for a non-negative x
/// is exactly `floor(3x)/3`: **x is quantised downward to a third of a
/// pixel.** Only y is quantised to a whole pixel, and that asymmetry is the
/// whole of the placement divergence.
///
/// Wave 4's measurement stands — the residual really is positional, and it
/// really is dominated by the baseline — because y is where the whole-pixel
/// quantisation lives, and a horizontal stem edge is what y moves.
///
/// # Which rounding runs is `FontAntiAliasingMode`, not `bClearType`
///
/// The antialiasing *mode* is derived separately from the `ClearType` flag:
/// with a smooth aliasing type on a display device at 32 bpp it is always
/// LCD, whatever the flag word said, so the conformance configuration takes
/// the thirds. With text smoothing off the derivation is skipped and the mode
/// stays monochrome — one bit per pixel, no LCD triple to shift into, so x
/// snaps to a whole pixel like y. Hence the argument here is [`TextAa`]
/// rather than a bare "is LCD" boolean: the two are the same decision.
///
/// `round` here is C's `round`: half away from zero, unlike
/// Rust's `round_ties_even`.
#[must_use]
pub fn snap_origin(origin: kurbo::Point, text_aa: TextAa) -> kurbo::Point {
    let x = match text_aa {
        // kLcd: `floor(x)` plus `(int)(x * 3) % 3` thirds. The C++ `(int)`
        // truncates toward zero and `%` keeps the sign, so this is written
        // the way the C++ computes it rather than as `(3x).floor() / 3`,
        // which differs on a negative origin — where upstream's negative
        // `x_subpixel` falls into the `x_subpixel == 2` arm.
        // Both smooth modes land here: `IsSmooth()` is true for `kAntiAliasing`
        // and `kLcd` alike, so `FontAntiAliasingMode` is `kLcd` either way and
        // the thirds are taken either way. `bClearType` decides only whether
        // the triples are averaged afterwards, which is a later stage.
        TextAa::Grayscale | TextAa::LcdSubpixel => {
            let whole = origin.x.floor();
            #[expect(
                clippy::cast_possible_truncation,
                reason = "the C++ is `static_cast<int>(x * 3) % 3`; a device \
                          origin beyond i32 has already been clamped by the \
                          ±32000 coordinate rule"
            )]
            let subpixel = f64::from((origin.x * 3.0) as i32 % 3);
            whole + subpixel / 3.0
        }
        // kMono: nearest, ties away from zero.
        TextAa::None => origin.x.round(),
    };
    kurbo::Point::new(x, origin.y.round())
}
/// Pull one glyph's origins back together after snapping, when consecutive
/// integer origins have drifted more than half a pixel from the fractional
/// spacing they came from.
///
/// It runs **only when the mode is not LCD and the run has more than one
/// glyph**, which under the conformance flags means it never runs at all —
/// only `--no-smoothtext` reaches it. The rule is deliberately conservative:
/// it gives up entirely unless the run is axis-aligned (every origin sharing
/// an x, or every origin sharing a y after the snap), and it never touches
/// the first or last glyph.
///
/// Note the loop bound. The C++ walks `i` from `size - 1` down to `2`
/// exclusive and edits `glyphs[i - 1]`, so glyph 0 is never adjusted and the
/// *last* glyph is only ever read. That asymmetry is upstream's, not a
/// transcription slip, and it is why a two-glyph run is a no-op even though
/// the size guard admits it.
/// the size guard admits it.
#[expect(
    clippy::float_cmp,
    reason = "the C++ compares snapped origins, which are whole pixels there \
              and exact integers in this f64 after `snap_origin` — an epsilon \
              would admit a run the oracle rejects as non-axis-aligned"
)]
pub fn adjust_glyph_space(origins: &mut [kurbo::Point], device: &[kurbo::Point]) {
    debug_assert_eq!(origins.len(), device.len());
    let (Some(first), Some(last)) = (origins.first().copied(), origins.last().copied()) else {
        return;
    };
    if origins.len() <= 1 {
        return;
    }
    let vertical = last.x == first.x;
    if !vertical && last.y != first.y {
        return;
    }
    // Reading one axis of a point, chosen once for the whole run.
    let axis = |p: kurbo::Point| if vertical { p.y } else { p.x };

    for i in (2..origins.len()).rev() {
        let (Some(next_origin), Some(next_f)) = (origins.get(i), device.get(i)) else {
            continue;
        };
        let (Some(cur_origin), Some(cur_f)) = (origins.get(i - 1), device.get(i - 1)) else {
            continue;
        };
        let space = axis(*next_origin) - axis(*cur_origin);
        let space_f = axis(*next_f) - axis(*cur_f);
        // The fractional spacing exceeds the integer one by more than half a
        // pixel, so the snap has stretched this gap: close it by a pixel.
        if space_f.abs() - space.abs() <= 0.5 {
            continue;
        }
        let nudge = if space > 0.0 { -1.0 } else { 1.0 };
        if let Some(target) = origins.get_mut(i - 1) {
            if vertical {
                target.y += nudge;
            } else {
                target.x += nudge;
            }
        }
    }
}
/// The stroked-text CTM un-transform.
///
/// A stroke's width is measured in user space (ISO 32000-1 §8.4.3.2), so when
/// the text state's CTM carries a non-unit x or y scale the text matrix is
/// pre-divided by it and the scale is folded into the device matrix instead.
/// Returns the adjusted `(text_matrix, device_matrix)` pair.
///
/// `ctm` is the four-float slot stored on the text state, already transposed
/// as `[a, c, b, d]`. kurbo is column-vector, so the C++ row-vector
/// `text_matrix *= ctm.GetInverse(); device = ctm * mtObj2Device` (leftmost
/// applies first) is spelled `text = ctm.inverse() * text` and `device =
/// mtObj2Device * ctm`. Writing it the other way round moves the page-matrix
/// translation by the CTM scale and lands every glyph in the wrong place.
/// wrong place.
#[must_use]
#[expect(
    clippy::float_cmp,
    reason = "the exact `a == 1 && d == 1` is upstream's unit-scale short \
              circuit; with a tolerance a slightly-off-unit CTM would skip \
              the split and stroke at the wrong width, which is the whole \
              point of the function"
)]
pub fn stroke_ctm_split(text_matrix: Affine, to_device: Affine, ctm: [f64; 4]) -> (Affine, Affine) {
    let [a, b, c, d] = ctm;
    if a == 1.0 && d == 1.0 {
        return (text_matrix, to_device);
    }
    let scale = Affine::new([a, b, c, d, 0.0, 0.0]);
    let det = scale.determinant();
    if det == 0.0 || !det.is_finite() {
        return (text_matrix, to_device);
    }
    (scale.inverse() * text_matrix, to_device * scale)
}

/// The `(text_matrix, device_matrix)` pair [`stroke_ctm_split`] produces for
/// one object, using the CTM the content interpreter stored on the text state.
#[must_use]
pub(crate) fn stroke_text_matrices(
    object: &TextObject,
    state: &pdfrum_page::GraphicsState,
    to_device: Affine,
) -> (Affine, Affine) {
    let [a, b, c, d] = state.text.stroke_ctm;
    stroke_ctm_split(
        object.matrix,
        to_device,
        [f64::from(a), f64::from(b), f64::from(c), f64::from(d)],
    )
}
/// Lay out one text object's glyphs.
///
/// # The two coordinate systems this has to keep straight
///
/// `pdfrum-page` hands a text object *two* pieces of placement, and they are
/// in different spaces. `TextObject::matrix` is `ctm * text_matrix *
/// horizontal_scale` — **text space to page space**, with no font size —
/// while `TextObject::position` is `ctm * text_matrix` already applied to the
/// pen, i.e. a **page-space** point.
///
/// Composing the two naively applies the matrix twice, which shifts the run
/// wherever the text matrix has a translation and is invisible wherever it
/// does not — so it survives every fixture whose `Tm` is the identity. The
/// run's origin is therefore recovered by pulling `position` *back* through
/// the matrix, and the pen then advances in text space where the advances are
/// actually defined.
///
/// Advances follow the same rules `pdfrum-page` used to build the object:
/// each code's width scaled by the font size, plus the character spacing,
/// plus the word spacing on a single-byte space only, plus any kerning
/// between segments. The horizontal scale is *not* applied again, because
/// `matrix` already carries it.
///
/// # Two per-glyph corrections ride along
///
/// Both [`japan1_adjust`] and [`glyph_spacing_adjust`] shift and reshape a
/// glyph *inside* its em box without touching the advance, so each is folded
/// into that one glyph's origin and matrix and the pen walks the run as if
/// neither existed. A glyph can take both; the spacing squeeze then sits
/// innermost, which is what carries its factor into the Japan1 transform's
/// first column alone.
///
/// # The glyph origins are then snapped
///
/// Unless [`RenderOptions::subpixel_text_positioning`] asks otherwise, a run
/// the oracle would draw through `DrawNormalText` has every glyph's device
/// origin pushed onto the blit grid by [`snap_origin`], per glyph and
/// independently. The snap is applied as a *device translation* on top of the
/// glyph's matrix, so the outline keeps its own shape and orientation and
/// only its placement moves — which is what blitting a bitmap at a fixed
/// origin amounts to.
///
/// **Three conditions gate it**, and each is a run the oracle itself places
/// fractionally:
///
/// - the run is **not stroked** — `if (is_clip || is_stroke)` takes
///   `DrawTextPath`, which places every glyph at its true origin. A
///   pattern-coloured one takes `DrawTextPathWithPattern` and never reaches
///   here at all, so `render_text` has already excluded it. Note that
///   `is_clip` is **not** a text render mode: `ProcessText` is called twice,
///   once from `ProcessClipPath` with a `clipping_path` and once from
///   `ProcessObjectNoClip` with `nullptr`, and only the first sets it. So a
///   `Tr 4` fill-and-clip run still snaps on its painting pass, and it is the
///   clip *accumulation* — which this engine builds in `pdfrum-page`, not
///   here — that does not.
/// - the run is **small**, `|char2device.a| + |char2device.b| <= 50`
///   ([`takes_bitmap_path`]); above that `DrawNormalText` itself defers to
///   `DrawTextPath`.
/// - the caller has not asked for [`RenderOptions::subpixel_text_positioning`].
///
/// `AdjustGlyphSpace` then runs over the whole run, but only in the non-LCD
/// mode, which is `--no-smoothtext` and not conformance. See
/// [`adjust_glyph_space`].
/// [`adjust_glyph_space`].
#[must_use]
pub fn place_glyphs(
    object: &TextObject,
    state: &pdfrum_page::GraphicsState,
    cache: &mut GlyphCache,
    to_device: Affine,
    opts: &RenderOptions,
    kinds: TextPaintKinds,
) -> Vec<PlacedGlyph> {
    let mut out = Vec::new();
    place_glyphs_into(&mut out, object, state, cache, to_device, opts, kinds);
    out
}

/// Where and how one glyph of a run is placed, the parts of
/// [`place_glyphs_into`]'s state a single glyph needs.
///
/// `pen` is in the text space the advances accumulate in; `vertical` and
/// `spacing` are the font's writing mode and whether it applies the
/// `/Widths`-versus-face glyph squeeze.
struct Placement {
    pen: kurbo::Point,
    size: f32,
    text_to_device: Affine,
    vertical: bool,
    spacing: bool,
}

/// One decoded glyph, placed against the pen.
///
/// Both per-glyph corrections move and reshape the glyph *within* its em box
/// without touching the advance, so they apply to this glyph's origin and
/// matrix and the caller's pen walks on as if neither were there.
fn place_one_glyph(
    outline: std::sync::Arc<BezPath>,
    key: GlyphKey,
    font: &Font,
    item: &CharItem,
    gid: pdfrum_font::Gid,
    at: &Placement,
) -> PlacedGlyph {
    let japan1 = japan1_adjust(font, item, at.size);
    // The position vector `v` carries the glyph from its horizontal origin to
    // its vertical one, so it is *subtracted* to place the outline against the
    // vertical pen (ISO 32000-1 §9.7.4.3). It moves the glyph alone: the pen
    // has already taken `w1` and walks on regardless.
    let vert_origin = if at.vertical {
        let (vx, vy) = font.vert_origin(item.code).unwrap_or((0.0, 880.0));
        let scale = f64::from(at.size) / 1000.0;
        Vec2::new(-f64::from(vx) * scale, -f64::from(vy) * scale)
    } else {
        Vec2::ZERO
    };
    let space = if at.spacing {
        #[expect(
            clippy::cast_possible_truncation,
            reason = "a /Widths entry is a small integer on the C++ side too; \
                      the f32 is this crate's own carrier"
        )]
        let declared = item.width as i32;
        glyph_spacing_adjust(declared, font.glyph_advance(gid), at.size)
    } else {
        GlyphAdjust::NONE
    };
    // The spacing squeeze is a *glyph-space* horizontal scale, so it sits
    // innermost — to the right of the Japan1 reshaping, which is what carries
    // the factor into that transform's `a` and `b` alone.
    PlacedGlyph {
        outline,
        matrix: glyph_matrix(
            at.size,
            at.pen + japan1.origin + space.origin + vert_origin,
            at.text_to_device,
        ) * japan1.matrix
            * space.matrix,
        key,
        bitmap: None,
    }
}

/// [`place_glyphs`] into a buffer the caller owns.
///
/// `out` is cleared first and refilled; a caller that walks many text objects
/// keeps one buffer and allocates once for the run rather than once per object.
/// That is the whole difference — the placement is identical, and
/// [`place_glyphs`] is this with a fresh `Vec`.
///
/// It exists so that a page's text objects do not each allocate a
/// `Vec<PlacedGlyph>` of their own.
pub fn place_glyphs_into(
    out: &mut Vec<PlacedGlyph>,
    object: &TextObject,
    state: &pdfrum_page::GraphicsState,
    cache: &mut GlyphCache,
    to_device: Affine,
    opts: &RenderOptions,
    kinds: TextPaintKinds,
) {
    // The capacity the buffer arrives with. Only a *growth* past it is an
    // allocation, and the instrument must count that rather than the number of
    // calls — otherwise reusing the buffer leaves the allocation column
    // unchanged and reads as if the reuse had not happened.
    let arrived_with = out.capacity();
    out.clear();
    let Some((font, size)) = &object.font else {
        return;
    };
    // A stroke keeps the text matrix and the device matrix apart, the way
    // `DrawTextPath` does: the outline is placed in the post-split text
    // space and the CTM lives on the device matrix so line width is not
    // scaled by the font-size/1000 that maps the outline into that space.
    // A fill composes them, which is the same for coverage and is what the
    // bitmap path wants.
    let text_to_device = if kinds.stroke {
        stroke_text_matrices(object, state, to_device).0
    } else {
        to_device * object.matrix
    };
    // Pull the page-space start back into the text space the advances live
    // in. A singular text matrix has no text space to speak of, and the
    // object would not have been drawable anyway.
    let det = object.matrix.determinant();
    if det == 0.0 || !det.is_finite() {
        return;
    }
    let mut pen = object.matrix.inverse() * object.position;
    // Writing mode 1 walks the advance down the *y* axis rather than along x
    // (ISO 32000-1 §9.4.4). `item.width` already carries `w1` from `/W2` and
    // `/DW2` rather than the horizontal width, so the two modes differ only in
    // which coordinate the pen accumulates into and in the position vector
    // each glyph is displaced by below.
    let vertical = font.is_vertical();
    let subst_weight = font.subst().map_or(0, pdfrum_font::SubstFont::raw_weight);
    let subst_italic = font.subst().map_or(0, |s| s.italic_angle);
    let widths_drive_the_design = width_drives_the_design_space(font);
    let spacing = font.applies_glyph_spacing();

    for segment in &object.segments {
        for item in font.decode(&segment.codes) {
            let advance = f64::from(item.width) / 1000.0 * f64::from(*size)
                + f64::from(state.text.char_space);
            // Word spacing applies to a single-byte space only, which is why
            // a CID-keyed code of 0x20 does not earn it.
            let word = if item.code.0 == 0x20 && item.cid.is_none() {
                f64::from(state.text.word_space)
            } else {
                0.0
            };
            if let Some(gid) = item.gid {
                let key = GlyphKey {
                    font: font.id(),
                    gid,
                    dest_width: if widths_drive_the_design {
                        #[expect(
                            clippy::cast_possible_truncation,
                            reason = "`GetCharWidth` is an int on the C++ side \
                                      and a /Widths entry is a small number; \
                                      the f32 is this crate's own carrier"
                        )]
                        {
                            item.width as i32
                        }
                    } else {
                        0
                    },
                    weight: subst_weight,
                    italic_angle: subst_italic,
                    vertical: item.vertical_glyph,
                };
                if let Some(outline) = cache.shared(font, key) {
                    out.push(place_one_glyph(
                        outline,
                        key,
                        font,
                        &item,
                        gid,
                        &Placement {
                            pen,
                            size: *size,
                            text_to_device,
                            vertical,
                            spacing,
                        },
                    ));
                }
            }
            if vertical {
                pen.y += advance + word;
            } else {
                pen.x += advance + word;
            }
        }
        // `TextSegment::kerning` is the adjustment that **follows** its
        // string, and a leading one is carried separately as the object's
        // starting position — so it moves the pen after the glyphs, not
        // before them. Applying it at the top of this loop double-counted a
        // leading adjustment and dropped the last one, which put every kerned
        // run one adjustment out of place. It is negated: a positive `TJ`
        // number moves text *left*.
        let kern = f64::from(segment.kerning) / 1000.0 * f64::from(*size);
        if vertical {
            pen.y -= kern;
        } else {
            pen.x -= kern;
        }
    }
    if snaps_origins(opts, kinds, *size, text_to_device) {
        snap_run(out, opts.text_aa);
    }
    if out.capacity() > arrived_with {
        crate::walkprofile::alloc_items(
            crate::walkprofile::Site::GlyphVec,
            out.capacity(),
            core::mem::size_of::<PlacedGlyph>(),
        );
    }
}

/// Whether the PDF's own `/Widths` reach the glyph's *outline* rather than
/// only its advance.
///
/// ```cpp
/// if (!IsEmbedded() && !IsCIDFont()) {
///   text_char_pos.font_char_width_ = GetCharWidth(char_code);
/// } else {
///   text_char_pos.font_char_width_ = 0;
/// }
/// ```
///
/// That width becomes a `dest_width` at the face, where a
/// **Multiple-Master** face's width axis is solved until the glyph's own
/// advance equals it. The two internal generics the substitution ladder
/// terminates on —
/// Chrome Sans and Chrome Serif — *are* MM Type 1 faces, so this is not a
/// corner: it is how every non-embedded font in the corpus gets drawn at the
/// width its PDF declares rather than at the fallback face's own.
///
/// Skipping it drew every substituted glyph at the face's default design
/// position. On `5.5_simple_font.pdf` — whose whole point is a `/Widths`
/// array with values like `a = 800, b = 100, c = 400` against a face whose
/// own are 452, 470 and 480 — the glyphs overran their advances and piled
/// into each other, which read as dropped characters and as an MM band drawn
/// "too narrow". Both were the same defect: the advances were always right
/// and the outlines were always wrong.
///
/// The gate is exactly upstream's, and both halves matter. An **embedded**
/// font is drawn from its own program, where the PDF's width is metadata and
/// not a design parameter. A **CID** font's `dest_width` is zeroed even when
/// substituted, because a CID font's widths are keyed by CID rather than by
/// character code and the C++ declines to reconcile the two.
#[must_use]
pub fn width_drives_the_design_space(font: &Font) -> bool {
    !font.is_embedded() && !matches!(font, Font::Type0(_))
}

/// Whether this run's origins are snapped: the three gates named on
/// [`place_glyphs`].
#[must_use]
pub fn snaps_origins(
    opts: &RenderOptions,
    kinds: TextPaintKinds,
    font_size: f32,
    text_to_device: Affine,
) -> bool {
    !opts.subpixel_text_positioning && !kinds.stroke && takes_bitmap_path(font_size, text_to_device)
}

/// Snap a whole laid-out run onto the oracle's blit grid.
///
/// Split out from [`place_glyphs`] because the two halves are separable and
/// only this one is the oracle's placement rule: the layout above is where
/// the glyphs *are*, and this is where the oracle *draws* them.
///
/// Two things come out of it, and keeping them apart is the point.
/// [`PlacedGlyph::matrix`] gets the snap folded in as a device translation, so
/// that filling the outline lands where the oracle blits — which is what the
/// pre-wave-7 engine did and what still runs whenever a bitmap cannot be
/// produced. And [`PlacedGlyph::bitmap`] gets the *integer* origin together
/// with the third-of-a-pixel remainder as a phase, because the bitmap path does
/// not translate by a third of a pixel: it averages a different window of the
/// same 3×-wide bitmap, which is a different set of bytes rather than the same
/// bytes moved.
fn snap_run(glyphs: &mut [PlacedGlyph], text_aa: TextAa) {
    if glyphs.is_empty() {
        return;
    }
    // Each glyph's matrix maps the outline's own origin to the device, so the
    // device origin is simply the matrix applied to the origin point.
    let device: Vec<kurbo::Point> = glyphs
        .iter()
        .map(|g| g.matrix * kurbo::Point::ZERO)
        .collect();
    let mut snapped: Vec<kurbo::Point> = device.iter().map(|p| snap_origin(*p, text_aa)).collect();
    // `AdjustGlyphSpace` is guarded on the mode *not* being LCD, so it is
    // reachable only under `--no-smoothtext`.
    if text_aa == TextAa::None {
        adjust_glyph_space(&mut snapped, &device);
    }
    for ((glyph, from), to) in glyphs.iter_mut().zip(&device).zip(&snapped) {
        let delta = Vec2::new(to.x - from.x, to.y - from.y);
        // Pre-multiplying translates in *device* space, which is where the
        // snap happens. Folding it into the glyph matrix instead would scale
        // and rotate the nudge by the text matrix.
        glyph.matrix = Affine::translate(delta) * glyph.matrix;
        glyph.bitmap = bitmap_placement(*from, *to, text_aa);
    }
}

/// The bitmap origin and phase for one glyph, or `None` in the mono mode,
/// which has no LCD bitmap to shift a window into.
///
/// `snapped.x` is `floor(x) + phase/3` under `kLcd`, so the integer origin is
/// its own floor — recovered from the snapped value rather than recomputed from
/// the true one, because `AdjustGlyphSpace` may have moved it and the two must
/// not disagree.
fn bitmap_placement(
    device: kurbo::Point,
    snapped: kurbo::Point,
    text_aa: TextAa,
) -> Option<BitmapPlacement> {
    if text_aa != TextAa::Grayscale {
        // `kMono` renders a 1-bit mask through `CompositeOneBPPMask`, not an
        // LCD triple, and it is reachable only under `--no-smoothtext`. Left on
        // the outline path, where the whole-pixel snap above already places it.
        return None;
    }
    Some(BitmapPlacement {
        origin: kurbo::Point::new(snapped.x.floor(), snapped.y),
        phase: crate::glyph::SubpixelPhase::of(device.x),
    })
}

/// Whether a font has real outlines, which decides the stroke-to-fill
/// fallback above.
#[must_use]
pub fn has_face(font: &Font) -> bool {
    !matches!(font, Font::Type3(_))
}

/// One Type 3 character, placed.
///
/// A Type 3 glyph is a *content stream*, not an outline, so what a placement
/// yields is the character's code — with which the caller looks up the
/// procedure's objects — and the matrix taking glyph space to device space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacedType3Char {
    /// The character code, keying `TextObject::type3_metrics`.
    pub code: u32,
    /// Glyph space to device space, `font_matrix * font_size` composed with
    /// the pen position and the text-to-device transform.
    pub matrix: Affine,
}

/// Lay out one Type 3 text object's characters.
///
/// The matrix differs from an ordinary glyph's in one way that matters: an
/// outline arrives pre-scaled to 1000 units per em, so [`glyph_matrix`]
/// divides the font size by a thousand. A Type 3 procedure's coordinates are
/// in **glyph space**, whose relationship to text space is stated by the
/// font's own `/FontMatrix` and is not a thousandth in general — a font may
/// declare any matrix at all, and several in the corpus do. So the font
/// matrix is composed in explicitly and the font size scales it, which is
/// `char_matrix = font_matrix scaled by (font_size, font_size)`.
///
/// Advances still come from `/Widths` on the thousandth convention, matching
/// how `pdfrum-page` computed the object's own advance; making the two
/// disagree would slide a Type 3 run relative to the pen the page recorded.
#[must_use]
pub fn place_type3_chars(
    object: &TextObject,
    state: &pdfrum_page::GraphicsState,
    to_device: Affine,
) -> Vec<PlacedType3Char> {
    let Some((font, size)) = &object.font else {
        return Vec::new();
    };
    let Some(type3) = font.type3() else {
        return Vec::new();
    };
    let text_to_device = to_device * object.matrix;
    let det = object.matrix.determinant();
    if det == 0.0 || !det.is_finite() {
        return Vec::new();
    }
    let char_matrix = type3.font_matrix * Affine::scale(f64::from(*size));
    let mut pen = object.matrix.inverse() * object.position;
    let mut out = Vec::new();

    for segment in &object.segments {
        for item in font.decode(&segment.codes) {
            let advance = f64::from(item.width) / 1000.0 * f64::from(*size)
                + f64::from(state.text.char_space);
            let word = if item.code.0 == 0x20 && item.cid.is_none() {
                f64::from(state.text.word_space)
            } else {
                0.0
            };
            out.push(PlacedType3Char {
                code: item.code.0,
                matrix: text_to_device * Affine::translate((pen.x, pen.y)) * char_matrix,
            });
            pen.x += advance + word;
        }
        // After the glyphs, as in `place_glyphs` — the adjustment follows its
        // string.
        pen.x -= f64::from(segment.kerning) / 1000.0 * f64::from(*size);
    }
    out
}
/// A text run's bounding rectangle in page space, or `None` when it is empty.
///
/// The pen walks the run exactly as [`place_glyphs`] does, and each
/// character's **glyph box** — not its outline — grows the extent.
/// Horizontal writing accumulates
/// x from the pen and y from the raw box; vertical writing swaps the two
/// roles and offsets each box by the character's vertical origin first. The
/// finished box is then scaled by the font size on the axis that was left in
/// 1000/em units, and mapped through the run's own matrix.
///
/// Only [`crate::walk`]'s pattern-text path wants this, and it wants it
/// because upstream fills that rectangle rather than the glyphs. The stroke
/// inflation `CalcPositionDataInternal` applies is deliberately absent: that
/// arm of `DrawTextPathWithPattern` draws glyph outlines instead and never
/// reads the rectangle at all.
/// reads the rectangle at all.
#[must_use]
pub fn run_rect(object: &TextObject, state: &pdfrum_page::GraphicsState) -> Option<kurbo::Rect> {
    let (font, size) = object.font.as_ref()?;
    let vertical = font.is_vertical();
    let (mut min_x, mut max_x) = (f64::MAX, f64::MIN);
    let (mut min_y, mut max_y) = (f64::MAX, f64::MIN);
    let det = object.matrix.determinant();
    if det == 0.0 || !det.is_finite() {
        return None;
    }
    // The same start [`place_glyphs`] uses: the run's page-space origin pulled
    // back into the text space the advances live in. Upstream keeps the two
    // apart — `CalcPositionDataInternal` walks from zero and `GetTextMatrix`
    // carries the origin — and composing them here is the same arithmetic.
    let start = object.matrix.inverse() * object.position;
    let mut pen = start.x;
    let size = f64::from(*size);

    for segment in &object.segments {
        for item in font.decode(&segment.codes) {
            let bbox = font.char_bbox(item.code);
            if vertical {
                let (ox, oy) = font.vert_origin(item.code).unwrap_or((0.0, 880.0));
                let (left, right) = (bbox.x0 - f64::from(ox), bbox.x1 - f64::from(ox));
                let (top, bottom) = (bbox.y1 - f64::from(oy), bbox.y0 - f64::from(oy));
                min_x = min_x.min(left).min(right);
                max_x = max_x.max(left).max(right);
                for edge in [pen + top * size / 1000.0, pen + bottom * size / 1000.0] {
                    min_y = min_y.min(edge);
                    max_y = max_y.max(edge);
                }
            } else {
                min_y = min_y.min(bbox.y0).min(bbox.y1);
                max_y = max_y.max(bbox.y0).max(bbox.y1);
                for edge in [pen + bbox.x0 * size / 1000.0, pen + bbox.x1 * size / 1000.0] {
                    min_x = min_x.min(edge);
                    max_x = max_x.max(edge);
                }
            }
            pen += f64::from(item.width) / 1000.0 * size;
            // Word spacing on a single-byte space only, as everywhere else.
            if item.code.0 == 0x20 && item.cid.is_none() {
                pen += f64::from(state.text.word_space);
            }
            pen += f64::from(state.text.char_space);
        }
        // After the glyphs, as in `place_glyphs` — the adjustment follows its
        // string.
        pen -= f64::from(segment.kerning) / 1000.0 * size;
    }
    if min_x > max_x || min_y > max_y {
        return None;
    }
    // The axis still in 1000/em units takes the font size; the other already
    // has it, because the pen carried it.
    let (min_x, max_x, min_y, max_y) = if vertical {
        (
            start.x + min_x * size / 1000.0,
            start.x + max_x * size / 1000.0,
            min_y,
            max_y,
        )
    } else {
        (
            min_x,
            max_x,
            start.y + min_y * size / 1000.0,
            start.y + max_y * size / 1000.0,
        )
    };
    let rect = kurbo::Rect::new(min_x, min_y, max_x, max_y);
    let corners = [
        (rect.x0, rect.y0),
        (rect.x1, rect.y0),
        (rect.x1, rect.y1),
        (rect.x0, rect.y1),
    ]
    .map(|(x, y)| object.matrix * kurbo::Point::new(x, y));
    let mut out = kurbo::Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for p in corners {
        out.x0 = out.x0.min(p.x);
        out.y0 = out.y0.min(p.y);
        out.x1 = out.x1.max(p.x);
        out.y1 = out.y1.max(p.y);
    }
    Some(out)
}

#[cfg(test)]
mod tests {
    // The snapping tests assert *exact* placements — that is what the rule
    // being pinned is — and index fixtures whose length the fixture fixes.
    #![allow(
        clippy::float_cmp,
        clippy::indexing_slicing,
        reason = "a snapped origin is an exact value, and a tolerance here \
                  would let a wrong rounding pass"
    )]

    use kurbo::Point;

    use super::*;

    #[test]
    fn invisible_paints_nothing() {
        assert_eq!(paint_kinds(TextRenderMode::Invisible, true), None);
    }

    /// A run showing `text` at page-space `(x, y)`, in Helvetica at 20 pt.
    fn run(text: &[u8], x: f64, y: f64) -> TextObject {
        let font = std::sync::Arc::new(pdfrum_font::Font::load_standard(
            pdfrum_font::StandardFont::Helvetica,
            &pdfrum_font::FontCache::default(),
        ));
        TextObject {
            segments: Box::new([pdfrum_page::TextSegment {
                codes: text.to_vec().into_boxed_slice(),
                kerning: 0.0,
            }]),
            position: Point::new(x, y),
            matrix: Affine::IDENTITY,
            font: Some((font, 20.0)),
            font_source: None,
            render_mode: TextRenderMode::Fill,
            type3_metrics: std::collections::BTreeMap::new(),
        }
    }

    /// The rectangle `DrawTextPathWithPattern` fills is the run's own extent:
    /// it starts at the run's origin, grows rightward with the advances, and
    /// its height is the glyph boxes rather than the font size.
    #[test]
    fn a_runs_rect_starts_at_its_origin_and_spans_its_advances() {
        let state = pdfrum_page::GraphicsState::default();
        let short = run_rect(&run(b"H", 100.0, 50.0), &state).expect("a box");
        let long = run_rect(&run(b"HHHH", 100.0, 50.0), &state).expect("a box");
        assert!(
            (short.x0 - 100.0).abs() < 2.0,
            "starts at the origin: {short:?}"
        );
        assert!(short.y0 > 49.0 && short.y0 < 51.0, "sits on the baseline");
        assert!(short.y1 > 60.0, "rises to the cap height: {short:?}");
        assert!(
            long.width() > short.width() * 3.0,
            "four glyphs span four advances: {long:?} vs {short:?}"
        );
        // The origin moves the box and nothing else.
        let moved = run_rect(&run(b"H", 200.0, 50.0), &state).expect("a box");
        assert!((moved.x0 - short.x0 - 100.0).abs() < 1e-6);
        assert!((moved.width() - short.width()).abs() < 1e-6);
    }

    /// Character spacing is graphics state, and it widens the box the same way
    /// it widens the run — `CalcPositionDataInternal` adds it to the pen.
    #[test]
    fn character_spacing_widens_the_rect() {
        let plain = pdfrum_page::GraphicsState::default();
        let spaced = pdfrum_page::GraphicsState {
            text: pdfrum_page::TextState {
                char_space: 10.0,
                ..pdfrum_page::TextState::default()
            },
            ..pdfrum_page::GraphicsState::default()
        };
        let a = run_rect(&run(b"HH", 0.0, 0.0), &plain).expect("a box");
        let b = run_rect(&run(b"HH", 0.0, 0.0), &spaced).expect("a box");
        assert!(b.width() > a.width() + 9.0, "{a:?} vs {b:?}");
    }

    #[test]
    fn a_run_with_no_characters_has_no_rect() {
        let state = pdfrum_page::GraphicsState::default();
        assert!(run_rect(&run(b"", 0.0, 0.0), &state).is_none());
    }

    #[test]
    fn clip_only_mode_paints_nothing_but_clips() {
        let k = paint_kinds(TextRenderMode::Clip, true).expect("Tr 7 is not invisible");
        assert!(!k.fill && !k.stroke && k.clip);
    }

    #[test]
    fn stroke_without_a_face_falls_back_to_fill() {
        let with = paint_kinds(TextRenderMode::Stroke, true).expect("some");
        assert!(with.stroke && !with.fill);
        let without = paint_kinds(TextRenderMode::Stroke, false).expect("some");
        assert!(
            without.fill && !without.stroke,
            "no outlines means fill instead"
        );
    }

    #[test]
    fn fill_stroke_keeps_the_fill_when_there_is_no_face() {
        let k = paint_kinds(TextRenderMode::FillStroke, false).expect("some");
        assert!(k.fill);
        assert!(!k.stroke, "only the stroke half is dropped");
    }

    #[test]
    fn every_clip_mode_contributes_to_the_clip() {
        for mode in [
            TextRenderMode::FillClip,
            TextRenderMode::StrokeClip,
            TextRenderMode::FillStrokeClip,
            TextRenderMode::Clip,
        ] {
            assert!(paint_kinds(mode, true).is_some_and(|k| k.clip), "{mode:?}");
        }
        for mode in [
            TextRenderMode::Fill,
            TextRenderMode::Stroke,
            TextRenderMode::FillStroke,
        ] {
            assert!(paint_kinds(mode, true).is_some_and(|k| !k.clip), "{mode:?}");
        }
    }

    #[test]
    fn glyph_matrix_scales_by_size_over_1000_without_flipping() {
        // Outlines are 1000 units per em, so a full em at size 1000 is a
        // whole unit of text space, unmirrored: the single y flip lives in
        // the page matrix, where every object kind sees it. Flipping here
        // too would mirror each letter about its own baseline.
        let m = glyph_matrix(1000.0, Point::ZERO, Affine::IDENTITY);
        let p = m * Point::new(0.0, 1000.0);
        assert!(
            (p.y - 1000.0).abs() < 1e-9,
            "y is not flipped here: {}",
            p.y
        );
        assert!((p.x - 0.0).abs() < 1e-9);

        let half = glyph_matrix(500.0, Point::ZERO, Affine::IDENTITY);
        let p = half * Point::new(1000.0, 0.0);
        assert!((p.x - 500.0).abs() < 1e-9, "half size halves the advance");
    }

    #[test]
    fn the_pen_translates_in_text_space_before_the_size_scale() {
        // A pen at x = 40 with a size of 12 puts the glyph's own origin at
        // 40 text units, not at 40 * 12 / 1000.
        let m = glyph_matrix(12.0, Point::new(40.0, 0.0), Affine::IDENTITY);
        let origin = m * Point::ZERO;
        assert!((origin.x - 40.0).abs() < 1e-9, "origin at {}", origin.x);
    }

    #[test]
    fn stroke_ctm_split_is_identity_at_unit_scale() {
        let text = Affine::translate((3.0, 4.0));
        let device = Affine::scale(2.0);
        let (t, d) = stroke_ctm_split(text, device, [1.0, 0.0, 0.0, 1.0]);
        assert_eq!(t, text);
        assert_eq!(d, device);
    }

    #[test]
    fn stroke_ctm_split_moves_the_scale_into_the_device_matrix() {
        let text = Affine::IDENTITY;
        let device = Affine::IDENTITY;
        let (t, d) = stroke_ctm_split(text, device, [2.0, 0.0, 0.0, 3.0]);
        // The product is unchanged — the split only moves where the scale
        // lives, so the glyph lands in the same place but the stroke width is
        // measured in text space.
        // kurbo composition is `device * text`, matching the walk's
        // `to_device * object.matrix`.
        let composed = d * t;
        for (a, b) in composed
            .as_coeffs()
            .iter()
            .zip(Affine::IDENTITY.as_coeffs().iter())
        {
            assert!((a - b).abs() < 1e-9, "{composed:?}");
        }
        assert!(
            (d.as_coeffs()[0] - 2.0).abs() < 1e-9,
            "the x scale moved to the device matrix"
        );
    }

    #[test]
    fn stroke_ctm_split_preserves_the_composed_transform_under_a_page_flip() {
        // A y-flip with a translation, the shape of `page_matrix`. The
        // row-vector spelling of the split would scale that translation by
        // the CTM and land the glyphs off the page.
        let text = Affine::translate((10.0, 20.0));
        let device = Affine::translate((0.0, 100.0)) * Affine::scale_non_uniform(1.0, -1.0);
        let (t, d) = stroke_ctm_split(text, device, [2.0, 0.0, 0.0, 3.0]);
        let original = device * text;
        let split = d * t;
        for (a, b) in original.as_coeffs().iter().zip(split.as_coeffs().iter()) {
            assert!(
                (a - b).abs() < 1e-9,
                "original {original:?} vs split {split:?}"
            );
        }
    }

    #[test]
    fn a_stroked_run_under_a_scaled_ctm_measures_width_in_user_space() {
        // object.matrix already carries the CTM, as `pdfrum-page` writes it.
        // After the split the glyph matrix must not, or `draw_path` would
        // scale the line width by font_size/1000 as well as by the CTM.
        let mut object = run(b"I", 10.0, 10.0);
        object.render_mode = TextRenderMode::Stroke;
        object.matrix = Affine::scale_non_uniform(2.0, 3.0);
        let state = pdfrum_page::GraphicsState {
            text: pdfrum_page::TextState {
                stroke_ctm: [2.0, 0.0, 0.0, 3.0],
                render_mode: TextRenderMode::Stroke,
                ..pdfrum_page::TextState::default()
            },
            ..pdfrum_page::GraphicsState::default()
        };
        let kinds = paint_kinds(TextRenderMode::Stroke, true).expect("stroke paints");
        let mut cache = pdfrum_font::GlyphCache::default();
        let glyphs = place_glyphs(
            &object,
            &state,
            &mut cache,
            Affine::IDENTITY,
            &RenderOptions::default(),
            kinds,
        );
        assert!(!glyphs.is_empty(), "Helvetica has an I");
        let [a, ..] = glyphs[0].matrix.as_coeffs();
        // Font size 20, 1000-unit outlines: the em scale is 0.02, not 0.04.
        assert!(
            (a.abs() - 20.0 / 1000.0).abs() < 1e-6,
            "the CTM scale is not in the glyph matrix: a={a}"
        );
        let (_, device_m) = stroke_text_matrices(&object, &state, Affine::IDENTITY);
        let width = crate::stroke::device_width(1.0, crate::stroke::split_for_stroke(device_m));
        assert!(
            (width - 2.0).abs() < 1e-6,
            "1 user-space unit scaled by the CTM x, got {width}"
        );
    }

    #[test]
    fn the_conformance_snap_quantises_x_to_thirds_and_y_to_whole_pixels() {
        // The mode resolves to `kLcd` (bClearType only decides `normalize`),
        // where `origin_.x = floor(x)` and the blit adds
        // `(int)(x * 3) % 3` thirds back (`cfx_renderdevice.cpp:1254, 1352`).
        // So x lands on a third and y on a whole pixel.
        let near = |p: Point, x: f64, y: f64| {
            assert!(
                (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9,
                "{p:?} is not ({x}, {y})"
            );
        };
        near(
            snap_origin(Point::new(10.9, 100.4), TextAa::Grayscale),
            10.0 + 2.0 / 3.0,
            100.0,
        );
        near(
            snap_origin(Point::new(10.1, 100.6), TextAa::Grayscale),
            10.0,
            101.0,
        );
        near(
            snap_origin(Point::new(10.5, 0.0), TextAa::Grayscale),
            10.0 + 1.0 / 3.0,
            0.0,
        );
        // A third is not a whole pixel: the x quantum is small enough that a
        // 9-pixel glyph's stems barely move, which is why the whole-pixel
        // read of this rule cost 58 files when it was tried.
        near(
            snap_origin(Point::new(10.99, 0.0), TextAa::Grayscale),
            10.0 + 2.0 / 3.0,
            0.0,
        );
    }

    #[test]
    fn only_y_is_quantised_to_a_whole_pixel_under_lcd() {
        // The asymmetry is the placement divergence: a baseline at 100.4 is
        // drawn at 100, which moves every horizontal stem edge, while an x of
        // 10.4 moves by at most a third.
        for tenth in 0..10 {
            let x = 10.0 + f64::from(tenth) / 10.0;
            let p = snap_origin(Point::new(x, 100.4), TextAa::Grayscale);
            assert!((p.x - x).abs() <= 1.0 / 3.0, "x moved {} at {x}", p.x - x);
            assert_eq!(p.y, 100.0);
        }
    }

    #[test]
    fn no_smoothtext_rounds_x_instead_of_flooring_it() {
        // `--no-smoothtext` leaves `anti_alias` at its `kMono` initialiser,
        // so `anti_alias_is_lcd` is false and x takes `FXSYS_roundf` too.
        assert_eq!(
            snap_origin(Point::new(10.9, 5.0), TextAa::None),
            Point::new(11.0, 5.0)
        );
        assert_eq!(
            snap_origin(Point::new(10.1, 5.0), TextAa::None),
            Point::new(10.0, 5.0)
        );
        // Half away from zero, which is C `round` and not `round_ties_even`.
        assert_eq!(
            snap_origin(Point::new(10.5, -2.5), TextAa::None),
            Point::new(11.0, -3.0)
        );
    }

    #[test]
    fn y_always_rounds_whatever_the_mode_is() {
        for aa in [TextAa::Grayscale, TextAa::None] {
            assert_eq!(snap_origin(Point::new(0.0, 7.6), aa).y, 8.0, "{aa:?}");
            assert_eq!(snap_origin(Point::new(0.0, 7.4), aa).y, 7.0, "{aa:?}");
        }
    }

    #[test]
    fn the_bitmap_path_is_a_small_text_rule() {
        // char2device = text2device * Scale(size, -size), so |a| + |b| is the
        // em's device extent. 12 pt at unit scale is well under 50.
        assert!(takes_bitmap_path(12.0, Affine::IDENTITY));
        assert!(
            takes_bitmap_path(50.0, Affine::IDENTITY),
            "the `> 50` is strict"
        );
        assert!(!takes_bitmap_path(51.0, Affine::IDENTITY));
        // A device scale counts: 12 pt at 5x is 60 device units.
        assert!(!takes_bitmap_path(12.0, Affine::scale(5.0)));
        // And so does a rotation, through `b`.
        assert!(!takes_bitmap_path(
            40.0,
            Affine::rotate(std::f64::consts::FRAC_PI_4)
        ));
    }

    #[test]
    fn a_stroked_run_does_not_snap_but_a_clipping_one_does() {
        let opts = RenderOptions::default();
        let fill = TextPaintKinds {
            fill: true,
            stroke: false,
            clip: false,
        };
        assert!(snaps_origins(&opts, fill, 12.0, Affine::IDENTITY));
        // `if (is_clip || is_stroke)` sends the run to `DrawTextPath`, which
        // places every glyph at its true fractional origin.
        assert!(!snaps_origins(
            &opts,
            TextPaintKinds {
                stroke: true,
                ..fill
            },
            12.0,
            Affine::IDENTITY
        ));
        // But `is_clip` is the *caller*, not the render mode: the painting
        // pass always passes `clipping_path = nullptr`
        // (`cpdf_renderstatus.cpp:312`), so a `Tr 4` run still snaps when it
        // paints. Reading `is_clip` as "the mode has a clip bit" costs six
        // files, which is how this was found.
        assert!(snaps_origins(
            &opts,
            TextPaintKinds { clip: true, ..fill },
            12.0,
            Affine::IDENTITY
        ));
        // And large text never snaps, whatever it paints.
        assert!(!snaps_origins(&opts, fill, 80.0, Affine::IDENTITY));
    }

    #[test]
    fn the_knob_turns_the_snap_off() {
        let fill = TextPaintKinds {
            fill: true,
            stroke: false,
            clip: false,
        };
        let subpixel = RenderOptions {
            subpixel_text_positioning: true,
            ..RenderOptions::default()
        };
        assert!(!snaps_origins(&subpixel, fill, 12.0, Affine::IDENTITY));
        assert!(snaps_origins(
            &RenderOptions::default(),
            fill,
            12.0,
            Affine::IDENTITY
        ));
    }

    #[test]
    fn adjust_glyph_space_never_moves_the_first_or_last_glyph() {
        // The C++ loop is `for (i = size - 1; i > 1; --i)` editing `[i - 1]`,
        // so index 0 and index size-1 are read-only.
        let device: Vec<Point> = (0..4)
            .map(|i| Point::new(f64::from(i) * 9.9, 0.0))
            .collect();
        let mut origins: Vec<Point> = device
            .iter()
            .map(|p| snap_origin(*p, TextAa::None))
            .collect();
        let (first, last) = (origins[0], origins[3]);
        adjust_glyph_space(&mut origins, &device);
        assert_eq!(origins[0], first);
        assert_eq!(origins[3], last);
    }

    #[test]
    fn adjust_glyph_space_closes_a_gap_the_snap_stretched() {
        // Spacing of 10.6 px snaps to 11, 11, 11 while the true gaps are
        // 10.6 — an error of 0.6 > 0.5, so the middle origins pull back one
        // pixel each. Round to 0, 11, 21, 32; the walk fixes index 2 then 1.
        let device: Vec<Point> = (0..4)
            .map(|i| Point::new(f64::from(i) * 10.6, 0.0))
            .collect();
        let mut origins: Vec<Point> = device
            .iter()
            .map(|p| snap_origin(*p, TextAa::None))
            .collect();
        assert_eq!(
            origins.iter().map(|p| p.x).collect::<Vec<_>>(),
            vec![0.0, 11.0, 21.0, 32.0]
        );
        adjust_glyph_space(&mut origins, &device);
        // Gap 3->2 is 32-21 = 11 against 10.6: error 0.0, left alone. Gap
        // 2->1 is 21-11 = 10 against 10.6: error 0.6, so index 1 pulls to 10.
        assert_eq!(
            origins.iter().map(|p| p.x).collect::<Vec<_>>(),
            vec![0.0, 10.0, 21.0, 32.0]
        );
    }

    #[test]
    fn adjust_glyph_space_declines_a_run_that_is_not_axis_aligned() {
        let device = vec![
            Point::new(0.0, 0.0),
            Point::new(10.0, 5.0),
            Point::new(20.0, 10.0),
        ];
        let mut origins = device.clone();
        adjust_glyph_space(&mut origins, &device);
        assert_eq!(origins, device, "a diagonal run is left entirely alone");
    }

    #[test]
    fn the_japan1_transform_shifts_the_origin_without_touching_the_advance() {
        // CID 7888 — U+3002 reached through `/90pv-RKSJ-H`, the row
        // `bug_1402.pdf` lands on. `{7888, 127, 0, 0, 127, 79, 94}` unpacks to
        // the identity matrix and a translation of (79/127, 94/127) em, which
        // is why that file's glyphs had the right shape and the wrong place.
        let t = pdfrum_font::CidTransform {
            cid: 7888,
            a: 127,
            b: 0,
            c: 0,
            d: 127,
            e: 79,
            f: 94,
        };
        let unpack = |b: u8| f64::from(pdfrum_font::cid_transform_to_float(b));
        let size = 36.0_f64;
        let dx = unpack(t.e) * size;
        let dy = unpack(t.f) * size;
        // Roughly (+22.4, +26.7) in text space, which after the page matrix's
        // single y flip is the (-22, +27) device displacement the file showed
        // when the transform was not applied at all.
        assert!((dx - 22.394).abs() < 0.01, "e * font_size is {dx}");
        assert!((dy - 26.646).abs() < 0.01, "f * font_size is {dy}");

        // Byte 127 is exactly 1 and byte 0 exactly 0, so this row's matrix is
        // the identity: the glyph is moved, never reshaped.
        assert_eq!(unpack(t.a), 1.0);
        assert_eq!(unpack(t.b), 0.0);
        assert_eq!(unpack(t.c), 0.0);
        assert_eq!(unpack(t.d), 1.0);

        // And the shift reaches the *matrix* rather than the pen: two glyphs
        // one advance apart stay one advance apart.
        let origin = Vec2::new(dx, dy);
        let a = glyph_matrix(36.0, Point::new(0.0, 0.0) + origin, Affine::IDENTITY);
        let b = glyph_matrix(36.0, Point::new(50.0, 0.0) + origin, Affine::IDENTITY);
        let step = b.translation() - a.translation();
        assert!(
            (step.x - 50.0).abs() < 1e-9 && step.y.abs() < 1e-9,
            "the advance is unchanged by the adjustment, got {step:?}"
        );
    }

    #[test]
    fn a_degenerate_ctm_leaves_the_matrices_alone() {
        let (t, d) = stroke_ctm_split(Affine::IDENTITY, Affine::IDENTITY, [0.0, 0.0, 0.0, 0.0]);
        assert_eq!(t, Affine::IDENTITY);
        assert_eq!(d, Affine::IDENTITY);
    }

    // -----------------------------------------------------------------------
    // The glyph-spacing correction.
    // -----------------------------------------------------------------------

    /// A document advance wider than the face's centres the glyph in it, by
    /// shifting the origin half the excess and leaving the outline alone.
    #[test]
    fn a_wider_declared_width_centres_the_glyph_without_stretching_it() {
        // 700 declared against a 500-unit glyph, at 40 pt: half of 200
        // thousandths of an em is 100, which is 4 pt.
        let a = glyph_spacing_adjust(700, 500, 40.0);
        assert_eq!(a.origin, Vec2::new(4.0, 0.0));
        assert_eq!(a.matrix, Affine::IDENTITY);
    }

    /// The one-unit slack: a single 1000/em unit of disagreement is rounding,
    /// not design, and must not move the glyph.
    #[test]
    fn a_declared_width_one_unit_wider_is_left_alone() {
        assert_eq!(glyph_spacing_adjust(501, 500, 40.0), GlyphAdjust::NONE);
        assert_eq!(glyph_spacing_adjust(500, 500, 40.0), GlyphAdjust::NONE);
        // Two units over is past the slack.
        assert_ne!(glyph_spacing_adjust(502, 500, 40.0), GlyphAdjust::NONE);
    }

    /// A document advance narrower than the face's squeezes the outline to
    /// fit and leaves the origin where it was.
    #[test]
    fn a_narrower_declared_width_squeezes_the_glyph_without_moving_it() {
        // `bug_601362.pdf`'s own numbers: /MissingWidth 506 against an 'A'
        // the face draws 667 units wide.
        let a = glyph_spacing_adjust(506, 667, 40.0);
        assert_eq!(a.origin, Vec2::ZERO);
        let ratio = 506.0 / 667.0;
        assert_eq!(a.matrix, Affine::scale_non_uniform(ratio, 1.0));
        // Horizontal only: a point on the baseline moves in, one above it
        // keeps its height.
        let p = a.matrix * Point::new(667.0, 700.0);
        assert!((p.x - 506.0).abs() < 1e-9, "x is {}", p.x);
        assert_eq!(p.y, 700.0);
        // And the glyph's own origin is a fixed point, so the squeeze pulls
        // the outline back toward the pen rather than off it.
        assert_eq!(a.matrix * Point::ZERO, Point::ZERO);
    }

    /// A face that cannot report an advance disables both branches, and a
    /// zero declared width disables only the squeeze — scaling to zero would
    /// erase the glyph.
    #[test]
    fn a_zero_width_on_either_side_declines_the_correction() {
        assert_eq!(glyph_spacing_adjust(700, 0, 40.0), GlyphAdjust::NONE);
        assert_eq!(glyph_spacing_adjust(0, 0, 40.0), GlyphAdjust::NONE);
        assert_eq!(glyph_spacing_adjust(0, 500, 40.0), GlyphAdjust::NONE);
    }

    /// Neither branch touches the advance: two glyphs one advance apart stay
    /// one advance apart however far the correction moves or squeezes them.
    #[test]
    fn neither_branch_disturbs_the_pen() {
        for (declared, face) in [(700, 500), (506, 667)] {
            let a = glyph_spacing_adjust(declared, face, 40.0);
            let first = glyph_matrix(40.0, Point::ZERO + a.origin, Affine::IDENTITY) * a.matrix;
            let second =
                glyph_matrix(40.0, Point::new(20.0, 0.0) + a.origin, Affine::IDENTITY) * a.matrix;
            let step = second.translation() - first.translation();
            assert!(
                (step.x - 20.0).abs() < 1e-9 && step.y.abs() < 1e-9,
                "declared {declared} face {face} moved the pen by {step:?}"
            );
        }
    }

    /// Where a Japan1 transform and a squeeze both fire, the factor reaches
    /// the transform's `a` and `b` columns alone — which is what right-
    /// multiplying by the horizontal scale does.
    #[test]
    fn the_squeeze_composes_into_a_japan1_transform_s_first_column() {
        let japan1 = Affine::new([0.5, 0.25, -0.125, 0.75, 0.0, 0.0]);
        let squeeze = glyph_spacing_adjust(500, 1000, 40.0).matrix;
        let [a, b, c, d, ..] = (japan1 * squeeze).as_coeffs();
        assert_eq!([a, b, c, d], [0.25, 0.125, -0.125, 0.75]);
    }

    /// A standard-14 font is the gate's fourth refusal, and every run in
    /// these tests uses one — so the correction never reaches them.
    #[test]
    fn a_standard_font_run_declines_the_correction() {
        let object = run(b"Hi", 0.0, 0.0);
        let (font, _) = object.font.as_ref().expect("the run has a font");
        assert!(!font.applies_glyph_spacing());
    }
}