pdfboss-cli 1.2.0

PDF command line: PDF to text, Markdown and PNG, image extraction, PDF creation, and the json/hex/q/tui explorer
//! `pdfboss json`: dump the whole document as a pretty-printed JSON value
//! tree, plus the JSON writer shared with `pdfboss q`.

use pdfboss_core::Stream;
use serde_json::Value;

use crate::input::{use_color, Input};
use crate::q::value::{build_tree, TreeFlags};

/// `pdfboss json <file-or-url> [--raw|--decode] [--pages ..] [--no-logical]
/// [--content-ops] [--layout]`: dumps the full value tree for piping to
/// external tools.
pub fn cmd_json(
    input_spec: &str,
    flags: &TreeFlags,
    layout: bool,
    password: &str,
) -> Result<(), String> {
    let input = Input::open_with_password(input_spec, password)?;
    let opts = flags.element_opts()?;
    let elements = input.collect_elements(opts);
    let mut decode = |s: &Stream| input.decode_stream(s);
    let mut tree = build_tree(
        &elements,
        flags.stream_data(),
        flags.content_ops,
        &mut decode,
    );
    if layout {
        let value = layout_value(&input, &flags.pages)?;
        if let Value::Object(map) = &mut tree {
            map.insert("layout".to_string(), value);
        }
    }
    let mut text = String::new();
    write_json_pretty(&mut text, &tree, 0, use_color());
    println!("{text}");
    Ok(())
}

/// Builds the `"layout"` array `--layout` adds to the tree: one
/// `pdfboss_output::PageLayout` per page. A `--pages` filter (1-based,
/// deduplicated and sorted the way the elements path already surfaces its
/// `pages[]` array) ranks each requested page's headings against itself,
/// via `page_layout`, matching `md --page`; out-of-range numbers are
/// skipped, matching the elements path's own permissiveness. A
/// full-document run ranks every page's headings against the whole
/// document via `document_layout`.
fn layout_value(input: &Input, pages: &Option<Vec<usize>>) -> Result<Value, String> {
    let count = input.page_count();
    let layouts = match pages {
        Some(numbers) => {
            let mut numbers: Vec<usize> = numbers
                .iter()
                .copied()
                .filter(|&n| n >= 1 && n <= count)
                .collect();
            numbers.sort_unstable();
            numbers.dedup();
            let mut layouts = Vec::with_capacity(numbers.len());
            for n in numbers {
                let (spans, rulings) = input.page_spans_and_rulings(n - 1)?;
                layouts.push(pdfboss_output::page_layout_with_rulings(&spans, &rulings));
            }
            layouts
        }
        None => {
            let mut pages = Vec::with_capacity(count);
            for index in 0..count {
                pages.push(input.page_spans_and_rulings(index)?);
            }
            pdfboss_output::document_layout_with_rulings(&pages)
        }
    };
    serde_json::to_value(&layouts).map_err(|e| e.to_string())
}

/// Two-space-indented JSON with optional ANSI coloring: keys cyan, strings
/// green, numbers yellow, booleans/null magenta. The uncolored layout is the
/// stable wire format golden tests pin.
pub fn write_json_pretty(out: &mut String, v: &Value, indent: usize, color: bool) {
    const KEY: &str = "\x1b[36m";
    const STR: &str = "\x1b[32m";
    const NUM: &str = "\x1b[33m";
    const LIT: &str = "\x1b[35m";
    const RESET: &str = "\x1b[0m";
    let paint = |out: &mut String, code: &str, text: &str| {
        if color {
            out.push_str(code);
            out.push_str(text);
            out.push_str(RESET);
        } else {
            out.push_str(text);
        }
    };
    match v {
        Value::Null => paint(out, LIT, "null"),
        Value::Bool(b) => paint(out, LIT, if *b { "true" } else { "false" }),
        Value::Number(n) => paint(out, NUM, &n.to_string()),
        Value::String(s) => {
            let quoted = serde_json::to_string(s).expect("strings always serialize");
            paint(out, STR, &quoted);
        }
        Value::Array(items) => {
            if items.is_empty() {
                out.push_str("[]");
                return;
            }
            out.push_str("[\n");
            for (i, item) in items.iter().enumerate() {
                push_indent(out, indent + 1);
                write_json_pretty(out, item, indent + 1, color);
                if i + 1 < items.len() {
                    out.push(',');
                }
                out.push('\n');
            }
            push_indent(out, indent);
            out.push(']');
        }
        Value::Object(map) => {
            if map.is_empty() {
                out.push_str("{}");
                return;
            }
            out.push_str("{\n");
            for (i, (key, value)) in map.iter().enumerate() {
                push_indent(out, indent + 1);
                let quoted = serde_json::to_string(key).expect("strings always serialize");
                paint(out, KEY, &quoted);
                out.push_str(": ");
                write_json_pretty(out, value, indent + 1, color);
                if i + 1 < map.len() {
                    out.push(',');
                }
                out.push('\n');
            }
            push_indent(out, indent);
            out.push('}');
        }
    }
}

fn push_indent(out: &mut String, level: usize) {
    for _ in 0..level {
        out.push_str("  ");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn plain(v: &Value) -> String {
        let mut out = String::new();
        write_json_pretty(&mut out, v, 0, false);
        out
    }

    #[test]
    fn scalars_print_bare() {
        assert_eq!(plain(&json!(null)), "null");
        assert_eq!(plain(&json!(true)), "true");
        assert_eq!(plain(&json!(42)), "42");
        assert_eq!(plain(&json!(1.5)), "1.5");
        assert_eq!(plain(&json!("a\"b")), r#""a\"b""#);
    }

    #[test]
    fn empty_containers_stay_inline() {
        assert_eq!(plain(&json!([])), "[]");
        assert_eq!(plain(&json!({})), "{}");
    }

    #[test]
    fn pretty_prints_nested_containers_two_space_indented() {
        let v = json!({"a": [1, 2], "b": {"c": "x"}});
        assert_eq!(
            plain(&v),
            "{\n  \"a\": [\n    1,\n    2\n  ],\n  \"b\": {\n    \"c\": \"x\"\n  }\n}"
        );
    }

    #[test]
    fn color_paints_keys_strings_numbers_and_literals() {
        let v = json!({"k": ["s", 7, true, null]});
        let mut out = String::new();
        write_json_pretty(&mut out, &v, 0, true);
        assert!(
            out.contains("\x1b[36m\"k\"\x1b[0m"),
            "key not cyan: {out:?}"
        );
        assert!(
            out.contains("\x1b[32m\"s\"\x1b[0m"),
            "string not green: {out:?}"
        );
        assert!(
            out.contains("\x1b[33m7\x1b[0m"),
            "number not yellow: {out:?}"
        );
        assert!(
            out.contains("\x1b[35mtrue\x1b[0m"),
            "bool not magenta: {out:?}"
        );
        assert!(
            out.contains("\x1b[35mnull\x1b[0m"),
            "null not magenta: {out:?}"
        );
    }

    #[test]
    fn uncolored_output_carries_no_escapes() {
        let v = json!({"k": [1, "s"]});
        assert!(!plain(&v).contains('\x1b'));
    }
}