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 | | | || | | | | || | | +-----++ +-----+-----------+-----------+--------++--------------+-----------+ "}), ); } }