Skip to main content

docling_pdf/
enrich.rs

1//! Optional enrichment models (docling's `do_picture_classification` /
2//! `do_code_enrichment` / `do_formula_enrichment`, issue #76).
3//!
4//! * [`PictureClassifier`] — docling-project/DocumentFigureClassifier-v2.5, an
5//!   EfficientNet image classifier over 26 figure classes (bar_chart, logo,
6//!   signature, …). The HF repo ships the ONNX graph as-is; docling's ViT
7//!   preprocessing is 224×224 bilinear + rescale + normalize, and the raw
8//!   logits are softmaxed and sorted descending — the full distribution lands
9//!   on the picture item like docling's `PictureClassificationData`.
10//!
11//! * [`CodeFormula`] — docling-project/CodeFormulaV2, an Idefics3/SmolVLM-class
12//!   VLM that rewrites a code crop as clean source text prefixed with
13//!   `<_language_>`, or a formula crop as LaTeX. Exported to three graphs by
14//!   `scripts/install/export_code_formula.py` (vision tower+connector, token
15//!   embeddings, and a KV-cached Llama decoder step verified argmax-identical
16//!   to `transformers.generate`); this module ports the Idefics3 preprocessing
17//!   (longest-edge 2048 resize → multiple-of-512 resize → 512×512 tiling + a
18//!   squashed global tile), the tiled `<image>` prompt, and the greedy decode.
19
20use image::RgbImage;
21use ort::session::Session;
22use ort::value::Tensor;
23use tokenizers::Tokenizer;
24
25use docling_core::PictureClass;
26
27/// docling crops enrichment inputs at `images_scale` pixels per point:
28/// 2.0 for the picture classifier, 1.67 (≈120 dpi) for CodeFormula.
29pub const CLASSIFIER_SCALE: f32 = 2.0;
30pub const CODE_FORMULA_SCALE: f32 = 1.67;
31/// CodeFormula expands the region box by 18% of its size on every side
32/// (docling's `expansion_factor`) before cropping.
33pub const CODE_FORMULA_EXPANSION: f32 = 0.18;
34
35// ---------------------------------------------------------------------------
36// Picture classifier
37// ---------------------------------------------------------------------------
38
39/// The 26 classes of DocumentFigureClassifier-v2.5, indexed by model class id
40/// (`config.json` `id2label`).
41pub const PICTURE_CLASSES: [&str; 26] = [
42    "logo",
43    "photograph",
44    "icon",
45    "engineering_drawing",
46    "line_chart",
47    "bar_chart",
48    "other",
49    "table",
50    "flow_chart",
51    "screenshot_from_computer",
52    "signature",
53    "screenshot_from_manual",
54    "geographical_map",
55    "pie_chart",
56    "page_thumbnail",
57    "stamp",
58    "music",
59    "calendar",
60    "qr_code",
61    "bar_code",
62    "full_page_image",
63    "scatter_plot",
64    "chemistry_structure",
65    "topographical_map",
66    "crossword_puzzle",
67    "box_plot",
68];
69
70const CLASSIFIER_SIDE: u32 = 224;
71/// ViT preprocessing constants from the model's `preprocessor_config.json`.
72const CLASSIFIER_MEAN: [f32; 3] = [0.485, 0.456, 0.406];
73const CLASSIFIER_STD: [f32; 3] = [0.478_539_44, 0.473_286_4, 0.474_341_63];
74
75pub struct PictureClassifier {
76    session: Session,
77}
78
79impl PictureClassifier {
80    /// Load from `DOCLING_PICTURE_CLASSIFIER_ONNX` /
81    /// `.models/picture_classifier(_int8).onnx`. `None` when the graph is
82    /// absent — the caller warns once and skips classification.
83    pub fn load_with(intra: usize) -> Option<Self> {
84        let path = crate::model_path(
85            "DOCLING_PICTURE_CLASSIFIER_ONNX",
86            ".models/picture_classifier.onnx",
87            ".models/picture_classifier_int8.onnx",
88        );
89        if !std::path::Path::new(&path).exists() {
90            eprintln!(
91                "docling-pdf: picture classifier model not found ({path}); \
92                 picture classification skipped. Run scripts/install/download_dependencies.sh."
93            );
94            return None;
95        }
96        let builder = docling_onnx::session_builder()
97            .map_err(|e| eprintln!("docling-pdf: picture classifier: {e}"))
98            .ok()?
99            .with_intra_threads(intra)
100            .ok()?;
101        let builder = docling_onnx::apply(builder)
102            .and_then(docling_onnx::cap_before_1_29)
103            .map_err(|e| eprintln!("docling-pdf: picture classifier: {e}"))
104            .ok()?;
105        let session = docling_onnx::commit_uncached(builder, &path)
106            .map_err(|e| eprintln!("docling-pdf: picture classifier load {path}: {e}"))
107            .ok()?;
108        Some(Self { session })
109    }
110
111    /// Classify one picture crop: the full 26-class distribution, descending
112    /// confidence (docling attaches all predicted classes, not just the top).
113    pub fn classify(&mut self, crop: &RgbImage) -> Result<Vec<PictureClass>, String> {
114        let resized = image::imageops::resize(
115            crop,
116            CLASSIFIER_SIDE,
117            CLASSIFIER_SIDE,
118            image::imageops::FilterType::Triangle,
119        );
120        let n = (CLASSIFIER_SIDE * CLASSIFIER_SIDE) as usize;
121        let mut data = vec![0f32; 3 * n];
122        for (i, px) in resized.pixels().enumerate() {
123            for c in 0..3 {
124                data[c * n + i] = (px[c] as f32 / 255.0 - CLASSIFIER_MEAN[c]) / CLASSIFIER_STD[c];
125            }
126        }
127        let input = Tensor::from_array((
128            [
129                1usize,
130                3,
131                CLASSIFIER_SIDE as usize,
132                CLASSIFIER_SIDE as usize,
133            ],
134            data,
135        ))
136        .map_err(|e| format!("picture classifier: input: {e}"))?;
137        let outputs = docling_onnx::run(&mut self.session, ort::inputs!["input" => input])
138            .map_err(|e| format!("picture classifier: inference: {e}"))?;
139        let (_, logits) = outputs[0]
140            .try_extract_tensor::<f32>()
141            .map_err(|e| format!("picture classifier: output: {e}"))?;
142        // softmax → (class, prob) sorted descending, like docling's engine.
143        let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
144        let exp: Vec<f32> = logits.iter().map(|&v| (v - max).exp()).collect();
145        let sum: f32 = exp.iter().sum();
146        let mut preds: Vec<PictureClass> = exp
147            .iter()
148            .enumerate()
149            .map(|(i, &e)| PictureClass {
150                class_name: PICTURE_CLASSES.get(i).copied().unwrap_or("other").into(),
151                confidence: e / sum,
152            })
153            .collect();
154        preds.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
155        Ok(preds)
156    }
157}
158
159// ---------------------------------------------------------------------------
160// CodeFormula (Idefics3 VLM)
161// ---------------------------------------------------------------------------
162
163/// Which prompt the model gets for a region.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum CodeFormulaKind {
166    Code,
167    Formula,
168}
169
170/// Idefics3 processor constants (CodeFormulaV2's `preprocessor_config.json` /
171/// tokenizer). The token ids are fixed by the checkpoint's tokenizer.json.
172const TILE: u32 = 512; // max_image_size.longest_edge
173const LONGEST_EDGE: u32 = 2048; // size.longest_edge
174const MAX_IMAGE_SIZE: u32 = 4096; // transformers' hard upper bound
175const IMAGE_SEQ_LEN: usize = 64; // visual tokens per tile
176const IMAGE_TOKEN_ID: i64 = 100270; // <image>
177const EOS_ID: i64 = 100338; // <end_of_utterance>
178const MODEL_MAX_LEN: usize = 8192;
179const HIDDEN: usize = 576;
180const N_LAYERS: usize = 30;
181const N_KV: usize = 3;
182const HEAD_DIM: usize = 64;
183
184pub struct CodeFormula {
185    vision: Session,
186    embed: Session,
187    decoder: Session,
188    tokenizer: Tokenizer,
189}
190
191impl CodeFormula {
192    /// Load the three graphs + tokenizer from `DOCLING_CODE_FORMULA_DIR`
193    /// (default `.models/code_formula/`). `None` when absent, with a one-time
194    /// warning from the caller's slot.
195    pub fn load_with(intra: usize) -> Option<Self> {
196        let dir = docling_core::env::nonempty("DOCLING_CODE_FORMULA_DIR")
197            .unwrap_or_else(|| crate::resolve_asset(".models/code_formula"));
198        let file = |name: &str| format!("{dir}/{name}");
199        // INT8 variants take priority when present, like the other models.
200        let graph = |base: &str| {
201            let int8 = file(&format!("{base}_int8.onnx"));
202            if !crate::prefer_fp32() && std::path::Path::new(&int8).exists() {
203                int8
204            } else {
205                file(&format!("{base}.onnx"))
206            }
207        };
208        for f in [&graph("vision"), &graph("embed"), &graph("decoder_kv")] {
209            if !std::path::Path::new(f.as_str()).exists() {
210                eprintln!(
211                    "docling-pdf: CodeFormula model not found ({f}); code/formula \
212                     enrichment skipped. Run scripts/install/download_dependencies.sh."
213                );
214                return None;
215            }
216        }
217        let load = |p: String| {
218            let builder = docling_onnx::session_builder()
219                .map_err(|e| eprintln!("docling-pdf: CodeFormula: {e}"))
220                .ok()?
221                .with_intra_threads(intra)
222                .ok()?;
223            let builder = docling_onnx::apply(builder)
224                .map_err(|e| eprintln!("docling-pdf: CodeFormula: {e}"))
225                .ok()?;
226            docling_onnx::commit_uncached(builder, &p)
227                .map_err(|e| eprintln!("docling-pdf: CodeFormula load {p}: {e}"))
228                .ok()
229        };
230        let tokenizer = Tokenizer::from_file(file("tokenizer.json"))
231            .map_err(|e| eprintln!("docling-pdf: CodeFormula tokenizer: {e}"))
232            .ok()?;
233        Some(Self {
234            vision: load(graph("vision"))?,
235            embed: load(graph("embed"))?,
236            decoder: load(graph("decoder_kv"))?,
237            tokenizer,
238        })
239    }
240
241    /// Run the VLM on a code/formula crop and return the post-processed text
242    /// (still carrying the `<_language_>` prefix for code — see
243    /// [`extract_code_language`]).
244    pub fn predict(&mut self, crop: &RgbImage, kind: CodeFormulaKind) -> Result<String, String> {
245        // Debug aid: dump each crop the VLM sees (compare against docling's
246        // `prepare_element` output when chasing a generation divergence).
247        if let Some(dir) = docling_core::env::nonempty("DOCLING_RS_ENRICH_DEBUG") {
248            use std::sync::atomic::{AtomicUsize, Ordering};
249            static N: AtomicUsize = AtomicUsize::new(0);
250            let n = N.fetch_add(1, Ordering::Relaxed);
251            let _ = crop.save(format!("{dir}/rs_crop_{n}.png"));
252        }
253        let (tiles, rows, cols) = preprocess_idefics3(crop);
254        let n_tiles = tiles.len() / (3 * (TILE * TILE) as usize);
255
256        // Vision tower over the tile batch → [T, 64, 576]. (Scoped: the
257        // session outputs hold a mutable borrow of the session until dropped.)
258        let feats: Vec<f32> = {
259            let input = Tensor::from_array(([n_tiles, 3, TILE as usize, TILE as usize], tiles))
260                .map_err(|e| format!("code-formula: vision input: {e}"))?;
261            let outputs =
262                docling_onnx::run(&mut self.vision, ort::inputs!["pixel_values" => input])
263                    .map_err(|e| format!("code-formula: vision: {e}"))?;
264            let (_, feats) = outputs["image_features"]
265                .try_extract_tensor::<f32>()
266                .map_err(|e| format!("code-formula: vision output: {e}"))?;
267            feats.to_vec()
268        };
269
270        // Prompt: the chat template with the single <image> expanded into the
271        // per-tile token grid (transformers' Idefics3Processor layout).
272        let query = match kind {
273            CodeFormulaKind::Code => "<code>",
274            CodeFormulaKind::Formula => "<formula>",
275        };
276        let prompt = format!(
277            "<|start_of_role|>user:{}{query}<end_of_utterance>\nassistant:",
278            image_prompt(rows, cols)
279        );
280        let enc = self
281            .tokenizer
282            .encode(prompt, false)
283            .map_err(|e| format!("code-formula: tokenize: {e}"))?;
284        let ids: Vec<i64> = enc.get_ids().iter().map(|&v| v as i64).collect();
285        let seq = ids.len();
286
287        // Token embeddings, then scatter the visual tokens into the <image>
288        // positions (Idefics3's inputs_merger).
289        let mut embeds = self.embed_ids(&ids)?;
290        let image_positions: Vec<usize> = ids
291            .iter()
292            .enumerate()
293            .filter(|(_, &t)| t == IMAGE_TOKEN_ID)
294            .map(|(i, _)| i)
295            .collect();
296        if image_positions.len() != n_tiles * IMAGE_SEQ_LEN {
297            return Err(format!(
298                "code-formula: {} image tokens for {} tiles",
299                image_positions.len(),
300                n_tiles
301            ));
302        }
303        for (v, &pos) in image_positions.iter().enumerate() {
304            embeds[pos * HIDDEN..(pos + 1) * HIDDEN]
305                .copy_from_slice(&feats[v * HIDDEN..(v + 1) * HIDDEN]);
306        }
307
308        // Greedy KV-cache decode until <end_of_utterance> (docling caps
309        // generation at the model's 8192 context). The K/V caches stay owned
310        // `ort` values fed straight back into the next step — never extracted
311        // or copied (they grow every step, so per-step copies would be
312        // O(steps²) float traffic; same pattern as the TableFormer decoder).
313        // ort's array constructors reject a 0-length dim, so the zero-`past`
314        // first-step tensors go through the session allocator.
315        let mut cache: Option<(ort::value::DynValue, ort::value::DynValue)> = None;
316        let empty = {
317            let mk = || {
318                Tensor::<f32>::new(
319                    self.decoder.allocator(),
320                    [N_LAYERS, 1, N_KV, 0usize, HEAD_DIM],
321                )
322                .map_err(|e| format!("code-formula: empty kv cache: {e}"))
323            };
324            (mk()?, mk()?)
325        };
326        let mut past_len = 0usize;
327        let mut positions: Vec<i64> = (0..seq as i64).collect();
328        let mut x = embeds;
329        let mut x_seq = seq;
330        let mut out_ids: Vec<u32> = Vec::new();
331        let max_new = MODEL_MAX_LEN.saturating_sub(seq);
332        for _ in 0..max_new {
333            let embeds_t = Tensor::from_array(([1usize, x_seq, HIDDEN], x))
334                .map_err(|e| format!("code-formula: embeds: {e}"))?;
335            let pos_t = Tensor::from_array(([1usize, positions.len()], positions.clone()))
336                .map_err(|e| format!("code-formula: positions: {e}"))?;
337            let next = {
338                let mut out = match cache.as_ref() {
339                    Some((k, v)) => docling_onnx::run(
340                        &mut self.decoder,
341                        ort::inputs![
342                        "inputs_embeds" => embeds_t, "position_ids" => pos_t,
343                        "past_k" => k, "past_v" => v],
344                    ),
345                    None => docling_onnx::run(
346                        &mut self.decoder,
347                        ort::inputs![
348                        "inputs_embeds" => embeds_t, "position_ids" => pos_t,
349                        "past_k" => &empty.0, "past_v" => &empty.1],
350                    ),
351                }
352                .map_err(|e| format!("code-formula: decoder: {e}"))?;
353                let (_, logits) = out["logits"]
354                    .try_extract_tensor::<f32>()
355                    .map_err(|e| format!("code-formula: logits: {e}"))?;
356                let next = logits
357                    .iter()
358                    .enumerate()
359                    .max_by(|a, b| a.1.total_cmp(b.1))
360                    .map(|(i, _)| i as i64)
361                    .unwrap_or(EOS_ID);
362                cache = Some((
363                    out.remove("new_k")
364                        .ok_or_else(|| "code-formula: new_k missing".to_string())?,
365                    out.remove("new_v")
366                        .ok_or_else(|| "code-formula: new_v missing".to_string())?,
367                ));
368                next
369            };
370            past_len += x_seq;
371            if next == EOS_ID {
372                break;
373            }
374            out_ids.push(next as u32);
375            x = self.embed_ids(&[next])?;
376            x_seq = 1;
377            positions = vec![past_len as i64];
378        }
379
380        let text = self
381            .tokenizer
382            .decode(&out_ids, false)
383            .map_err(|e| format!("code-formula: decode: {e}"))?;
384        Ok(post_process(&text))
385    }
386
387    fn embed_ids(&mut self, ids: &[i64]) -> Result<Vec<f32>, String> {
388        let input = Tensor::from_array(([1usize, ids.len()], ids.to_vec()))
389            .map_err(|e| format!("code-formula: ids: {e}"))?;
390        let out = docling_onnx::run(&mut self.embed, ort::inputs!["input_ids" => input])
391            .map_err(|e| format!("code-formula: embed: {e}"))?;
392        let (_, embeds) = out["inputs_embeds"]
393            .try_extract_tensor::<f32>()
394            .map_err(|e| format!("code-formula: embed output: {e}"))?;
395        Ok(embeds.to_vec())
396    }
397}
398
399/// The `<image>` expansion for a rows×cols tile grid + global tile
400/// (transformers' `Idefics3Processor.replace_image_token`).
401fn image_prompt(rows: u32, cols: u32) -> String {
402    let img = "<image>".repeat(IMAGE_SEQ_LEN);
403    let mut s = String::new();
404    for r in 1..=rows {
405        for c in 1..=cols {
406            s.push_str(&format!("<fake_token_around_image><row_{r}_col_{c}>{img}"));
407        }
408        s.push('\n');
409    }
410    s.push_str(&format!(
411        "\n<fake_token_around_image><global-img>{img}<fake_token_around_image>"
412    ));
413    s
414}
415
416/// Idefics3 image preprocessing: longest-edge 2048 resize (LANCZOS), resize to
417/// multiples of 512, split into 512×512 tiles (row-major) plus the whole image
418/// squashed to 512×512 as the trailing global tile, then `(x/255 - 0.5)/0.5`.
419/// Returns the flattened `[T,3,512,512]` tensor data and the grid shape.
420fn preprocess_idefics3(crop: &RgbImage) -> (Vec<f32>, u32, u32) {
421    use image::imageops::FilterType;
422    // 1. longest edge → 2048 exactly (up- or down-scale), short side rounded
423    //    to even, both clamped below 4096 (rescale_to_max_len).
424    let (w0, h0) = crop.dimensions();
425    let (mut w, mut h) = rescale_to_max_len(w0, h0, LONGEST_EDGE);
426    (h, w) = scale_below_upper_bound(h, w, MAX_IMAGE_SIZE);
427    let img = image::imageops::resize(crop, w, h, FilterType::Lanczos3);
428
429    // 2. ceil each side to a multiple of the 512 tile (resize_for_vision_encoder).
430    let (tw, th) = if w >= h {
431        let tw = w.div_ceil(TILE) * TILE;
432        let th0 = (tw as f64 / (w as f64 / h as f64)) as u32;
433        (tw, th0.div_ceil(TILE) * TILE)
434    } else {
435        let th = h.div_ceil(TILE) * TILE;
436        let tw0 = (th as f64 * (w as f64 / h as f64)) as u32;
437        (tw0.div_ceil(TILE) * TILE, th)
438    };
439    let img = image::imageops::resize(&img, tw, th, FilterType::Lanczos3);
440
441    // 3. tiles (row-major) + the global squash.
442    let (rows, cols) = (th / TILE, tw / TILE);
443    let mut tensor = Vec::with_capacity(((rows * cols + 1) * 3 * TILE * TILE) as usize);
444    for r in 0..rows {
445        for c in 0..cols {
446            let tile = image::imageops::crop_imm(&img, c * TILE, r * TILE, TILE, TILE).to_image();
447            push_normalized(&mut tensor, &tile);
448        }
449    }
450    let global = image::imageops::resize(&img, TILE, TILE, FilterType::Lanczos3);
451    push_normalized(&mut tensor, &global);
452    (tensor, rows, cols)
453}
454
455/// transformers' `_resize_output_size_rescale_to_max_len`: longest edge to
456/// `max_len` exactly, the short side `int(long/aspect)` bumped to even.
457fn rescale_to_max_len(w0: u32, h0: u32, max_len: u32) -> (u32, u32) {
458    let aspect = w0 as f64 / h0 as f64;
459    let (w, h) = if w0 >= h0 {
460        let w = max_len;
461        let mut h = (w as f64 / aspect) as u32;
462        if !h.is_multiple_of(2) {
463            h += 1;
464        }
465        (w, h)
466    } else {
467        let h = max_len;
468        let mut w = (h as f64 * aspect) as u32;
469        if !w.is_multiple_of(2) {
470            w += 1;
471        }
472        (w, h)
473    };
474    (w.max(1), h.max(1))
475}
476
477/// transformers' `_resize_output_size_scale_below_upper_bound` (a no-op unless
478/// the even-bump pushed a side past the hard 4096 cap).
479fn scale_below_upper_bound(h0: u32, w0: u32, max_len: u32) -> (u32, u32) {
480    let aspect = w0 as f64 / h0 as f64;
481    let (h, w) = if w0 >= h0 && w0 > max_len {
482        let w = max_len;
483        (((w as f64 / aspect) as u32).max(1), w)
484    } else if h0 > w0 && h0 > max_len {
485        let h = max_len;
486        (h, ((h as f64 * aspect) as u32).max(1))
487    } else {
488        (h0, w0)
489    };
490    (h.max(1), w.max(1))
491}
492
493/// Append one 512×512 tile as CHW `(x/255 - 0.5)/0.5`.
494fn push_normalized(tensor: &mut Vec<f32>, tile: &RgbImage) {
495    let n = (TILE * TILE) as usize;
496    let base = tensor.len();
497    tensor.resize(base + 3 * n, 0.0);
498    for (i, px) in tile.pixels().enumerate() {
499        for c in 0..3 {
500            tensor[base + c * n + i] = px[c] as f32 / 255.0 * 2.0 - 1.0;
501        }
502    }
503}
504
505/// docling's CodeFormulaModel post-processing: truncate at
506/// `<end_of_utterance>`, remove the closing/query artifacts, strip leading
507/// whitespace.
508fn post_process(text: &str) -> String {
509    let mut t = match text.find("<end_of_utterance>") {
510        Some(i) => &text[..i],
511        None => text,
512    }
513    .to_string();
514    for tok in ["</code>", "</formula>", "<loc_0><loc_0><loc_500><loc_500>"] {
515        t = t.replace(tok, "");
516    }
517    t.trim_start().to_string()
518}
519
520/// docling's `_extract_code_language`: an output beginning with
521/// `<_language_>` yields `(remainder, Some(language))`.
522pub fn extract_code_language(s: &str) -> (String, Option<String>) {
523    let rest = match s.strip_prefix("<_") {
524        Some(r) => r,
525        None => return (s.to_string(), None),
526    };
527    // The language is everything up to the closing `_>` that contains neither
528    // `_` nor `>` (docling's `[^_>]+`).
529    match rest.find("_>") {
530        Some(end) if !rest[..end].is_empty() && !rest[..end].contains(['_', '>']) => {
531            let lang = rest[..end].to_string();
532            let remainder = rest[end + 2..].trim_start().to_string();
533            (remainder, Some(lang))
534        }
535        _ => (s.to_string(), None),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn language_prefix_extraction() {
545        assert_eq!(
546            extract_code_language("<_JavaScript_> function f() {}"),
547            (
548                "function f() {}".to_string(),
549                Some("JavaScript".to_string())
550            )
551        );
552        assert_eq!(
553            extract_code_language("plain text"),
554            ("plain text".to_string(), None)
555        );
556        assert_eq!(
557            extract_code_language("<_x_y_> t"),
558            ("<_x_y_> t".to_string(), None)
559        );
560    }
561
562    #[test]
563    fn idefics3_grid_matches_processor() {
564        // An 800×300 crop resizes to 2048×768, tiles to 2048×1024 → 4×2 grid
565        // (+ global) — the shape verified against transformers' processor.
566        let img = RgbImage::new(800, 300);
567        let (tensor, rows, cols) = preprocess_idefics3(&img);
568        assert_eq!((rows, cols), (2, 4));
569        assert_eq!(tensor.len(), 9 * 3 * 512 * 512);
570    }
571
572    #[test]
573    fn image_prompt_layout() {
574        let p = image_prompt(1, 2);
575        assert!(p.starts_with("<fake_token_around_image><row_1_col_1><image>"));
576        assert!(p.contains("<row_1_col_2>"));
577        let tail = "<fake_token_around_image><global-img>".to_owned()
578            + &"<image>".repeat(64)
579            + "<fake_token_around_image>";
580        assert!(p.ends_with(&tail));
581        assert_eq!(p.matches("<image>").count(), 3 * 64);
582    }
583}