docling 1.26.0

DocumentConverter and format backends for docling.rs (a Rust port of docling).
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
//! Legacy spreadsheet-interchange backends (#216): DIF, SYLK and dBase —
//! the table relics that outlived their applications. All three parse
//! natively (docling has no readers for any of them):
//!
//! - **DIF** (`.dif`, Data Interchange Format): line pairs — a `type,number`
//!   line then a value line. Type 1 carries a quoted string, type 0 a number
//!   (the value line is just a validity marker), type −1 a directive (`BOT`
//!   starts a row, `EOD` ends the data).
//! - **SYLK** (`.slk`/`.sylk`, Symbolic Link): `;`-separated records; `C`
//!   records place a cell at `X`/`Y` (1-based, sticky — an omitted
//!   coordinate repeats the previous one) with the value in `K` (quoted
//!   string with `;;` escaping, or a bare number).
//! - **dBase** (`.dbf`): binary — a header with 32-byte field descriptors
//!   (name, type, width) and fixed-width records; the field names become
//!   the table's header row. Deleted records (`*` flag) are skipped, memo
//!   fields (their content lives in a `.dbt` sidecar) come out empty, and
//!   `D` dates render as ISO `YYYY-MM-DD`. High bytes decode as
//!   Windows-1252.
//!
//! DIF and SYLK are sheet snapshots, so they run through the same
//! flood-fill region splitting as ODS sheets — a `.dif`/`.slk` saved from a
//! spreadsheet converts to the same tables as the sheet itself. dBase is a
//! single database table and converts as one.

use std::collections::HashMap;

use crate::backend::odf::emit_sheet_regions;
use crate::backend::rtf::decode_byte;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node, Table};

pub struct InterchangeBackend;

impl DeclarativeBackend for InterchangeBackend {
    fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
        // The three formats are trivially distinguishable by content, so the
        // extension only routes here and a misnamed file still converts. The
        // text signatures go first — they are exact, while the dBase magic is
        // only a version byte plus plausibility checks.
        let mut doc = DoclingDocument::new(&source.name);
        if let Ok(text) = source.text() {
            if text.trim_start().starts_with("ID;") {
                convert_sylk(text, &mut doc)?;
                return Ok(doc);
            }
            if text.lines().next().map(str::trim) == Some("TABLE") {
                convert_dif(text, &mut doc)?;
                return Ok(doc);
            }
        }
        if looks_like_dbf(&source.bytes) {
            convert_dbf(&source.bytes, &mut doc)?;
            return Ok(doc);
        }
        Err(ConversionError::Parse(
            "interchange: neither DIF (TABLE header), SYLK (ID; record) nor dBase".into(),
        ))
    }
}

/// Render a cell number the way spreadsheet display does: integers without a
/// decimal point, everything else through the shortest `f64` form.
fn number_text(v: f64) -> String {
    if v.fract() == 0.0 && v.abs() < 1e15 {
        format!("{}", v as i64)
    } else {
        format!("{v}")
    }
}

// ----------------------------------------------------------------- DIF

fn convert_dif(text: &str, doc: &mut DoclingDocument) -> Result<(), ConversionError> {
    let mut lines = text.lines();
    // Header sections (TABLE/VECTORS/TUPLES/…) are name + "v,n" + "\"…\""
    // triples until DATA opens the cell stream.
    loop {
        let Some(name) = lines.next() else {
            return Err(ConversionError::Parse("dif: no DATA section".into()));
        };
        lines.next();
        lines.next();
        if name.trim() == "DATA" {
            break;
        }
    }

    let mut cells: HashMap<(usize, usize), String> = HashMap::new();
    let mut row = 0usize;
    let mut col = 0usize;
    let mut started = false;
    while let Some(head) = lines.next() {
        let value_line = lines.next().unwrap_or("");
        let (kind, number) = head.split_once(',').unwrap_or((head, ""));
        match kind.trim() {
            "-1" => match value_line.trim() {
                "BOT" => {
                    if started {
                        row += 1;
                    }
                    started = true;
                    col = 0;
                }
                _ => break, // EOD
            },
            // Numeric cell: the value rides the header line; the value line
            // is a validity marker (V / NA / ERROR).
            "0" => {
                let text = match value_line.trim() {
                    "NA" | "ERROR" | "TRUE" | "FALSE" => value_line.trim().to_string(),
                    _ => number
                        .trim()
                        .parse::<f64>()
                        .map(number_text)
                        .unwrap_or_else(|_| number.trim().to_string()),
                };
                if !text.is_empty() {
                    cells.insert((row, col), text);
                }
                col += 1;
            }
            // String cell: the value line, quotes stripped.
            "1" => {
                let text = value_line.trim();
                let text = text.strip_prefix('"').unwrap_or(text);
                let text = text.strip_suffix('"').unwrap_or(text);
                if !text.is_empty() {
                    cells.insert((row, col), text.to_string());
                }
                col += 1;
            }
            _ => {}
        }
    }
    emit_sheet_regions(&cells, doc);
    Ok(())
}

// ----------------------------------------------------------------- SYLK

fn convert_sylk(text: &str, doc: &mut DoclingDocument) -> Result<(), ConversionError> {
    let mut cells: HashMap<(usize, usize), String> = HashMap::new();
    let (mut x, mut y) = (1usize, 1usize);
    for line in text.lines() {
        let mut fields = split_sylk(line);
        let Some(kind) = fields.next() else { continue };
        if kind != "C" {
            continue;
        }
        let mut value: Option<String> = None;
        for field in fields {
            let (tag, rest) = field.split_at(1.min(field.len()));
            match tag {
                "X" => x = rest.parse().unwrap_or(x),
                "Y" => y = rest.parse().unwrap_or(y),
                "K" => {
                    let text = if let Some(quoted) = rest.strip_prefix('"') {
                        quoted.strip_suffix('"').unwrap_or(quoted).to_string()
                    } else {
                        rest.parse::<f64>()
                            .map(number_text)
                            .unwrap_or_else(|_| rest.to_string())
                    };
                    value = Some(text);
                }
                _ => {}
            }
        }
        if let Some(v) = value {
            if !v.is_empty() {
                // SYLK is 1-based; the shared region splitter trims margins.
                cells.insert((y, x), v);
            }
        }
    }
    emit_sheet_regions(&cells, doc);
    Ok(())
}

/// Split a SYLK record on `;` while honoring the `;;` escape inside values
/// (a doubled semicolon is a literal one, not a separator).
fn split_sylk(line: &str) -> impl Iterator<Item = String> {
    let mut fields: Vec<String> = Vec::new();
    let mut current = String::new();
    let mut chars = line.chars().peekable();
    while let Some(c) = chars.next() {
        if c == ';' {
            if chars.peek() == Some(&';') {
                chars.next();
                current.push(';');
            } else {
                fields.push(std::mem::take(&mut current));
                // continue into the next field
            }
        } else {
            current.push(c);
        }
    }
    fields.push(current);
    fields.into_iter().filter(|f| !f.is_empty())
}

// ----------------------------------------------------------------- dBase

/// dBase plausibility: a known version byte (dBase II–7 / FoxPro families,
/// with or without the memo flags in the high bits) followed by a sane
/// last-update date — the header proper is validated during the parse.
fn looks_like_dbf(d: &[u8]) -> bool {
    let known = matches!(
        d.first(),
        Some(
            0x02 | 0x03
                | 0x04
                | 0x05
                | 0x30
                | 0x31
                | 0x32
                | 0x43
                | 0x63
                | 0x83
                | 0x8b
                | 0x8e
                | 0xcb
                | 0xf5
                | 0xfb
        )
    );
    known && d.len() >= 32 && (1..=12).contains(&d[2]) && (1..=31).contains(&d[3])
}

struct DbfField {
    name: String,
    kind: u8,
    width: usize,
}

fn convert_dbf(d: &[u8], doc: &mut DoclingDocument) -> Result<(), ConversionError> {
    let err = |m: &str| ConversionError::Parse(format!("dbf: {m}"));
    let nrecords = u32::from_le_bytes(
        d.get(4..8)
            .ok_or_else(|| err("truncated header"))?
            .try_into()
            .unwrap(),
    ) as usize;
    let header_len = u16::from_le_bytes(d[8..10].try_into().unwrap()) as usize;
    let record_len = u16::from_le_bytes(d[10..12].try_into().unwrap()) as usize;

    // 32-byte field descriptors from offset 32 up to the 0x0D terminator.
    let mut fields: Vec<DbfField> = Vec::new();
    let mut off = 32usize;
    while off + 32 <= header_len.min(d.len()) && d[off] != 0x0D {
        let desc = &d[off..off + 32];
        let name_len = desc[..11].iter().position(|&b| b == 0).unwrap_or(11);
        fields.push(DbfField {
            name: desc[..name_len]
                .iter()
                .map(|&b| decode_byte(b, 1252))
                .collect(),
            kind: desc[11],
            width: desc[16] as usize,
        });
        off += 32;
    }
    if fields.is_empty() {
        return Err(err("no field descriptors"));
    }
    if record_len != 1 + fields.iter().map(|f| f.width).sum::<usize>() {
        return Err(err("record size does not match the field widths"));
    }

    let mut rows: Vec<Vec<String>> = vec![fields.iter().map(|f| f.name.clone()).collect()];
    for i in 0..nrecords {
        let start = header_len + i * record_len;
        let Some(record) = d.get(start..start + record_len) else {
            break; // truncated file: keep what parsed
        };
        if record[0] == b'*' {
            continue; // deleted
        }
        let mut row = Vec::with_capacity(fields.len());
        let mut p = 1usize;
        for field in &fields {
            let raw = &record[p..p + field.width];
            p += field.width;
            let text: String = raw.iter().map(|&b| decode_byte(b, 1252)).collect();
            let text = text.trim();
            row.push(match field.kind {
                // Memo content lives in the .dbt sidecar; degrade to empty.
                b'M' => String::new(),
                // YYYYMMDD → ISO date.
                b'D' if text.len() == 8 && text.chars().all(|c| c.is_ascii_digit()) => {
                    format!("{}-{}-{}", &text[..4], &text[4..6], &text[6..8])
                }
                b'L' => match text {
                    "T" | "t" | "Y" | "y" => "true".into(),
                    "F" | "f" | "N" | "n" => "false".into(),
                    _ => String::new(),
                },
                _ => text.to_string(),
            });
        }
        rows.push(row);
    }
    doc.push(Node::Table(Table {
        rows,
        location: None,
        structure: None,
        cell_blocks: None,
        cells: None,
        caption: None,
    }));
    Ok(())
}

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

    fn convert(name: &str, bytes: &[u8]) -> DoclingDocument {
        let source = SourceDocument::from_bytes(name.to_string(), InputFormat::Dif, bytes.to_vec());
        InterchangeBackend.convert(&source).unwrap()
    }

    #[test]
    fn dif_rows_numbers_and_strings() {
        let dif = "TABLE\n0,1\n\"S\"\nVECTORS\n0,2\n\"\"\nTUPLES\n0,2\n\"\"\nDATA\n0,0\n\"\"\n\
                   -1,0\nBOT\n1,0\n\"Year\"\n1,0\n\"Ducks\"\n\
                   -1,0\nBOT\n0,2019\nV\n0,1.5\nV\n\
                   -1,0\nEOD\n";
        let md = convert("t.dif", dif.as_bytes()).export_to_markdown();
        assert!(md.contains("Year"), "{md}");
        assert!(md.contains("2019"), "{md}");
        assert!(md.contains("1.5"), "integers bare, floats kept:\n{md}");
    }

    #[test]
    fn sylk_sticky_coordinates_and_escapes() {
        let slk = "ID;PCALCOOO32\nC;X1;Y1;K\"a;;b\"\nC;X2;K42\nC;X1;Y2;K\"c\"\nC;X2;K\"d\"\nE\n";
        let doc = convert("t.slk", slk.as_bytes());
        let tables: Vec<_> = doc
            .nodes
            .iter()
            .filter_map(|n| match n {
                Node::Table(t) => Some(t),
                _ => None,
            })
            .collect();
        assert_eq!(tables.len(), 1);
        // Y sticks from the previous record; ";;" is a literal semicolon.
        assert_eq!(
            tables[0].rows,
            vec![
                vec!["a;b".to_string(), "42".into()],
                vec!["c".into(), "d".into()]
            ]
        );
    }

    #[test]
    fn dbf_fields_types_and_deleted_records() {
        // Header: version 3, 3 records, two fields (NAME C5, BORN D8).
        let mut f = vec![0x03u8, 99, 1, 1];
        f.extend(3u32.to_le_bytes());
        let header_len = 32 + 2 * 32 + 1;
        f.extend((header_len as u16).to_le_bytes());
        f.extend((1u16 + 5 + 8).to_le_bytes());
        f.extend([0u8; 20]);
        let mut field = |name: &[u8], kind: u8, width: u8| {
            let mut desc = [0u8; 32];
            desc[..name.len()].copy_from_slice(name);
            desc[11] = kind;
            desc[16] = width;
            f.extend(desc);
        };
        field(b"NAME", b'C', 5);
        field(b"BORN", b'D', 8);
        f.push(0x0D);
        f.extend(b" Ana  19991231");
        f.extend(b"*Del  20000101"); // deleted, skipped
        f.extend(b" Bob  20010615");
        let doc = convert("t.dbf", &f);
        let Node::Table(t) = &doc.nodes[0] else {
            panic!("table expected")
        };
        assert_eq!(t.rows[0], vec!["NAME".to_string(), "BORN".into()]);
        assert_eq!(t.rows[1], vec!["Ana".to_string(), "1999-12-31".into()]);
        assert_eq!(t.rows[2], vec!["Bob".to_string(), "2001-06-15".into()]);
        assert_eq!(t.rows.len(), 3, "deleted record dropped");
    }

    #[test]
    fn garbage_is_an_error_not_a_panic() {
        let source = SourceDocument::from_bytes(
            "x.dif".to_string(),
            InputFormat::Dif,
            b"random text".to_vec(),
        );
        assert!(InterchangeBackend.convert(&source).is_err());
    }
}