kindling-mobi 0.14.5

Kindle MOBI/AZW3 builder for dictionaries, books, and comics. Drop-in kindlegen replacement.
Documentation
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
// Amazon Kindle Publishing Guidelines rule catalog.

use crate::profile::{Profile, ALL_PROFILES};

/// Version of the Amazon Kindle Publishing Guidelines PDF these rules target.
pub const KPG_VERSION: &str = "2026.1";

/// URL where the KPG PDF is published.
#[allow(dead_code)]
pub const KPG_PDF_URL: &str =
    "https://kindlegen.s3.amazonaws.com/AmazonKindlePublishingGuidelines.pdf";

/// Severity of a validation finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
    /// Finding is silenced; retained for round-trip compatibility with rule
    /// catalogs that assign every id a severity.
    #[allow(dead_code)]
    Suppressed,
    /// Usage-level note: stylistic, rarely surfaced.
    #[allow(dead_code)]
    Usage,
    /// Informational note. Does not affect pass/fail.
    Info,
    /// Something that should probably be fixed but is not a hard failure.
    Warning,
    /// Something that will almost certainly fail conversion or render badly.
    Error,
    /// Fatal: the manuscript cannot even be scanned further.
    #[allow(dead_code)]
    Fatal,
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Severity::Suppressed => write!(f, "suppressed"),
            Severity::Usage => write!(f, "usage"),
            Severity::Info => write!(f, "info"),
            Severity::Warning => write!(f, "warning"),
            Severity::Error => write!(f, "error"),
            Severity::Fatal => write!(f, "fatal"),
        }
    }
}

/// A single validation rule from the Kindle Publishing Guidelines.
#[derive(Debug, Clone, Copy)]
pub struct Rule {
    pub id: &'static str,
    pub section: &'static str,
    pub level: Severity,
    #[allow(dead_code)]
    pub title: &'static str,
    pub pdf_page: u32,
    pub description: &'static str,
    /// Bitmask of profiles this rule fires on; defaults to every profile.
    pub profile_mask: u8,
}

impl Rule {
    /// True if this rule should run against `profile`.
    #[allow(dead_code)]
    pub fn applies_to(&self, profile: Profile) -> bool {
        (self.profile_mask & profile.as_bit()) != 0
    }
}

/// Complete rule catalog. Order is not significant; checks look up rules by id.
pub const RULES: &[Rule] = &[
    // ---- Section 4: Cover Image Guidelines ----
    Rule {
        id: "R4.1.1",
        section: "4.1",
        level: Severity::Info,
        title: "Marketing cover uploaded separately",
        pdf_page: 15,
        description: "Marketing cover image is uploaded separately to KDP and cannot be \
                      validated from the manuscript. Ensure you upload a 2560x1600 JPEG \
                      per Kindle Publishing Guidelines.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R4.2.1",
        section: "4.2",
        level: Severity::Error,
        title: "Cover image required",
        pdf_page: 16,
        description: "No internal content cover image declared. Add either \
                      <item properties=\"coverimage\" ...> (Method 1, preferred) or \
                      <meta name=\"cover\" content=\"<id>\"/> (Method 2) to the OPF.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R4.2.2",
        section: "4.2",
        level: Severity::Error,
        title: "Cover image file missing",
        pdf_page: 16,
        description: "Cover image declared in OPF but the file does not exist on disk.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R4.2.3",
        section: "4.2",
        level: Severity::Warning,
        title: "Cover image too small",
        pdf_page: 15,
        description: "Cover image shortest side is below 500 px. Kindle will not display \
                      covers under 500 px on the shortest side.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R4.2.4",
        section: "4.2",
        level: Severity::Error,
        title: "No HTML cover page in spine",
        pdf_page: 16,
        description: "Do not add an HTML cover page to the content in addition to the \
                      cover image. This may cause the cover to appear twice or fail \
                      conversion. Remove the HTML page from the spine.",
        profile_mask: ALL_PROFILES,
    },

    // ---- Section 5: Navigation Guidelines ----
    Rule {
        id: "R5.1",
        section: "5",
        level: Severity::Warning,
        title: "TOC recommended for books over 20 pages",
        pdf_page: 17,
        description: "KPG strongly recommends a logical TOC for books longer than 20 pages.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.2.1",
        section: "5.2",
        level: Severity::Warning,
        title: "NCX required",
        pdf_page: 19,
        description: "No NCX file found in manifest (media-type application/x-dtbncx+xml). \
                      Amazon requires a logical TOC via an NCX or a toc nav element for all \
                      Kindle books.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.2.2",
        section: "5.2",
        level: Severity::Warning,
        title: "NCX must be referenced from spine",
        pdf_page: 19,
        description: "NCX is declared in manifest but the <spine> element has no \
                      toc=\"<id>\" attribute. KPG 5.2 requires referencing the NCX from \
                      the spine.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.2.3",
        section: "5.2",
        level: Severity::Error,
        title: "NCX references a file not in the manifest",
        pdf_page: 19,
        description: "NCX <content src=\"...\"/> points at a file that is not declared in \
                      the OPF manifest. KDP will reject the upload with \"broken link in \
                      your Table of Contents\". Either add the file to the manifest or \
                      remove the navPoint.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.3.1",
        section: "5.3",
        level: Severity::Error,
        title: "Guide references a file not in the manifest",
        pdf_page: 22,
        description: "OPF <guide><reference href=\"...\"/> points at a file that is not \
                      declared in the manifest. Either add the file or remove the \
                      reference.",
        profile_mask: ALL_PROFILES,
    },

    // ---- Section 6: HTML and CSS Guidelines ----
    Rule {
        id: "R6.1",
        section: "6.1",
        level: Severity::Warning,
        title: "Well-formed XHTML required",
        pdf_page: 22,
        description: "Content is not well-formed XHTML. Kindle requires well-formed HTML \
                      documents for reliable conversion.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.2",
        section: "6.2",
        level: Severity::Warning,
        title: "Avoid negative CSS values",
        pdf_page: 23,
        description: "Negative CSS value for margin/padding/line-height. Positioning with \
                      negative values can cause content to be cut off at screen edges.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.3",
        section: "6.3",
        level: Severity::Error,
        title: "No scripting",
        pdf_page: 23,
        description: "<script> tag found. Scripting is not supported; scripts will be \
                      stripped during conversion and any functionality relying on them \
                      will break.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.4",
        section: "6.4",
        level: Severity::Error,
        title: "No nested <p> tags",
        pdf_page: 23,
        description: "Nested <p> tags found. Files with nested <p> tags do not convert \
                      properly.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.5",
        section: "6.5",
        level: Severity::Error,
        title: "File reference case must match",
        pdf_page: 23,
        description: "File reference case does not match the actual filename on disk. \
                      Case-sensitive filesystems will fail to resolve the reference.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.6",
        section: "6",
        level: Severity::Error,
        title: "XML 1.0 required",
        pdf_page: 22,
        description: "XHTML file declares XML 1.1 in its prolog. kindlegen only supports \
                      XML 1.0 and will reject XML 1.1 files at conversion time. Change \
                      the declaration to version=\"1.0\".",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.7",
        section: "6",
        level: Severity::Error,
        title: "No external entities",
        pdf_page: 22,
        description: "DOCTYPE declares an external ENTITY (SYSTEM or PUBLIC). kindlegen \
                      crashes on external entity resolution and this is also an XXE \
                      security risk. Remove the external entity declaration.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.8",
        section: "6",
        level: Severity::Warning,
        title: "Irregular DOCTYPE",
        pdf_page: 22,
        description: "DOCTYPE is neither HTML5 (<!DOCTYPE html>) nor a canonical XHTML \
                      1.0/1.1 form. Unusual DOCTYPEs trigger quirks mode in the \
                      converter and break some fragments.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.9",
        section: "6",
        level: Severity::Error,
        title: "EPUB namespace wrong",
        pdf_page: 22,
        description: "xmlns:epub points at a URI other than \
                      http://www.idpf.org/2007/ops. This is the Vader Down bug class: \
                      kindlegen silently drops the epub:type attribute so structural \
                      nav entries point at blank pages.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.10",
        section: "6",
        level: Severity::Warning,
        title: "Undeclared entity",
        pdf_page: 22,
        description: "XHTML references a named entity that is not in the XML 1.0 \
                      predefined set or the common HTML5 whitelist. Undeclared \
                      entities render as literal text on Kindle.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.11",
        section: "6",
        level: Severity::Error,
        title: "HTML must be UTF-8",
        pdf_page: 23,
        description: "XHTML file begins with a UTF-16 BOM or declares a non-UTF-8 \
                      encoding. kindlegen only handles UTF-8; other encodings produce \
                      garbled text or an outright rejection.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.12",
        section: "6",
        level: Severity::Error,
        title: "CSS must be UTF-8",
        pdf_page: 24,
        description: "CSS file begins with a UTF-16 BOM or declares a non-UTF-8 \
                      @charset. Non-UTF-8 stylesheets are silently dropped wholesale \
                      by kindlegen.",
        profile_mask: ALL_PROFILES,
    },

    // ---- Section 10: Text-Heavy Reflowable Books ----
    Rule {
        id: "R10.3.1",
        section: "10.3.1",
        level: Severity::Warning,
        title: "Heading alignment should use default",
        pdf_page: 29,
        description: "Heading has an explicit text-align. KPG 10.3.1 recommends letting \
                      headings use the default alignment.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.1",
        section: "10.4.1",
        level: Severity::Error,
        title: "Use supported image format",
        pdf_page: 38,
        description: "Image is not in a supported format (JPEG, PNG, GIF, SVG).",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.2a",
        section: "10.4.2",
        level: Severity::Warning,
        title: "Image file too large",
        pdf_page: 38,
        description: "Image file exceeds 127 KB. Large image files increase download size \
                      and may fail conversion.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.2b",
        section: "10.4.2",
        level: Severity::Warning,
        title: "Image dimensions too large",
        pdf_page: 38,
        description: "Image exceeds 5 megapixels. Large images waste storage and may fail \
                      conversion.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.5.1",
        section: "10.5.1",
        level: Severity::Warning,
        title: "Avoid large tables",
        pdf_page: 43,
        description: "Table has more than 50 rows. KPG 10.5.1 recommends keeping tables \
                      below 100 rows and 10 columns; large tables may render poorly.",
        profile_mask: ALL_PROFILES,
    },

    // ---- Section 17/18.1: Supported Tags ----
    Rule {
        id: "R17.1",
        section: "17",
        level: Severity::Error,
        title: "Unsupported HTML tag",
        pdf_page: 22,
        description: "Unsupported HTML tag found. KPG 6.1 lists forms, frames, and \
                      JavaScript as unsupported; section 18.1 lists supported tags.",
        profile_mask: ALL_PROFILES,
    },

    // ---- Section 15: Dictionaries (Amazon-legacy KDP format) ----
    Rule {
        id: "R15.1",
        section: "15",
        level: Severity::Error,
        title: "DictionaryInLanguage required",
        pdf_page: 60,
        description: "Dictionary OPF must declare <x-metadata><DictionaryInLanguage> with a \
                      BCP47 language code. Without it, KDP's dict compiler will not enable \
                      lookup mode and the book will appear as a regular book.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.2",
        section: "15",
        level: Severity::Error,
        title: "DictionaryOutLanguage required",
        pdf_page: 60,
        description: "Dictionary OPF must declare <x-metadata><DictionaryOutLanguage> with a \
                      BCP47 language code. Without it, KDP's dict compiler will not enable \
                      lookup mode and the book will appear as a regular book.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.3",
        section: "15",
        level: Severity::Error,
        title: "DefaultLookupIndex must match an idx:entry name",
        pdf_page: 60,
        description: "The <x-metadata><DefaultLookupIndex> value must match at least one \
                      <idx:entry name=\"...\"> in the spine content. A mismatch causes Kindle \
                      to show 'no entries found' on every lookup.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.4",
        section: "15",
        level: Severity::Error,
        title: "At least one idx:entry required",
        pdf_page: 60,
        description: "Dictionary builds must contain at least one <idx:entry> element in spine \
                      content. If zero idx:entry elements are found, the file is not actually \
                      a dictionary.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.5",
        section: "15",
        level: Severity::Warning,
        title: "Spine content should be wrapped in <mbp:frameset>",
        pdf_page: 60,
        description: "Amazon's dictionary HTML parser expects entry content to be wrapped in \
                      <mbp:frameset>. Omitting it works sometimes and fails silently other \
                      times.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.6",
        section: "15",
        level: Severity::Error,
        title: "idx:orth must have a non-empty value attribute",
        pdf_page: 60,
        description: "Every <idx:orth> element must have a non-empty value=\"...\" attribute. \
                      An empty orth leaves a blank lookup entry and crashes lookup on \
                      Paperwhite firmware.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.7",
        section: "15",
        level: Severity::Warning,
        title: "OPF <guide> should contain reference type=\"index\"",
        pdf_page: 60,
        description: "OPF <guide> should include a <reference type=\"index\" ...> entry. Older \
                      Kindle firmware versions use this to locate the dictionary's entry \
                      section.",
        profile_mask: Profile::Dict.as_bit(),
    },

    // ---- Section 15: Dictionaries (epubcheck EPUB 3 DICT rules, gated on EPUB 3) ----
    Rule {
        id: "R15.e1",
        section: "15",
        level: Severity::Error,
        title: "EPUB 3 dict requires content with epub:type=\"dictionary\" (OPF_078)",
        pdf_page: 60,
        description: "OPF_078: An EPUB 3 dictionary must contain at least one content document \
                      with epub:type=\"dictionary\". Fires only when package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e2",
        section: "15",
        level: Severity::Error,
        title: "Dictionary content found but OPF lacks dc:type=dictionary (OPF_079)",
        pdf_page: 60,
        description: "OPF_079: idx:entry or dictionary content is present but the OPF does \
                      not declare <dc:type>dictionary</dc:type> in metadata. Fires only when \
                      package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e3",
        section: "15",
        level: Severity::Warning,
        title: "Search Key Map document must use .xml extension (OPF_080)",
        pdf_page: 60,
        description: "OPF_080: A Search Key Map Document referenced from a dictionary \
                      collection must have a .xml file extension. Fires only when \
                      package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e4",
        section: "15",
        level: Severity::Error,
        title: "Collection link target missing from manifest (OPF_081)",
        pdf_page: 60,
        description: "OPF_081: A resource referenced by a <collection> element must exist in \
                      the OPF manifest. Fires only when package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e5",
        section: "15",
        level: Severity::Error,
        title: "At most one Search Key Map per dictionary collection (OPF_082)",
        pdf_page: 60,
        description: "OPF_082: A dictionary collection may contain at most one Search Key Map \
                      Document. Fires only when package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e6",
        section: "15",
        level: Severity::Error,
        title: "At least one Search Key Map per dictionary collection (OPF_083)",
        pdf_page: 60,
        description: "OPF_083: A dictionary collection must contain at least one Search Key \
                      Map Document. Fires only when package_version is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    Rule {
        id: "R15.e7",
        section: "15",
        level: Severity::Error,
        title: "Dictionary collection may only contain XHTML or SKM docs (OPF_084)",
        pdf_page: 60,
        description: "OPF_084: A dictionary collection may only contain XHTML Content \
                      Documents or Search Key Map Documents. Fires only when package_version \
                      is 3.0.",
        profile_mask: Profile::Dict.as_bit(),
    },
    // ---- Section 11: Fixed-layout EPUB rules (epubcheck HTM_046-060, OPF_011) ----
    Rule {
        id: "R11.1",
        section: "11",
        level: Severity::Error,
        title: "Fixed-layout content without rendition:layout declaration (OPF_011)",
        pdf_page: 45,
        description: "OPF_011: Content looks fixed-layout (viewport meta present, pixel \
                      dimensions, image-heavy pages) but the OPF does not declare \
                      <meta property=\"rendition:layout\">pre-paginated</meta>. KDP will \
                      treat the book as reflowable and reflow the art, destroying the \
                      layout.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.2",
        section: "11",
        level: Severity::Error,
        title: "Fixed-layout XHTML missing viewport meta (HTM_046)",
        pdf_page: 45,
        description: "HTM_046: A fixed-layout content document must carry a <meta \
                      name=\"viewport\" content=\"width=..., height=...\"> so Kindle \
                      knows the page dimensions. Without it the page renders at the \
                      wrong size on every device.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.3",
        section: "11",
        level: Severity::Error,
        title: "Viewport meta missing width or height (HTM_047)",
        pdf_page: 45,
        description: "HTM_047: The viewport meta element must specify both width and \
                      height. Fixed-layout pages without one of these render with the \
                      wrong aspect ratio on Kindle.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.4",
        section: "11",
        level: Severity::Error,
        title: "Invalid rendition:spread value (HTM_048)",
        pdf_page: 45,
        description: "HTM_048: <meta property=\"rendition:spread\"> must be one of none, \
                      landscape, portrait, both, or auto. Unknown values are silently \
                      dropped, which usually means no two-page spread at all.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.5",
        section: "11",
        level: Severity::Error,
        title: "Invalid rendition:orientation value (HTM_049)",
        pdf_page: 45,
        description: "HTM_049: <meta property=\"rendition:orientation\"> must be one of \
                      auto, landscape, or portrait. Unknown values break orientation \
                      locking on Kindle Fire.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.6",
        section: "11",
        level: Severity::Error,
        title: "Invalid rendition:layout value (HTM_050)",
        pdf_page: 45,
        description: "HTM_050: <meta property=\"rendition:layout\"> must be one of \
                      pre-paginated or reflowable. Typos like \"fixed\" or \
                      \"prepaginated\" are silently ignored and the book falls back to \
                      reflowable.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.7",
        section: "11",
        level: Severity::Error,
        title: "Fixed-layout OPF but XHTML has no viewport (HTM_051)",
        pdf_page: 45,
        description: "HTM_051: OPF declares rendition:layout=pre-paginated but this \
                      spine document has no <meta name=\"viewport\"> element. The OPF \
                      declaration and the content disagree; Kindle picks the wrong \
                      layout.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.8",
        section: "11",
        level: Severity::Warning,
        title: "Fixed-layout page with no image content (HTM_052)",
        pdf_page: 45,
        description: "HTM_052: Fixed-layout pages without an <img>, <image>, or <svg> \
                      element render as a blank rectangle on Kindle comic readers. If \
                      this is intentional (title card), add a transparent 1x1 png.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    Rule {
        id: "R11.9",
        section: "11",
        level: Severity::Warning,
        title: "Fixed-layout missing original-resolution metadata (HTM_053)",
        pdf_page: 45,
        description: "HTM_053: KF8 fixed-layout builds should declare \
                      <meta name=\"original-resolution\" content=\"WxH\"/> so Kindle \
                      picks the right pixel scale. Missing the hint causes blurry \
                      rendering on high-DPI Paperwhites.",
        profile_mask: Profile::Comic.as_bit() | Profile::Textbook.as_bit(),
    },
    // ---- Section 7: Manifest and spine integrity (epubcheck OPF_*) ----
    Rule {
        id: "R7.1",
        section: "7",
        level: Severity::Warning,
        title: "File not declared in manifest (OPF_003)",
        pdf_page: 10,
        description: "OPF_003: A file exists in the EPUB content tree but is not declared \
                      in the manifest. Undeclared files are ignored by converters and \
                      waste space in the final book.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.2",
        section: "7",
        level: Severity::Error,
        title: "Declared media-type does not match file bytes (OPF_013)",
        pdf_page: 10,
        description: "OPF_013: A manifest item's declared media-type does not match the \
                      actual file, based on magic bytes. Kindle refuses to decode a file \
                      whose declared type disagrees with its content.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.3",
        section: "7",
        level: Severity::Error,
        title: "File contents do not match declared media-type (OPF_029)",
        pdf_page: 10,
        description: "OPF_029: The file bytes do not match any media-type we recognize \
                      from the declaration. Either the file is corrupt or the declared \
                      media-type is wrong.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.4",
        section: "7",
        level: Severity::Error,
        title: "Spine has no linear content (OPF_033)",
        pdf_page: 10,
        description: "OPF_033: Every <itemref> in the spine has linear=\"no\". At least \
                      one linear itemref is required or the book has no reading order.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.5",
        section: "7",
        level: Severity::Error,
        title: "Duplicate itemref idref (OPF_034)",
        pdf_page: 10,
        description: "OPF_034: Two <itemref> elements reference the same manifest id. The \
                      second reference is redundant and can confuse pagination.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.6",
        section: "7",
        level: Severity::Warning,
        title: "text/html used where xhtml was expected (OPF_035)",
        pdf_page: 10,
        description: "OPF_035: A manifest item has media-type text/html on a .xhtml/.html \
                      resource. EPUB uses application/xhtml+xml for content documents.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.7",
        section: "7",
        level: Severity::Warning,
        title: "Deprecated media-type (OPF_037)",
        pdf_page: 10,
        description: "OPF_037: The item uses a deprecated media-type (image/jpg, \
                      text/xml, application/x-dtbook+xml, text/x-oeb1-document). Replace \
                      it with the canonical equivalent.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.8",
        section: "7",
        level: Severity::Error,
        title: "Dangling fallback id (OPF_040)",
        pdf_page: 10,
        description: "OPF_040: A manifest item declares fallback=\"X\" but X is not a \
                      manifest id. The fallback chain is broken.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.9",
        section: "7",
        level: Severity::Error,
        title: "Dangling fallback-style id (OPF_041)",
        pdf_page: 10,
        description: "OPF_041: A manifest item declares fallback-style=\"X\" but X is not \
                      a manifest id. The fallback-style chain is broken.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.10",
        section: "7",
        level: Severity::Error,
        title: "Non-permissible spine media-type without fallback (OPF_042)",
        pdf_page: 10,
        description: "OPF_042: A spine item has a non-permissible media-type (not xhtml, \
                      svg, or dtbook) and no fallback attribute. Kindle will not render \
                      it as a reading-order page.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.11",
        section: "7",
        level: Severity::Error,
        title: "Spine fallback chain never reaches xhtml/svg (OPF_043)",
        pdf_page: 10,
        description: "OPF_043: A spine item with a non-standard media-type has a fallback \
                      chain that never terminates at an xhtml or svg resource. Kindle \
                      cannot reach a displayable form.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.12",
        section: "7",
        level: Severity::Error,
        title: "Duplicate manifest href (OPF_074)",
        pdf_page: 10,
        description: "OPF_074: Two manifest items share the same href. Each resource must \
                      appear at most once in the manifest.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R7.13",
        section: "7",
        level: Severity::Error,
        title: "Manifest item points at the OPF file itself (OPF_099)",
        pdf_page: 10,
        description: "OPF_099: A manifest item href resolves back to the OPF package file. \
                      The package file must not be listed in its own manifest.",
        profile_mask: ALL_PROFILES,
    },
    // ---- Section 8: OPF prefix attribute and manifest property grammar ----
    Rule {
        id: "R8.1",
        section: "8",
        level: Severity::Error,
        title: "Malformed package prefix attribute (OPF_004)",
        pdf_page: 14,
        description: "OPF_004: The <package prefix=\"...\"> attribute must follow the \
                      syntax `prefix: url [whitespace prefix: url]*`. Fires only when \
                      package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.2",
        section: "8",
        level: Severity::Error,
        title: "Duplicate prefix in package prefix attribute (OPF_005)",
        pdf_page: 14,
        description: "OPF_005: Each prefix name may only appear once in the package \
                      prefix attribute. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.3",
        section: "8",
        level: Severity::Error,
        title: "Reserved prefix rebound to non-standard URI (OPF_006)",
        pdf_page: 14,
        description: "OPF_006: A reserved prefix (dcterms, epub, marc, media, onix, opf, \
                      rendition, schema, xsd) may not be rebound to a non-standard URI in \
                      the package prefix attribute. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.4",
        section: "8",
        level: Severity::Error,
        title: "Prefix maps to a malformed URI (OPF_007)",
        pdf_page: 14,
        description: "OPF_007: A prefix declared in the package prefix attribute must map \
                      to a syntactically valid URI. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.5",
        section: "8",
        level: Severity::Error,
        title: "Manifest item property invalid for media-type (OPF_012)",
        pdf_page: 14,
        description: "OPF_012: A manifest item's properties=\"...\" value is not permitted \
                      for its media-type (e.g., nav on non-xhtml, cover-image on non-image, \
                      mathml on non-xhtml). Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.6",
        section: "8",
        level: Severity::Warning,
        title: "Spine XHTML uses a feature without declaring the property (OPF_014)",
        pdf_page: 14,
        description: "OPF_014: A spine XHTML contains MathML, SVG, scripts, or remote \
                      resources but the manifest item does not declare the matching \
                      property (mathml, svg, scripted, remote-resources). Fires only when \
                      package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.7",
        section: "8",
        level: Severity::Warning,
        title: "Manifest declares a property the content does not use (OPF_015)",
        pdf_page: 14,
        description: "OPF_015: A manifest item declares one of the feature properties \
                      (mathml, svg, scripted, remote-resources) but the content does not \
                      actually use that feature. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.8",
        section: "8",
        level: Severity::Error,
        title: "Property value is syntactically malformed (OPF_026)",
        pdf_page: 14,
        description: "OPF_026: A property value in a manifest item's properties attribute \
                      is syntactically malformed. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.9",
        section: "8",
        level: Severity::Warning,
        title: "Unknown property without a declared prefix (OPF_027)",
        pdf_page: 14,
        description: "OPF_027: A property name is not in the known EPUB property set and \
                      has no declared prefix. Fires only when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R8.10",
        section: "8",
        level: Severity::Error,
        title: "Property uses an undeclared prefix (OPF_028)",
        pdf_page: 14,
        description: "OPF_028: A property uses a prefix that is not in the default prefixes \
                      and was never declared in the package prefix attribute. Fires only \
                      when package_version is 3.0.",
        profile_mask: ALL_PROFILES,
    },
    // ---- Section 9: Cross-references and dead links (epubcheck RSC_*/OPF_091/OPF_098) ----
    Rule {
        id: "R9.1",
        section: "9",
        level: Severity::Warning,
        title: "Non-SVG image referenced with a fragment",
        pdf_page: 27,
        description: "RSC_009: An <img src=\"foo.png#fragment\"> uses a fragment identifier on a \
                      non-SVG raster image. Only SVG content documents support fragment \
                      targeting; the fragment is silently ignored elsewhere.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.2",
        section: "9",
        level: Severity::Warning,
        title: "Link targets a manifest item not in the spine",
        pdf_page: 27,
        description: "RSC_011: An <a href=\"...\"> points at a manifest item that is not \
                      listed in the spine. The target file will not be reachable through \
                      normal reading order and Kindle will not compile the jump target.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.3",
        section: "9",
        level: Severity::Error,
        title: "Fragment id not defined in the target file",
        pdf_page: 27,
        description: "RSC_012: The file on the left of '#' exists in the manifest but the \
                      '#anchor' id is not declared anywhere inside that file. The link will \
                      scroll to the top of the target instead of the intended element.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.4",
        section: "9",
        level: Severity::Error,
        title: "Fragment points into a resource without ids",
        pdf_page: 27,
        description: "RSC_014: The fragment identifier targets a CSS file, image, or font, \
                      none of which support element ids. The anchor is meaningless.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.5",
        section: "9",
        level: Severity::Error,
        title: "SVG <use> without a fragment identifier",
        pdf_page: 27,
        description: "RSC_015: An SVG <use> element must reference another symbol by \
                      fragment identifier (for example xlink:href=\"#icon\"). A bare file \
                      reference is a structural error.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.6",
        section: "9",
        level: Severity::Error,
        title: "href/src is not a valid URL",
        pdf_page: 27,
        description: "RSC_020: An href or src attribute value contains whitespace, control \
                      characters, or bare angle brackets. RFC 3986 cannot parse such a \
                      reference and Kindle will silently strip the link.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.7",
        section: "9",
        level: Severity::Error,
        title: "Relative URL escapes the EPUB container",
        pdf_page: 27,
        description: "RSC_026: A relative URL uses '..' path segments that would resolve \
                      outside the EPUB root. This is a packaging error and a security risk \
                      (path traversal).",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.8",
        section: "9",
        level: Severity::Warning,
        title: "data: URL in href or src",
        pdf_page: 27,
        description: "RSC_029: A data: URL is used in an href or src attribute. Kindle does \
                      not support data: URLs; images and stylesheets must be packaged as \
                      manifest items.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.9",
        section: "9",
        level: Severity::Error,
        title: "file: URL in href or src",
        pdf_page: 27,
        description: "RSC_030: A file: URL is used in an href or src attribute. file: \
                      references point at the author's local disk and will never resolve \
                      on a reader device.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.10",
        section: "9",
        level: Severity::Warning,
        title: "Relative URL carries a ?query component",
        pdf_page: 27,
        description: "RSC_033: A relative URL contains a '?query' component. kindlegen's \
                      URL hashing drops the query before resolving the reference, which \
                      breaks any link that depends on the query part.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.11",
        section: "9",
        level: Severity::Error,
        title: "Manifest item href contains a fragment",
        pdf_page: 27,
        description: "OPF_091: An OPF <manifest> item href must identify a whole resource. \
                      A '#' fragment is not allowed in manifest hrefs because manifest \
                      items are resources, not elements.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R9.12",
        section: "9",
        level: Severity::Error,
        title: "Manifest item href references an element",
        pdf_page: 27,
        description: "OPF_098: An OPF <manifest> item href points at an element (bare \
                      '#id') rather than a resource. Manifest hrefs must name a file.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.4",
        section: "5",
        level: Severity::Warning,
        title: "Pagebreak content but no page-list in NAV",
        pdf_page: 19,
        description: "Content documents contain epub:type=\"pagebreak\" elements but the NAV \
                      document has no <nav epub:type=\"page-list\"> list (NAV_003). Kindle \
                      will not expose page numbers for navigation.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.5",
        section: "5",
        level: Severity::Error,
        title: "Nav or NCX contains remote resource link",
        pdf_page: 19,
        description: "Nav document or NCX contains a link to a remote resource (http:// or \
                      https://) (NAV_010). Kindle navigation must point at packaged content, \
                      not the network.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.6",
        section: "5",
        level: Severity::Warning,
        title: "Nav TOC entries not in spine order",
        pdf_page: 19,
        description: "Nav TOC entries are not in spine reading order (NAV_011). An entry \
                      points at a spine item that comes after the next entry's spine item, \
                      so the Kindle chapter list reads backwards.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.7",
        section: "5",
        level: Severity::Error,
        title: "NCX dtb:uid does not match OPF identifier",
        pdf_page: 19,
        description: "NCX <meta name=\"dtb:uid\"> value does not match the OPF <dc:identifier> \
                      pointed at by <package unique-identifier> (NCX_001). Kindle's TOC will \
                      not bind to the book.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.8",
        section: "5",
        level: Severity::Warning,
        title: "NCX dtb:uid has surrounding whitespace",
        pdf_page: 19,
        description: "NCX dtb:uid value has leading or trailing whitespace (NCX_004). Some \
                      parsers treat this as an identifier mismatch against the OPF.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.9",
        section: "5",
        level: Severity::Warning,
        title: "NCX navPoint has empty text label",
        pdf_page: 19,
        description: "NCX navPoint has an empty <text> label inside <navLabel> (NCX_006). \
                      Empty labels render as blank lines in the Kindle TOC.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.10",
        section: "5",
        level: Severity::Error,
        title: "Guide reference target is not an OPS content document",
        pdf_page: 19,
        description: "OPF <guide><reference href> points at a file that is not a valid OPS \
                      Content Document (OPF_032). The target must be in the manifest with \
                      media-type application/xhtml+xml.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R5.11",
        section: "5",
        level: Severity::Error,
        title: "Spine toc attribute target is not an NCX",
        pdf_page: 19,
        description: "OPF <spine toc=\"X\"> points at a manifest item whose media-type is not \
                      application/x-dtbncx+xml (OPF_050). The toc attribute must name the NCX \
                      manifest item.",
        profile_mask: ALL_PROFILES,
    },
    // ---- Section 13: OCF filename rules ----
    Rule {
        id: "R13.1",
        section: "13",
        level: Severity::Error,
        title: "Illegal character in manifest href",
        pdf_page: 10,
        description: "Manifest item href contains a character that is not allowed in OCF \
                      filenames. OCF forbids < > : \" | ? * and any control character below \
                      U+0020.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R13.2",
        section: "13",
        level: Severity::Warning,
        title: "Space in manifest href",
        pdf_page: 10,
        description: "Manifest item href contains a space. Spaces in OCF filenames cause \
                      broken references on some readers and should be replaced with an \
                      underscore or hyphen.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R13.3",
        section: "13",
        level: Severity::Warning,
        title: "Trailing dot in manifest href",
        pdf_page: 10,
        description: "Manifest item href ends with a trailing dot. Windows silently drops \
                      the trailing dot when writing the file, so the reference will not \
                      resolve after extraction.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R13.4",
        section: "13",
        level: Severity::Warning,
        title: "Non-ASCII character in manifest href",
        pdf_page: 10,
        description: "Manifest item href contains a non-ASCII character (above U+007E). OCF \
                      permits Unicode in filenames but Kindle's pipeline has mixed support \
                      and these paths often fail to resolve.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R13.5",
        section: "13",
        level: Severity::Error,
        title: "Case-insensitive duplicate manifest hrefs",
        pdf_page: 10,
        description: "Two manifest items have hrefs that are equal after Unicode \
                      case-folding. Case-insensitive filesystems will overwrite one file \
                      with the other and at least one reference will break.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.3",
        section: "10",
        level: Severity::Error,
        title: "Image header or trailer corrupt (MED_004)",
        pdf_page: 38,
        description: "Image magic bytes match a known format but the file is truncated or \
                      its header/trailer is corrupt. Examples: a JPEG with no FF D9 EOI \
                      marker, a PNG missing its IHDR chunk, or a GIF without the \
                      terminating 3B byte. Kindle conversion will fail or render a blank \
                      image.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.4",
        section: "10",
        level: Severity::Error,
        title: "Image cannot be decoded (PKG_021)",
        pdf_page: 38,
        description: "Image file cannot be parsed at all. The bytes are too short to \
                      contain a valid header, or no known image magic signature (JPEG, \
                      PNG, GIF, SVG, WebP) matches the leading bytes.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R10.4.5",
        section: "10",
        level: Severity::Warning,
        title: "Image extension disagrees with magic bytes (PKG_022)",
        pdf_page: 38,
        description: "The file extension of an image manifest item disagrees with the \
                      format detected from its magic bytes (e.g. foo.jpg but the bytes \
                      are actually a PNG). Kindle may still convert the file, but the \
                      mismatch commonly points at a build-pipeline bug.",
        profile_mask: ALL_PROFILES,
    },
    // ---- Section 6: CSS forbidden properties and parse rules (epubcheck CSS_005-027) ----
    Rule {
        id: "R6.13",
        section: "6",
        level: Severity::Error,
        title: "CSS parse error (CSS_005 / CSS_027)",
        pdf_page: 24,
        description: "CSS_005: lightningcss failed to parse this stylesheet. Hard parse \
                      errors cause kindlegen to drop the stylesheet wholesale, so every \
                      rule in the file becomes a no-op on device.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.14",
        section: "6",
        level: Severity::Error,
        title: "Forbidden position value for reflowable Kindle (CSS_007)",
        pdf_page: 24,
        description: "CSS_007: Reflowable Kindle content cannot use position: fixed, \
                      absolute, or sticky. The KF8 renderer collapses them back to \
                      static, which typically destroys the intended layout.",
        profile_mask: Profile::Default.as_bit() | Profile::Dict.as_bit(),
    },
    Rule {
        id: "R6.15",
        section: "6",
        level: Severity::Error,
        title: "@import target unresolvable (CSS_015)",
        pdf_page: 24,
        description: "CSS_015: @import targets an external URL or a resource that is \
                      not declared in the OPF manifest. Kindlegen cannot follow those \
                      imports, so the imported rules are silently dropped.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.16",
        section: "6",
        level: Severity::Error,
        title: "CSS url() target unresolvable (CSS_020)",
        pdf_page: 24,
        description: "CSS_020: A url(...) reference in this stylesheet points at an \
                      external URL or a resource that is not in the manifest. The \
                      declaration using it will silently drop on device.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.17",
        section: "6",
        level: Severity::Error,
        title: "@font-face will be silently dropped (CSS_008 / CSS_017)",
        pdf_page: 24,
        description: "CSS_008 / CSS_017: @font-face block has no src descriptor or \
                      references a font file that is not in the manifest. Kindle drops \
                      unresolvable fonts silently and falls back to the system font.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.e1",
        section: "6",
        level: Severity::Warning,
        title: "CSS @namespace rule (CSS_025)",
        pdf_page: 24,
        description: "CSS_025: @namespace rules are ignored by kindlegen and any \
                      selectors that rely on the namespace will fail to match on device. \
                      Flatten the stylesheet before shipping.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R6.e2",
        section: "6",
        level: Severity::Warning,
        title: "Unsupported @media feature (CSS_019)",
        pdf_page: 24,
        description: "CSS_019: @media query uses a feature Kindle readers never match \
                      (hover, pointer, color-gamut, prefers-color-scheme). The enclosed \
                      rules will never take effect on device.",
        profile_mask: ALL_PROFILES,
    },
    // ---- Section 16: OPF metadata and package identity (epubcheck OPF_030-092) ----
    Rule {
        id: "R16.1",
        section: "16",
        level: Severity::Error,
        title: "Unique-identifier points at a missing dc:identifier (OPF_030)",
        pdf_page: 14,
        description: "OPF_030: <package unique-identifier=\"X\"> references an id that \
                      does not appear on any <dc:identifier>. Kindle cannot bind the \
                      book identity and the upload is rejected.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.2",
        section: "16",
        level: Severity::Warning,
        title: "<package> missing unique-identifier attribute (OPF_048)",
        pdf_page: 14,
        description: "OPF_048: The <package> element has no unique-identifier attribute. \
                      Every OPF must name a <dc:identifier> as the book's unique id.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.3",
        section: "16",
        level: Severity::Error,
        title: "<dc:date> is not W3CDTF syntax (OPF_053)",
        pdf_page: 14,
        description: "OPF_053: <dc:date> must follow W3CDTF (YYYY, YYYY-MM, YYYY-MM-DD, \
                      or with time and timezone). Non-conforming dates are silently \
                      dropped by the Kindle ingestion pipeline.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.4",
        section: "16",
        level: Severity::Warning,
        title: "<dc:date> is W3CDTF-shaped but not a valid date (OPF_054)",
        pdf_page: 14,
        description: "OPF_054: <dc:date> value parses as W3CDTF syntactically but names \
                      an impossible calendar date (e.g. 2024-02-30).",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.5",
        section: "16",
        level: Severity::Warning,
        title: "Empty Dublin Core element (OPF_055)",
        pdf_page: 14,
        description: "OPF_055: A <dc:*> element has no text content. Empty metadata \
                      elements are treated as missing by Kindle and surface as blanks \
                      in the store.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.6",
        section: "16",
        level: Severity::Warning,
        title: "Empty <metadata> child (OPF_072)",
        pdf_page: 14,
        description: "OPF_072: A <meta> or <x-metadata> child of <metadata> is empty. \
                      The element should either carry content or be removed.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.7",
        section: "16",
        level: Severity::Error,
        title: "opf:scheme=\"UUID\" value is not a valid RFC 4122 UUID (OPF_085)",
        pdf_page: 14,
        description: "OPF_085: A <dc:identifier opf:scheme=\"UUID\"> value must be a \
                      valid RFC 4122 UUID string. Kindle uses this to deduplicate \
                      uploads; a malformed UUID can cause the upload to be rejected.",
        profile_mask: ALL_PROFILES,
    },
    Rule {
        id: "R16.8",
        section: "16",
        level: Severity::Error,
        title: "<dc:language> is not a well-formed BCP47 tag (OPF_092)",
        pdf_page: 14,
        description: "OPF_092: <dc:language> must carry a BCP47 language tag (e.g. en, \
                      en-US, zh-Hant). Invalid tags cause Kindle to default to English \
                      and may flag the book for manual review.",
        profile_mask: ALL_PROFILES,
    },
];

/// Look up a rule by its id. Panics if the id is unknown.
pub fn get(id: &str) -> &'static Rule {
    RULES
        .iter()
        .find(|r| r.id == id)
        .unwrap_or_else(|| panic!("unknown KDP rule id: {}", id))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_all_rule_ids_unique() {
        let mut ids: Vec<&str> = RULES.iter().map(|r| r.id).collect();
        ids.sort();
        let len_before = ids.len();
        ids.dedup();
        assert_eq!(
            len_before,
            ids.len(),
            "duplicate rule id(s) in RULES catalog"
        );
    }

    #[test]
    fn test_get_rule_by_id() {
        let rule = get("R4.2.1");
        assert_eq!(rule.section, "4.2");
        assert_eq!(rule.level, Severity::Error);
    }

    #[test]
    #[should_panic(expected = "unknown KDP rule id")]
    fn test_get_unknown_rule_panics() {
        get("R999");
    }

    #[test]
    fn test_kpg_version_set() {
        assert!(!KPG_VERSION.is_empty());
    }

    #[test]
    fn test_applies_to_default_profile() {
        let rule = get("R4.2.1");
        assert!(rule.applies_to(Profile::Default));
        assert!(rule.applies_to(Profile::Comic));
        assert!(rule.applies_to(Profile::Dict));
    }
}