1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! A row or a column of a table with no text in it is not one (#714).
//!
//! A ruled table draws a rule under its head and another over its total, a
//! shaded statement draws a band between two groups of lines, and a
//! statement sets a gutter between its stub and its first figure. Where the
//! rules or the gutter stand far enough apart the grid reads the gap as a
//! row or a column of its own, and the table came out with a row of empty
//! cells over its head or an empty column beside every stub. No corpus
//! this engine is measured on writes such a row or column -- FinTabNet's
//! 3,511 rows on the local sample hold not one empty row -- and TEDS
//! charged each cell of it as a cell the reader did not want.
//!
//! THE RULE, ON THE FINAL CELLS. A row whose every cell is empty, and that
//! no cell above spans into, is dropped; the rows under it move up. A
//! column whose every cell is empty, and that no cell to its left spans
//! into, is dropped the same way. A row or column a label spans into is
//! that label's and stays. A table of nothing but empty cells is left
//! alone: there is nothing to keep.
// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.
use crate::blocks::{Block, BlockKind};
use crate::header_rows::tables_in;
/// Drop the empty rows and columns of every table in place.
pub(crate) fn drop_empty_lines(blocks: &mut Vec<Block>) {
let mut drop = vec![false; blocks.len()];
let mut changed = false;
for (first, end) in tables_in(blocks) {
for axis in [Axis::Row, Axis::Column] {
let table = &blocks[first..end];
let lines = table
.iter()
.filter_map(|b| axis.extent(b))
.map(|(at, span)| at + span)
.max()
.unwrap_or(0);
let mut empty = vec![true; lines as usize];
for b in table {
if let Some((at, span)) = axis.extent(b) {
for line in at..at + span {
if line != at || !b.text.trim().is_empty() {
empty[line as usize] = false;
}
}
}
}
if !empty.iter().any(|&e| e) || empty.iter().all(|&e| e) {
continue;
}
changed = true;
// Line `l` of the old table is line `l - shift[l]` of the new one.
let mut shift = vec![0u16; lines as usize];
let mut gone = 0u16;
for (line, is_empty) in empty.iter().enumerate() {
if *is_empty {
gone += 1;
}
shift[line] = gone;
}
for (i, b) in blocks[first..end].iter_mut().enumerate() {
let Some((at, _)) = axis.extent(b) else {
continue;
};
if empty[at as usize] {
drop[first + i] = true;
} else if let BlockKind::TableCell { row, column, .. } = &mut b.kind {
match axis {
Axis::Row => *row -= shift[at as usize],
Axis::Column => *column -= shift[at as usize],
}
}
}
}
}
if !changed {
return;
}
let mut n = 0usize;
let mut out = Vec::with_capacity(blocks.len());
for (i, block) in blocks.drain(..).enumerate() {
if drop[i] {
continue;
}
let mut block = block;
block.index = n;
n += 1;
out.push(block);
}
*blocks = out;
}
#[derive(Clone, Copy)]
enum Axis {
Row,
Column,
}
impl Axis {
/// Where a cell starts on this axis and how many lines it covers; `None`
/// for a block that is not a cell. A cell the row pass already dropped
/// is still counted by the column pass, which is harmless: its text is
/// empty, so it marks no column as kept.
fn extent(self, block: &Block) -> Option<(u16, u16)> {
match block.kind {
BlockKind::TableCell {
row,
column,
row_span,
column_span,
..
} => Some(match self {
Axis::Row => (row, row_span),
Axis::Column => (column, column_span),
}),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cell(row: u16, column: u16, text: &str) -> Block {
Block {
kind: BlockKind::TableCell {
row,
column,
row_span: 1,
column_span: 1,
header: false,
},
page: 0,
bbox: [
100.0 * column as f64,
700.0 - 12.0 * row as f64,
100.0 * column as f64 + 50.0,
710.0 - 12.0 * row as f64,
],
text: text.to_string(),
spans: Vec::new(),
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: Default::default(),
trace_learned: None,
}
}
fn grid(blocks: &[Block]) -> Vec<(u16, u16, String)> {
blocks
.iter()
.filter_map(|b| match b.kind {
BlockKind::TableCell { row, column, .. } => Some((row, column, b.text.clone())),
_ => None,
})
.collect()
}
#[test]
fn empty_rows_above_and_between_are_dropped() {
let mut blocks = vec![
cell(0, 0, ""),
cell(0, 1, ""),
cell(1, 0, ""),
cell(1, 1, ""),
cell(2, 0, "Exhibit"),
cell(2, 1, "Form"),
cell(3, 0, ""),
cell(3, 1, ""),
cell(4, 0, "4.15"),
cell(4, 1, "8-K"),
];
drop_empty_lines(&mut blocks);
assert_eq!(
grid(&blocks),
vec![
(0, 0, "Exhibit".to_string()),
(0, 1, "Form".to_string()),
(1, 0, "4.15".to_string()),
(1, 1, "8-K".to_string()),
]
);
assert!(blocks.iter().enumerate().all(|(i, b)| b.index == i));
}
#[test]
fn an_empty_column_beside_the_stub_is_dropped() {
let mut blocks = vec![
cell(0, 0, "Interest income"),
cell(0, 1, ""),
cell(0, 2, "$2,145"),
cell(1, 0, "Interest expense"),
cell(1, 1, ""),
cell(1, 2, "526"),
];
drop_empty_lines(&mut blocks);
assert_eq!(
grid(&blocks),
vec![
(0, 0, "Interest income".to_string()),
(0, 1, "$2,145".to_string()),
(1, 0, "Interest expense".to_string()),
(1, 1, "526".to_string()),
]
);
}
#[test]
fn a_line_a_label_spans_into_stays() {
let mut blocks = vec![
cell(0, 0, "Item 1A."),
cell(0, 1, "27"),
cell(1, 1, "38"),
cell(2, 0, ""),
cell(2, 1, ""),
cell(3, 0, "Item 2."),
cell(3, 1, "39"),
];
if let BlockKind::TableCell { row_span, .. } = &mut blocks[0].kind {
*row_span = 2;
}
drop_empty_lines(&mut blocks);
let g = grid(&blocks);
assert_eq!(g.len(), 5, "{g:?}");
assert_eq!(g[2], (1, 1, "38".to_string()));
assert_eq!(g[3], (2, 0, "Item 2.".to_string()));
// A head spanning two columns keeps the empty second column.
let mut blocks = vec![
cell(0, 0, "Years ended"),
cell(1, 0, "a"),
cell(1, 1, ""),
cell(2, 0, "b"),
cell(2, 1, ""),
];
if let BlockKind::TableCell { column_span, .. } = &mut blocks[0].kind {
*column_span = 2;
}
drop_empty_lines(&mut blocks);
assert_eq!(blocks.len(), 5);
}
#[test]
fn a_table_of_empty_cells_is_left_alone() {
let mut blocks = vec![cell(0, 0, ""), cell(0, 1, ""), cell(1, 0, "")];
drop_empty_lines(&mut blocks);
assert_eq!(blocks.len(), 3);
}
#[test]
fn two_tables_in_a_row_are_counted_apart() {
let mut blocks = vec![
cell(0, 0, ""),
cell(0, 1, ""),
cell(1, 0, "a"),
cell(1, 1, "1"),
cell(0, 0, "b"),
cell(0, 1, "2"),
cell(1, 0, ""),
cell(1, 1, ""),
];
drop_empty_lines(&mut blocks);
assert_eq!(
grid(&blocks),
vec![
(0, 0, "a".to_string()),
(0, 1, "1".to_string()),
(0, 0, "b".to_string()),
(0, 1, "2".to_string()),
]
);
}
}