Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions crates/docling/src/vlm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,7 @@ pub fn convert_vlm(
doc.name = source.name.clone();
doc
} else {
let body: Vec<String> = fragments.iter().map(|f| prose_fallback(f)).collect();
let xml = format!("<doclang version=\"0.7\">\n{}\n</doclang>", body.join("\n"));
let synthetic =
SourceDocument::from_bytes(&source.name, InputFormat::XmlDoclang, xml.into_bytes());
DoclangBackend.convert(&synthetic)?
parse_doclang_fragments(&source.name, &fragments)
};
if doc.nodes.is_empty() {
// The request loop succeeded, so this is a content problem, not a
Expand Down Expand Up @@ -338,6 +334,30 @@ fn looks_like_doctags(fragment: &str) -> bool {
|| fragment.contains("<section_header_level_")
}

/// Assemble the non-DocTags fragments (proper DocLang XML, or the prose a model
/// emits when it ignores the markup instruction) into a document.
///
/// The DocLang reader is a *strict* XML parser: a misbehaving model that leaves
/// a `<heading>`/`<text>` unclosed or nests a stray root produces markup it
/// rejects outright (`expected 'heading' tag, not 'doclang' …`), which — when
/// propagated — fails the entire conversion on one bad page. The DocTags path
/// never does that (#152: hostile model output, best-effort document out), so
/// neither should this one: on a parse error, salvage the fragments through the
/// tolerant DocTags parser, which degrades broken/unknown tags to text and
/// never errors. Well-formed DocLang still takes the strict reader, keeping its
/// heading levels and table structure.
fn parse_doclang_fragments(name: &str, fragments: &[String]) -> DoclingDocument {
let body: Vec<String> = fragments.iter().map(|f| prose_fallback(f)).collect();
let xml = format!("<doclang version=\"0.7\">\n{}\n</doclang>", body.join("\n"));
let synthetic = SourceDocument::from_bytes(name, InputFormat::XmlDoclang, xml.into_bytes());
let mut doc = match DoclangBackend.convert(&synthetic) {
Ok(doc) => doc,
Err(_) => docling_core::doctags::parse_pages(fragments.iter().map(String::as_str)),
};
doc.name = name.to_string();
doc
}

/// DocLang-path fallback for a model that ignored the markup instruction
/// entirely (plain prose / Markdown, no tags): one `<text>` per non-empty
/// line, instead of silently dropping the page's content.
Expand Down Expand Up @@ -396,7 +416,32 @@ fn encode_png(image: &image::RgbImage) -> Result<Vec<u8>, String> {

#[cfg(test)]
mod tests {
use super::{looks_like_doctags, prose_fallback, strip_wrappers};
use super::{looks_like_doctags, parse_doclang_fragments, prose_fallback, strip_wrappers};

/// A misbehaving model can emit malformed DocLang — here an unclosed
/// `<heading>` — that the strict XML reader rejects with
/// `expected 'heading' tag, not 'doclang'`. That hard error dropped the
/// whole `normal_4pages` fixture from the #153 corpus run; the fallback
/// must salvage the page's text instead of failing the conversion.
#[test]
fn malformed_doclang_degrades_instead_of_erroring() {
let frag = "<heading level=\"1\">Unclosed title\n<text>Body line.</text>".to_string();
let md = parse_doclang_fragments("normal_4pages.pdf", &[frag]).export_to_markdown();
// Best-effort salvage via the tolerant DocTags parser: the text that
// the strict reader would have thrown away with the whole page survives.
assert!(md.contains("Body line."), "md: {md:?}");
assert!(md.contains("Unclosed title"), "md: {md:?}");
}

/// Well-formed DocLang still takes the strict reader, which keeps the
/// heading *level* (the tolerant fallback would flatten it to text).
#[test]
fn wellformed_doclang_keeps_structure() {
let frag = "<heading level=\"2\">Sec</heading>\n<text>Para.</text>".to_string();
let md = parse_doclang_fragments("d.pdf", &[frag]).export_to_markdown();
assert!(md.contains("## Sec"), "md: {md:?}");
assert!(md.contains("Para."), "md: {md:?}");
}

#[test]
fn wrapper_stripping() {
Expand Down
32 changes: 32 additions & 0 deletions crates/docling/tests/vlm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,38 @@ fn vlm_parses_doctags_answers() {
assert!(md.contains("| H"), "md: {md:?}");
}

/// Conformance lock for #153: the `picture_classification` corpus fixture
/// scored 1.3% similarity in the live VLM run — a divergence that (per the
/// harness's own note) is render/model variance, *not* a parser defect. Feed
/// the model's exact DocTags for that document through `convert_vlm` and assert
/// the Markdown matches docling's groundtruth byte-for-byte, so our side of the
/// comparison stays provably correct (pictures + captions before the image
/// placeholder, multi-page `<page_break>`, reading order). Image leg → no
/// pdfium, runs everywhere.
#[test]
fn vlm_doctags_matches_groundtruth_picture_classification() {
let doctags = std::fs::read_to_string(
repo_root().join("tests/data/pdf/groundtruth/picture_classification.doctags.txt"),
)
.expect("doctags fixture");
let want = std::fs::read_to_string(
repo_root().join("tests/data/pdf/groundtruth/picture_classification.md"),
)
.expect("markdown groundtruth");
// The model emits one DocTags blob per document (root wrapper + internal
// <page_break>); the image leg is a single request → single answer.
let (endpoint, served, handle) = mock_openai(vec![doctags]);
let source = SourceDocument::from_bytes("page.png", InputFormat::Image, image_bytes());
let doc = convert_vlm(&source, &opts(endpoint)).expect("vlm conversion");
handle.join().expect("mock server");
assert_eq!(served.load(Ordering::SeqCst), 1);
assert_eq!(
doc.export_to_markdown().trim(),
want.trim(),
"VLM DocTags → Markdown must match docling's groundtruth for picture_classification"
);
}

#[test]
fn vlm_rejects_non_visual_formats() {
let source = SourceDocument::from_bytes("x.md", InputFormat::Md, b"# hi".to_vec());
Expand Down
Loading