From be05000ebad8acc3b5a10b40064096340156ffa1 Mon Sep 17 00:00:00 2001 From: myhloli Date: Tue, 29 Sep 2026 19:01:46 +0800 Subject: [PATCH 1/3] perf(pdf): batch native path evidence --- rust/docvortex-core/src/lib.rs | 2 +- rust/docvortex-pdfium/src/lib.rs | 1 + rust/docvortex-pdfium/src/path_evidence.rs | 617 +++++++++++++++++++ rust/docvortex-pdfium/src/paths.rs | 45 +- rust/docvortex-python/src/lib.rs | 7 + rust/docvortex-python/src/pdfium.rs | 95 +++ rust/docvortex-python/src/snapshot.rs | 48 ++ src/docvortex/_compute_backend.py | 4 +- src/docvortex/document/pdf/_object_bridge.py | 79 +++ src/docvortex/document/pdf/native_objects.py | 24 + tests/test_native_parity.py | 6 +- tests/test_pdf_drawing_lines_extraction.py | 26 + tests/unittest/test_pdf_document.py | 4 + 13 files changed, 943 insertions(+), 15 deletions(-) create mode 100644 rust/docvortex-pdfium/src/path_evidence.rs diff --git a/rust/docvortex-core/src/lib.rs b/rust/docvortex-core/src/lib.rs index 9cce7e89..afad33f3 100644 --- a/rust/docvortex-core/src/lib.rs +++ b/rust/docvortex-core/src/lib.rs @@ -1,6 +1,6 @@ //! 不访问 Python 对象或 PDFium 的单线程批量计算内核。 -pub const PROTOCOL_VERSION: u32 = 25; +pub const PROTOCOL_VERSION: u32 = 26; pub mod columns; pub mod note_index; pub mod row_geometry; diff --git a/rust/docvortex-pdfium/src/lib.rs b/rust/docvortex-pdfium/src/lib.rs index 7233e526..a13dd5e3 100644 --- a/rust/docvortex-pdfium/src/lib.rs +++ b/rust/docvortex-pdfium/src/lib.rs @@ -5,6 +5,7 @@ pub mod classification; pub mod drawing_lines; pub mod fonts; pub mod objects; +pub mod path_evidence; pub mod paths; pub mod text_colors; diff --git a/rust/docvortex-pdfium/src/path_evidence.rs b/rust/docvortex-pdfium/src/path_evidence.rs new file mode 100644 index 00000000..809d9cdb --- /dev/null +++ b/rust/docvortex-pdfium/src/path_evidence.rs @@ -0,0 +1,617 @@ +//! 批量生成 Path 的绘图线与摘要证据,保持 Python 参考实现的对象级隔离语义。 + +use crate::{objects, paths, ReadError}; +use std::ffi::{c_float, c_int, c_uint, c_void}; + +pub type Line = ((f64, f64), (f64, f64), (f64, f64, f64, f64), f64, i32); +pub type Rgba = (c_uint, c_uint, c_uint, c_uint); +pub type PathInfo = ( + (f64, f64, f64, f64), + c_int, + bool, + bool, + usize, + usize, + Option, +); +pub type Evidence = (Vec, Vec); + +type ColorFn = unsafe extern "system" fn( + *mut c_void, + *mut c_uint, + *mut c_uint, + *mut c_uint, + *mut c_uint, +) -> c_int; +type DrawModeFn = unsafe extern "system" fn(*mut c_void, *mut c_int, *mut c_int) -> c_int; +type StrokeWidthFn = unsafe extern "system" fn(*mut c_void, *mut c_float) -> c_int; + +/// 一次页面读取中复用的 Path 状态 ABI。 +struct StateApi { + subpaths: paths::Api, + draw_mode: DrawModeFn, + stroke_color: ColorFn, + stroke_width: StrokeWidthFn, + fill_color: ColorFn, +} + +/// 保存原始子路径及其一次转换后的页面视觉点与直线。 +struct PreparedSubpath { + raw: paths::Subpath, + points: Vec<(f64, f64)>, + lines: Vec<((f64, f64), (f64, f64))>, + closed: bool, +} + +/// 保存一次读取后的绘制状态;颜色失败沿用参考实现的 255 alpha。 +struct PathState { + fill_visible: bool, + stroke_visible: bool, + fill_rgba: Option, + raw_stroke_width: f64, +} + +/// 保留 Python min 对相同值与 NaN 的首项选择。 +fn minimum(a: f64, b: f64) -> f64 { + if b < a { + b + } else { + a + } +} + +/// 保留 Python max 对相同值与 NaN 的首项选择。 +fn maximum(a: f64, b: f64) -> f64 { + if b > a { + b + } else { + a + } +} + +/// 按页面旋转角转换 PDF 底左坐标为页面左上坐标。 +fn visual_point(point: (f64, f64), frame: [f64; 4], rotation: i32) -> (f64, f64) { + let (x, y) = point; + match rotation { + 90 => (y - frame[1], x - frame[0]), + 180 => (frame[2] - x, y - frame[1]), + 270 => (frame[3] - y, frame[2] - x), + _ => (x - frame[0], frame[3] - y), + } +} + +/// 对象坐标先乘对象/Form 矩阵,再转换为页面视觉坐标。 +fn transformed_point( + point: (f64, f64), + matrix: [f64; 6], + frame: [f64; 4], + rotation: i32, +) -> (f64, f64) { + let x = matrix[0] * point.0 + matrix[2] * point.1 + matrix[4]; + let y = matrix[1] * point.0 + matrix[3] * point.1 + matrix[5]; + visual_point((x, y), frame, rotation) +} + +/// 取页面视觉页尺寸;旋转 90/270 时交换宽高。 +fn page_size(frame: [f64; 4], rotation: i32) -> (f64, f64) { + let size = (frame[2] - frame[0], frame[3] - frame[1]); + if rotation == 90 || rotation == 270 { + (size.1, size.0) + } else { + size + } +} + +/// 读取一次 Path 绘制状态,颜色失败按不透明处理。 +unsafe fn path_state(raw: *mut c_void, api: &StateApi) -> PathState { + let (mut fill_mode, mut stroke) = (0, 0); + if (api.draw_mode)(raw, &mut fill_mode, &mut stroke) == 0 { + return PathState { + fill_visible: false, + stroke_visible: false, + fill_rgba: None, + raw_stroke_width: 0.0, + }; + } + let mut fill = (0, 0, 0, 255); + if fill_mode != 0 { + fill = color(raw, api.fill_color); + } + let mut stroke_rgba = (0, 0, 0, 255); + if stroke != 0 { + stroke_rgba = color(raw, api.stroke_color); + } + let fill_visible = fill_mode != 0 && fill.3 > 0; + let stroke_visible = stroke != 0 && stroke_rgba.3 > 0; + let mut width: c_float = 0.0; + let raw_stroke_width = if stroke_visible && (api.stroke_width)(raw, &mut width) != 0 { + let value = f64::from(width).abs(); + if value.is_finite() { + value + } else { + 0.0 + } + } else { + 0.0 + }; + PathState { + fill_visible, + stroke_visible, + fill_rgba: fill_visible.then_some(fill), + raw_stroke_width, + } +} + +/// 调用一个 PDFium 颜色函数;失败时保持 alpha=255 的参考语义。 +unsafe fn color(raw: *mut c_void, getter: ColorFn) -> Rgba { + let (mut red, mut green, mut blue, mut alpha) = (0, 0, 0, 255); + if getter(raw, &mut red, &mut green, &mut blue, &mut alpha) == 0 { + return (0, 0, 0, 255); + } + (red, green, blue, alpha) +} + +/// 一次转换整个子路径,避免绘图线和路径摘要重复矩阵乘法。 +fn prepare_subpath( + raw: paths::Subpath, + matrix: [f64; 6], + frame: [f64; 4], + rotation: i32, +) -> PreparedSubpath { + let points = raw + .points + .iter() + .map(|&point| transformed_point(point, matrix, frame, rotation)) + .collect(); + let lines = raw + .lines + .iter() + .map(|&(first, last)| { + ( + transformed_point(raw.points[first], matrix, frame, rotation), + transformed_point(raw.points[last], matrix, frame, rotation), + ) + }) + .collect(); + let closed = raw.closed; + PreparedSubpath { + raw, + points, + lines, + closed, + } +} + +/// 按局部线段法向量换算矩阵后的实际描边宽度。 +fn segment_stroke_width_with( + raw_width: f64, + start: (f64, f64), + end: (f64, f64), + matrix: [f64; 6], + hypot: &H, +) -> f64 +where + H: Fn(f64, f64) -> f64, +{ + let delta_x = end.0 - start.0; + let delta_y = end.1 - start.1; + let length = hypot(delta_x, delta_y); + let scale = if length <= 0.0 { + ((matrix[0] * matrix[3] - matrix[1] * matrix[2]).abs()).sqrt() + } else { + let normal_x = -delta_y / length; + let normal_y = delta_x / length; + let x = matrix[0] * normal_x + matrix[2] * normal_y; + let y = matrix[1] * normal_x + matrix[3] * normal_y; + hypot(x, y) + }; + let width = raw_width * scale; + if width.is_finite() { + width + } else { + 0.0 + } +} + +/// 将近水平或近竖直线段吸附为页面内绘图线。 +fn axis_line(start: (f64, f64), end: (f64, f64), width: f64, page: (f64, f64)) -> Option { + let (x0, y0) = start; + let (x1, y1) = end; + if ![x0, y0, x1, y1, width] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + let delta_x = (x1 - x0).abs(); + let delta_y = (y1 - y0).abs(); + if delta_x >= delta_y && delta_y <= 1.0_f64.max(delta_x * 0.02) { + let coordinate = (y0 + y1) / 2.0; + if !(0.0..=page.1).contains(&coordinate) { + return None; + } + let main_start = 0.0_f64.max(x0.min(x1)); + let main_end = page.0.min(x0.max(x1)); + if main_end - main_start < 1.0 { + return None; + } + let line_width = 0.0_f64.max(width).max(delta_y); + let half_width = line_width / 2.0; + return Some(( + (main_start, coordinate), + (main_end, coordinate), + ( + main_start, + 0.0_f64.max(coordinate - half_width), + main_end, + page.1.min(coordinate + half_width), + ), + line_width, + 0, + )); + } + if delta_y > delta_x && delta_x <= 1.0_f64.max(delta_y * 0.02) { + let coordinate = (x0 + x1) / 2.0; + if !(0.0..=page.0).contains(&coordinate) { + return None; + } + let main_start = 0.0_f64.max(y0.min(y1)); + let main_end = page.1.min(y0.max(y1)); + if main_end - main_start < 1.0 { + return None; + } + let line_width = 0.0_f64.max(width).max(delta_x); + let half_width = line_width / 2.0; + return Some(( + (coordinate, main_start), + (coordinate, main_end), + ( + 0.0_f64.max(coordinate - half_width), + main_start, + page.0.min(coordinate + half_width), + main_end, + ), + line_width, + 1, + )); + } + None +} + +/// 把闭合或参考实现认可的开放细长填充子路径折叠为中心线。 +fn thin_filled_line(subpath: &PreparedSubpath, page: (f64, f64)) -> Option { + if subpath.points.len() < 4 { + return None; + } + let mut x0 = subpath.points[0].0; + let mut x1 = subpath.points[0].0; + let mut y0 = subpath.points[0].1; + let mut y1 = subpath.points[0].1; + for &(x, y) in &subpath.points[1..] { + x0 = minimum(x0, x); + x1 = maximum(x1, x); + y0 = minimum(y0, y); + y1 = maximum(y1, y); + } + let (width, height) = (x1 - x0, y1 - y0); + if !subpath.closed && !open_thin_rectangle(subpath, x0, x1, y0, y1) { + return None; + } + let long_side = width.max(height); + let short_side = width.min(height); + if long_side < 1.0 || short_side > 2.0 || long_side < 4.0 * short_side.max(0.01) { + return None; + } + if width >= height { + axis_line( + (x0, (y0 + y1) / 2.0), + (x1, (y0 + y1) / 2.0), + short_side, + page, + ) + } else { + axis_line( + ((x0 + x1) / 2.0, y0), + ((x0 + x1) / 2.0, y1), + short_side, + page, + ) + } +} + +/// 仅接纳参考实现允许的开放四点三边轴对齐矩形。 +fn open_thin_rectangle(subpath: &PreparedSubpath, x0: f64, x1: f64, y0: f64, y1: f64) -> bool { + if subpath.points.len() != 4 || subpath.raw.lines.len() != 3 { + return false; + } + let rounded = subpath + .points + .iter() + .map(|&(x, y)| ((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)) + .collect::>(); + let expected = [(x0, y0), (x0, y1), (x1, y0), (x1, y1)] + .into_iter() + .map(|(x, y)| ((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)) + .collect::>(); + if rounded.iter().any(|point| !expected.contains(point)) { + return false; + } + subpath.raw.lines.iter().all(|&(first, last)| { + let a = subpath.raw.points[first]; + let b = subpath.raw.points[last]; + !((a.0 - b.0).abs() > 0.001 && (a.1 - b.1).abs() > 0.001) + }) +} + +/// 生成单个 Path 的绘图线候选,不应用对象裁剪。 +fn object_lines_with( + prepared: &[PreparedSubpath], + state: &PathState, + matrix: [f64; 6], + page: (f64, f64), + hypot: &H, +) -> Vec +where + H: Fn(f64, f64) -> f64, +{ + let mut output = Vec::new(); + if !state.fill_visible && !state.stroke_visible { + return output; + } + for subpath in prepared { + if state.fill_visible { + if let Some(line) = thin_filled_line(subpath, page) { + output.push(line); + continue; + } + } + if !state.stroke_visible { + continue; + } + for (index, (start, end)) in subpath.lines.iter().enumerate() { + let raw_start = subpath.raw.points[subpath.raw.lines[index].0]; + let raw_end = subpath.raw.points[subpath.raw.lines[index].1]; + let width = segment_stroke_width_with( + state.raw_stroke_width, + raw_start, + raw_end, + matrix, + hypot, + ); + if let Some(line) = axis_line(*start, *end, width, page) { + output.push(line); + } + } + } + output +} + +/// 生成单个 Path 的摘要候选;无效几何返回 None。 +#[allow(clippy::too_many_arguments)] +unsafe fn object_info_with( + raw: *mut c_void, + api: &StateApi, + prepared: &[PreparedSubpath], + state: &PathState, + matrix: [f64; 6], + frame: [f64; 4], + rotation: i32, + depth: usize, + source_index: usize, + hypot: &H, +) -> Option +where + H: Fn(f64, f64) -> f64, +{ + let segment_count = (api.subpaths.count)(raw); + if segment_count <= 0 || prepared.is_empty() { + return None; + } + let mut points = Vec::new(); + for subpath in prepared { + points.extend_from_slice(&subpath.points); + } + if points + .iter() + .any(|&(x, y)| !x.is_finite() || !y.is_finite()) + { + return None; + } + let page = page_size(frame, rotation); + let mut stroke_margin = 0.0; + if state.stroke_visible { + let x = hypot(matrix[0], matrix[1]); + let y = hypot(matrix[2], matrix[3]); + stroke_margin = 0.5 * state.raw_stroke_width * maximum(x, y); + } + let mut point = points[0]; + let mut bbox = [point.0, point.1, point.0, point.1]; + for current in &points[1..] { + point = *current; + bbox[0] = minimum(bbox[0], point.0); + bbox[1] = minimum(bbox[1], point.1); + bbox[2] = maximum(bbox[2], point.0); + bbox[3] = maximum(bbox[3], point.1); + } + let bbox = ( + 0.0_f64.max(bbox[0] - stroke_margin), + 0.0_f64.max(bbox[1] - stroke_margin), + page.0.min(bbox[2] + stroke_margin), + page.1.min(bbox[3] + stroke_margin), + ); + if bbox.2 > bbox.0 && bbox.3 > bbox.1 { + Some(( + bbox, + segment_count, + state.fill_visible, + state.stroke_visible, + depth, + source_index, + state.fill_rgba, + )) + } else { + None + } +} + +/// 将父坐标裁剪转换为视觉外框并与对象边界求交。 +fn clipped_bbox( + bbox: (f64, f64, f64, f64), + clip: Option<[f64; 4]>, + frame: [f64; 4], + rotation: i32, +) -> Option<(f64, f64, f64, f64)> { + let clip = match clip { + Some(value) => value, + // 无裁剪时直接保留原 bbox,不能用 Option None 误判为空交集。 + None => return Some(bbox), + }; + if clip[2] <= clip[0] || clip[3] <= clip[1] { + return None; + } + let corners = [ + visual_point((clip[0], clip[1]), frame, rotation), + visual_point((clip[0], clip[3]), frame, rotation), + visual_point((clip[2], clip[1]), frame, rotation), + visual_point((clip[2], clip[3]), frame, rotation), + ]; + if corners + .iter() + .any(|&(x, y)| !x.is_finite() || !y.is_finite()) + { + return None; + } + let xs = [corners[0].0, corners[1].0, corners[2].0, corners[3].0]; + let ys = [corners[0].1, corners[1].1, corners[2].1, corners[3].1]; + let visual = ( + xs.iter().copied().reduce(minimum).unwrap(), + ys.iter().copied().reduce(minimum).unwrap(), + xs.iter().copied().reduce(maximum).unwrap(), + ys.iter().copied().reduce(maximum).unwrap(), + ); + let result = ( + maximum(bbox.0, visual.0), + maximum(bbox.1, visual.1), + minimum(bbox.2, visual.2), + minimum(bbox.3, visual.3), + ); + (result.2 > result.0 && result.3 > result.1).then_some(result) +} + +/// 裁剪一条已吸附的轴线,并按方向重建端点。 +fn clipped_line( + line: Line, + clip: Option<[f64; 4]>, + frame: [f64; 4], + rotation: i32, +) -> Option { + let clipped = clipped_bbox(line.2, clip, frame, rotation)?; + if clipped == line.2 { + return Some(line); + } + let (start, end) = if line.4 == 0 { + let y = minimum(clipped.3, maximum(clipped.1, line.0 .1)); + ((clipped.0, y), (clipped.2, y)) + } else { + let x = minimum(clipped.2, maximum(clipped.0, line.0 .0)); + ((x, clipped.1), (x, clipped.3)) + }; + Some((start, end, clipped, line.3, line.4)) +} + +/// 单次遍历全部 Path,并允许绑定层传入宿主解释器的 hypot 语义。 +/// +/// # Safety +/// 调用方须验证全部 20 个函数 ABI,并持有同一 PDFium 页面、运行库和全局锁。 +#[allow(clippy::too_many_arguments)] +pub unsafe fn read_path_evidence_with_hypot f64>( + addresses: Vec, + handle: usize, + frame: [f64; 4], + rotation: i32, + max_depth: usize, + want_lines: bool, + want_infos: bool, + hypot: H, +) -> Result { + if addresses.len() != 20 + || addresses.contains(&0) + || handle == 0 + || max_depth > 64 + || ![0, 90, 180, 270].contains(&rotation) + { + return Err(ReadError::InvalidInput( + "invalid PDFium path evidence arguments", + )); + } + if !frame.iter().all(|value| value.is_finite()) || frame[2] <= frame[0] || frame[3] <= frame[1] + { + return Err(ReadError::InvalidInput( + "invalid PDFium path evidence geometry", + )); + } + let objects = objects::read_objects(addresses[..11].to_vec(), handle, 2, max_depth)?; + let state_api = StateApi { + subpaths: paths::Api { + count: std::mem::transmute:: c_int>( + addresses[11], + ), + get: std::mem::transmute::< + usize, + unsafe extern "system" fn(*mut c_void, c_int) -> *mut c_void, + >(addresses[12]), + point: std::mem::transmute::< + usize, + unsafe extern "system" fn(*mut c_void, *mut c_float, *mut c_float) -> c_int, + >(addresses[13]), + kind: std::mem::transmute:: c_int>( + addresses[14], + ), + closes: std::mem::transmute:: c_int>( + addresses[15], + ), + }, + draw_mode: std::mem::transmute::(addresses[16]), + stroke_color: std::mem::transmute::(addresses[17]), + stroke_width: std::mem::transmute::(addresses[18]), + fill_color: std::mem::transmute::(addresses[19]), + }; + let page = page_size(frame, rotation); + let mut lines = Vec::new(); + let mut infos = Vec::new(); + for (source_index, (address, matrix, _parent, depth, clip)) in objects.into_iter().enumerate() { + let raw = address as *mut c_void; + let raw_subpaths = paths::read_subpaths_with(&state_api.subpaths, address)?; + let state = path_state(raw, &state_api); + let prepared: Vec<_> = raw_subpaths + .into_iter() + .map(|subpath| prepare_subpath(subpath, matrix, frame, rotation)) + .collect(); + if want_lines { + for line in object_lines_with(&prepared, &state, matrix, page, &hypot) { + if let Some(line) = clipped_line(line, clip, frame, rotation) { + lines.push(line); + } + } + } + if want_infos { + if let Some(info) = object_info_with( + raw, + &state_api, + &prepared, + &state, + matrix, + frame, + rotation, + depth, + source_index, + &hypot, + ) { + if let Some(bbox) = clipped_bbox(info.0, clip, frame, rotation) { + infos.push((bbox, info.1, info.2, info.3, info.4, info.5, info.6)); + } + } + } + } + Ok((lines, infos)) +} diff --git a/rust/docvortex-pdfium/src/paths.rs b/rust/docvortex-pdfium/src/paths.rs index e0b12f29..0cd77182 100644 --- a/rust/docvortex-pdfium/src/paths.rs +++ b/rust/docvortex-pdfium/src/paths.rs @@ -4,6 +4,19 @@ use crate::ReadError; use std::ffi::{c_float, c_int, c_void}; pub type Point = (f64, f64); +type CountFn = unsafe extern "system" fn(*mut c_void) -> c_int; +type GetFn = unsafe extern "system" fn(*mut c_void, c_int) -> *mut c_void; +type PointFn = unsafe extern "system" fn(*mut c_void, *mut c_float, *mut c_float) -> c_int; +type SegmentFlagFn = unsafe extern "system" fn(*mut c_void) -> c_int; + +/// Path 段读取 ABI;同一页所有对象复用函数指针,不做逐对象重建。 +pub struct Api { + pub(crate) count: CountFn, + pub(crate) get: GetFn, + pub(crate) point: PointFn, + pub(crate) kind: SegmentFlagFn, + pub(crate) closes: SegmentFlagFn, +} #[derive(Default)] pub struct Subpath { @@ -25,28 +38,36 @@ pub unsafe fn read_subpaths( "invalid PDFium path bridge arguments", )); } - let count: unsafe extern "system" fn(*mut c_void) -> c_int = std::mem::transmute(addresses[0]); - let get: unsafe extern "system" fn(*mut c_void, c_int) -> *mut c_void = - std::mem::transmute(addresses[1]); - let point: unsafe extern "system" fn(*mut c_void, *mut c_float, *mut c_float) -> c_int = - std::mem::transmute(addresses[2]); - let kind: unsafe extern "system" fn(*mut c_void) -> c_int = std::mem::transmute(addresses[3]); - let closes: unsafe extern "system" fn(*mut c_void) -> c_int = std::mem::transmute(addresses[4]); + let api = Api { + count: std::mem::transmute::(addresses[0]), + get: std::mem::transmute::(addresses[1]), + point: std::mem::transmute::(addresses[2]), + kind: std::mem::transmute::(addresses[3]), + closes: std::mem::transmute::(addresses[4]), + }; + read_subpaths_with(&api, handle) +} + +/// 使用已核验 ABI 解码一个 Path;段读取失败时保留参考实现的跳过语义。 +/// +/// # Safety +/// 调用方须保持对象所属页面存活,并持有 PDFium 全局访问锁。 +pub unsafe fn read_subpaths_with(api: &Api, handle: usize) -> Result, ReadError> { let mut output = Vec::new(); let mut current = Subpath::default(); let mut current_index = 0; - for index in 0..count(handle as *mut c_void).max(0) { - let segment = get(handle as *mut c_void, index); + for index in 0..(api.count)(handle as *mut c_void).max(0) { + let segment = (api.get)(handle as *mut c_void, index); if segment.is_null() { continue; } let (mut x, mut y) = (0.0, 0.0); - if point(segment, &mut x, &mut y) == 0 { + if (api.point)(segment, &mut x, &mut y) == 0 { continue; } let value = (f64::from(x), f64::from(y)); - let segment_type = kind(segment); - let segment_closes = closes(segment) != 0; + let segment_type = (api.kind)(segment); + let segment_closes = (api.closes)(segment) != 0; if segment_type == 2 { if !current.points.is_empty() { output.push(std::mem::take(&mut current)); diff --git a/rust/docvortex-python/src/lib.rs b/rust/docvortex-python/src/lib.rs index 77f70cca..94ada135 100644 --- a/rust/docvortex-python/src/lib.rs +++ b/rust/docvortex-python/src/lib.rs @@ -63,6 +63,10 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { snapshot::read_pdfium_text_snapshot, module )?)?; + module.add_function(wrap_pyfunction!( + snapshot::visual_evidence_stage_stats, + module + )?)?; module.add_class::()?; module.add_class::()?; module.add_function(wrap_pyfunction!(text_pipeline::group_text_lines, module)?)?; @@ -92,6 +96,9 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { pdfium::pdfium_drawing_line_stage_stats, module )?)?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(pdfium::read_pdfium_path_evidence, module)?)?; module.add_function(wrap_pyfunction!( pdfium::read_pdfium_visual_batches, module diff --git a/rust/docvortex-python/src/pdfium.rs b/rust/docvortex-python/src/pdfium.rs index 1cbb4e65..ed9f4aaa 100644 --- a/rust/docvortex-python/src/pdfium.rs +++ b/rust/docvortex-python/src/pdfium.rs @@ -174,6 +174,101 @@ pub fn pdfium_drawing_line_stage_stats() -> (u64, u64, u64) { docvortex_pdfium::drawing_lines::stage_stats() } +type PathLineRecord = docvortex_pdfium::path_evidence::Line; +type PathInfoRecord = docvortex_pdfium::path_evidence::PathInfo; + +/// 按固定批次物化 Path 绘图线,页面证据仍留在 Rust 直到 Python 消费。 +#[pyclass(module = "docvortex._native")] +pub struct PdfiumPathLineBatches { + records: std::vec::IntoIter, +} + +#[pymethods] +impl PdfiumPathLineBatches { + /// 返回当前批次迭代器。 + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + /// 每次最多返回 1024 条未合并轴线。 + fn __next__(&mut self) -> Option> { + let records: Vec<_> = self.records.by_ref().take(RECORD_BATCH_SIZE).collect(); + (!records.is_empty()).then_some(records) + } +} + +/// 按固定批次物化 Path 摘要,避免整页记录在边界外重复保存。 +#[pyclass(module = "docvortex._native")] +pub struct PdfiumPathInfoBatches { + records: std::vec::IntoIter, +} + +#[pymethods] +impl PdfiumPathInfoBatches { + /// 返回当前批次迭代器。 + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + /// 每次最多返回 1024 条路径摘要。 + fn __next__(&mut self) -> Option> { + let records: Vec<_> = self.records.by_ref().take(RECORD_BATCH_SIZE).collect(); + (!records.is_empty()).then_some(records) + } +} + +/// 单次遍历批量生成 Path 双侧证据;复杂状态也留在原生路径中复刻。 +#[pyfunction] +pub fn read_pdfium_path_evidence( + py: Python<'_>, + addresses: Vec, + handle: usize, + frame: [f64; 4], + rotation: i32, + max_depth: usize, + want_lines: bool, + want_infos: bool, +) -> PyResult> { + if !want_lines && !want_infos { + return Err(PyValueError::new_err( + "PDFium path evidence requires one output side", + )); + } + let hypot = py.import("math")?.getattr("hypot")?; + let records = unsafe { + docvortex_pdfium::path_evidence::read_path_evidence_with_hypot( + addresses, + handle, + frame, + rotation, + max_depth, + want_lines, + want_infos, + // 保持解释器 math.hypot 的逐位语义;异常输入由其返回 NaN/Inf,不中断同页。 + |x: f64, y: f64| { + hypot + .call1((x, y)) + .and_then(|value| value.extract::()) + .unwrap_or(f64::NAN) + }, + ) + } + .map_err(|error| match error { + ReadError::InvalidInput(message) => PyValueError::new_err(message), + ReadError::Pdfium(message) => PdfiumReadError::new_err(message), + ReadError::Allocation(message) => PyMemoryError::new_err(message), + })?; + let (lines, infos) = records; + Ok(Some(( + PdfiumPathLineBatches { + records: lines.into_iter(), + }, + PdfiumPathInfoBatches { + records: infos.into_iter(), + }, + ))) +} + type VisualRecord = ( u32, f64, diff --git a/rust/docvortex-python/src/snapshot.rs b/rust/docvortex-python/src/snapshot.rs index 05d31827..c87d039a 100644 --- a/rust/docvortex-python/src/snapshot.rs +++ b/rust/docvortex-python/src/snapshot.rs @@ -843,6 +843,9 @@ impl NativeTextSnapshot { rotation: i32, supported_angles: Vec, ) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyList>)> { + // 剖析开关只统计私有管线的五个连续阶段,不影响正式解析结果。 + let profile = std::env::var_os("DOCVORTEX_PROFILE_VISUAL_EVIDENCE").is_some(); + let total_started = std::time::Instant::now(); if size.iter().chain(&supported_angles).any(|v| !v.is_finite()) { return Err(PyValueError::new_err("nonfinite visual snapshot arguments")); } @@ -892,7 +895,11 @@ impl NativeTextSnapshot { font_weight, }); } + let font_ns = total_started.elapsed(); + let unicode_started = std::time::Instant::now(); let unicode = super::text_pipeline::unicode_properties(py, &records)?; + let unicode_ns = unicode_started.elapsed(); + let runs_started = std::time::Instant::now(); let runs = py.detach(|| { text_pipeline::prepare_visual_lines( &records, @@ -903,8 +910,49 @@ impl NativeTextSnapshot { &families, ) }); + let runs_ns = runs_started.elapsed(); + let geometry_started = std::time::Instant::now(); let (geometry, chars) = self.geometry(py)?; + let geometry_ns = geometry_started.elapsed(); + let materialize_started = std::time::Instant::now(); let lines = super::text_pipeline::materialize_visual_runs(py, &chars, runs, &signatures)?; + let materialize_ns = materialize_started.elapsed(); + if profile { + record_visual_stage_stats([ + font_ns, + unicode_ns, + runs_ns, + geometry_ns, + materialize_ns, + total_started.elapsed(), + ]); + } Ok((geometry, lines)) } } + +/// 累计剖析中的视觉证据阶段耗时,单位为纳秒。 +fn record_visual_stage_stats(values: [std::time::Duration; 6]) { + let mut slot = VISUAL_STAGE_NS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut next = [0; 7]; + next[..6].copy_from_slice(&slot[..6]); + for (index, value) in values.iter().enumerate() { + next[index] = next[index].saturating_add(value.as_nanos() as u64); + } + next[6] = slot[6].saturating_add(1); + *slot = next; +} + +/// 暴露累计视觉证据剖析,最后一项为页面调用数。 +#[pyfunction] +pub fn visual_evidence_stage_stats() -> [u64; 7] { + let slot = VISUAL_STAGE_NS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *slot +} + +/// 保存累计视觉证据剖析的阶段耗时和调用数。 +static VISUAL_STAGE_NS: std::sync::Mutex<[u64; 7]> = std::sync::Mutex::new([0; 7]); diff --git a/src/docvortex/_compute_backend.py b/src/docvortex/_compute_backend.py index 36641cbc..b8ee692a 100644 --- a/src/docvortex/_compute_backend.py +++ b/src/docvortex/_compute_backend.py @@ -10,7 +10,7 @@ from pathlib import Path from types import ModuleType -_PROTOCOL_VERSION = 25 +_PROTOCOL_VERSION = 26 _SELECTED_MODE = None _LOAD_FAILURE = None @@ -88,6 +88,8 @@ def backend_info() -> dict[str, str | int | None]: "pdfium_object_bridge_unavailable_reason": "not probed", "pdfium_text_visibility_bridge_calls": 0, "pdfium_text_visibility_bridge_unavailable_reason": "not probed", + "pdfium_path_evidence_bridge_calls": 0, + "pdfium_path_evidence_bridge_unavailable_reason": "not probed", } ), **( diff --git a/src/docvortex/document/pdf/_object_bridge.py b/src/docvortex/document/pdf/_object_bridge.py index 4232bfa5..a8d6dd60 100644 --- a/src/docvortex/document/pdf/_object_bridge.py +++ b/src/docvortex/document/pdf/_object_bridge.py @@ -13,6 +13,8 @@ _UNAVAILABLE_REASON = "not probed" _DRAWING_CALLS = 0 _DRAWING_UNAVAILABLE_REASON = "not probed" +_PATH_EVIDENCE_CALLS = 0 +_PATH_EVIDENCE_UNAVAILABLE_REASON = "not probed" _TEXT_VISIBILITY_CALLS = 0 _TEXT_VISIBILITY_UNAVAILABLE_REASON = "not probed" @@ -24,6 +26,8 @@ def bridge_info(): "pdfium_object_bridge_unavailable_reason": _UNAVAILABLE_REASON, "pdfium_drawing_line_bridge_calls": _DRAWING_CALLS, "pdfium_drawing_line_bridge_unavailable_reason": _DRAWING_UNAVAILABLE_REASON, + "pdfium_path_evidence_bridge_calls": _PATH_EVIDENCE_CALLS, + "pdfium_path_evidence_bridge_unavailable_reason": _PATH_EVIDENCE_UNAVAILABLE_REASON, "pdfium_text_visibility_bridge_calls": _TEXT_VISIBILITY_CALLS, "pdfium_text_visibility_bridge_unavailable_reason": _TEXT_VISIBILITY_UNAVAILABLE_REASON, } @@ -228,3 +232,78 @@ def read_path_subpaths(raw_object): addresses.append(ct.cast(function, ct.c_void_p).value) with pdfium_guard(): return native.read_pdfium_subpaths(addresses, ct.cast(raw_object, ct.c_void_p).value) + + +def read_path_evidence(page, page_bbox, rotation, max_depth, *, want_lines, want_path_infos): + """一次原生遍历同时生成 Path 绘图线与摘要,供标准参考路径差分。""" + global _PATH_EVIDENCE_CALLS, _PATH_EVIDENCE_UNAVAILABLE_REASON + native = get_native() + reader = getattr(native, "read_pdfium_path_evidence", None) + if native is None or reader is None or type(page) is not pdfium.PdfPage or not page.raw: + _PATH_EVIDENCE_UNAVAILABLE_REASON = "python backend or unsupported native extension" + return None + if ct.sizeof(raw.FS_MATRIX) != 24 or any( + getattr(raw.FS_MATRIX, name).offset != offset * 4 for offset, name in enumerate("abcdef") + ): + _PATH_EVIDENCE_UNAVAILABLE_REASON = "FS_MATRIX ABI mismatch" + return None + obj, clip, segment = raw.FPDF_PAGEOBJECT, raw.FPDF_CLIPPATH, raw.FPDF_PATHSEGMENT + specs = ( + ("FPDFPage_CountObjects", ct.c_int, (raw.FPDF_PAGE,)), + ("FPDFPage_GetObject", obj, (raw.FPDF_PAGE, ct.c_int)), + ("FPDFFormObj_CountObjects", ct.c_int, (obj,)), + ("FPDFFormObj_GetObject", obj, (obj, ct.c_ulong)), + ("FPDFPageObj_GetMatrix", ct.c_int, (obj, ct.POINTER(raw.FS_MATRIX))), + ("FPDFPageObj_GetType", ct.c_int, (obj,)), + ("FPDFPageObj_GetClipPath", clip, (obj,)), + ("FPDFClipPath_CountPaths", ct.c_int, (clip,)), + ("FPDFClipPath_CountPathSegments", ct.c_int, (clip, ct.c_int)), + ("FPDFClipPath_GetPathSegment", segment, (clip, ct.c_int, ct.c_int)), + ("FPDFPathSegment_GetPoint", ct.c_int, (segment, ct.POINTER(ct.c_float), ct.POINTER(ct.c_float))), + ("FPDFPath_CountSegments", ct.c_int, (obj,)), + ("FPDFPath_GetPathSegment", segment, (obj, ct.c_int)), + ("FPDFPathSegment_GetPoint", ct.c_int, (segment, ct.POINTER(ct.c_float), ct.POINTER(ct.c_float))), + ("FPDFPathSegment_GetType", ct.c_int, (segment,)), + ("FPDFPathSegment_GetClose", ct.c_int, (segment,)), + ("FPDFPath_GetDrawMode", ct.c_int, (obj, ct.POINTER(ct.c_int), ct.POINTER(ct.c_int))), + ( + "FPDFPageObj_GetStrokeColor", + ct.c_int, + (obj, ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint)), + ), + ("FPDFPageObj_GetStrokeWidth", ct.c_int, (obj, ct.POINTER(ct.c_float))), + ( + "FPDFPageObj_GetFillColor", + ct.c_int, + (obj, ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint), ct.POINTER(ct.c_uint)), + ), + ) + addresses = [] + for name, result, arguments in specs: + function = getattr(raw, name, None) + if ( + not isinstance(function, ct._CFuncPtr) + or function.restype is not result + or tuple(function.argtypes or ()) != arguments + or getattr(function, "errcheck", None) is not None + ): + _PATH_EVIDENCE_UNAVAILABLE_REASON = f"unsupported symbol or ABI: {name}" + return None + addresses.append(ct.cast(function, ct.c_void_p).value) + try: + with pdfium_guard(): + records = reader( + addresses, + ct.cast(page.raw, ct.c_void_p).value, + tuple(page_bbox), + rotation, + max_depth, + want_lines, + want_path_infos, + ) + except native.PdfiumReadError as exc: + raise pdfium.PdfiumError(str(exc)) from exc + _PATH_EVIDENCE_CALLS += 1 + _PATH_EVIDENCE_UNAVAILABLE_REASON = None + line_batches, info_batches = records + return chain.from_iterable(line_batches), chain.from_iterable(info_batches) diff --git a/src/docvortex/document/pdf/native_objects.py b/src/docvortex/document/pdf/native_objects.py index ccb4e28b..ab15311f 100644 --- a/src/docvortex/document/pdf/native_objects.py +++ b/src/docvortex/document/pdf/native_objects.py @@ -1077,6 +1077,30 @@ def _extract_page_paths_and_lines( standard_path = ( _path_info_from_object is _STANDARD_PATH_INFO_EXTRACTOR and _extract_path_drawing_lines is _STANDARD_PATH_LINE_EXTRACTOR ) + if standard_path: + from ._object_bridge import read_path_evidence + + native_records = read_path_evidence( + page, + page_bbox, + page_rotation, + DRAWING_FORM_MAX_DEPTH, + want_lines=want_lines, + want_path_infos=want_path_infos, + ) + if native_records is not None: + native_lines, native_infos = native_records + drawing_lines = [ + PDFDrawingLine(start, end, bbox, width, "horizontal" if orientation == 0 else "vertical") + for start, end, bbox, width, orientation in native_lines + ] + path_infos = [PDFPathInfo(*record) for record in native_infos] + merged_lines = ( + _merge_collinear_drawing_lines(drawing_lines, _drawing_page_size(page_bbox, page_rotation)) + if want_lines + else drawing_lines + ) + return merged_lines, path_infos for source_index, member in enumerate(_clipped_objects_of_type(page, pdfium_c.FPDF_PAGEOBJ_PATH)): raw_obj, matrix, form_depth = member.raw, member.matrix, member.depth try: diff --git a/tests/test_native_parity.py b/tests/test_native_parity.py index 4fba52a7..7e1cac43 100644 --- a/tests/test_native_parity.py +++ b/tests/test_native_parity.py @@ -30,6 +30,7 @@ def test_native_registration_contract(native): "build_geometry_style": "(samples, by_line, keys, sample_type, run_type, line_type, page_sizes=None, bbox_type=None)", "build_geometry_runs": "(samples, by_line, keys, sample_type, run_type)", "text_snapshot_stats": "()", + "visual_evidence_stage_stats": "()", "classification_snapshot_stats": "()", "read_pdfium_classification": "(addresses, handle, count, cjk_ranges, allowed_controls, private_range, normalize_font)", "script_snapshot_stats": "()", @@ -62,6 +63,7 @@ def test_native_registration_contract(native): "read_pdfium_text_visibility": "(addresses, handle, frame, rotation, max_depth)", "read_pdfium_drawing_lines": "(addresses, handle, bbox, rotation)", "pdfium_drawing_line_stage_stats": "()", + "read_pdfium_path_evidence": "(addresses, handle, frame, rotation, max_depth, want_lines, want_infos)", "read_pdfium_visual_batches": "(addresses, handle, count, extended, frame, angle)", "script_roles": "(records)", "script_roles_raw": "(loose, tight, origins, flags, fonts, fallback)", @@ -96,13 +98,15 @@ def test_native_registration_contract(native): "PdfiumVisualCharacterBatches": ("docvortex._native", None), "PdfiumObjectBatches": ("docvortex._native", None), "PdfiumDrawingLineBatches": ("docvortex._native", None), + "PdfiumPathLineBatches": ("docvortex._native", None), + "PdfiumPathInfoBatches": ("docvortex._native", None), "PreparedRuleCandidates": ("builtins", "(centers, rows)"), "PdfiumReadError": ("_native", None), "StableColumnClusters": ("builtins", "(compensated)"), "TableNoteMetrics": ("builtins", "(items)"), "TableRowGeometry": ("builtins", "(boxes)"), } - constants = {"PROTOCOL_VERSION": 25, "PDFIUM_RECORD_BATCH_SIZE": 1024} + constants = {"PROTOCOL_VERSION": 26, "PDFIUM_RECORD_BATCH_SIZE": 1024} assert {name for name in dir(native) if not name.startswith("__")} == functions.keys() | classes.keys() | constants.keys() for name, signature in functions.items(): function = getattr(native, name) diff --git a/tests/test_pdf_drawing_lines_extraction.py b/tests/test_pdf_drawing_lines_extraction.py index cb65b869..b93f63fb 100644 --- a/tests/test_pdf_drawing_lines_extraction.py +++ b/tests/test_pdf_drawing_lines_extraction.py @@ -157,6 +157,8 @@ def counting(*args, **kwargs): def test_path_dual_consumption_reads_draw_state_once(monkeypatch) -> None: """同一 Path 同时产出线与摘要时,绘制状态只允许读取一次。""" + from docvortex.document.pdf import _object_bridge + original = native_objects._get_path_visibility calls = 0 @@ -167,6 +169,8 @@ def counted(*args, **kwargs): return original(*args, **kwargs) monkeypatch.setattr(native_objects, "_get_path_visibility", counted) + # 联合属性读取是 Python 参考路径的约束;原生批量路径另由桥接命中测试覆盖。 + monkeypatch.setattr(_object_bridge, "read_path_evidence", lambda *args, **kwargs: None) with PDFDocument(_vector_page_pdf(5)) as document: vector = document[0].get_vector_geometry() lines, path_infos = vector.drawing_lines, vector.path_infos @@ -175,6 +179,28 @@ def counted(*args, **kwargs): assert calls == 5 +def test_native_path_evidence_matches_python_reference(monkeypatch) -> None: + """批量原生 Path 证据与逐对象 Python 参考输出逐字段且按顺序一致。""" + from docvortex.document.pdf import _object_bridge + from docvortex._compute_backend import backend_info, get_native + from docvortex.document.pdf.native_coordinates import _normalize_pdf_page_bbox + + original_reader = _object_bridge.read_path_evidence + with PDFDocument(_vector_page_pdf(80)) as document: + with document._open_page(0) as page: + bbox = _normalize_pdf_page_bbox(page.get_bbox()) + native_lines, native_infos = native_objects._extract_page_paths_and_lines(page, bbox, 0) + monkeypatch.setattr(_object_bridge, "read_path_evidence", lambda *args, **kwargs: None) + reference_lines, reference_infos = native_objects._extract_page_paths_and_lines(page, bbox, 0) + + monkeypatch.setattr(_object_bridge, "read_path_evidence", original_reader) + assert native_lines == reference_lines + assert native_infos == reference_infos + assert len(native_infos) == 80 + if get_native() is not None: + assert backend_info()["pdfium_path_evidence_bridge_calls"] >= 1 + + def test_drawing_lines_match_vector_and_snapshot_entries() -> None: """独立线接口、矢量几何和页面快照的绘图线逐字段且按顺序一致。""" data = _vector_page_pdf(600) diff --git a/tests/unittest/test_pdf_document.py b/tests/unittest/test_pdf_document.py index e2296ace..c138d7de 100644 --- a/tests/unittest/test_pdf_document.py +++ b/tests/unittest/test_pdf_document.py @@ -1199,6 +1199,7 @@ def fail_text( def test_native_page_snapshot_decodes_each_path_once(monkeypatch: pytest.MonkeyPatch) -> None: """同一 Path 解码由绘图线和路径信息共享,避免独立接口的重复工作。""" + from docvortex.document.pdf import _object_bridge counts: list[object] = [] original = pdf_document._read_raw_path_subpaths @@ -1210,6 +1211,9 @@ def record_decode(raw_object: Any) -> list[pdf_document._PathSubpath]: return original(raw_object) monkeypatch.setattr(native_objects, "_read_raw_path_subpaths", record_decode) + # 本例验证 Python 联合解码;原生批量路径和简单绘图线各有独立桥接测试。 + monkeypatch.setattr(_object_bridge, "read_path_evidence", lambda *args, **kwargs: None) + monkeypatch.setattr(_object_bridge, "read_drawing_lines", lambda *args, **kwargs: None) with pdf_document.PDFDocument(_build_drawing_pdf()) as document: document.get_page_drawing_lines(0) document.get_page_path_infos(0) From be0b1a1db2633e759767e36ae18c65474dee3662 Mon Sep 17 00:00:00 2001 From: myhloli Date: Tue, 29 Sep 2026 19:30:21 +0800 Subject: [PATCH 2/3] docs(pdf): record stage 19 path evidence validation --- docs/rust-pdf-stage19-evidence.md | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/rust-pdf-stage19-evidence.md diff --git a/docs/rust-pdf-stage19-evidence.md b/docs/rust-pdf-stage19-evidence.md new file mode 100644 index 00000000..c6f3968e --- /dev/null +++ b/docs/rust-pdf-stage19-evidence.md @@ -0,0 +1,40 @@ +# PDF 原生内核阶段 19:Path 双侧证据批量化 + +## 实现边界 + +本阶段以已完成的 Stage 18 分支提交 `634aabe` 为基线,不重做 TEXT 可见性、render-session、tight spacing 或 Stage 18 的视觉证据复用。私有协议从 25 升到 26,公开 `auto|python|rust` 和渲染 `auto|legacy|session` 选择保持不变;旧扩展在 `auto` 下仍显式回退,`rust` 下继续报协议不匹配。 + +新增原生 Path evidence bridge:一次遍历页面/Form 内全部 PATH 叶子,复用同一组 PDFium 函数指针,批量读取子路径、绘制状态、填充/描边颜色、描边宽、Form 矩阵与累积裁剪。Rust 内一次完成对象坐标到页面视觉坐标转换,同时产出未合并绘图线和 `PDFPathInfo`;Python 只物化公开 dataclass 并保留既有共线合并规则。描边宽度中的 `hypot` 调用绑定当前解释器的 `math.hypot`,避免跨实现最后一位的浮点差异。颜色读取失败沿用 alpha 255;损坏 Path 段按参考实现跳过;ABI、非标准页面或旧扩展缺失时整页回退既有 Python 参考。诊断新增 `pdfium_path_evidence_bridge_calls` 与回退原因。 + +`NativeTextSnapshot.prepare_visual_evidence()` 增加仅在 `DOCVORTEX_PROFILE_VISUAL_EVIDENCE` 显式开启时累计的阶段计时,可区分字体归一化、Unicode 属性、视觉 run、字符几何物化和最终 run 物化。正式路径不输出计时,不改变公共 SDK、输出 schema 或行/字符成员关系。 + +## 验证 + +- Path 联合证据新增原生/Python 参考逐字段且按顺序一致的差分;Python 双消费测试继续禁用原生桥接,验证每个对象解码一次;原生桥接报告区分真实执行与回退。 +- 32 PDF/299 页逐页调用原生 Path evidence 后强制走 Python 参考:绘图线与 `PDFPathInfo` 全量相等,原生命中 299 次、无回退。 +- 32 PDF/299 页公开回放各两次,Stage 18 `634aabe` 与本阶段完整 ModelJson、MiddleJson、素材哈希和诊断摘要全部一致。 +- MinerU 当前 `dev@ee87e8e`:31 份 eligible Flash 文本完整输出一致;32 份实际 medium 共享链路完整输出一致。共享链路继续直接比较当前双源输出,不误称匹配 Stage 5 旧录制哈希。 +- Rust/session 完整测试:5474 passed / 1 skipped。Python/legacy 完整测试:4769 passed / 706 skipped。Cargo workspace tests、Clippy `-D warnings`、rustfmt、Ruff check 和改动文件 format check 均通过。 +- ABI3 wheel 构建为 `docvortex-0.5.7-cp310-abi3-macosx_11_0_arm64.whl`;独立安装环境中 CPython 3.14 的 Python/auto 与 Rust 输出一致,协议 26,Path bridge 实际命中。源码扩展与 wheel 扩展 SHA-256 均为 `f6dcdb5654cd30c3617aecbc22fd57ec3c1a49b8aa6d1e13ef2e3df062ec0ab2`。 + +## 性能与资源 + +正式计时均为每文档一次预热、五次热运行;公开 parse 按文档先基线后候选交替执行,MinerU 按文档交替方向执行,并另用独立进程采样进程树 RSS。结果仅代表本机语料与当前 PDFium 环境。 + +| 链路 | Stage 18 基线 | Stage 19 候选 | 降幅 | 最大单档结果 | +| --- | ---: | ---: | ---: | --- | +| DocVortex 公开 parse,32 PDF | 15.484642 s | 15.202684 s | 1.82% | 耗时 1.0020;RSS 1.0330 | +| MinerU Flash 文本,31 PDF | 15.335368 s | 15.045106 s | 1.89% | 耗时 1.0266;RSS 1.0165 | +| MinerU medium 共享,32 PDF | 15.531999 s | 14.566413 s | 6.22% | 耗时 1.0411;RSS 1.0142 | + +公开 parse 无任何首轮超过 5% 的耗时退化;最大耗时比率 0.20%,最大 RSS 增加 3.30%,低于持续退化与资源门槛。Flash 最大耗时比率 2.66%,无持续退化;medium 最大首轮耗时比率 4.11%,未触发反序复测。完整输出均相等。 + +同一 32 PDF cProfile 对照显示,`_extract_page_paths_and_lines()` 从 0.940791 s 降至 0.129739 s(约 86.2%);Stage 18 的 `_path_object_evidence()` 逐对象热点在本阶段候选中消失。`_collect_document_sources()` 从 4.119941 s 降至 3.171536 s。单次 cProfile 不替代正式中位数。 + +对八样本焦点集显式开启 `DOCVORTEX_PROFILE_VISUAL_EVIDENCE` 后,161 次调用累计:字体准备 0.008330 s、Unicode 属性 0.043204 s、视觉 run 0.041006 s、字符几何物化 0.257527 s、最终 run 物化 0.008681 s,总耗时 0.319887 s。字符几何物化约占 80.5%,是 Stage 20 的首要候选;原始 text snapshot 读取约 0.349 s 仍是第二大候选。 + +## 后续状态 + +- 原始 2 倍性能目标仍未完成;本报告不把正确性或单阶段收益表述为该目标完成。 +- Stage 20 不应继续扩大 Path 范围。优先研究延迟/分批物化 `PDFPageTextGeometry.chars`,并细分 `Bbox`/字典构造与后续消费者是否可共享原生字符对象;其次研究直接持有 PDFium textpage 的原生 snapshot 读取。 +- 证据目录为 `output/pdf/native-kernel-20260929-stage19/`。本阶段分支不自动合并、发版或发布 PyPI。 From 732152ffb05cd81916c5bdaef4be9445168cde29 Mon Sep 17 00:00:00 2001 From: myhloli Date: Tue, 29 Sep 2026 21:41:26 +0800 Subject: [PATCH 3/3] fix(pdf): align native open path geometry checks --- rust/docvortex-pdfium/src/path_evidence.rs | 15 +++++--- tests/test_pdf_drawing_lines_extraction.py | 45 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/rust/docvortex-pdfium/src/path_evidence.rs b/rust/docvortex-pdfium/src/path_evidence.rs index 809d9cdb..cde7a42b 100644 --- a/rust/docvortex-pdfium/src/path_evidence.rs +++ b/rust/docvortex-pdfium/src/path_evidence.rs @@ -333,14 +333,17 @@ fn open_thin_rectangle(subpath: &PreparedSubpath, x0: f64, x1: f64, y0: f64, y1: .into_iter() .map(|(x, y)| ((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)) .collect::>(); - if rounded.iter().any(|point| !expected.contains(point)) { + // 与参考实现的集合相等语义保持一致:四个角点必须全部出现,缺失角点的退化开放路径不能折叠。 + if expected.iter().any(|point| !rounded.contains(point)) + || rounded.iter().any(|point| !expected.contains(point)) + { return false; } - subpath.raw.lines.iter().all(|&(first, last)| { - let a = subpath.raw.points[first]; - let b = subpath.raw.points[last]; - !((a.0 - b.0).abs() > 0.001 && (a.1 - b.1).abs() > 0.001) - }) + // 轴对齐判断必须发生在对象/Form 矩阵与页面坐标转换之后,与 Python 参考实现一致。 + subpath + .lines + .iter() + .all(|&(a, b)| !((a.0 - b.0).abs() > 0.001 && (a.1 - b.1).abs() > 0.001)) } /// 生成单个 Path 的绘图线候选,不应用对象裁剪。 diff --git a/tests/test_pdf_drawing_lines_extraction.py b/tests/test_pdf_drawing_lines_extraction.py index b93f63fb..c0cdd2eb 100644 --- a/tests/test_pdf_drawing_lines_extraction.py +++ b/tests/test_pdf_drawing_lines_extraction.py @@ -201,6 +201,51 @@ def test_native_path_evidence_matches_python_reference(monkeypatch) -> None: assert backend_info()["pdfium_path_evidence_bridge_calls"] >= 1 +def _open_filled_path_pdf(path_commands: bytes) -> bytes: + """构造仅包含一个开放填充 Path 的最小 PDF,用于原生/参考差分。""" + content = b"0 0 0 rg\n" + path_commands + return _pdf( + [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n%s\nendstream" % (len(content), content), + ] + ) + + +@pytest.mark.parametrize( + ("path_commands", "expected_line_count"), + [ + # 缺失左上角且重复右下角时,参考实现不接受该开放路径为细矩形。 + (b"20 20 m 30 20 l 30 21 l 30 20 l f", 0), + # 对象矩阵会把两条局部斜边转换为轴对齐边,应按转换后的线段判定。 + (b"q 1 0 -2 1 40 0 cm 20 20 m 30 20 l 32 21 l 22 21 l f Q", 1), + ], +) +def test_native_open_filled_path_edges_match_reference(monkeypatch, path_commands, expected_line_count) -> None: + """覆盖开放细矩形的角点完整性与矩阵后轴对齐两个后端分歧行为。""" + from docvortex.document.pdf import _object_bridge + from docvortex._compute_backend import backend_info, get_native + from docvortex.document.pdf.native_coordinates import _normalize_pdf_page_bbox + + original_reader = _object_bridge.read_path_evidence + calls_before = backend_info()["pdfium_path_evidence_bridge_calls"] + with PDFDocument(_open_filled_path_pdf(path_commands)) as document: + with document._open_page(0) as page: + bbox = _normalize_pdf_page_bbox(page.get_bbox()) + native_lines, native_infos = native_objects._extract_page_paths_and_lines(page, bbox, 0) + monkeypatch.setattr(_object_bridge, "read_path_evidence", lambda *args, **kwargs: None) + reference_lines, reference_infos = native_objects._extract_page_paths_and_lines(page, bbox, 0) + + monkeypatch.setattr(_object_bridge, "read_path_evidence", original_reader) + assert native_lines == reference_lines + assert native_infos == reference_infos + assert len(native_lines) == expected_line_count + if get_native() is not None and hasattr(get_native(), "read_pdfium_path_evidence"): + assert backend_info()["pdfium_path_evidence_bridge_calls"] > calls_before + + def test_drawing_lines_match_vector_and_snapshot_entries() -> None: """独立线接口、矢量几何和页面快照的绘图线逐字段且按顺序一致。""" data = _vector_page_pdf(600)