Skip to main content

pdf_engine/
markdown_images.rs

1//! Figures in the block model: listing, naming, pixels, and references.
2//!
3//! A figure is a placed image or a drawn vector cluster that stands beside
4//! prose. The text pass records both as boxes; this module turns those boxes
5//! into [`Block`](crate::blocks::Block)s late in extraction, names them, and
6//! renders their pixels for embeds and written files.
7//!
8//! Names are derived, never stored: `figure-<page>-<ordinal>.png` with the
9//! page and the figure's number on that page, both counted from one, in the
10//! order the blocks list them. Every renderer -- markers, embeds, files --
11//! enumerates the same way, so one figure has one name everywhere.
12
13// Copyright (c) 2026 Innovation Trigger B.V.
14//
15// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
16// the PDFluent Commercial Licence. See the LICENSE file in this repository --
17// that file travels with the copy you received, which a URL does not.
18
19use crate::blocks::{Block, BlockKind, ExtractOptions};
20use crate::error::{EngineError, Result};
21use crate::render::{PixelFormat, RenderOptions};
22
23/// Resolution figure boxes are rendered at, in dots per inch.
24///
25/// Display points scaled by 150/72: a 20-point side becomes a 42-pixel one.
26pub const FIGURE_DPI: f64 = 150.0;
27
28/// How many pages the standalone graphics scan reads before it stops.
29///
30/// [`PdfDocument::extract_image_boxes`](crate::PdfDocument::extract_image_boxes)
31/// re-interprets pages for their boxes alone, so past this many pages the
32/// cost outweighs the listing and later pages report no figures. Extraction
33/// itself is not capped: it reads every page for text anyway, and the boxes
34/// come free with that pass.
35pub const MAX_GRAPHICS_SCAN_PAGES: usize = 50;
36
37/// One figure of the document, with its rendered pixels: a placed image or
38/// a drawn vector cluster, unified -- a drawing beside prose is a figure
39/// the way a photograph is.
40#[derive(Debug, Clone, PartialEq)]
41pub struct Figure {
42    /// 0-based page index the figure stands on.
43    pub page: usize,
44    /// Bounding box in PDF user space, `[x0, y0, x1, y1]` with y up.
45    pub bbox: [f64; 4],
46    /// The figure's file name, [`figure_name`] of its page and number.
47    pub name: String,
48    /// The figure's box rendered at [`FIGURE_DPI`], encoded as PNG.
49    pub png: Vec<u8>,
50}
51
52/// The file name of a figure: `figure-<page>-<ordinal>.png`.
53///
54/// Both counts arrive 0-based and leave 1-based and zero-padded, so the
55/// first figure of the first page is `figure-0001-01.png`.
56pub fn figure_name(page: usize, ordinal: usize) -> String {
57    format!("figure-{:04}-{:02}.png", page + 1, ordinal + 1)
58}
59
60/// Whether a point falls strictly inside a box.
61fn contains(point: (f64, f64), bbox: &[f64; 4]) -> bool {
62    point.0 > bbox[0] && point.0 < bbox[2] && point.1 > bbox[1] && point.1 < bbox[3]
63}
64
65/// Drop the body blocks drawn over a placed image.
66///
67/// A block goes when its centre falls strictly inside an image box on its
68/// own page. Running heads and feet never go: page chrome is positioned
69/// independently of figures. Figures themselves carry no text and stay.
70pub(crate) fn drop_text_over_images(blocks: Vec<Block>, images: &[Vec<[f64; 4]>]) -> Vec<Block> {
71    blocks
72        .into_iter()
73        .filter(|block| {
74            if block.text.trim().is_empty() {
75                return true;
76            }
77            if matches!(block.kind, BlockKind::Header | BlockKind::Footer) {
78                return true;
79            }
80            let centre = (
81                (block.bbox[0] + block.bbox[2]) / 2.0,
82                (block.bbox[1] + block.bbox[3]) / 2.0,
83            );
84            !images
85                .get(block.page)
86                .is_some_and(|boxes| boxes.iter().any(|bbox| contains(centre, bbox)))
87        })
88        .collect()
89}
90
91/// Merge figure blocks into finished text blocks.
92///
93/// One figure per placed image on every page, then one per drawn cluster on
94/// every page that [`page_has_text`] marks: a page with no text block still
95/// carries its images -- a scan reads as its figure markers -- but no drawn
96/// cluster, which without prose beside it is page furniture. Figures stand
97/// where they fall vertically -- before the first text block whose top edge
98/// is below theirs -- the furniture stays trailing, and every block is
99/// renumbered in place.
100pub(crate) fn merge_figures(
101    blocks: Vec<Block>,
102    images: &[Vec<[f64; 4]>],
103    graphics: &[Vec<[f64; 4]>],
104    page_has_text: &[bool],
105) -> Vec<Block> {
106    let (mut body, furniture): (Vec<Block>, Vec<Block>) = blocks
107        .into_iter()
108        .partition(|block| !matches!(block.kind, BlockKind::Header | BlockKind::Footer));
109    body.sort_by_key(|block| block.page);
110    let pages = images.len().max(graphics.len());
111    let mut merged: Vec<Block> = Vec::with_capacity(body.len() + pages);
112    let mut at = 0usize;
113    for page in 0..pages {
114        let end = body[at..]
115            .iter()
116            .take_while(|block| block.page == page)
117            .count()
118            + at;
119        let text = &body[at..end];
120        at = end;
121        if !page_has_text.get(page).copied().unwrap_or(false) {
122            // A textless page still carries its placed images: a scan is one
123            // figure marker, not an empty document. Drawn clusters stay
124            // withheld -- without prose they are page furniture, not figures.
125            if let Some(boxes) = images.get(page) {
126                let mut figures: Vec<[f64; 4]> = boxes.clone();
127                figures.sort_by(|a, b| b[3].total_cmp(&a[3]));
128                for bbox in figures {
129                    merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
130                }
131            }
132            merged.extend(text.iter().cloned());
133            continue;
134        }
135        let mut figures: Vec<([f64; 4], f64)> = Vec::new();
136        if let Some(boxes) = images.get(page) {
137            figures.extend(boxes.iter().map(|bbox| (*bbox, bbox[3])));
138        }
139        if let Some(boxes) = graphics.get(page) {
140            figures.extend(boxes.iter().map(|bbox| (*bbox, bbox[3])));
141        }
142        figures.sort_by(|a, b| b.1.total_cmp(&a.1));
143        let mut figures = figures.into_iter().peekable();
144        for block in text {
145            while figures.peek().is_some_and(|(_, top)| *top >= block.bbox[3]) {
146                if let Some((bbox, _)) = figures.next() {
147                    merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
148                }
149            }
150            merged.push(block.clone());
151        }
152        for (bbox, _) in figures {
153            merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
154        }
155    }
156    merged.extend(body[at..].iter().cloned());
157    merged.extend(furniture);
158    for (index, block) in merged.iter_mut().enumerate() {
159        block.index = index;
160    }
161    merged
162}
163
164/// Render one page's figure boxes to PNGs, in order, from a single render.
165///
166/// Boxes are in PDF points, y up; the render is [`FIGURE_DPI`] pixels per
167/// point, y down. A box is floored at its low corner and ceiled at its high
168/// one, so the crop always covers the figure, then clamped to the page: a
169/// figure wholly off the page comes back as one white pixel rather than as
170/// an error. Rotated pages map best-effort onto the same frame.
171pub(crate) fn render_crops(
172    doc: &crate::PdfDocument,
173    page: usize,
174    boxes: &[[f64; 4]],
175) -> Result<Vec<Vec<u8>>> {
176    if boxes.is_empty() {
177        return Ok(Vec::new());
178    }
179    let rendered = doc.render_page(
180        page,
181        &RenderOptions {
182            dpi: FIGURE_DPI,
183            ..Default::default()
184        },
185    )?;
186    if rendered.pixel_format != PixelFormat::Rgba8 {
187        return Err(EngineError::RenderError(format!(
188            "figure render of page {} came back {:?}, not RGBA",
189            page, rendered.pixel_format
190        )));
191    }
192    let scale = FIGURE_DPI / 72.0;
193    let (width, height) = (rendered.width, rendered.height);
194    let mut out = Vec::with_capacity(boxes.len());
195    for bbox in boxes {
196        let x0 = ((bbox[0] * scale).floor() as i64).clamp(0, width as i64);
197        let x1 = ((bbox[2] * scale).ceil() as i64).clamp(0, width as i64);
198        let y1 = (height as i64 - (bbox[1] * scale).floor() as i64).clamp(0, height as i64);
199        let y0 = (height as i64 - (bbox[3] * scale).ceil() as i64).clamp(0, height as i64);
200        let (x0, x1) = (x0.min(x1), x0.max(x1));
201        let (y0, y1) = (y0.min(y1), y0.max(y1));
202        let (crop_w, crop_h) = ((x1 - x0).max(1) as u32, (y1 - y0).max(1) as u32);
203        let mut rgb = vec![255u8; crop_w as usize * crop_h as usize * 3];
204        if x1 > x0 && y1 > y0 {
205            for (row, y) in (y0..y1).enumerate() {
206                for (col, x) in (x0..x1).enumerate() {
207                    let from = (y as usize * width as usize + x as usize) * 4;
208                    let Some(px) = rendered.pixels.get(from..from + 4) else {
209                        continue;
210                    };
211                    // Premultiplied RGBA over the white page: the white shows
212                    // through by exactly the missing share of alpha.
213                    let missing = 255 - px[3];
214                    let to = (row * crop_w as usize + col) * 3;
215                    if let Some(slot) = rgb.get_mut(to..to + 3) {
216                        slot[0] = px[0].saturating_add(missing);
217                        slot[1] = px[1].saturating_add(missing);
218                        slot[2] = px[2].saturating_add(missing);
219                    }
220                }
221            }
222        }
223        out.push(png_bytes(crop_w, crop_h, &rgb));
224    }
225    Ok(out)
226}
227
228/// Encode RGB bytes as a PNG: 8-bit truecolour, one filter byte per row.
229///
230/// Stored deflate blocks -- no compressor on board, so cooperation with one
231/// is out -- which every decoder reads; the bytes are deterministic for the
232/// pixels. Short input reads as black past its end rather than panicking.
233pub(crate) fn png_bytes(width: u32, height: u32, rgb: &[u8]) -> Vec<u8> {
234    let stride = width as usize * 3;
235    let mut raw = Vec::with_capacity(height as usize * (stride + 1));
236    for y in 0..height as usize {
237        raw.push(0);
238        match rgb.get(y * stride..(y + 1) * stride) {
239            Some(row) => raw.extend_from_slice(row),
240            None => raw.extend(std::iter::repeat_n(0, stride)),
241        }
242    }
243    // A zlib stream of stored blocks: header, then `BFINAL | 00` + LEN +
244    // NLEN + up to 65535 bytes each, then the Adler-32 of the raw rows.
245    let mut zlib = vec![0x78, 0x01];
246    let mut chunks = raw.chunks(65535).peekable();
247    while let Some(chunk) = chunks.next() {
248        let last = chunks.peek().is_none();
249        zlib.push(u8::from(last));
250        let len = chunk.len() as u16;
251        zlib.extend_from_slice(&len.to_le_bytes());
252        zlib.extend_from_slice(&(!len).to_le_bytes());
253        zlib.extend_from_slice(chunk);
254    }
255    // An empty image still needs its one empty stored block.
256    if raw.is_empty() {
257        zlib.extend_from_slice(&[1, 0, 0, 255, 255]);
258    }
259    zlib.extend_from_slice(&adler32(&raw).to_be_bytes());
260    let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10];
261    let mut ihdr = Vec::with_capacity(13);
262    ihdr.extend_from_slice(&width.to_be_bytes());
263    ihdr.extend_from_slice(&height.to_be_bytes());
264    ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
265    push_chunk(&mut png, b"IHDR", &ihdr);
266    push_chunk(&mut png, b"IDAT", &zlib);
267    push_chunk(&mut png, b"IEND", &[]);
268    png
269}
270
271/// Append one PNG chunk: length, type, data, and the IEEE CRC of type+data.
272fn push_chunk(png: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
273    png.extend_from_slice(&(data.len() as u32).to_be_bytes());
274    png.extend_from_slice(kind);
275    png.extend_from_slice(data);
276    let mut crc_input = Vec::with_capacity(4 + data.len());
277    crc_input.extend_from_slice(kind);
278    crc_input.extend_from_slice(data);
279    png.extend_from_slice(&crc32(&crc_input).to_be_bytes());
280}
281
282/// The IEEE CRC-32, bit by bit: figures are small and no table is carried.
283fn crc32(data: &[u8]) -> u32 {
284    let mut crc = 0xffff_ffffu32;
285    for byte in data {
286        crc ^= u32::from(*byte);
287        for _ in 0..8 {
288            let lsb = crc & 1;
289            crc >>= 1;
290            if lsb == 1 {
291                crc ^= 0xedb8_8320;
292            }
293        }
294    }
295    !crc
296}
297
298/// The Adler-32 of zlib trailers, modulo the prime at every step.
299fn adler32(data: &[u8]) -> u32 {
300    let mut low: u32 = 1;
301    let mut high: u32 = 0;
302    for byte in data {
303        low = (low + u32::from(*byte)) % 65521;
304        high = (high + low) % 65521;
305    }
306    (high << 16) | low
307}
308
309/// The Markdown for blocks whose figures resolve past markers.
310///
311/// Embeds render each figure's box and set the block's text to the data URL;
312/// a figure directory writes each render under it and sets the text to the
313/// written path, creating the directory first. Markers need no document and
314/// never arrive here. What each figure is called is [`figure_name`],
315/// enumerated per page in block order.
316pub(crate) fn resolve_figure_texts(
317    doc: &crate::PdfDocument,
318    blocks: &mut [Block],
319    options: &ExtractOptions,
320) -> Result<()> {
321    use base64::Engine as _;
322    use std::collections::BTreeMap;
323    // One render per page that carries figures, whatever the mode. The
324    // ordinal is the figure's place among its page's figures in block
325    // order -- the same enumeration the marker renderer uses.
326    let mut pages: BTreeMap<usize, Vec<(usize, [f64; 4])>> = BTreeMap::new();
327    for (at, block) in blocks.iter().enumerate() {
328        if matches!(block.kind, BlockKind::Figure) {
329            pages.entry(block.page).or_default().push((at, block.bbox));
330        }
331    }
332    if pages.is_empty() {
333        return Ok(());
334    }
335    if let Some(dir) = options.figure_image_dir.as_ref() {
336        std::fs::create_dir_all(dir).map_err(EngineError::Io)?;
337    }
338    for (page, refs) in &pages {
339        let boxes: Vec<[f64; 4]> = refs.iter().map(|(_, bbox)| *bbox).collect();
340        let renders = render_crops(doc, *page, &boxes)?;
341        for (ordinal, ((at, _), png)) in refs.iter().zip(renders).enumerate() {
342            let name = figure_name(*page, ordinal);
343            let text = match options.figure_image_dir.as_ref() {
344                Some(dir) => {
345                    let path = dir.join(&name);
346                    std::fs::write(&path, &png).map_err(EngineError::Io)?;
347                    path.to_string_lossy().into_owned()
348                }
349                None => {
350                    let encoded = base64::engine::general_purpose::STANDARD.encode(&png);
351                    format!("data:image/png;base64,{encoded}")
352                }
353            };
354            if let Some(block) = blocks.get_mut(*at) {
355                block.text = text;
356            }
357        }
358    }
359    Ok(())
360}