Skip to content

Commit 70ce9a1

Browse files
committed
feat(stream): streaming Markdown conversion, default in the CLI
Add an incremental Markdown path so the converter can be embedded in apps that want output as it is produced rather than one buffered string. - core: `MarkdownStreamer` renders finalized, in-order node batches into Markdown chunks whose concatenation is byte-identical to the buffered `export_to_markdown_images` (placeholder + embedded image modes). Strict-mode recovered links are applied per chunk with a carry-forward queue. - pdf: `Pipeline::convert_streaming` emits each page's nodes in document order as the parallel worker pool finishes them. A `StreamAssembler` holds back only the one-page tail that might still merge across a page break, so cross-page paragraph merging is preserved; an in-order reorder buffer on the calling thread feeds `emit` (so it needn't be Send) with channel backpressure. - fleischwolf: `DocumentConverter::convert_streaming` / `convert_streaming_images` return a `MarkdownStream` iterator of chunks, produced on a background thread (dropping it cancels). PDF streams page by page; other backends build their document then stream it through the same API. JSON and the referenced image mode stay on the buffered path. - cli: stream Markdown to stdout by default; `--no-stream` opts back into buffering. Tests: streamer byte-identity unit tests; `StreamAssembler` equivalence to one-shot `merge_continuations` over random page splits; a regression check that streamed Markdown equals the buffered export for every non-PDF corpus source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3AUFjF71HP2YY9cD32wx8
1 parent 9d3cf08 commit 70ce9a1

13 files changed

Lines changed: 926 additions & 15 deletions

File tree

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,40 @@ strict: Foo ***both***. | ```rust (lang kept) | Name: ___
118118
`result.document.export_to_markdown_with(strict)` overrides the mode per call.
119119
Python docling has no such switch.
120120

121+
### Streaming Markdown
122+
123+
For embedding in real apps, `convert_streaming` returns the document's Markdown
124+
as an iterator of chunks instead of one big string — handy for piping a long
125+
document straight to stdout, an HTTP response, or a socket as it is produced:
126+
127+
```rust
128+
use std::io::Write;
129+
use fleischwolf::{DocumentConverter, SourceDocument};
130+
131+
let source = SourceDocument::from_file("input.pdf").unwrap();
132+
let mut out = std::io::stdout();
133+
for chunk in DocumentConverter::new().convert_streaming(source).unwrap() {
134+
out.write_all(chunk.unwrap().as_bytes()).unwrap();
135+
}
136+
```
137+
138+
The headline win is PDF. The ML pipeline already processes pages **in parallel**;
139+
streaming emits each page's Markdown **in document order, as soon as it is ready**
140+
(with a one-page look-ahead so paragraphs that wrap across a page break still
141+
merge), so output starts flowing before the last page is done. The conversion
142+
runs on a background thread and the chunk iterator applies backpressure; dropping
143+
it cancels the work. Concatenating every chunk is **byte-identical** to the
144+
buffered `export_to_markdown()`.
145+
146+
Streaming is Markdown-only — JSON serializes docling-core's reference-based tree
147+
and needs every node up front. Picture placeholders and `embedded` data-URI
148+
images stream; the `referenced` mode writes sidecar files, so it stays on the
149+
buffered `export_to_markdown_with_images` path. Use
150+
`convert_streaming_images(source, ImageMode::Embedded)` to pick the image mode.
151+
152+
The CLI streams Markdown by default (`--no-stream` opts back into buffering;
153+
`--to json` and `--images referenced` always buffer).
154+
121155
## Testing
122156

123157
All commands run from the `fleischwolf/` workspace root.
@@ -171,8 +205,13 @@ cargo run -p fleischwolf-cli -- --to json crates/fleischwolf/sample.html > out.j
171205
cargo run -p fleischwolf-cli -- --images embedded document.pdf
172206
cargo run -p fleischwolf-cli -- --images referenced document.pdf > out.md
173207

174-
# or via the example
208+
# stream Markdown to stdout page by page (the CLI's default; --no-stream to buffer)
209+
cargo run -p fleischwolf-cli -- document.pdf
210+
cargo run -p fleischwolf-cli -- --no-stream document.pdf
211+
212+
# or via the examples
175213
cargo run -p fleischwolf --example convert -- crates/fleischwolf/sample.md
214+
cargo run -p fleischwolf --example stream -- crates/fleischwolf/sample.md
176215

177216
# score HTML output against the latest published docling (installed from PyPI)
178217
scripts/conformance.sh html

crates/fleischwolf-cli/src/main.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! A stand-in for `docling.cli.main`; the full Typer-style CLI (batch mode,
44
//! pipeline options) is a later phase.
55
//!
6-
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] <input-file>
6+
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] <input-file>
77
//! --to md|json output format (default: md). `json` emits docling-core's
88
//! native DoclingDocument JSON (export_to_dict).
99
//! --images MODE picture handling for Markdown (mirrors docling's
@@ -15,7 +15,12 @@
1515
//! the bytes. Off by default; fetches over the network.
1616
//! --strict cleaner, more conformant Markdown instead of byte-for-byte
1717
//! docling-legacy output (Markdown only).
18+
//! --no-stream build the whole document before printing Markdown instead
19+
//! of streaming it page by page. Streaming is the default for
20+
//! Markdown (placeholder/embedded images); JSON and referenced
21+
//! images always use the buffered path.
1822
23+
use std::io::{self, Write};
1924
use std::path::Path;
2025
use std::process::ExitCode;
2126

@@ -26,13 +31,15 @@ fn main() -> ExitCode {
2631
let mut to = "md".to_string();
2732
let mut images = "placeholder".to_string();
2833
let mut fetch_images = false;
34+
let mut no_stream = false;
2935
let mut bench_warm: Option<usize> = None;
3036
let mut path: Option<String> = None;
3137
let mut args = std::env::args().skip(1);
3238
while let Some(arg) = args.next() {
3339
match arg.as_str() {
3440
"--strict" => strict = true,
3541
"--fetch-images" => fetch_images = true,
42+
"--no-stream" => no_stream = true,
3643
"--to" => to = args.next().unwrap_or_default(),
3744
"--images" => images = args.next().unwrap_or_default(),
3845
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
@@ -67,7 +74,7 @@ fn main() -> ExitCode {
6774
};
6875

6976
let Some(path) = path else {
70-
eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] <input-file>");
77+
eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] <input-file>");
7178
return ExitCode::from(2);
7279
};
7380

@@ -97,11 +104,48 @@ fn main() -> ExitCode {
97104
};
98105
}
99106

100-
let document = match DocumentConverter::new()
107+
let converter = DocumentConverter::new()
101108
.strict(strict)
102-
.fetch_images(fetch_images)
103-
.convert(source)
104-
{
109+
.fetch_images(fetch_images);
110+
111+
// Stream Markdown by default: print each chunk as the converter produces it
112+
// (page by page for PDF). JSON needs the whole tree, and the referenced image
113+
// mode writes sidecar files, so both keep the buffered path. `--no-stream` opts
114+
// back into buffering for the streamable cases too.
115+
let is_markdown = matches!(to.as_str(), "md" | "markdown");
116+
if is_markdown && image_mode != ImageMode::Referenced && !no_stream {
117+
let stream = match converter.convert_streaming_images(source, image_mode) {
118+
Ok(s) => s,
119+
Err(e) => {
120+
eprintln!("error: {e}");
121+
return ExitCode::FAILURE;
122+
}
123+
};
124+
let stdout = io::stdout();
125+
let mut out = io::BufWriter::new(stdout.lock());
126+
for chunk in stream {
127+
match chunk {
128+
Ok(s) => {
129+
if let Err(e) = out.write_all(s.as_bytes()) {
130+
eprintln!("error: writing output: {e}");
131+
return ExitCode::FAILURE;
132+
}
133+
}
134+
Err(e) => {
135+
let _ = out.flush();
136+
eprintln!("error: {e}");
137+
return ExitCode::FAILURE;
138+
}
139+
}
140+
}
141+
if let Err(e) = out.flush() {
142+
eprintln!("error: writing output: {e}");
143+
return ExitCode::FAILURE;
144+
}
145+
return ExitCode::SUCCESS;
146+
}
147+
148+
let document = match converter.convert(source) {
105149
Ok(result) => result.document,
106150
Err(e) => {
107151
eprintln!("error: {e}");

crates/fleischwolf-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ mod markdown;
1818

1919
pub use document::{DoclingDocument, Node, PictureImage, Table};
2020
pub use labels::DocItemLabel;
21-
pub use markdown::ImageMode;
21+
pub use markdown::{ImageMode, MarkdownStreamer};

crates/fleischwolf-core/src/markdown.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,128 @@ fn apply_links(body: &str, links: &[(String, String)]) -> String {
9999
out
100100
}
101101

102+
/// Like [`apply_links`] but over a single chunk, consuming from a shared queue so
103+
/// the same `[anchor](href)` rewriting can be applied incrementally as Markdown is
104+
/// streamed out. Each queued link is matched (in document order) against `chunk`
105+
/// and rewritten in place; a link whose anchor is not in this chunk is carried
106+
/// forward in the queue for a later chunk. Anchors are recovered in document
107+
/// order and a chunk is always a contiguous run of whole blocks, so this
108+
/// reproduces [`apply_links`]' single moving cursor: the link lands in whichever
109+
/// chunk contains its anchor, identically to the buffered path. (A link whose
110+
/// anchor never appears is carried to the end and dropped — the same no-op
111+
/// `apply_links` performs for an unlocatable anchor.)
112+
fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
113+
let mut out = chunk.to_string();
114+
let mut cursor = 0usize;
115+
let mut carried: Vec<(String, String)> = Vec::new();
116+
for (anchor_raw, href) in std::mem::take(queue) {
117+
let anchor = anchor_raw
118+
.replace('&', "&amp;")
119+
.replace('<', "&lt;")
120+
.replace('>', "&gt;");
121+
if anchor.is_empty() {
122+
continue;
123+
}
124+
if let Some(rel) = out[cursor..].find(&anchor) {
125+
let at = cursor + rel;
126+
let replacement = format!("[{anchor}]({href})");
127+
out.replace_range(at..at + anchor.len(), &replacement);
128+
cursor = at + replacement.len();
129+
} else {
130+
// Not in this chunk; try again when its block is flushed.
131+
carried.push((anchor_raw, href));
132+
}
133+
}
134+
*queue = carried;
135+
out
136+
}
137+
138+
/// Incremental Markdown serializer: feed finalized, in-document-order batches of
139+
/// [`Node`]s and receive Markdown chunks whose concatenation is **byte-identical**
140+
/// to [`to_markdown_images`] over the same nodes. This is the streaming
141+
/// counterpart of the buffered serializer — used to emit a document's Markdown in
142+
/// chunks (e.g. page by page, as the parallel PDF pipeline finishes pages) instead
143+
/// of building the whole string up front.
144+
///
145+
/// Only [`ImageMode::Placeholder`] and [`ImageMode::Embedded`] are streamable:
146+
/// [`ImageMode::Referenced`] needs a side-channel for the image bytes, which only
147+
/// the buffered [`to_markdown_images`] provides.
148+
///
149+
/// Each [`push`](Self::push) must contain whole blocks in reading order: a caller
150+
/// must not split a run of list items across two pushes (the run would render as
151+
/// two separate lists). Finalized PDF page batches already satisfy this.
152+
pub struct MarkdownStreamer {
153+
strict: bool,
154+
images: ImageMode,
155+
compact_tables: bool,
156+
/// Whether any non-empty chunk has been emitted yet (drives `\n\n` joins and
157+
/// the trailing newline).
158+
emitted_any: bool,
159+
/// Recovered links not yet placed (strict mode), consumed in document order.
160+
links: Vec<(String, String)>,
161+
}
162+
163+
impl MarkdownStreamer {
164+
/// Create a streamer. `compact_tables` mirrors [`DoclingDocument::compact_tables`].
165+
pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
166+
debug_assert!(
167+
images != ImageMode::Referenced,
168+
"referenced image mode is not streamable; use to_markdown_images"
169+
);
170+
Self {
171+
strict,
172+
images,
173+
compact_tables,
174+
emitted_any: false,
175+
links: Vec::new(),
176+
}
177+
}
178+
179+
/// Render one finalized batch of nodes (plus any links recovered from the same
180+
/// span, in document order) into the next Markdown chunk. Returns an empty
181+
/// string when the batch produces no output (e.g. empty tables/pictures), in
182+
/// which case nothing should be written.
183+
pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
184+
self.links.extend(links.iter().cloned());
185+
let mut ctx = Ctx {
186+
strict: self.strict,
187+
compact_tables: self.compact_tables,
188+
images: self.images,
189+
// Referenced mode is rejected at construction, so the artifact sink is
190+
// never touched.
191+
artifacts_dir: String::new(),
192+
artifacts: Vec::new(),
193+
pic_index: 0,
194+
};
195+
let mut blocks: Vec<String> = Vec::new();
196+
render(nodes, &mut blocks, &mut ctx);
197+
if blocks.is_empty() {
198+
return String::new();
199+
}
200+
let mut body = blocks.join("\n\n");
201+
if self.strict && !self.links.is_empty() {
202+
body = apply_links_chunk(&body, &mut self.links);
203+
}
204+
let chunk = if self.emitted_any {
205+
format!("\n\n{body}")
206+
} else {
207+
body
208+
};
209+
self.emitted_any = true;
210+
chunk
211+
}
212+
213+
/// Emit the trailing newline that finishes the document (empty if no content
214+
/// was produced). Call exactly once, after the final [`push`](Self::push).
215+
pub fn finish(self) -> String {
216+
if self.emitted_any {
217+
"\n".to_string()
218+
} else {
219+
String::new()
220+
}
221+
}
222+
}
223+
102224
/// In `strict` mode, rewrite inline text for readability rather than byte-for-byte
103225
/// docling fidelity: undo the legacy `\_` underscore escaping, and tighten stray
104226
/// spaces around punctuation (`[ 37 , 36 ]` → `[37, 36]`, `( x )` → `(x)`). This
@@ -476,6 +598,101 @@ mod tests {
476598
assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
477599
}
478600

601+
/// Drive a document's nodes through [`MarkdownStreamer`] in the given page
602+
/// splits and assert the concatenated chunks equal the buffered serializer.
603+
fn assert_stream_matches(
604+
doc: &DoclingDocument,
605+
strict: bool,
606+
images: ImageMode,
607+
splits: &[usize],
608+
) {
609+
let want = to_markdown_images(doc, strict, images, "artifacts").0;
610+
let mut streamer = MarkdownStreamer::new(strict, images, doc.compact_tables);
611+
let mut got = String::new();
612+
let mut start = 0;
613+
for &end in splits {
614+
// Links only matter in strict mode; feed them all with the first batch
615+
// that has content (document order is preserved by the queue).
616+
let links = if start == 0 {
617+
doc.links.as_slice()
618+
} else {
619+
&[]
620+
};
621+
got.push_str(&streamer.push(&doc.nodes[start..end], links));
622+
start = end;
623+
}
624+
got.push_str(&streamer.push(
625+
&doc.nodes[start..],
626+
if start == 0 {
627+
doc.links.as_slice()
628+
} else {
629+
&[]
630+
},
631+
));
632+
got.push_str(&streamer.finish());
633+
assert_eq!(
634+
got, want,
635+
"streamed output diverged (splits={splits:?}, strict={strict})"
636+
);
637+
}
638+
639+
#[test]
640+
fn streaming_is_byte_identical_to_buffered() {
641+
let mut doc = DoclingDocument::new("d");
642+
doc.add_heading(1, "Title");
643+
doc.add_paragraph("First paragraph.");
644+
doc.push(Node::ListItem {
645+
ordered: false,
646+
number: 1,
647+
first_in_list: true,
648+
text: "a".into(),
649+
level: 0,
650+
});
651+
doc.push(Node::ListItem {
652+
ordered: false,
653+
number: 2,
654+
first_in_list: false,
655+
text: "b".into(),
656+
level: 0,
657+
});
658+
doc.push(Node::Code {
659+
language: Some("rust".into()),
660+
text: "let x = 1;".into(),
661+
});
662+
doc.push(Node::Table(Table {
663+
rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
664+
}));
665+
doc.push(Node::Picture {
666+
caption: Some("Fig 1".into()),
667+
image: None,
668+
});
669+
doc.add_paragraph("Last paragraph.");
670+
671+
// A run of list items must never straddle a split, so try splits that fall
672+
// on safe block boundaries (the streaming PDF assembler guarantees this).
673+
for &strict in &[false, true] {
674+
for &images in &[ImageMode::Placeholder, ImageMode::Embedded] {
675+
for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6][..]] {
676+
assert_stream_matches(&doc, strict, images, splits);
677+
}
678+
}
679+
}
680+
}
681+
682+
#[test]
683+
fn streaming_applies_recovered_links_in_strict_mode() {
684+
let mut doc = DoclingDocument::new("d");
685+
doc.add_paragraph("See LinkedIn for details.");
686+
doc.add_paragraph("And GitHub too.");
687+
doc.links = vec![
688+
("LinkedIn".into(), "https://lnkd/".into()),
689+
("GitHub".into(), "https://gh/".into()),
690+
];
691+
// The second anchor lives in the second block, so it must be carried across
692+
// the page boundary and placed when that block streams out.
693+
assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
694+
}
695+
479696
#[test]
480697
fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
481698
let mut doc = DoclingDocument::new("t");

0 commit comments

Comments
 (0)