Skip to main content

setzer_model/
presets.rs

1//! Trim sizes and retailer margin rules, as data.
2//!
3//! Retailers change these occasionally. Each rule block below names the pages
4//! it was taken from; [`SPECS_VERIFIED_ON`] is the day they were last compared
5//! with the live pages. Re-verify before every release.
6
7use serde::{Deserialize, Serialize};
8
9const MM_PER_INCH: f32 = 25.4;
10
11/// Day the retailer numbers in this file were last checked against the
12/// sources named next to them (ISO 8601).
13pub 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    /// Landscape, as BoD lists it (21 wide, 15 high).
44    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    /// Named presets shown in the UI.
50    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/// Minimum margins a retailer requires, in millimetres.
64#[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    /// Books on Demand (Germany).
76    Bod,
77    Generic,
78}
79
80/// Bleed a retailer wants on interior pages.
81#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82pub struct Bleed {
83    pub mm: f32,
84    /// Also on the binding (inside) edge. KDP and IngramSpark want bleed on
85    /// the top, bottom and outside edge only.
86    pub on_gutter: bool,
87}
88
89/// Page counts a retailer prints (paperback, black ink on white paper).
90#[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
102// KDP, checked 2026-10-21 against:
103//   https://kdp.amazon.com/en_US/help/topic/GVBQ3CMEQW3W2VL6
104//     ("Set Trim Size, Bleed, and Margins")
105//   https://kdp.amazon.com/help/topic/G201857950
106//     ("Paperback Submission Guidelines")
107//   https://kdp.amazon.com/de_DE/help/topic/GVBQ3CMEQW3W2VL6
108//     (German store; same table, in mm)
109// Nothing changed since the table was first encoded. The pages carry no
110// revision date. The text above the margin table says 0.25 in = 6.35 mm, the
111// table rounds to 6.4 mm; the inch values are what KDP's checker uses.
112
113/// KDP minimum inside (gutter) margin by page count: (up to this many
114/// pages, margin in inches). Longer books than the last row (KDP's
115/// paperback maximum) use the last row.
116pub 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
124/// KDP minimum outside margin (top, bottom, outer edge) in inches, without
125/// and with bleed.
126pub const KDP_OUTSIDE_MARGIN_IN: f32 = 0.25;
127pub const KDP_OUTSIDE_MARGIN_BLEED_IN: f32 = 0.375;
128
129/// KDP bleed in inches, beyond the trim on top, bottom and outside edge
130/// only (a 6 × 9 in trim becomes 6.125 × 9.25 in). If one page bleeds, the
131/// whole interior file must carry bleed.
132pub const KDP_BLEED_IN: f32 = 0.125;
133
134/// KDP paperback page-count limits. The minimum is 24 for black ink; standard
135/// colour ink starts at 72. The maximum is for black ink on white paper on
136/// most trim sizes; cream paper stops at 776, groundwood at 812, and a few
137/// large trims are lower (8.5 × 8.5 and 8.5 × 11 in: 590 on white).
138pub const KDP_MIN_PAGES: u32 = 24;
139pub const KDP_MAX_PAGES: u32 = 828;
140
141// IngramSpark, checked 2026-10-21 against:
142//   https://www.ingramspark.com/hubfs/downloads/file-creation-guide.pdf
143//     (File Creation Guide, dated 8.24.26: black & white text, quick reference)
144//   https://www.ingramspark.com/hubfs/downloads/trim-sizes.pdf
145//     (trim size matrix: page ranges, custom trim sizes)
146// Margin and bleed unchanged. The guide *recommends* at least 0.5 in (13 mm)
147// on all sides of the text block, page numbers included; it has no separate
148// or page-count-dependent gutter figure (blog posts that say otherwise are
149// quoting KDP's table), and it allows 1/16 in of printing variance. Treated
150// as the minimum here. Not checked: the help-centre article "Resolving
151// Common File Issues" (HTTP 403 from here), which might state a hard limit
152// below the recommendation.
153
154/// IngramSpark recommended minimum margin on every side, in inches.
155pub const INGRAM_MARGIN_IN: f32 = 0.5;
156
157/// IngramSpark bleed in inches: 0.125 in (3 mm) on the three trim edges (top,
158/// bottom, outside), none on the bind edge. A book without bleed is
159/// submitted at trim size.
160pub const INGRAM_BLEED_IN: f32 = 0.125;
161
162/// IngramSpark paperback (perfect bound) page-count limits from the trim size
163/// matrix: 18 minimum; the maximum is for black ink on 50 lb white paper.
164/// Crème 50 lb stops at 1050; groundwood 38 lb and white 70 lb at 900. Text
165/// files are stored with an even page count (a blank page is added).
166pub const INGRAM_MIN_PAGES: u32 = 18;
167pub const INGRAM_MAX_PAGES: u32 = 1200;
168
169// BoD (Books on Demand, Germany), checked 2026-10-21 against:
170//   https://www.bod.de/bodfiles/DE-Storage/documents/bod-autorenbroschuere.pdf
171//     (author brochure: the ten standard formats)
172//   https://www.bod.de/buch/buch-gestalten
173//     (bleed: "5 mm, 12 x 19 cm becomes 13 x 20 cm", no crop marks)
174//   https://blog.bod.de/gestalten/zehn-grundregeln-fuer-ein-professionelles-buchblockdesign/
175//     (margin order; bleed "an jeder Seite" 5 mm in a staff reply, 2024-09-04)
176//   https://blog.bod.de/schreiben/das-einrichten-des-buchblocks-mit-microsoft-word/
177//     (example margins; staff reply of 2025-07-17: keep non-bleeding content
178//      at least 5 mm inside the page edge)
179//   https://bod.de/buch/buch-drucken and
180//   https://blog.bod.de/gestalten/buchformat-softcover-alles-was-du-wissen-musst/
181//     (paperback and hardcover: 24 to 900 pages, divisible by two)
182// BoD publishes NO minimum-margin table and no gutter rule that scales with
183// page count. What exists: the 5 mm guidance above (a staff comment, and it
184// does not say whether the binding edge is included), and a tutorial example
185// of 1.7 cm top, bottom and inside, 2 cm outside, which BoD calls an example,
186// not a requirement. The minimum below is that 5 mm; a usable gutter for
187// thick books is a typographic choice made on top of it. Page limits
188// conflict between pages: an older post (2024-04-16) says 90 g paper holds
189// "up to 700 pages", the publisher page lists 1,050 (90 g white) and 900
190// (90 g crème); the author-facing 24 to 900 is used. The orientation of A4
191// is unclear in the brochure (listed 21 x 29.7 once, 29.7 x 21 once);
192// portrait is used. Custom sizes: the publisher page gives 10.8 x 17 cm to
193// 21.6 x 29.7 cm in 1 mm steps, the author pages offer only the ten below.
194
195/// BoD's ten standard formats (width × height), as listed in the author
196/// brochure: seven standard, two large (19 × 27, 21 × 21) and one maxi
197/// (21 × 29.7).
198pub 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
211/// BoD bleed in millimetres: 5 mm on every side, so 12 × 19 cm is delivered
212/// as 13 × 20 cm. Only needed when content runs to the page edge.
213pub const BOD_BLEED_MM: f32 = 5.0;
214
215/// BoD minimum distance of content from the page edge, in millimetres (see
216/// the note above: guidance from a staff comment, not a specification).
217pub const BOD_MIN_MARGIN_MM: f32 = 5.0;
218
219/// BoD paperback and hardcover page-count limits; the count must be even
220/// (blank pages are added and approved in the print preview otherwise).
221pub const BOD_MIN_PAGES: u32 = 24;
222pub const BOD_MAX_PAGES: u32 = 900;
223
224impl Retailer {
225    /// Minimum margins for a book with `pages` pages.
226    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            // IngramSpark recommends 0.5 in all round; treat as minimum.
245            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    /// Bleed for interiors with content that runs to the page edge. Generic
261    /// is Kernsetzer's own default: 3 mm on the three outer edges.
262    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    /// Page counts the retailer prints; `None` where Kernsetzer states no limit.
284    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        // KDP's published rows: 24–150, 151–300, 301–500, 501–700, 701–828.
343        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        // KDP's own example: 6 × 9 in plus bleed is 6.125 × 9.25 in.
387        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        // Even the thickest KDP book leaves at least half the page width
450        // for text on the smallest trim.
451        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        // 0.5 in (13 mm) on all sides, no gutter scaling, no bleed effect.
463        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        // 0.125 in (3 mm) on top, bottom and outside; none on the bind edge.
471        let bleed = Retailer::IngramSpark.bleed();
472        assert!(close(bleed.mm, 3.175) && !bleed.on_gutter);
473        // The guide's bleed chart: 6 × 9 in becomes 6.125 × 9.25 in, 5 × 8 in
474        // becomes 5.125 × 8.25 in.
475        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        // Sizes from IngramSpark's trim size matrix (inches) that Kernsetzer's
490        // trim presets correspond to: 5 × 8, 5.25 × 8, 5.5 × 8.5, 5.83 × 8.27
491        // (A5), 6 × 9 and 4.72 × 7.48 in (12 × 19 cm). 13.5 × 21.5 cm is not a
492        // standard size there; it needs a custom trim (width 4–8.5 in, length
493        // 6–11 in on white paper).
494        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        // Seven standard, two large, one maxi.
523        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        // 5 mm bleed on every side: 12 × 19 cm is delivered as 13 × 20 cm.
546        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        // 24 to 900 pages.
554        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            // The publisher page's maximum format is 21.6 × 29.7 cm.
565            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        // The German formats Kernsetzer already lists are all BoD formats.
574        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        // Only BoD wants bleed on the binding edge as well.
603        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        // KDP's US-store trim table (regular and large), in inches, as of
634        // SPECS_VERIFIED_ON. A5 appears only in the Japan-store table; 12 × 19
635        // and 13.5 × 21.5 cm in neither (custom trim sizes are not described
636        // on the pages above), so only the inch presets are checked.
637        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}