@@ -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 ( '&' , "&" )
119+ . replace ( '<' , "<" )
120+ . replace ( '>' , ">" ) ;
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 \n x_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