mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Refs #294. Preserve rectangle geometry without inventing rule fields from its edges. Co-authored-by: OpenAI Codex <[email protected]>
This commit is contained in:
1 parent
29f4edf5be
commit
0b417441f6
2 files changed
+187
-6
No files matched your search
+109
-6
@@ -16,8 +16,7 @@ pub struct Word {
|
||||
pub rect: [f64; 4],
|
||||
}
|
||||
|
||||
/// Shapes drawn on the page, in user space: rectangles (`re`) and straight horizontal
|
||||
/// segments.
|
||||
/// Shapes drawn on the page, in user space: axis-aligned rectangle paths and horizontal segments.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Shapes {
|
||||
pub boxes: Vec<[f64; 4]>,
|
||||
@@ -40,6 +39,60 @@ pub struct Candidate {
|
||||
}
|
||||
|
||||
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
|
||||
#[derive(Default)]
|
||||
struct DetectionPath {
|
||||
points: Vec<[f64; 2]>,
|
||||
rules: Vec<[f64; 4]>,
|
||||
complex: bool,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl DetectionPath {
|
||||
fn finish(&mut self, paths: &mut Vec<Self>) {
|
||||
let path = std::mem::take(self);
|
||||
if !path.rules.is_empty() || (!path.complex && path.points.len() >= 4) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangle(&self, implicitly_closed: bool) -> Option<[f64; 4]> {
|
||||
if self.complex {
|
||||
return None;
|
||||
}
|
||||
if self.points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
let mut points = self.points.as_slice();
|
||||
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
|
||||
if repeated_end {
|
||||
points = &points[..points.len() - 1];
|
||||
}
|
||||
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
|
||||
return None;
|
||||
}
|
||||
let mut horizontal = [false; 4];
|
||||
for i in 0..4 {
|
||||
let (a, b) = (points[i], points[(i + 1) % 4]);
|
||||
let h = (a[1] - b[1]).abs() < 1e-6;
|
||||
let v = (a[0] - b[0]).abs() < 1e-6;
|
||||
if h == v {
|
||||
return None; // Diagonal or zero-length edge.
|
||||
}
|
||||
horizontal[i] = h;
|
||||
}
|
||||
if (0..4).any(|i| horizontal[i] == horizontal[(i + 1) % 4]) {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min),
|
||||
points.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min),
|
||||
points.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max),
|
||||
points.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
let mut out = Shapes::default();
|
||||
let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out };
|
||||
@@ -61,7 +114,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
let mut ctm = Matrix::IDENTITY;
|
||||
let mut stack = Vec::new();
|
||||
let mut path_boxes: Vec<[f64; 4]> = Vec::new();
|
||||
let mut path_rules: Vec<[f64; 4]> = Vec::new();
|
||||
let mut subpaths: Vec<DetectionPath> = Vec::new();
|
||||
let mut path = DetectionPath::default();
|
||||
let mut cur: Option<[f64; 2]> = None;
|
||||
for op in parse(&data).ops {
|
||||
match op.op.as_slice() {
|
||||
@@ -73,27 +127,75 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
}
|
||||
}
|
||||
b"re" => {
|
||||
path.finish(&mut subpaths);
|
||||
if let Some([x, y, w, h]) = op.nums::<4>() {
|
||||
path_boxes.push(ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]));
|
||||
let (x, y) = ctm.apply(x, y);
|
||||
cur = Some([x, y]);
|
||||
path.complex = true;
|
||||
path.points.push([x, y]);
|
||||
}
|
||||
}
|
||||
b"m" => {
|
||||
path.finish(&mut subpaths);
|
||||
cur = op.nums::<2>().map(|[x, y]| {
|
||||
let (a, b) = ctm.apply(x, y);
|
||||
[a, b]
|
||||
})
|
||||
});
|
||||
if let Some(p) = cur {
|
||||
path.points.push(p);
|
||||
}
|
||||
}
|
||||
b"l" => {
|
||||
if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) {
|
||||
// Continuing after closepath is not a single four-edge rectangle.
|
||||
if path.closed {
|
||||
path.complex = true;
|
||||
}
|
||||
let (bx, by) = ctm.apply(x, y);
|
||||
let b = [bx, by];
|
||||
if !path.complex {
|
||||
if path.points.len() >= 5 {
|
||||
path.complex = true;
|
||||
} else {
|
||||
if path.points.is_empty() {
|
||||
path.points.push(a);
|
||||
}
|
||||
path.points.push(b);
|
||||
}
|
||||
}
|
||||
if (a[1] - b[1]).abs() < 0.5 && (a[0] - b[0]).abs() > 1.0 {
|
||||
path_rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
|
||||
path.rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
|
||||
}
|
||||
cur = Some(b);
|
||||
}
|
||||
}
|
||||
b"c" | b"v" | b"y" => {
|
||||
path.complex = true;
|
||||
let end = if op.op.as_slice() == b"c" { op.nums::<6>().map(|v| [v[4], v[5]]) } else { op.nums::<4>().map(|v| [v[2], v[3]]) };
|
||||
if let Some([x, y]) = end {
|
||||
let (x, y) = ctm.apply(x, y);
|
||||
cur = Some([x, y]);
|
||||
}
|
||||
}
|
||||
b"h" => {
|
||||
path.closed = true;
|
||||
cur = path.points.first().copied().or(cur);
|
||||
}
|
||||
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
if matches!(op.op.as_slice(), b"s" | b"b" | b"b*") {
|
||||
path.closed = true;
|
||||
}
|
||||
path.finish(&mut subpaths);
|
||||
let filled = !matches!(op.op.as_slice(), b"S" | b"s");
|
||||
let mut path_rules = Vec::new();
|
||||
for subpath in subpaths.drain(..) {
|
||||
if let Some(rect) = subpath.rectangle(filled) {
|
||||
path_boxes.push(rect);
|
||||
} else {
|
||||
path_rules.extend(subpath.rules);
|
||||
}
|
||||
}
|
||||
for r in path_boxes.drain(..) {
|
||||
// A very flat filled rectangle is a rule.
|
||||
if r[3] - r[1] < 1.5 && r[2] - r[0] > 1.0 {
|
||||
@@ -107,7 +209,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
}
|
||||
b"n" => {
|
||||
path_boxes.clear();
|
||||
path_rules.clear();
|
||||
subpaths.clear();
|
||||
path = DetectionPath::default();
|
||||
cur = None;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1865,3 +1865,81 @@ fn invalid_appearance_patches_are_refused_before_anything_is_written() {
|
||||
assert!(set_props(&mut doc, "name", &props).is_err());
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_preserves_closed_path_rectangle_height() {
|
||||
let shapes = |content: &[u8]| {
|
||||
let mut doc = fixture();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
|
||||
crate::detect::page_shapes(&doc, 0)
|
||||
};
|
||||
let shorthand = shapes(b"50 100 120 40 re S");
|
||||
let outlined = shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h S");
|
||||
assert_eq!(outlined, shorthand, "equivalent rectangle encodings must have equal detection geometry");
|
||||
let fields = crate::detect::detect(&[], &outlined, &[], &[]);
|
||||
assert_eq!(fields.len(), 1);
|
||||
assert_eq!(fields[0].rect, [51.0, 101.0, 169.0, 139.0]);
|
||||
}
|
||||
|
||||
fn detection_test_shapes(content: &[u8]) -> crate::detect::Shapes {
|
||||
let mut doc = fixture();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
|
||||
crate::detect::page_shapes(&doc, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
|
||||
for end in ["h S", "s", "50 100 l S", "f", "F", "f*", "B", "B*", "b", "b*"] {
|
||||
let content = format!("50 100 m 170 100 l 170 140 l 50 140 l {end}");
|
||||
let shapes = detection_test_shapes(content.as_bytes());
|
||||
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0]], "{end}");
|
||||
assert!(shapes.rules.is_empty(), "rectangle edges must not become extra fields: {end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
|
||||
for content in [
|
||||
"50 100 m 170 100 l 170 140 l 50 140 l S",
|
||||
"50 100 m 170 110 l 170 150 l 50 140 l h S",
|
||||
"50 100 m 170 100 l 170 140 l 100 150 80 150 50 140 c h S",
|
||||
"50 100 m 170 100 l 170 140 l h 50 140 l f",
|
||||
"50 100 m 170 100 l 50 100 l 50 140 l h S",
|
||||
] {
|
||||
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_preserves_multiple_subpaths_and_discards_unpainted_paths() {
|
||||
let shapes = detection_test_shapes(b"50 100 120 40 re 200 100 m 320 100 l 320 140 l 200 140 l h 50 400 m 170 400 l S");
|
||||
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
|
||||
assert_eq!(shapes.rules, [[50.0, 400.0, 170.0, 400.0]]);
|
||||
assert_eq!(detection_test_shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h W n"), crate::detect::Shapes::default());
|
||||
let continued = detection_test_shapes(b"50 100 120 40 re 170 100 l 170 140 l 50 140 l h S");
|
||||
assert_eq!(continued.boxes, [[50.0, 100.0, 170.0, 140.0]], "continuing re must not invent a second box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_transforms_path_rectangles_and_preserves_rules() {
|
||||
let scaled = detection_test_shapes(b"q 2 0 0 2 0 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S Q 10 20 m 100 20 l S");
|
||||
assert_eq!(scaled.boxes, [[100.0, 200.0, 340.0, 280.0]]);
|
||||
assert_eq!(scaled.rules, [[10.0, 20.0, 100.0, 20.0]]);
|
||||
let rotated = detection_test_shapes(b"0 1 -1 0 300 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S");
|
||||
assert_eq!(rotated.boxes, [[160.0, 50.0, 200.0, 170.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
|
||||
let paths = "50 100 m 170 100 l 170 140 l 50 140 l 200 100 m 320 100 l 320 140 l 200 140 l";
|
||||
let stroked = detection_test_shapes(format!("{paths} s").as_bytes());
|
||||
assert_eq!(stroked.boxes, [[200.0, 100.0, 320.0, 140.0]]);
|
||||
assert_eq!(stroked.rules, [[50.0, 100.0, 170.0, 100.0], [50.0, 140.0, 170.0, 140.0]]);
|
||||
let filled = detection_test_shapes(format!("{paths} f").as_bytes());
|
||||
assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
|
||||
assert!(filled.rules.is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user