Files
2026-10-15 12:48:15 -03:00

205 lines
8.3 KiB
Rust

//! Recovers the text a produced PDF draws: the writer stores text as glyph id
//! runs inside flate-compressed page streams, so a test that wants to assert
//! on rendered frontmatter properties must inflate the streams and decode the
//! runs through the fonts' `ToUnicode` maps — the same round-trip the PDF
//! crate's own integration tests rely on.
use flate2::read::ZlibDecoder;
use std::collections::BTreeMap;
use std::io::Read as _;
/// The text every page of `pdf` draws, joined in page order.
pub fn page_text(pdf: &[u8]) -> String {
let objects = objects(pdf);
let mut out = String::new();
for number in 1..objects.len() {
let body = body_of(&objects, number);
if !body.contains("/Type /Page ") {
continue;
}
let names = font_names(&body);
let maps = font_maps(&objects, &names);
let Some(stream) = reference(&body, "/Contents").and_then(|target| inflate(raw_of(&objects, target))) else {
continue;
};
out.push_str(&drawn(&stream, &maps));
out.push('\n');
}
out
}
/// Walks one content stream: `/F<n> size Tf` selects the font, `<hex> Tj`
/// draws the run through that font's glyph map.
fn drawn(stream: &str, maps: &BTreeMap<String, BTreeMap<u16, String>>) -> String {
let mut out = String::new();
let mut font = String::from("/F1");
let mut hex: Option<&str> = None;
for token in stream.split_whitespace() {
if let Some(name) = token.strip_prefix('/') {
if name.starts_with('F') && name.get(1..).is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit())) {
font = format!("/{name}");
}
continue;
}
match token {
"Tj" => {
if let Some(map) = maps.get(&font) {
for word in hex.unwrap_or_default().as_bytes().chunks(4) {
let Some(word) = std::str::from_utf8(word).ok().and_then(|text| u16::from_str_radix(text, 16).ok()) else { continue };
if let Some(text) = map.get(&word) {
out.push_str(text);
}
}
out.push(' ');
}
}
_ => {
hex = token.strip_prefix('<').map(|rest| rest.trim_end_matches('>'));
}
}
}
out.trim().to_owned()
}
/// The `ToUnicode` glyph id to text maps of one page's font resources, keyed
/// by the `/F<n>` name the content stream uses.
fn font_maps(objects: &[Vec<u8>], names: &BTreeMap<usize, String>) -> BTreeMap<String, BTreeMap<u16, String>> {
let mut out = BTreeMap::new();
for (number, name) in names {
let object = body_of(objects, *number);
if !object.contains("/Subtype /Type0") {
continue;
}
let Some(target) = reference(&object, "/ToUnicode") else { continue };
let Some(text) = inflate(raw_of(objects, target)) else { continue };
out.insert(name.clone(), parse_cmap(&text));
}
out
}
/// The whole `/Font << /F1 5 0 R .. >>` mapping of one page: object number
/// to resource name.
fn font_names(page: &str) -> BTreeMap<usize, String> {
let mut out = BTreeMap::new();
let Some(dict) = page.split("/Font <<").nth(1).and_then(|rest| rest.split(">>").next()) else { return out };
for entry in dict.split('/').skip(1) {
let mut parts = entry.split_whitespace();
let Some(name) = parts.next() else { continue };
let Some(target) = parts.next().and_then(|value| value.parse::<usize>().ok()) else { continue };
out.insert(target, format!("/{name}"));
}
out
}
/// Reads a `bfchar`-style `ToUnicode` `CMap` into a glyph id to text map.
fn parse_cmap(cmap: &str) -> BTreeMap<u16, String> {
let mut out = BTreeMap::new();
let mut rest = cmap;
while let Some(found) = rest.find('<') {
let Some((gid, after)) = hex_word(rest.get(found + 1..).unwrap_or_default()) else { break };
let Some((text, after)) = hex_word(after.trim_start()) else { break };
if let Ok(gid) = u16::from_str_radix(gid, 16) {
out.insert(gid, decode_utf16(text));
}
rest = after;
}
out
}
/// One `<hex>` value and the text after it.
fn hex_word(text: &str) -> Option<(&str, &str)> {
let text = text.strip_prefix('<').unwrap_or(text).trim_start();
let end = text.find('>')?;
Some((text.get(..end)?, text.get(end + 1..)?))
}
/// UTF-16BE hex (with or without a BOM) as text.
fn decode_utf16(hex: &str) -> String {
let hex = hex.strip_prefix("FEFF").unwrap_or(hex);
let units: Vec<u16> = hex.as_bytes().chunks(4).filter_map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap_or_default(), 16).ok()).collect();
String::from_utf16_lossy(&units)
}
/// The inflated text of a stream object body.
fn inflate(object: &[u8]) -> Option<String> {
let data = stream_data(object)?;
let mut out = Vec::new();
ZlibDecoder::new(data).read_to_end(&mut out).ok()?;
String::from_utf8(out).ok()
}
/// The compressed bytes between `stream\n` and the last `\nendstream`.
fn stream_data(object: &[u8]) -> Option<&[u8]> {
let start = find(object, b"stream\n", 0)? + b"stream\n".len();
let end = object.windows(10).rposition(|probe| probe == b"\nendstream")?;
object.get(start..end)
}
/// The file offsets of every object, read from the xref table rather than by
/// scanning for `N 0 obj` (binary font payloads hold those bytes by chance).
fn offsets(pdf: &[u8]) -> Vec<usize> {
let Some(start) = find(pdf, b"startxref", 0) else { return Vec::new() };
let rest = pdf.get(start + b"startxref\n".len()..).unwrap_or_default();
let line_end = rest.iter().position(|byte| byte == &b'\n').unwrap_or(rest.len());
let Ok(table) = std::str::from_utf8(rest.get(..line_end).unwrap_or_default()).unwrap_or_default().trim().parse::<usize>() else {
return Vec::new();
};
let head = pdf.get(table + 5..).unwrap_or_default();
let head_end = head.iter().position(|byte| byte == &b'\n').unwrap_or(head.len());
let count = std::str::from_utf8(head.get(..head_end).unwrap_or_default())
.unwrap_or_default()
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let entries = table + 5 + head_end + 1 + 20;
(1..count)
.filter_map(|number| {
let cell = pdf.get(entries + (number - 1) * 20..entries + (number - 1) * 20 + 10)?;
std::str::from_utf8(cell).unwrap_or_default().trim().parse::<usize>().ok()
})
.collect()
}
/// Every object body of the document as raw bytes, indexed by number (the
/// table is 1-based, entry 0 is the free entry).
fn objects(pdf: &[u8]) -> Vec<Vec<u8>> {
let mut out = vec![Vec::new()];
for offset in offsets(pdf) {
let body = pdf.get(offset..).unwrap_or_default();
let head = find(body, b" obj", 0).map_or(body.len(), |at| at + 4);
let dict_end = find(body, b"endobj", 0);
let stream_at = find(body, b"stream\n", 0).filter(|at| dict_end.is_none_or(|end| at < &end));
// A stream object ends after `endstream`; a binary payload can hold
// the bytes of `endobj` by chance.
let end = match stream_at {
Some(at) => find(body, b"\nendstream", at).map_or(body.len(), |stop| stop + 10),
None => dict_end.map_or(body.len(), |at| at + 6),
};
out.push(body.get(head..end).unwrap_or_default().to_vec());
}
out
}
/// First position of `needle` in `hay` at or after `from`.
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|window| window == needle).map(|at| at + from)
}
/// The body of one object as text; entry `n` holds object `n 0 obj`.
fn body_of(objects: &[Vec<u8>], number: usize) -> String {
String::from_utf8_lossy(raw_of(objects, number)).to_string()
}
/// The raw bytes of one object body.
fn raw_of(objects: &[Vec<u8>], number: usize) -> &[u8] {
objects.get(number).map(Vec::as_slice).unwrap_or_default()
}
/// The object number a `/Key N 0 R` entry points at.
fn reference(text: &str, key: &str) -> Option<usize> {
let tail = text.split(key).nth(1)?.trim_start();
let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
}