pdf-docx 1.2.0

PDF to DOCX conversion with text, tables, and images
Documentation
//! Table statistics of the DOCX conversion over a set of PDFs, for before/after
//! comparisons of table detection.
//!
//! Usage: `cargo run -p pdf-docx --release --example table_stats -- <dir-or-pdf>...`
//!
//! Prints one line per document and a total. "glyph cells" are table cells
//! holding at most one character, the symptom of #408.

// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.

use std::io::Read;
use std::path::{Path, PathBuf};

/// Seconds one document may take before it is reported as timed out.
const TIMEOUT_SECS: u64 = 60;

fn collect(path: &Path, out: &mut Vec<PathBuf>) {
    if path.is_dir() {
        let mut entries: Vec<_> = std::fs::read_dir(path)
            .into_iter()
            .flatten()
            .flatten()
            .map(|e| e.path())
            .collect();
        entries.sort();
        for e in entries {
            collect(&e, out);
        }
    } else if path.extension().is_some_and(|e| e == "pdf") {
        out.push(path.to_path_buf());
    }
}

#[derive(Default)]
struct Stats {
    docs: usize,
    failed: usize,
    timed_out: usize,
    tables: usize,
    cells: usize,
    glyph_cells: usize,
    max_cols: usize,
}

fn main() {
    let mut files = Vec::new();
    for arg in std::env::args().skip(1) {
        collect(Path::new(&arg), &mut files);
    }
    let mut total = Stats::default();
    for f in &files {
        // A document that does not finish within the limit is reported and
        // counted, never skipped silently. The stuck thread is left behind; the
        // process exits at the end.
        let (tx, rx) = std::sync::mpsc::channel();
        let path = f.clone();
        std::thread::Builder::new()
            .stack_size(64 * 1024 * 1024)
            .spawn(move || {
                let r = std::panic::catch_unwind(|| {
                    let data = std::fs::read(&path).ok()?;
                    pdf_docx::convert_pdf_bytes_to_docx(&data).ok()
                });
                let _ = tx.send(r);
            })
            .expect("spawn");
        let result = match rx.recv_timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) {
            Ok(r) => r,
            Err(_) => {
                eprintln!("TIMEOUT after {TIMEOUT_SECS}s: {}", f.display());
                total.timed_out += 1;
                continue;
            }
        };
        let Ok(Some(docx)) = result else {
            eprintln!("FAILED: {}", f.display());
            total.failed += 1;
            continue;
        };
        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).expect("zip");
        let mut xml = String::new();
        zip.by_name("word/document.xml")
            .expect("document.xml")
            .read_to_string(&mut xml)
            .expect("utf-8");
        let mut tables = 0;
        let mut cells = 0;
        let mut glyph_cells = 0;
        let mut max_cols = 0;
        for t in xml.split("<w:tbl>").skip(1) {
            let t = t.split("</w:tbl>").next().unwrap_or("");
            tables += 1;
            max_cols = max_cols.max(t.matches("<w:gridCol").count());
            for c in t.split("<w:tc>").skip(1) {
                cells += 1;
                let text: String = c
                    .split("<w:t ")
                    .skip(1)
                    .filter_map(|s| {
                        s.split_once('>')
                            .and_then(|(_, r)| r.split("</w:t>").next())
                    })
                    .collect();
                if text.trim().chars().count() <= 1 {
                    glyph_cells += 1;
                }
            }
        }
        println!(
            "{}\ttables={tables}\tcells={cells}\tglyph_cells={glyph_cells}\tmax_cols={max_cols}",
            f.display()
        );
        total.docs += 1;
        total.tables += tables;
        total.cells += cells;
        total.glyph_cells += glyph_cells;
        total.max_cols = total.max_cols.max(max_cols);
    }
    println!(
        "TOTAL docs={} timed_out={} failed={} tables={} cells={} glyph_cells={} max_cols={}",
        total.docs,
        total.timed_out,
        total.failed,
        total.tables,
        total.cells,
        total.glyph_cells,
        total.max_cols
    );
    std::process::exit(0);
}