1use serde::{Deserialize, Serialize};
8
9const MM_PER_INCH: f32 = 25.4;
10
11pub const SPECS_VERIFIED_ON: &str = "2026-10-21";
14
15#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16pub struct TrimSize {
17 pub width_mm: f32,
18 pub height_mm: f32,
19}
20
21impl TrimSize {
22 pub const fn mm(width_mm: f32, height_mm: f32) -> Self {
23 Self {
24 width_mm,
25 height_mm,
26 }
27 }
28
29 pub const fn inches(w: f32, h: f32) -> Self {
30 Self::mm(w * MM_PER_INCH, h * MM_PER_INCH)
31 }
32
33 pub const US_5X8: Self = Self::inches(5.0, 8.0);
34 pub const US_5_25X8: Self = Self::inches(5.25, 8.0);
35 pub const US_5_5X8_5: Self = Self::inches(5.5, 8.5);
36 pub const US_6X9: Self = Self::inches(6.0, 9.0);
37 pub const A5: Self = Self::mm(148.0, 210.0);
38 pub const DE_12X19: Self = Self::mm(120.0, 190.0);
39 pub const DE_13_5X21_5: Self = Self::mm(135.0, 215.0);
40 pub const DE_15_5X22: Self = Self::mm(155.0, 220.0);
41 pub const DE_17X22: Self = Self::mm(170.0, 220.0);
42 pub const DE_17X17: Self = Self::mm(170.0, 170.0);
43 pub const DE_21X15: Self = Self::mm(210.0, 150.0);
45 pub const DE_19X27: Self = Self::mm(190.0, 270.0);
46 pub const DE_21X21: Self = Self::mm(210.0, 210.0);
47 pub const DE_21X29_7: Self = Self::mm(210.0, 297.0);
48
49 pub fn presets() -> &'static [(&'static str, TrimSize)] {
51 &[
52 ("5 × 8 in", Self::US_5X8),
53 ("5.25 × 8 in", Self::US_5_25X8),
54 ("5.5 × 8.5 in", Self::US_5_5X8_5),
55 ("6 × 9 in", Self::US_6X9),
56 ("A5 (148 × 210 mm)", Self::A5),
57 ("12 × 19 cm", Self::DE_12X19),
58 ("13.5 × 21.5 cm", Self::DE_13_5X21_5),
59 ]
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65pub struct MinMargins {
66 pub inside_mm: f32,
67 pub outside_mm: f32,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Retailer {
73 Kdp,
74 IngramSpark,
75 Bod,
77 Generic,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82pub struct Bleed {
83 pub mm: f32,
84 pub on_gutter: bool,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91pub struct PageLimits {
92 pub min: u32,
93 pub max: u32,
94}
95
96impl PageLimits {
97 pub fn contains(self, pages: u32) -> bool {
98 (self.min..=self.max).contains(&pages)
99 }
100}
101
102pub const KDP_INSIDE_MARGINS_IN: &[(u32, f32)] = &[
117 (150, 0.375),
118 (300, 0.5),
119 (500, 0.625),
120 (700, 0.75),
121 (828, 0.875),
122];
123
124pub const KDP_OUTSIDE_MARGIN_IN: f32 = 0.25;
127pub const KDP_OUTSIDE_MARGIN_BLEED_IN: f32 = 0.375;
128
129pub const KDP_BLEED_IN: f32 = 0.125;
133
134pub const KDP_MIN_PAGES: u32 = 24;
139pub const KDP_MAX_PAGES: u32 = 828;
140
141pub const INGRAM_MARGIN_IN: f32 = 0.5;
156
157pub const INGRAM_BLEED_IN: f32 = 0.125;
161
162pub const INGRAM_MIN_PAGES: u32 = 18;
167pub const INGRAM_MAX_PAGES: u32 = 1200;
168
169pub const BOD_TRIM_SIZES: &[(&str, TrimSize)] = &[
199 ("12 × 19 cm", TrimSize::DE_12X19),
200 ("13.5 × 21.5 cm", TrimSize::DE_13_5X21_5),
201 ("A5 (148 × 210 mm)", TrimSize::A5),
202 ("15.5 × 22 cm", TrimSize::DE_15_5X22),
203 ("17 × 22 cm", TrimSize::DE_17X22),
204 ("17 × 17 cm", TrimSize::DE_17X17),
205 ("21 × 15 cm", TrimSize::DE_21X15),
206 ("19 × 27 cm", TrimSize::DE_19X27),
207 ("21 × 21 cm", TrimSize::DE_21X21),
208 ("21 × 29.7 cm", TrimSize::DE_21X29_7),
209];
210
211pub const BOD_BLEED_MM: f32 = 5.0;
214
215pub const BOD_MIN_MARGIN_MM: f32 = 5.0;
218
219pub const BOD_MIN_PAGES: u32 = 24;
222pub const BOD_MAX_PAGES: u32 = 900;
223
224impl Retailer {
225 pub fn min_margins(self, pages: u32, bleed: bool) -> MinMargins {
227 match self {
228 Retailer::Kdp => {
229 let inside_in = KDP_INSIDE_MARGINS_IN
230 .iter()
231 .find(|(max, _)| pages <= *max)
232 .or(KDP_INSIDE_MARGINS_IN.last())
233 .map_or(0.875, |(_, m)| *m);
234 let outside_in = if bleed {
235 KDP_OUTSIDE_MARGIN_BLEED_IN
236 } else {
237 KDP_OUTSIDE_MARGIN_IN
238 };
239 MinMargins {
240 inside_mm: inside_in * MM_PER_INCH,
241 outside_mm: outside_in * MM_PER_INCH,
242 }
243 }
244 Retailer::IngramSpark => MinMargins {
246 inside_mm: INGRAM_MARGIN_IN * MM_PER_INCH,
247 outside_mm: INGRAM_MARGIN_IN * MM_PER_INCH,
248 },
249 Retailer::Bod => MinMargins {
250 inside_mm: BOD_MIN_MARGIN_MM,
251 outside_mm: BOD_MIN_MARGIN_MM,
252 },
253 Retailer::Generic => MinMargins {
254 inside_mm: 15.0,
255 outside_mm: 10.0,
256 },
257 }
258 }
259
260 pub fn bleed(self) -> Bleed {
263 match self {
264 Retailer::Kdp => Bleed {
265 mm: KDP_BLEED_IN * MM_PER_INCH,
266 on_gutter: false,
267 },
268 Retailer::IngramSpark => Bleed {
269 mm: INGRAM_BLEED_IN * MM_PER_INCH,
270 on_gutter: false,
271 },
272 Retailer::Bod => Bleed {
273 mm: BOD_BLEED_MM,
274 on_gutter: true,
275 },
276 Retailer::Generic => Bleed {
277 mm: 3.0,
278 on_gutter: false,
279 },
280 }
281 }
282
283 pub fn page_limits(self) -> Option<PageLimits> {
285 let (min, max) = match self {
286 Retailer::Kdp => (KDP_MIN_PAGES, KDP_MAX_PAGES),
287 Retailer::IngramSpark => (INGRAM_MIN_PAGES, INGRAM_MAX_PAGES),
288 Retailer::Bod => (BOD_MIN_PAGES, BOD_MAX_PAGES),
289 Retailer::Generic => return None,
290 };
291 Some(PageLimits { min, max })
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 const ALL: [Retailer; 4] = [
300 Retailer::Kdp,
301 Retailer::IngramSpark,
302 Retailer::Bod,
303 Retailer::Generic,
304 ];
305
306 fn close(a: f32, b: f32) -> bool {
307 (a - b).abs() < 0.001
308 }
309
310 fn inside_in(pages: u32) -> f32 {
311 Retailer::Kdp.min_margins(pages, false).inside_mm / MM_PER_INCH
312 }
313
314 #[test]
315 fn six_by_nine_in_mm() {
316 assert!((TrimSize::US_6X9.width_mm - 152.4).abs() < 0.01);
317 assert!((TrimSize::US_6X9.height_mm - 228.6).abs() < 0.01);
318 }
319
320 #[test]
321 fn kdp_inside_margin_grows_with_pages() {
322 let small = Retailer::Kdp.min_margins(120, false);
323 let big = Retailer::Kdp.min_margins(420, false);
324 assert!((small.inside_mm - 9.525).abs() < 0.01);
325 assert!((big.inside_mm - 15.875).abs() < 0.01);
326 assert!(Retailer::Kdp.min_margins(10_000, true).outside_mm > small.outside_mm);
327 }
328
329 #[test]
330 fn kdp_table_is_well_formed() {
331 assert!(!KDP_INSIDE_MARGINS_IN.is_empty());
332 for pair in KDP_INSIDE_MARGINS_IN.windows(2) {
333 let ((p0, m0), (p1, m1)) = (pair[0], pair[1]);
334 assert!(p0 < p1, "page limits must ascend: {p0} {p1}");
335 assert!(m0 < m1, "margins must grow: {m0} {m1}");
336 }
337 assert!(KDP_INSIDE_MARGINS_IN.iter().all(|(_, m)| *m > 0.0));
338 }
339
340 #[test]
341 fn kdp_rows_apply_up_to_and_including_their_limit() {
342 let expected = [
344 (1, 0.375),
345 (24, 0.375),
346 (150, 0.375),
347 (151, 0.5),
348 (300, 0.5),
349 (301, 0.625),
350 (500, 0.625),
351 (501, 0.75),
352 (700, 0.75),
353 (701, 0.875),
354 (828, 0.875),
355 ];
356 for (pages, margin) in expected {
357 assert!(
358 close(inside_in(pages), margin),
359 "{pages} pages: {}",
360 inside_in(pages)
361 );
362 }
363 for (i, &(max, margin)) in KDP_INSIDE_MARGINS_IN.iter().enumerate() {
364 assert!(close(inside_in(max), margin));
365 if let Some(&(_, next)) = KDP_INSIDE_MARGINS_IN.get(i + 1) {
366 assert!(close(inside_in(max + 1), next), "{} pages", max + 1);
367 }
368 }
369 }
370
371 #[test]
372 fn kdp_page_limits_frame_the_margin_table() {
373 assert_eq!(KDP_MIN_PAGES, 24);
374 assert_eq!(KDP_MAX_PAGES, 828);
375 assert_eq!(
376 KDP_INSIDE_MARGINS_IN.last().map(|r| r.0),
377 Some(KDP_MAX_PAGES)
378 );
379 assert!(KDP_INSIDE_MARGINS_IN[0].0 > KDP_MIN_PAGES);
380 }
381
382 #[test]
383 fn kdp_bleed_is_an_eighth_of_an_inch() {
384 assert!(close(KDP_BLEED_IN, 0.125));
385 assert!(close(KDP_BLEED_IN * MM_PER_INCH, 3.175));
386 let bleed = |trim: TrimSize| {
388 (
389 (trim.width_mm + KDP_BLEED_IN * MM_PER_INCH) / MM_PER_INCH,
390 (trim.height_mm + 2.0 * KDP_BLEED_IN * MM_PER_INCH) / MM_PER_INCH,
391 )
392 };
393 let (w, h) = bleed(TrimSize::US_6X9);
394 assert!(close(w, 6.125) && close(h, 9.25), "{w} × {h}");
395 }
396
397 #[test]
398 fn kdp_beyond_the_table_keeps_the_last_row() {
399 let last = KDP_INSIDE_MARGINS_IN.last().unwrap().1;
400 for pages in [829, 1_000, u32::MAX] {
401 assert!(close(inside_in(pages), last));
402 }
403 assert!(close(inside_in(0), KDP_INSIDE_MARGINS_IN[0].1));
404 }
405
406 #[test]
407 fn kdp_outside_margin_depends_on_bleed_only() {
408 for pages in [0, 24, 150, 151, 500, 828, 2_000] {
409 let plain = Retailer::Kdp.min_margins(pages, false);
410 let bleed = Retailer::Kdp.min_margins(pages, true);
411 assert!(close(plain.outside_mm, 0.25 * MM_PER_INCH));
412 assert!(close(bleed.outside_mm, 0.375 * MM_PER_INCH));
413 assert_eq!(plain.inside_mm, bleed.inside_mm);
414 }
415 }
416
417 #[test]
418 fn other_retailers_are_flat() {
419 for pages in [0, 100, 500, 1_000] {
420 for bleed in [false, true] {
421 let ingram = Retailer::IngramSpark.min_margins(pages, bleed);
422 assert!(close(ingram.inside_mm, 12.7) && close(ingram.outside_mm, 12.7));
423 let bod = Retailer::Bod.min_margins(pages, bleed);
424 assert_eq!((bod.inside_mm, bod.outside_mm), (5.0, 5.0));
425 let generic = Retailer::Generic.min_margins(pages, bleed);
426 assert_eq!((generic.inside_mm, generic.outside_mm), (15.0, 10.0));
427 }
428 }
429 }
430
431 #[test]
432 fn margins_never_shrink_as_books_grow() {
433 for r in ALL {
434 for bleed in [false, true] {
435 let mut prev = r.min_margins(0, bleed);
436 for pages in 1..=1_000 {
437 let m = r.min_margins(pages, bleed);
438 assert!(m.inside_mm >= prev.inside_mm, "{r:?} {pages}");
439 assert!(m.outside_mm >= prev.outside_mm, "{r:?} {pages}");
440 assert!(m.inside_mm > 0.0 && m.outside_mm > 0.0);
441 prev = m;
442 }
443 }
444 }
445 }
446
447 #[test]
448 fn margins_fit_every_trim_preset() {
449 for (name, trim) in TrimSize::presets() {
452 for r in ALL {
453 let m = r.min_margins(828, true);
454 let text = trim.width_mm - m.inside_mm - m.outside_mm;
455 assert!(text > trim.width_mm / 2.0, "{name} {r:?}: {text} mm");
456 }
457 }
458 }
459
460 #[test]
461 fn ingram_numbers_match_the_file_creation_guide() {
462 for pages in [18, 200, 600, 1_200] {
464 for bleed in [false, true] {
465 let m = Retailer::IngramSpark.min_margins(pages, bleed);
466 assert!(close(m.inside_mm, 0.5 * MM_PER_INCH));
467 assert!(close(m.outside_mm, 0.5 * MM_PER_INCH));
468 }
469 }
470 let bleed = Retailer::IngramSpark.bleed();
472 assert!(close(bleed.mm, 3.175) && !bleed.on_gutter);
473 for (trim, w_in, h_in) in [
476 (TrimSize::US_6X9, 6.125, 9.25),
477 (TrimSize::US_5X8, 5.125, 8.25),
478 ] {
479 let w = (trim.width_mm + bleed.mm) / MM_PER_INCH;
480 let h = (trim.height_mm + 2.0 * bleed.mm) / MM_PER_INCH;
481 assert!(close(w, w_in) && close(h, h_in), "{w} × {h}");
482 }
483 let limits = Retailer::IngramSpark.page_limits().unwrap();
484 assert_eq!((limits.min, limits.max), (18, 1_200));
485 }
486
487 #[test]
488 fn presets_sit_inside_ingram_trim_matrix() {
489 let standard = [
495 (5.0, 8.0),
496 (5.25, 8.0),
497 (5.5, 8.5),
498 (5.83, 8.27),
499 (6.0, 9.0),
500 (4.72, 7.48),
501 ];
502 for trim in [
503 TrimSize::US_5X8,
504 TrimSize::US_5_25X8,
505 TrimSize::US_5_5X8_5,
506 TrimSize::US_6X9,
507 TrimSize::A5,
508 TrimSize::DE_12X19,
509 ] {
510 assert!(
511 standard.iter().any(|&(w, h)| {
512 (trim.width_mm - w * MM_PER_INCH).abs() < 0.6
513 && (trim.height_mm - h * MM_PER_INCH).abs() < 0.6
514 }),
515 "{trim:?}"
516 );
517 }
518 }
519
520 #[test]
521 fn bod_numbers_match_its_author_pages() {
522 assert_eq!(BOD_TRIM_SIZES.len(), 10);
524 let cm = |t: TrimSize| (t.width_mm / 10.0, t.height_mm / 10.0);
525 let listed: Vec<(f32, f32)> = BOD_TRIM_SIZES.iter().map(|(_, t)| cm(*t)).collect();
526 for expected in [
527 (12.0, 19.0),
528 (13.5, 21.5),
529 (14.8, 21.0),
530 (15.5, 22.0),
531 (17.0, 22.0),
532 (17.0, 17.0),
533 (21.0, 15.0),
534 (19.0, 27.0),
535 (21.0, 21.0),
536 (21.0, 29.7),
537 ] {
538 assert!(
539 listed
540 .iter()
541 .any(|&(w, h)| close(w, expected.0) && close(h, expected.1)),
542 "{expected:?}"
543 );
544 }
545 let bleed = Retailer::Bod.bleed();
547 assert!(close(bleed.mm, 5.0) && bleed.on_gutter);
548 let (w, h) = (
549 TrimSize::DE_12X19.width_mm + 2.0 * bleed.mm,
550 TrimSize::DE_12X19.height_mm + 2.0 * bleed.mm,
551 );
552 assert!(close(w, 130.0) && close(h, 200.0), "{w} × {h}");
553 let limits = Retailer::Bod.page_limits().unwrap();
555 assert_eq!((limits.min, limits.max), (24, 900));
556 assert!(limits.contains(24) && limits.contains(900));
557 assert!(!limits.contains(23) && !limits.contains(901));
558 }
559
560 #[test]
561 fn bod_trim_sizes_are_distinct_and_fit_its_maximum() {
562 for (i, (name, trim)) in BOD_TRIM_SIZES.iter().enumerate() {
563 assert!(trim.width_mm > 0.0 && trim.height_mm > 0.0, "{name}");
564 assert!(trim.width_mm <= 216.0 && trim.height_mm <= 297.0, "{name}");
566 assert!(
567 BOD_TRIM_SIZES[i + 1..]
568 .iter()
569 .all(|(n, t)| n != name && t != trim),
570 "duplicate {name}"
571 );
572 }
573 for trim in [TrimSize::DE_12X19, TrimSize::DE_13_5X21_5, TrimSize::A5] {
575 assert!(BOD_TRIM_SIZES.iter().any(|(_, t)| *t == trim), "{trim:?}");
576 }
577 }
578
579 #[test]
580 fn page_limits_per_retailer() {
581 assert_eq!(
582 Retailer::Kdp.page_limits(),
583 Some(PageLimits { min: 24, max: 828 })
584 );
585 assert_eq!(Retailer::Generic.page_limits(), None);
586 for r in ALL {
587 if let Some(limits) = r.page_limits() {
588 assert!(limits.min > 0 && limits.min < limits.max, "{r:?}");
589 assert!(limits.min % 2 == 0 && limits.max % 2 == 0, "{r:?}");
590 }
591 }
592 }
593
594 #[test]
595 fn bleed_per_retailer() {
596 for r in ALL {
597 let bleed = r.bleed();
598 assert!(bleed.mm > 0.0 && bleed.mm <= 5.0, "{r:?}");
599 }
600 assert!(close(Retailer::Kdp.bleed().mm, 3.175) && !Retailer::Kdp.bleed().on_gutter);
601 assert!(close(Retailer::Generic.bleed().mm, 3.0));
602 let on_gutter: Vec<_> = ALL.iter().filter(|r| r.bleed().on_gutter).collect();
604 assert_eq!(on_gutter, [&Retailer::Bod]);
605 }
606
607 #[test]
608 fn retailer_names_in_json() {
609 for (r, name) in [
610 (Retailer::Kdp, "kdp"),
611 (Retailer::IngramSpark, "ingram-spark"),
612 (Retailer::Bod, "bod"),
613 (Retailer::Generic, "generic"),
614 ] {
615 let json = serde_json::to_string(&r).unwrap();
616 assert_eq!(json, format!("\"{name}\""));
617 assert_eq!(serde_json::from_str::<Retailer>(&json).unwrap(), r);
618 }
619 }
620
621 #[test]
622 fn specs_verified_on_is_an_iso_date() {
623 let parts: Vec<u32> = SPECS_VERIFIED_ON
624 .split('-')
625 .map(|p| p.parse().unwrap())
626 .collect();
627 assert_eq!(SPECS_VERIFIED_ON.len(), 10);
628 assert!(parts[0] >= 2026 && (1..=12).contains(&parts[1]) && (1..=31).contains(&parts[2]));
629 }
630
631 #[test]
632 fn inch_presets_are_kdp_standard_trims() {
633 const KDP_US_TRIMS_IN: [(f32, f32); 16] = [
638 (5.0, 8.0),
639 (5.06, 7.81),
640 (5.25, 8.0),
641 (5.5, 8.5),
642 (6.0, 9.0),
643 (6.14, 9.21),
644 (6.69, 9.61),
645 (7.0, 10.0),
646 (7.44, 9.69),
647 (7.5, 9.25),
648 (8.0, 10.0),
649 (8.25, 6.0),
650 (8.25, 8.25),
651 (8.27, 11.69),
652 (8.5, 8.5),
653 (8.5, 11.0),
654 ];
655 for trim in [
656 TrimSize::US_5X8,
657 TrimSize::US_5_25X8,
658 TrimSize::US_5_5X8_5,
659 TrimSize::US_6X9,
660 ] {
661 assert!(
662 KDP_US_TRIMS_IN
663 .iter()
664 .any(|&(w, h)| TrimSize::inches(w, h) == trim),
665 "{trim:?}"
666 );
667 }
668 }
669
670 #[test]
671 fn trim_presets() {
672 let presets = TrimSize::presets();
673 for (i, (name, trim)) in presets.iter().enumerate() {
674 assert!(
675 trim.width_mm > 0.0 && trim.width_mm < trim.height_mm,
676 "{name}"
677 );
678 assert!(
679 presets[i + 1..].iter().all(|(n, t)| n != name && t != trim),
680 "duplicate preset {name}"
681 );
682 }
683 assert!(close(TrimSize::US_5X8.width_mm, 127.0));
684 assert!(close(TrimSize::US_5_5X8_5.height_mm, 215.9));
685 assert_eq!(TrimSize::A5, TrimSize::mm(148.0, 210.0));
686 }
687}