Skip to content

Commit 99c33ad

Browse files
committed
feat(pdf): render PDF hyperlink annotations as Markdown links in strict mode
The standard docling PDF pipeline discards link annotations, so emitting them would break byte-for-byte conformance. This adds them to the Rust-only `strict(true)` output only — legacy/docling output (and the 4/14 groundtruth conformance + 91/91 snapshot baseline) is left untouched. - pdfium_backend: extract per-page link annotations (rect + URI), restricted to web/mailto/tel schemes, into `PdfPage.links`. - assemble: resolve each link rect to the visible anchor text via per-word cells (line-merged cells would over-capture a whole line), cleaned the same way prose is; accumulate `(anchor, href)` onto the document in reading order. - core: `DoclingDocument.links` + a strict-only serializer pass that wraps each anchor as `[anchor](href)`, matching the body's HTML-escaped form and consuming repeated anchors in document order. Verified on a real CV PDF: LinkedIn/GitHub/email/cert links render in strict mode; default output has none. Note: this does not change PDF conformance (still 4/14). The remaining near-misses (RTL justified-space reconstruction, two-column figure-overflow reading order, TableFormer multi-row headers, layout misclassification) are model-level and not safe quick fixes; documented in PDF_CONFORMANCE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RkJ8nFxQQB2Qn3sTgg8hT
1 parent f6312e0 commit 99c33ad

7 files changed

Lines changed: 185 additions & 3 deletions

File tree

COMPARING.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,9 @@ tail of docling-specific quirks (below), each typically 1–2 lines.
273273
> These numbers are for **legacy** mode (`DocumentConverter::new()`), which aims
274274
> for byte-for-byte docling output. The Rust-only `strict(true)` mode instead
275275
> emits cleaner Markdown (code-fence languages kept, no `***x*** .` run-spacing,
276-
> no `\_`/entity re-escaping) — it deliberately *diverges* from docling, so don't
277-
> measure conformance against it.
276+
> no `\_`/entity re-escaping, and **PDF hyperlink annotations rendered as
277+
> `[anchor](href)`** — web/mail/tel targets that docling's pipeline drops) — it
278+
> deliberately *diverges* from docling, so don't measure conformance against it.
278279
279280
### HTML
280281

crates/fleischwolf-core/src/document.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ pub struct DoclingDocument {
2424
/// (its committed groundtruth corpus predates the padded serializer); DOCX/HTML
2525
/// leave it `false` to match current published docling.
2626
pub compact_tables: bool,
27+
/// Hyperlinks recovered from the source, as `(anchor_text, href)` pairs in
28+
/// document order. docling's standard pipeline drops PDF link annotations, so
29+
/// these are rendered as Markdown `[anchor](href)` **only in strict mode**
30+
/// (legacy/docling output is left byte-for-byte unchanged). The PDF backend
31+
/// populates this from pdfium link annotations; other backends leave it empty.
32+
pub links: Vec<(String, String)>,
2733
}
2834

2935
/// A single piece of document content.
@@ -98,6 +104,7 @@ impl DoclingDocument {
98104
nodes: Vec::new(),
99105
strict_markdown: false,
100106
compact_tables: false,
107+
links: Vec::new(),
101108
}
102109
}
103110

crates/fleischwolf-core/src/markdown.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ pub fn to_markdown_images(
5555
};
5656
let mut blocks: Vec<String> = Vec::new();
5757
render(&doc.nodes, &mut blocks, &mut ctx);
58-
let body = blocks.join("\n\n");
58+
let mut body = blocks.join("\n\n");
59+
// Strict mode only: turn recovered source hyperlinks into Markdown links.
60+
// docling's standard pipeline drops them, so doing this in legacy mode would
61+
// diverge from docling — hence strict-only, leaving conformance output intact.
62+
if strict && !doc.links.is_empty() {
63+
body = apply_links(&body, &doc.links);
64+
}
5965
let md = if body.is_empty() {
6066
String::new()
6167
} else {
@@ -64,6 +70,35 @@ pub fn to_markdown_images(
6470
(md, ctx.artifacts)
6571
}
6672

73+
/// Wrap each recovered link's anchor text in Markdown `[anchor](href)`. Anchors
74+
/// arrive cleaned (curly quotes/dashes already normalized) but un-escaped, so we
75+
/// match against the body's HTML-escaped (`&`/`<`/`>`) form, the way prose nodes
76+
/// were serialized. Links are consumed in document order from a moving cursor, so
77+
/// a repeated anchor (e.g. two "issues") links its successive occurrences rather
78+
/// than all pointing at the first. An anchor that can't be located is skipped
79+
/// (its text may have been split across a line wrap or table cell).
80+
fn apply_links(body: &str, links: &[(String, String)]) -> String {
81+
let mut out = body.to_string();
82+
let mut cursor = 0usize;
83+
for (anchor, href) in links {
84+
let anchor = anchor
85+
.replace('&', "&amp;")
86+
.replace('<', "&lt;")
87+
.replace('>', "&gt;");
88+
if anchor.is_empty() {
89+
continue;
90+
}
91+
if let Some(rel) = out[cursor..].find(&anchor) {
92+
let at = cursor + rel;
93+
// Don't relink inside an already-emitted `](` Markdown link target.
94+
let replacement = format!("[{anchor}]({href})");
95+
out.replace_range(at..at + anchor.len(), &replacement);
96+
cursor = at + replacement.len();
97+
}
98+
}
99+
out
100+
}
101+
67102
/// In `strict` mode, rewrite inline text for readability rather than byte-for-byte
68103
/// docling fidelity: undo the legacy `\_` underscore escaping, and tighten stray
69104
/// spaces around punctuation (`[ 37 , 36 ]` → `[37, 36]`, `( x )` → `(x)`). This
@@ -364,6 +399,41 @@ mod tests {
364399
assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
365400
}
366401

402+
#[test]
403+
fn strict_renders_recovered_links_legacy_does_not() {
404+
let mut doc = DoclingDocument::new("cv");
405+
doc.add_paragraph("Find me on LinkedIn or GitHub.");
406+
doc.links = vec![
407+
("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
408+
("GitHub".into(), "https://github.com/x/".into()),
409+
];
410+
// Legacy/docling mode: links are left untouched (conformance preserved).
411+
assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
412+
// Strict mode: anchors become Markdown links.
413+
assert_eq!(
414+
doc.export_to_markdown_with(true),
415+
"Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
416+
);
417+
}
418+
419+
#[test]
420+
fn strict_links_match_escaped_anchor_and_consume_in_order() {
421+
let mut doc = DoclingDocument::new("d");
422+
// The PDF assembler HTML-escapes prose, so by serialization time the body
423+
// already carries `&amp;`; the anchor is stored un-escaped. The matcher must
424+
// escape the anchor to find it. Two identical anchors link in document order.
425+
doc.add_paragraph("AI &amp; ML here, and issues here, then issues there.");
426+
doc.links = vec![
427+
("AI & ML".into(), "https://a/".into()),
428+
("issues".into(), "https://first/".into()),
429+
("issues".into(), "https://second/".into()),
430+
];
431+
assert_eq!(
432+
doc.export_to_markdown_with(true),
433+
"[AI &amp; ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
434+
);
435+
}
436+
367437
#[test]
368438
fn renders_compact_table() {
369439
let mut doc = DoclingDocument::new("t");

crates/fleischwolf-pdf/src/assemble.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,58 @@ fn fix_arabic_lam_alef(s: &str) -> String {
224224
out.into_iter().collect()
225225
}
226226

227+
/// Resolve each page hyperlink to the visible text it covers, as `(anchor, uri)`
228+
/// in reading order. The anchor is the cells whose centre falls in the link rect,
229+
/// joined left-to-right and cleaned the same way prose is (so it matches the
230+
/// serialized text), deduped against the immediately-preceding link so pdfium's
231+
/// occasional duplicate annotation doesn't double-list. Empty anchors are dropped.
232+
pub(crate) fn resolve_link_anchors(page: &PdfPage) -> Vec<(String, String)> {
233+
let mut out: Vec<(String, String)> = Vec::new();
234+
// Use per-word cells, not the line-merged `cells`: a link rect covers a few
235+
// words on a line, and a whole merged line cell would over-capture (its centre
236+
// lands in one link's rect, grabbing the entire line as that link's anchor).
237+
let words = if page.word_cells.is_empty() {
238+
&page.cells
239+
} else {
240+
&page.word_cells
241+
};
242+
for link in &page.links {
243+
let mut inside: Vec<&TextCell> = words
244+
.iter()
245+
.filter(|c| {
246+
let (cx, cy) = ((c.l + c.r) / 2.0, (c.t + c.b) / 2.0);
247+
cx >= link.l && cx <= link.r && cy >= link.t && cy <= link.b
248+
})
249+
.collect();
250+
// Reading order: top band then left-to-right (link anchors are LTR).
251+
let band = inside
252+
.iter()
253+
.map(|c| (c.b - c.t).abs())
254+
.fold(0.0f32, f32::max)
255+
.max(1.0);
256+
inside.sort_by_key(|c| ((c.t / band).round() as i64, (c.l * 10.0) as i64));
257+
let anchor = clean_text(
258+
&inside
259+
.iter()
260+
.map(|c| c.text.trim())
261+
.filter(|t| !t.is_empty())
262+
.collect::<Vec<_>>()
263+
.join(" "),
264+
);
265+
if anchor.is_empty() {
266+
continue;
267+
}
268+
if out
269+
.last()
270+
.is_some_and(|(a, u)| a == &anchor && u == &link.uri)
271+
{
272+
continue;
273+
}
274+
out.push((anchor, link.uri.clone()));
275+
}
276+
out
277+
}
278+
227279
/// Cells assigned to a region (best container), in reading order, joined.
228280
fn region_text(region: &Region, cells: &[TextCell]) -> String {
229281
let mut inside: Vec<&TextCell> = cells
@@ -483,6 +535,8 @@ pub fn assemble_page(
483535
table_rows: &[Option<Vec<Vec<String>>>],
484536
doc: &mut DoclingDocument,
485537
) {
538+
// Recover this page's hyperlinks (rendered only in strict Markdown).
539+
doc.links.extend(resolve_link_anchors(page));
486540
// Pair each region with its precomputed TableFormer grid (indexed by original
487541
// order) and order by reading order together, so they stay aligned.
488542
let mut items: Vec<(Region, Option<Vec<Vec<String>>>)> = regions

crates/fleischwolf-pdf/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ impl Pipeline {
129129
code_cells: Vec::new(),
130130
word_cells: Vec::new(),
131131
image,
132+
links: Vec::new(),
132133
};
133134
self.process_pages(vec![page], name)
134135
}

crates/fleischwolf-pdf/src/mets.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub fn convert_mets_gbs(bytes: &[u8], name: &str) -> Result<DoclingDocument, Pdf
7272
code_cells: Vec::new(),
7373
word_cells: Vec::new(),
7474
image,
75+
links: Vec::new(),
7576
});
7677
}
7778
if pages.is_empty() {

crates/fleischwolf-pdf/src/pdfium_backend.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ pub struct PdfPage {
4545
/// matching.
4646
pub word_cells: Vec<TextCell>,
4747
pub image: RgbImage,
48+
/// Hyperlink annotations on the page (rect in top-left page coords + target
49+
/// URI), restricted to web/mail/tel schemes. Used only by strict Markdown.
50+
pub links: Vec<LinkAnnot>,
51+
}
52+
53+
/// A PDF link annotation: its rectangle (top-left page coordinates, matching
54+
/// [`TextCell`]) and target URI.
55+
#[derive(Debug, Clone)]
56+
pub struct LinkAnnot {
57+
pub l: f32,
58+
pub t: f32,
59+
pub r: f32,
60+
pub b: f32,
61+
pub uri: String,
4862
}
4963

5064
/// A parsed PDF: per-page text cells and page images.
@@ -152,9 +166,43 @@ fn extract_page(
152166
code_cells,
153167
word_cells,
154168
image,
169+
links: extract_links(page, height),
155170
})
156171
}
157172

173+
/// Collect web/mail/tel hyperlink annotations on a page, mapping each link's
174+
/// rectangle into top-left page coordinates (like [`TextCell`]). `file://` and
175+
/// in-document destinations are skipped — only externally meaningful targets are
176+
/// rendered. pdfium occasionally lists a link twice; rects are kept as-is and the
177+
/// caller dedupes by resolved anchor text.
178+
fn extract_links(page: &pdfium_render::prelude::PdfPage<'_>, page_h: f32) -> Vec<LinkAnnot> {
179+
let mut out = Vec::new();
180+
for link in page.links().iter() {
181+
let Some(uri) = link
182+
.action()
183+
.and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
184+
else {
185+
continue;
186+
};
187+
let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
188+
.iter()
189+
.any(|s| uri.starts_with(s));
190+
if !scheme_ok {
191+
continue;
192+
}
193+
if let Ok(rect) = link.rect() {
194+
out.push(LinkAnnot {
195+
l: rect.left().value,
196+
t: page_h - rect.top().value,
197+
r: rect.right().value,
198+
b: page_h - rect.bottom().value,
199+
uri,
200+
});
201+
}
202+
}
203+
out
204+
}
205+
158206
/// Fallback line cells from pdfium-render's style segments (one cell per
159207
/// segment). Used only when the raw-FFI text page can't be loaded.
160208
fn segment_cells(text: &PdfPageText, page_h: f32) -> Vec<TextCell> {

0 commit comments

Comments
 (0)