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
//! A stub that wraps onto the line of its figures is one stub (#714).
//!
//! A statement sets a long label over two lines and its figures beside
//! the second: `Fair value of outstanding stock options, direct
//! acquisition costs and` on one line, `the effect of FleetBoston shares
//! already owned by the Corporation | 1,360` on the next. The grid read
//! the first line as a row of its own with nothing beside it. Every
//! table corpus this engine is measured on writes the label as one cell
//! on the figures' row, and TEDS charged the extra row and the cut label.
//!
//! THE RULE, ON THE FINAL CELLS. A row with text in its stub alone,
//! followed by a row whose stub opens with a lowercase letter and that
//! has a figure beside it, is the first line of that row's stub: the
//! texts join with a space, the boxes as one, the spans in order, and the
//! rows under it move up. A section label (`Operating results`) opens
//! with a capital and stays a row; so does a stub-only row over a stub
//! that opens with a capital.
// 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;
/// Join every wrapped stub of every table in place.
pub(crate) fn join_wrapped_stubs(blocks: &mut Vec<Block>) {
let mut drop = vec![false; blocks.len()];
let mut changed = false;
for (first, end) in tables_in(blocks) {
let table = &blocks[first..end];
let rows = table
.iter()
.filter_map(|b| match b.kind {
BlockKind::TableCell { row, row_span, .. } => Some(row + row_span),
_ => None,
})
.max()
.unwrap_or(0) as usize;
if rows < 2 {
continue;
}
// Per row: the stub's block, whether the row has text past the
// stub, and whether any cell spans.
let mut stub: Vec<Option<usize>> = vec![None; rows];
let mut beyond: Vec<bool> = vec![false; rows];
let mut spans: Vec<bool> = vec![false; rows];
for (i, b) in table.iter().enumerate() {
if let BlockKind::TableCell {
row,
column,
row_span,
column_span,
..
} = b.kind
{
let r = row as usize;
if row_span > 1 || column_span > 1 {
spans[r] = true;
}
if column == 0 {
stub[r].get_or_insert(first + i);
} else if !b.text.trim().is_empty() {
beyond[r] = true;
}
}
}
let mut gone = vec![false; rows];
for r in 0..rows - 1 {
if spans[r] || spans[r + 1] || beyond[r] || !beyond[r + 1] {
continue;
}
let (Some(above), Some(below)) = (stub[r], stub[r + 1]) else {
continue;
};
if blocks[above].text.trim().is_empty()
|| !blocks[below]
.text
.trim_start()
.chars()
.next()
.is_some_and(char::is_lowercase)
{
continue;
}
let (text, bbox, spans_above) = {
let a = &blocks[above];
(a.text.trim().to_string(), a.bbox, a.spans.clone())
};
let b = &mut blocks[below];
b.text = format!("{text} {}", b.text.trim_start());
b.bbox = [
b.bbox[0].min(bbox[0]),
b.bbox[1].min(bbox[1]),
b.bbox[2].max(bbox[2]),
b.bbox[3].max(bbox[3]),
];
let mut joined = spans_above;
joined.append(&mut b.spans);
b.spans = joined;
drop[above] = true;
gone[r] = true;
changed = true;
}
if !gone.iter().any(|&g| g) {
continue;
}
// The rows that stay are counted again; a dropped row's other
// cells (empty ones) go with it.
let mut new_row = vec![0u16; rows];
let mut n = 0u16;
for r in 0..rows {
new_row[r] = n;
if !gone[r] {
n += 1;
}
}
for (i, b) in blocks[first..end].iter_mut().enumerate() {
if let BlockKind::TableCell { row, .. } = &mut b.kind {
let r = *row as usize;
if gone[r] {
drop[first + i] = true;
} else {
*row = new_row[r];
}
}
}
}
if !changed {
return;
}
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 = out.len();
out.push(block);
}
*blocks = out;
}
#[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 a_stub_wrapped_over_its_figures_is_one_stub() {
let mut blocks = vec![
cell(0, 0, "Total purchase"),
cell(0, 1, "$47,253"),
cell(1, 0, "Fair value of options and"),
cell(1, 1, ""),
cell(2, 0, "the effect of shares owned"),
cell(2, 1, "1,360"),
cell(3, 0, "Total"),
cell(3, 1, "9"),
];
join_wrapped_stubs(&mut blocks);
assert_eq!(
grid(&blocks),
vec![
(0, 0, "Total purchase".to_string()),
(0, 1, "$47,253".to_string()),
(
1,
0,
"Fair value of options and the effect of shares owned".to_string()
),
(1, 1, "1,360".to_string()),
(2, 0, "Total".to_string()),
(2, 1, "9".to_string()),
]
);
assert!(blocks.iter().enumerate().all(|(i, b)| b.index == i));
}
#[test]
fn a_section_label_over_a_capitalised_stub_stays_a_row() {
let mut blocks = vec![
cell(0, 0, "Operating results"),
cell(0, 1, ""),
cell(1, 0, "Net sales"),
cell(1, 1, "8,023"),
];
join_wrapped_stubs(&mut blocks);
assert_eq!(blocks.len(), 4);
}
#[test]
fn a_lowercase_stub_under_a_full_row_stays_a_row() {
let mut blocks = vec![
cell(0, 0, "Net sales"),
cell(0, 1, "8,023"),
cell(1, 0, "of which exports"),
cell(1, 1, "1,000"),
];
join_wrapped_stubs(&mut blocks);
assert_eq!(blocks.len(), 4);
}
}