vesey.techpdftxtractreposrcpdf.rs
use ::pdf::content::{Color, Op, TextDrawAdjusted};
use ::pdf::file::FileOptions;
use ::pdf::font::{Font, FontData, ToUnicodeMap, Widths};
use std::collections::HashMap;
use tracing::{debug, info, warn};

/// Re-exported so callers can name the error type returned by [`run`] without
/// depending on the `pdf` crate directly.
pub use ::pdf::PdfError;

/// Re-exported so callers can name the byte-source bound on [`run_backend`]
/// without depending on the `pdf` crate directly. Implemented by the `pdf`
/// crate for anything that derefs to `[u8]` (`Vec<u8>`, `&[u8]`, `Box<[u8]>`,
/// `Arc<[u8]>`, a memory-mapped file, …).
pub use ::pdf::backend::Backend;

/// How to resolve two text items that snap to the same grid cell.
#[derive(Clone, Copy, Default)]
pub enum Collision {
    /// First item placed wins; later items skip occupied cells entirely.
    First,
    /// Later item overwrites whatever is already in the cell.
    Last,
    /// Later item advances past occupied cells to the next empty one.
    #[default]
    Walk,
}

/// How to handle whitespace when printing the rendered grid.
#[derive(Clone, Copy, Default)]
pub enum Trim {
    /// No trimming — print the full grid as-is (only trailing spaces per row removed).
    #[default]
    None,
    /// Crop to the bounding box of all content: remove leading/trailing blank rows
    /// and leading/trailing blank columns.
    Bounds,
    /// Bounding-box crop plus collapse internal runs of blank rows → one blank row,
    /// and runs of entirely-blank columns → one space.
    Collapse,
}

/// User-supplied grid overrides. Either field may be `None` to autodetect.
/// Specifying `cols` fixes cell width and derives cell height at 2:1 (h:w).
/// Specifying `rows` fixes cell height and derives cell width.
/// Both together fully constrain the grid.
pub struct GridConfig {
    pub cols: Option<usize>,
    pub rows: Option<usize>,
    pub collision: Collision,
    pub trim: Trim,
}

/// A single decoded text object positioned in PDF point space.
struct TextItem {
    /// Distance from the page's left edge in points.
    x: f32,
    /// Distance from the page's bottom edge in points.
    y: f32,
    font_size: f32,
    /// Rendered width of the string in page points (sum of glyph advances).
    /// Zero when no font metrics are available.
    width_pts: f32,
    text: String,
}

/// A horizontal or vertical line segment in page-relative point space.
struct LineSegment {
    x1: f32,
    y1: f32,
    x2: f32,
    y2: f32,
    /// Index of the source rect in stream order (for debug correlation).
    idx: usize,
}

/// All extracted data for one page, ready for grid rendering.
struct PageData {
    page_num: u32,
    /// Page width in points (MediaBox right − left).
    width: f32,
    /// Page height in points (MediaBox top − bottom).
    height: f32,
    items: Vec<TextItem>,
    segments: Vec<LineSegment>,
}

/// One page rendered as a monospaced text grid.
pub struct RenderedPage {
    /// 1-based page number this grid was rendered from.
    pub page_num: u32,
    /// Grid width in character cells.
    pub cols: usize,
    /// Grid height in character cells.
    pub rows: usize,
    /// Rendered rows, top to bottom, after trimming. Join with `'\n'` to print.
    pub lines: Vec<String>,
}

impl RenderedPage {
    /// The rendered page as a single newline-joined string (no trailing newline).
    pub fn text(&self) -> String {
        self.lines.join("\n")
    }
}

/// Render the requested pages of a PDF file on disk.
///
/// Convenience wrapper over [`run_backend`]: reads the file fully into memory
/// (PDF parsing needs random access to the whole document) and renders it.
pub fn run(
    pdf_path: impl AsRef<std::path::Path>,
    page_nums: &[u32],
    config: GridConfig,
) -> Result<Vec<RenderedPage>, PdfError> {
    run_backend(std::fs::read(pdf_path)?, page_nums, config)
}

/// Render the requested pages from an in-memory PDF.
///
/// `data` is any byte source accepted by the `pdf` crate's [`Backend`] trait —
/// `Vec<u8>`, `&[u8]`, `Box<[u8]>`, `Arc<[u8]>`, a memory-mapped file, etc.
/// (anything that derefs to `[u8]`). To render from a `Read`er or other stream,
/// read it fully into a `Vec<u8>` first and pass that here: PDF parsing seeks
/// via the cross-reference table and so requires random access, not a stream.
///
/// Loads every requested page, extracts positioned text, autodetects (or accepts
/// overridden) cell dimensions, then renders each page as a monospaced grid.
///
/// Returns one [`RenderedPage`] per page that was successfully loaded; pages that
/// fail to load (missing, no MediaBox, decode error) are logged and skipped.
pub fn run_backend<B: Backend>(
    data: B,
    page_nums: &[u32],
    config: GridConfig,
) -> Result<Vec<RenderedPage>, PdfError> {
    let file = FileOptions::cached().load(data)?;
    let resolver = file.resolver();

    // --- Pass 1: extract all pages into memory ---
    let mut pages: Vec<PageData> = Vec::new();
    for &page_num in page_nums {
        let page = match file.get_page(page_num - 1) {
            Ok(p) => p,
            Err(e) => {
                warn!("page {} not found: {}", page_num, e);
                continue;
            }
        };

        let bbox = match page.media_box() {
            Ok(b) => b,
            Err(e) => {
                warn!("page {} has no MediaBox: {}", page_num, e);
                continue;
            }
        };
        let (page_left, page_bottom) = (bbox.left, bbox.bottom);
        let width = bbox.right - bbox.left;
        let height = bbox.top - bbox.bottom;

        let font_unicode = build_font_map(&page, &resolver);

        let (items, segments) = page.contents.as_ref().map_or_else(
            || (Vec::new(), Vec::new()),
            |contents| match contents.operations(&resolver) {
                Ok(ops) => (
                    extract_items(&ops, &font_unicode, page_left, page_bottom),
                    extract_segments(&ops, page_left, page_bottom, width, height),
                ),
                Err(e) => {
                    warn!("page {} op decode error: {}", page_num, e);
                    (vec![], vec![])
                }
            },
        );

        pages.push(PageData {
            page_num,
            width,
            height,
            items,
            segments,
        });
    }

    // --- Pass 2: render each page ---
    let mut rendered = Vec::with_capacity(pages.len());
    for page in &pages {
        let (cell_w, cell_h) = compute_cell_size(page, &config);
        let cols = config
            .cols
            .unwrap_or_else(|| (page.width / cell_w).round() as usize)
            .max(1);
        let rows = config
            .rows
            .unwrap_or_else(|| (page.height / cell_h).ceil() as usize)
            .max(1);
        debug!("page {}: cell {:.2}pt×{:.2}pt → {}c×{}r", page.page_num, cell_w, cell_h, cols, rows);
        info!("=== Page {} ({}c × {}r) ===", page.page_num, cols, rows);
        let lines = render_page(
            page,
            cols,
            rows,
            cell_w,
            cell_h,
            config.collision,
            config.trim,
        );
        rendered.push(RenderedPage {
            page_num: page.page_num,
            cols,
            rows,
            lines,
        });
    }
    Ok(rendered)
}

// ---------------------------------------------------------------------------
// Grid sizing
// ---------------------------------------------------------------------------

/// Compute the cell width and height for a single page.
///
/// Priority (highest first):
///   1. Both cols and rows given → divide page dimensions.
///   2. Only cols given → cell_w = page_w/cols, cell_h = cell_w * 2.
///   3. Only rows given → cell_h = page_h/rows, cell_w = cell_h / 2.
///   4. Neither → weighted-average glyph advance (capped to prevent overflow).
fn compute_cell_size(page: &PageData, config: &GridConfig) -> (f32, f32) {
    match (config.cols, config.rows) {
        (Some(c), Some(r)) => (page.width / c as f32, page.height / r as f32),
        (Some(c), None) => {
            let cell_w = page.width / c as f32;
            (cell_w, cell_w * 2.0)
        }
        (None, Some(r)) => {
            let cell_h = page.height / r as f32;
            (cell_h / 2.0, cell_h)
        }
        (None, None) => {
            // Weighted-average glyph advance width from font metrics.
            let mut total_width = 0.0f32;
            let mut total_chars = 0usize;
            for item in &page.items {
                let n = item.text.chars().count();
                if item.width_pts > 0.0 && n > 0 {
                    total_width += item.width_pts;
                    total_chars += n;
                }
            }
            let candidate_cell_w = if total_chars > 0 {
                total_width / total_chars as f32
            } else {
                modal_font_size_single(page) * 0.3
            };

            // Cap: cell_w <= (page_w - x) / len for every text item.
            let overflow_cap = page.items.iter().filter_map(|item| {
                let n = item.text.chars().count();
                if n == 0 || item.x >= page.width { return None; }
                Some((page.width - item.x) / n as f32)
            }).fold(f32::INFINITY, f32::min);

            let cell_w = if overflow_cap.is_finite() && overflow_cap < candidate_cell_w {
                overflow_cap
            } else {
                candidate_cell_w
            };

            (cell_w, cell_w * 2.0)
        }
    }
}

fn modal_font_size_single(page: &PageData) -> f32 {
    let mut counts: HashMap<u32, usize> = HashMap::new();
    for item in &page.items {
        let key = (item.font_size * 2.0).round() as u32;
        *counts.entry(key).or_default() += item.text.chars().count();
    }
    counts
        .into_iter()
        .max_by_key(|(_, n)| *n)
        .map(|(key, _)| key as f32 / 2.0)
        .unwrap_or(10.0)
}

// ---------------------------------------------------------------------------
// Text extraction
// ---------------------------------------------------------------------------

/// Build a map from font resource name → (ToUnicode map, is_CID) for a page.
///
/// CID fonts (Type0) encode characters as 2-byte big-endian values; simple
/// fonts use single bytes.  We record which kind each font is so the decoder
/// knows how to split raw string bytes into character codes.
struct FontInfo {
    unicode_map: Option<ToUnicodeMap>,
    is_cid: bool,
    widths: Option<Widths>,
}

fn build_font_map(
    page: &::pdf::object::PageRc,
    resolver: &impl ::pdf::object::Resolve,
) -> HashMap<String, FontInfo> {
    let mut map = HashMap::new();
    if let Ok(resources) = page.resources() {
        for (name, lazy_font) in resources.fonts.iter() {
            if let Ok(maybe_ref) = lazy_font.load(resolver) {
                let font: &Font = &maybe_ref;
                let is_cid = matches!(font.data, FontData::Type0(_));
                let unicode_map = font.to_unicode(resolver).and_then(|r| r.ok());
                let widths = font.widths(resolver).ok().flatten();
                map.insert(name.as_str().to_owned(), FontInfo { unicode_map, is_cid, widths });
            }
        }
    }
    map
}

/// Walk the content stream operations for one page and collect every decoded
/// text run as a `TextItem` positioned relative to the page's bottom-left corner.
fn extract_items(
    ops: &[Op],
    fonts: &HashMap<String, FontInfo>,
    page_left: f32,
    page_bottom: f32,
) -> Vec<TextItem> {
    let mut items = Vec::new();

    // PDF graphics state
    let identity = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
    let mut ctm: [f32; 6] = identity; // current transformation matrix
    let mut ctm_stack: Vec<[f32; 6]> = Vec::new();

    // PDF text state
    let mut tm = identity; // text matrix
    let mut tlm = identity; // text line matrix
    let mut font_size = 1.0f32;
    let mut leading = 0.0f32;
    let mut in_text = false;
    let mut current_font: Option<String> = None;

    // Sum glyph advance widths for raw bytes using font metrics.
    // Widths in PDF are in units of 1/1000 of the text space unit.
    let glyph_width_pts = |bytes: &[u8], font_name: Option<&str>, fs: f32, h_scale: f32| -> f32 {
        let Some(name) = font_name else { return 0.0 };
        let Some(info) = fonts.get(name) else { return 0.0 };
        let Some(widths) = &info.widths else { return 0.0 };
        let total: f32 = if info.is_cid {
            bytes.chunks(2).map(|c| {
                let code = if c.len() == 2 { u16::from_be_bytes([c[0], c[1]]) } else { c[0] as u16 };
                widths.get(code as usize)
            }).sum()
        } else {
            bytes.iter().map(|&b| widths.get(b as usize)).sum()
        };
        total / 1000.0 * fs * h_scale
    };

    // Transform text-space (tx, ty) → page-relative coords using CTM.
    let text_pos = |ctm: &[f32; 6], tm: &[f32; 6]| -> (f32, f32) {
        let px = ctm[0] * tm[4] + ctm[2] * tm[5] + ctm[4];
        let py = ctm[1] * tm[4] + ctm[3] * tm[5] + ctm[5];
        (px - page_left, py - page_bottom)
    };

    for op in ops {
        match op {
            Op::Save => ctm_stack.push(ctm),
            Op::Restore => ctm = ctm_stack.pop().unwrap_or(identity),

            // cm — concatenate matrix onto CTM: new_ctm = old_ctm × m
            Op::Transform { matrix: m } => {
                ctm = mat_mul(ctm, [m.a, m.b, m.c, m.d, m.e, m.f]);
            }

            Op::BeginText => {
                in_text = true;
                tm = identity;
                tlm = identity;
            }
            Op::EndText => {
                in_text = false;
            }

            Op::TextFont { name, size } => {
                font_size = *size;
                current_font = Some(name.as_str().to_owned());
            }

            // TL sets leading; the leading half of TD also emits this variant
            Op::Leading { leading: l } => {
                leading = *l;
            }

            // Tm — set text matrix and line matrix absolutely (raw values; CTM applied at record time)
            Op::SetTextMatrix { matrix: m } => {
                tm = [m.a, m.b, m.c, m.d, m.e, m.f];
                tlm = tm;
            }

            // Td / TD both emit MoveTextPosition (TD also emits Leading first)
            Op::MoveTextPosition { translation: t } => {
                tlm = translate(tlm, t.x, t.y);
                tm = tlm;
            }

            // T* — move to start of next line using current leading
            Op::TextNewline => {
                tlm = translate(tlm, 0.0, -leading);
                tm = tlm;
            }

            // Tj — show a single string
            Op::TextDraw { text } if in_text => {
                let s = decode_string(text.as_bytes(), current_font.as_deref(), fonts);
                if !s.trim().is_empty() {
                    let (x, y) = text_pos(&ctm, &tm);
                    let h_scale = ctm[0] * tm[0];
                    let width_pts = glyph_width_pts(text.as_bytes(), current_font.as_deref(), font_size, h_scale);
                    items.push(TextItem { x, y, font_size, width_pts, text: s });
                }
            }

            // TJ — show strings interleaved with kerning adjustments
            Op::TextDrawAdjusted { array } if in_text => {
                let mut combined = String::new();
                let mut raw_bytes: Vec<u8> = Vec::new();
                for item in array {
                    if let TextDrawAdjusted::Text(t) = item {
                        combined.push_str(&decode_string(t.as_bytes(), current_font.as_deref(), fonts));
                        raw_bytes.extend_from_slice(t.as_bytes());
                    }
                    // Spacing entries shift glyphs but we ignore sub-cell kerning
                }
                if !combined.trim().is_empty() {
                    let (x, y) = text_pos(&ctm, &tm);
                    let h_scale = ctm[0] * tm[0];
                    let width_pts = glyph_width_pts(&raw_bytes, current_font.as_deref(), font_size, h_scale);
                    items.push(TextItem { x, y, font_size, width_pts, text: combined });
                }
            }

            Op::MoveTo { p } => {
                let (x, y) = pt2(&ctm, p.x, p.y, page_left, page_bottom);
                debug!(op = "moveto", x, y);
            }
            Op::LineTo { p } => {
                let (x, y) = pt2(&ctm, p.x, p.y, page_left, page_bottom);
                debug!(op = "lineto", x, y);
            }
            Op::CurveTo { c1, c2, p } => {
                let (x, y) = pt2(&ctm, p.x, p.y, page_left, page_bottom);
                let (x1, y1) = pt2(&ctm, c1.x, c1.y, page_left, page_bottom);
                let (x2, y2) = pt2(&ctm, c2.x, c2.y, page_left, page_bottom);
                debug!(op = "curveto", x1, y1, x2, y2, x, y);
            }
            Op::Rect { rect } => {
                let (x0, y0) = pt2(&ctm, rect.x, rect.y, page_left, page_bottom);
                let (x1, y1) = pt2(
                    &ctm,
                    rect.x + rect.width,
                    rect.y + rect.height,
                    page_left,
                    page_bottom,
                );
                debug!(op = "rect", x0, y0, x1, y1);
            }
            Op::Close => {
                debug!(op = "close");
            }
            Op::Stroke => {
                debug!(op = "stroke");
            }
            Op::LineWidth { width } => {
                debug!(op = "linewidth", width);
            }

            _ => {}
        }
    }
    items
}

/// Walk the content stream and collect every horizontal or vertical line segment,
/// with CTM applied.  Clip paths (EndPath without stroke/fill) are discarded.
/// Rects spanning >90% of the page in both dimensions are skipped (backgrounds).
fn extract_segments(
    ops: &[Op],
    page_left: f32,
    page_bottom: f32,
    page_width: f32,
    page_height: f32,
) -> Vec<LineSegment> {
    let identity = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
    let mut ctm: [f32; 6] = identity;
    let mut ctm_stack: Vec<[f32; 6]> = Vec::new();

    let mut subpath_start: Option<(f32, f32)> = None;
    let mut current_pos: Option<(f32, f32)> = None;
    let mut pending: Vec<LineSegment> = Vec::new();
    let mut segments: Vec<LineSegment> = Vec::new();
    let mut rect_count: usize = 0;
    let mut pending_idx: usize = 0;
    let fmt_color = |c: &Color| -> String {
        match c {
            Color::Gray(g)   => format!("gray({:.2})", g),
            Color::Rgb(rgb)  => format!("rgb({:.2},{:.2},{:.2})", rgb.red, rgb.green, rgb.blue),
            Color::Cmyk(k)   => format!("cmyk({:.2},{:.2},{:.2},{:.2})", k.cyan, k.magenta, k.yellow, k.key),
            Color::Other(_)  => "other".into(),
        }
    };
    let mut fill_color   = String::from("?");
    let mut stroke_color = String::from("?");

    let flush = |pending: &mut Vec<LineSegment>, out: &mut Vec<LineSegment>| {
        // Keep only H/V segments of non-trivial length
        for s in pending.drain(..) {
            let dx = (s.x2 - s.x1).abs();
            let dy = (s.y2 - s.y1).abs();
            let horiz = dy < 0.5;
            let vert = dx < 0.5;
            if !horiz && !vert {
                continue;
            } // diagonal — ignore
            if horiz && dx < 0.5 {
                continue;
            } // degenerate point
            if vert && dy < 0.5 {
                continue;
            }
            out.push(s);
        }
    };

    for op in ops {
        match op {
            Op::Save => ctm_stack.push(ctm),
            Op::Restore => ctm = ctm_stack.pop().unwrap_or(identity),
            Op::Transform { matrix: m } => {
                ctm = mat_mul(ctm, [m.a, m.b, m.c, m.d, m.e, m.f]);
            }
            Op::FillColor   { color } => fill_color   = fmt_color(color),
            Op::StrokeColor { color } => stroke_color = fmt_color(color),

            Op::MoveTo { p } => {
                let pos = pt2(&ctm, p.x, p.y, page_left, page_bottom);
                subpath_start = Some(pos);
                current_pos = Some(pos);
            }
            Op::LineTo { p } => {
                let to = pt2(&ctm, p.x, p.y, page_left, page_bottom);
                if let Some((x1, y1)) = current_pos {
                    pending.push(LineSegment { x1, y1, x2: to.0, y2: to.1, idx: pending_idx });
                }
                current_pos = Some(to);
            }
            Op::Close => {
                if let (Some((x1, y1)), Some((xs, ys))) = (current_pos, subpath_start) {
                    pending.push(LineSegment { x1, y1, x2: xs, y2: ys, idx: pending_idx });
                }
                current_pos = subpath_start;
            }
            Op::Rect { rect } => {
                let (x, y) = pt2(&ctm, rect.x, rect.y, page_left, page_bottom);
                let w = rect.width * ctm[0];
                let h = rect.height * ctm[3];
                let idx = rect_count;
                rect_count += 1;
                debug!(op = "rect", idx, x0 = x, y0 = y, x1 = x + w, y1 = y + h, fill = %fill_color, stroke = %stroke_color);
                // Skip near-full-page background rects
                if w.abs() > page_width * 0.9 && h.abs() > page_height * 0.9 {
                    continue;
                }
                pending_idx = idx;
                pending.push(LineSegment { x1: x, y1: y, x2: x + w, y2: y, idx }); // bottom
                pending.push(LineSegment { x1: x + w, y1: y, x2: x + w, y2: y + h, idx }); // right
                pending.push(LineSegment { x1: x + w, y1: y + h, x2: x, y2: y + h, idx }); // top
                pending.push(LineSegment { x1: x, y1: y + h, x2: x, y2: y, idx }); // left
                subpath_start = None;
                current_pos = None;
            }

            Op::Stroke | Op::Fill { .. } | Op::FillAndStroke { .. } => {
                flush(&mut pending, &mut segments);
                subpath_start = None;
                current_pos = None;
            }
            Op::EndPath => {
                // Clip path or abandoned path — discard without recording
                pending.clear();
                subpath_start = None;
                current_pos = None;
            }

            _ => {}
        }
    }
    segments
}

/// Apply CTM to a point and return page-relative coordinates.
fn pt2(ctm: &[f32; 6], x: f32, y: f32, page_left: f32, page_bottom: f32) -> (f32, f32) {
    (
        ctm[0] * x + ctm[2] * y + ctm[4] - page_left,
        ctm[1] * x + ctm[3] * y + ctm[5] - page_bottom,
    )
}

// ---------------------------------------------------------------------------
// Grid rendering
// ---------------------------------------------------------------------------

/// Place all text items from `page` onto a `cols × rows` character grid and
/// print it line by line, trimming trailing whitespace from each row.
///
/// PDF's y-axis points upward; we flip it so the top of the page is row 0.
/// Each item's characters are placed left-to-right starting at the snapped
/// column; items that run off the right edge are silently clipped.
/// First-write-wins on collision (earlier items in the stream have priority).
fn render_page(
    page: &PageData,
    cols: usize,
    rows: usize,
    cell_w: f32,
    cell_h: f32,
    collision: Collision,
    trim: Trim,
) -> Vec<String> {
    let mut grid: Vec<Vec<char>> = vec![vec![' '; cols]; rows];

    // --- Pass 1: draw line segments (lowest priority) ---
    draw_segments(
        &mut grid,
        &page.segments,
        page.height,
        cell_w,
        cell_h,
        cols,
        rows,
    );

    // --- Pass 2: draw text (always wins over line chars) ---
    // Sort into reading order (top-to-bottom, left-to-right) so that
    // first-write-wins gives priority to content earlier on the page.
    let mut items: Vec<&TextItem> = page.items.iter().collect();
    items.sort_by(|a, b| {
        b.y.partial_cmp(&a.y)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
    });

    for item in &items {
        let col = (item.x / cell_w).round() as isize;
        let center_y = item.y + item.font_size * 0.5;
        let row = ((page.height - center_y) / cell_h).round() as isize;

        debug!(x = item.x, y = item.y, row, col, text = %item.text);

        if row >= 0 && (row as usize) < rows {
            let row = row as usize;
            match collision {
                Collision::First => {
                    for (i, ch) in item.text.chars().enumerate() {
                        let c = col + i as isize;
                        if c >= 0 && (c as usize) < cols {
                            let cell = &mut grid[row][c as usize];
                            // Text overwrites line chars; respects other text (first-wins)
                            if *cell == ' ' || is_line_char(*cell) {
                                *cell = ch;
                            }
                        }
                    }
                }
                Collision::Last => {
                    for (i, ch) in item.text.chars().enumerate() {
                        let c = col + i as isize;
                        if c >= 0 && (c as usize) < cols {
                            grid[row][c as usize] = ch;
                        }
                    }
                }
                Collision::Walk => {
                    let mut c = col;
                    for ch in item.text.chars() {
                        // Advance past existing text; overwrite line chars in place
                        while c >= 0 && (c as usize) < cols {
                            let cell = grid[row][c as usize];
                            if cell == ' ' || is_line_char(cell) {
                                break;
                            }
                            c += 1;
                        }
                        if c >= 0 && (c as usize) < cols {
                            grid[row][c as usize] = ch;
                            c += 1;
                        }
                    }
                }
            }
        }
    }

    trim_grid(&grid, trim)
}

fn is_line_char(c: char) -> bool {
    matches!(c, '-' | '|' | '+')
}

/// Snap all collected line segments onto the character grid using `-`, `|`, `+`.
/// `+` is placed at every segment endpoint and at intersections; it beats `-`/`|`.
fn draw_segments(
    grid: &mut [Vec<char>],
    segments: &[LineSegment],
    page_height: f32,
    cell_w: f32,
    cell_h: f32,
    cols: usize,
    rows: usize,
) {
    for seg in segments {
        let horiz = (seg.y2 - seg.y1).abs() < 0.5;
        let vert = (seg.x2 - seg.x1).abs() < 0.5;

        if horiz {
            let row = ((page_height - seg.y1) / cell_h).round() as isize;
            if row < 0 || row as usize >= rows {
                continue;
            }
            let row = row as usize;
            let c1 = (seg.x1.min(seg.x2) / cell_w).round() as isize;
            let c2 = (seg.x1.max(seg.x2) / cell_w).round() as isize;
            debug!(rect = seg.idx, dir = "horiz", row, c1, c2);
            for c in c1..=c2 {
                if c < 0 || c as usize >= cols {
                    continue;
                }
                let ch = if c == c1 || c == c2 { '+' } else { '-' };
                place_line_char(&mut grid[row][c as usize], ch);
            }
        } else if vert {
            let col = (seg.x1 / cell_w).round() as isize;
            if col < 0 || col as usize >= cols {
                continue;
            }
            let col = col as usize;
            let r1 = ((page_height - seg.y1.max(seg.y2)) / cell_h).round() as isize;
            let r2 = ((page_height - seg.y1.min(seg.y2)) / cell_h).round() as isize;
            debug!(rect = seg.idx, dir = "vert", col, r1, r2);
            for r in r1..=r2 {
                if r < 0 || r as usize >= rows {
                    continue;
                }
                let ch = if r == r1 || r == r2 { '+' } else { '|' };
                place_line_char(&mut grid[r as usize][col], ch);
            }
        }
    }
}

/// Write a line character into a grid cell, respecting priority: `+` > `-`/`|` > ` `.
fn place_line_char(cell: &mut char, ch: char) {
    match (*cell, ch) {
        (_, '+') => *cell = '+', // + always wins
        (' ', _) => *cell = ch,  // blank → anything
        ('+', _) => {}           // + never overwritten
        _ => {}                  // - or | not overwritten by same-priority
    }
}

/// Return the bounding box (row_start, row_end, col_start, col_end) of all
/// non-space content in the grid.  Returns `None` if the grid is entirely blank.
fn content_bounds(grid: &[Vec<char>]) -> Option<(usize, usize, usize, usize)> {
    let row_start = grid.iter().position(|r| r.iter().any(|&c| c != ' '))?;
    let row_end = grid.iter().rposition(|r| r.iter().any(|&c| c != ' '))?;
    let rows = &grid[row_start..=row_end];
    let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
    let col_start =
        (0..ncols).find(|&c| rows.iter().any(|r| r.get(c).copied().unwrap_or(' ') != ' '))?;
    let col_end =
        (0..ncols).rfind(|&c| rows.iter().any(|r| r.get(c).copied().unwrap_or(' ') != ' '))?;
    Some((row_start, row_end, col_start, col_end))
}

fn trim_grid(grid: &[Vec<char>], trim: Trim) -> Vec<String> {
    if let Some(first) = grid.first() {
        assert!(
            grid.iter().all(|r| r.len() == first.len()),
            "trim_grid: all rows must have the same length"
        );
    }
    match trim {
        Trim::None => grid
            .iter()
            .map(|r| r.iter().collect::<String>().trim_end().to_string())
            .collect(),

        Trim::Bounds => {
            let Some((rs, re, cs, ce)) = content_bounds(grid) else {
                return vec![];
            };
            grid[rs..=re]
                .iter()
                .map(|r| {
                    let s: String = (cs..=ce)
                        .map(|c| r.get(c).copied().unwrap_or(' '))
                        .collect();
                    s.trim_end().to_string()
                })
                .collect()
        }

        Trim::Collapse => {
            let Some((rs, re, cs, ce)) = content_bounds(grid) else {
                return vec![];
            };
            let rows = &grid[rs..=re];

            // Column blank: every cell is ' ' or '-' (but not '|', '+', or text content)
            let blank_col: Vec<bool> = (cs..=ce)
                .map(|c| {
                    rows.iter()
                        .all(|r| matches!(r.get(c).copied().unwrap_or(' '), ' ' | '-'))
                })
                .collect();

            let mut out: Vec<String> = Vec::new();
            let mut prev_was_blank_row = false;
            for row in rows {
                // Row blank: every cell is ' ' or '|' (but not '-', '+', or text content)
                let row_blank =
                    (cs..=ce).all(|c| matches!(row.get(c).copied().unwrap_or(' '), ' ' | '|'));
                let mut line = String::new();
                let mut in_blank_run = false;
                for (i, &col_blank) in blank_col.iter().enumerate() {
                    let ch = row.get(cs + i).copied().unwrap_or(' ');
                    if col_blank {
                        if !in_blank_run { line.push(ch); }
                        in_blank_run = true;
                    } else {
                        line.push(ch);
                        in_blank_run = false;
                    }
                }
                if row_blank {
                    // Collapse consecutive blank rows into one representative row
                    if !prev_was_blank_row {
                        out.push(line.trim_end().to_string());
                    }
                    prev_was_blank_row = true;
                } else {
                    out.push(line);
                    prev_was_blank_row = false;
                }
            }
            out
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Decode a PDF string's raw bytes into Unicode using the font's ToUnicode CMap.
///
/// Falls back to lossy UTF-8 if no font or no CMap is available.
/// CID fonts (Type0) split bytes into 2-byte big-endian character codes;
/// simple fonts treat each byte as its own character code.
fn decode_string(
    bytes: &[u8],
    font_name: Option<&str>,
    fonts: &HashMap<String, FontInfo>,
) -> String {
    let Some(name) = font_name else {
        return String::from_utf8_lossy(bytes).into_owned();
    };
    let Some(info) = fonts.get(name) else {
        return String::from_utf8_lossy(bytes).into_owned();
    };
    let Some(map) = &info.unicode_map else {
        return String::from_utf8_lossy(bytes).into_owned();
    };
    let is_cid = info.is_cid;

    let mut result = String::new();
    if is_cid {
        for chunk in bytes.chunks(2) {
            let gid = if chunk.len() == 2 {
                u16::from_be_bytes([chunk[0], chunk[1]])
            } else {
                chunk[0] as u16
            };
            if let Some(s) = map.get(gid) {
                result.push_str(s);
            }
        }
    } else {
        for &byte in bytes {
            if let Some(s) = map.get(byte as u16) {
                result.push_str(s);
            }
        }
    }
    result
}

/// Apply a 2D translation to a PDF text matrix.
///
/// A text matrix is stored as `[a b c d e f]` (column-major), where
/// `(e, f)` is the translation component.  Moving by `(tx, ty)` in text
/// space maps to page space as: e' = e + tx·a + ty·c, f' = f + tx·b + ty·d.
fn translate(m: [f32; 6], tx: f32, ty: f32) -> [f32; 6] {
    [
        m[0],
        m[1],
        m[2],
        m[3],
        m[4] + tx * m[0] + ty * m[2],
        m[5] + tx * m[1] + ty * m[3],
    ]
}

/// Concatenate two PDF matrices: result = a × b.
///
/// Implements the `cm` operator semantics: the left operand is the existing
/// CTM and the right operand is the new transform being appended.
fn mat_mul(a: [f32; 6], b: [f32; 6]) -> [f32; 6] {
    [
        a[0] * b[0] + a[2] * b[1],
        a[1] * b[0] + a[3] * b[1],
        a[0] * b[2] + a[2] * b[3],
        a[1] * b[2] + a[3] * b[3],
        a[0] * b[4] + a[2] * b[5] + a[4],
        a[1] * b[4] + a[3] * b[5] + a[5],
    ]
}

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

    /// Parse an indoc-style multiline string into a grid.
    /// Only the trailing empty line produced by indoc's closing `"` is stripped;
    /// internal blank lines are preserved so tests can express blank rows.
    fn parse_grid(s: &str) -> Vec<Vec<char>> {
        let lines: Vec<&str> = s.lines().collect();
        let end = lines.len()
            - if lines.last().map(|l| l.is_empty()).unwrap_or(false) {
                1
            } else {
                0
            };
        lines[..end].iter().map(|l| l.chars().collect()).collect()
    }

    /// Parse an indoc string into the Vec<String> form that trim_grid returns,
    /// so assert_eq! comparisons can be written as multiline string literals.
    fn expected(s: &str) -> Vec<String> {
        let lines: Vec<&str> = s.lines().collect();
        let end = lines.len()
            - if lines.last().map(|l| l.is_empty()).unwrap_or(false) {
                1
            } else {
                0
            };
        lines[..end].iter().map(|l| l.to_string()).collect()
    }

    fn trim_bounds(s: &str) -> Vec<String> {
        trim_grid(&parse_grid(s), Trim::Bounds)
    }

    fn trim_collapse(s: &str) -> Vec<String> {
        trim_grid(&parse_grid(s), Trim::Collapse)
    }

    #[test]
    fn test() {
        assert_eq!(
            trim_bounds(indoc! {"
                 ABC    
                 DEF    

            "}),
            expected(indoc! {"
                ABC
                DEF
            "}),
        );
    }

    #[test]
    fn test2() {
        assert_eq!(
            trim_collapse(indoc! {"
                 ABC    
                 DEF    

            "}),
            expected(indoc! {"
                ABC
                DEF
            "}),
        );
    }

    #[test]
    fn test3() {
        assert_eq!(
            trim_collapse(indoc! {"
                 ABC    
                 +-----+
                 DEF    
            "}),
            expected(indoc! {"
                ABC  
                +---+
                DEF  
            "}),
        );
    }

    #[test]
    fn test4() {
        assert_eq!(
            trim_collapse(indoc! {"
                +
                |
                |
                +
            "}),
            expected(indoc! {"
                +
                |
                +
            "}),
        );
    }
    #[test]
    fn test5() {
        assert_eq!(
            trim_collapse(indoc! {"
            +-PH7--------++             +-I/O-------+-UART3_CTS---------------+-SPI1_MISO--------------+OWA_OUT-------------------++RGMII0_TXCTL/-----------+-PH_EI---NT7--------+
            |            ||             |           |                         |                        |                          ||                        |                    |
            |            ||             |           |                         |                        |                          ||RMII0_TXEN              |                    |
            |            ||             |           |                         |                        |                          ||                        |                    |
            +------------++             +-----------+-------------------------+------------------------+--------------------------++------------------------+--------------------+
            "}),
            expected(indoc! {"
            +-PH7-++ +-I/O-+-UART3_CTS-+-SPI1_MISO-+OWA_OUT-++RGMII0_TXCTL/-+-PH_EI-NT7-+
            |     || |     |           |           |        ||              |           |
            |     || |     |           |           |        ||RMII0_TXEN    |           |
            |     || |     |           |           |        ||              |           |
            +-----++ +-----+-----------+-----------+--------++--------------+-----------+
            "}),
        );
    }
}