Files
pdfcraft/crates/export/src/layout.rs
T
BFlat edc5c757d3 Word tables get outer borders only where the page rules them (#6626)
A grid drawn with inside lines only no longer gets a box in Word.
2026-11-09 00:09:23 +08:00

577 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
/// A paragraph of a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Block {
pub text: String,
/// [x0, y0, x1, y1] on the displayed page in points (y up).
pub rect: [f64; 4],
/// Font size in points.
pub size: f64,
pub bold: bool,
pub italic: bool,
/// Text fill colour as sRGB components in 0..=1 (black when unknown).
pub color: [f64; 3],
/// Counterclockwise baseline angle in user space (degrees).
pub rotation: f64,
/// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`,
/// which `text` leaves out. A block that continues this one on its line is a run of the same
/// paragraph, and these say whether a space separates them (#4158).
pub space_around: [bool; 2],
}
/// An image of a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// "png" or "jpg".
pub ext: &'static str,
/// Shared: a picture drawn in many places is held once.
pub bytes: std::sync::Arc<[u8]>,
pub rect: [f64; 4],
/// Width and height before rotation, in points.
pub size: [f64; 2],
/// Counterclockwise rotation in user space (degrees).
pub rotation: f64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Page {
pub width: f64,
pub height: f64,
pub blocks: Vec<Block>,
pub images: Vec<Image>,
/// The colour the whole page is painted before its content (a page background), as sRGB
/// components in 0..=1; `None` for a page with no background of its own.
pub background: Option<[f64; 3]>,
/// The ruled lines the page draws (horizontal and vertical strokes, thin filled bars), each
/// as its box [x0, y0, x1, y1] on the displayed page in points (y up).
pub rules: Vec<[f64; 4]>,
}
/// One cell of a detected table. `span` is how many grid columns the cell covers (a header
/// row that stretches over sub-columns).
#[derive(Clone, Debug, PartialEq)]
pub struct Cell {
pub text: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
/// Text fill colour as sRGB components in 0..=1.
pub color: [f64; 3],
pub span: usize,
}
/// A table detected from the page's text layout: rows of cells, and the left edge of each
/// grid column (user space, y up) so the writers can size the columns.
#[derive(Clone, Debug, PartialEq)]
pub struct Table {
pub rect: [f64; 4],
pub cols: Vec<f64>,
pub rows: Vec<Vec<Cell>>,
/// `Some` when the page's ruled lines separate every pair of neighbouring cells, so the
/// writers draw the inside borders; it holds which outer edges (top, left, bottom, right) the
/// lines also draw, as only those get a border.
pub ruled: Option<[bool; 4]>,
}
/// What goes into the output, in order.
pub(crate) enum Item<'a> {
/// A paragraph's runs (at least one) and its heading level.
Para(Vec<Run<'a>>, u8),
/// Owned: tables are detected per page inside [`items`], so they cannot borrow.
Table(Table),
Img(&'a Image),
PageBreak,
}
#[cfg(test)]
thread_local! {
/// Entries read to find a font size's or a colour's slot, for the tests that keep it constant.
pub(crate) static LOOKUP_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` entries read by a lookup (tests only).
#[inline]
pub(crate) fn count_lookup_steps(_n: usize) {
#[cfg(test)]
LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n)));
}
/// The body text size: the size most of the document's characters are set in.
pub(crate) fn body_size(pages: &[Page]) -> f64 {
// Sizes in first-seen order (which breaks ties), and where each one is in it.
let mut by: Vec<(i64, usize)> = Vec::new();
let mut slot: HashMap<i64, usize> = HashMap::new();
for b in pages.iter().flat_map(|p| &p.blocks) {
let k = (b.size * 2.0).round() as i64;
count_lookup_steps(1);
let i = *slot.entry(k).or_insert_with(|| {
by.push((k, 0));
by.len() - 1
});
if let Some(e) = by.get_mut(i) {
e.1 = e.1.saturating_add(b.text.len());
}
}
by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0)
}
/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text.
fn level(b: &Block, body: f64) -> u8 {
let short = b.text.len() < 200;
if short && b.size >= body * 1.6 {
1
} else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) {
2
} else {
0
}
}
/// How far two cell left edges may drift (points) and still be the same grid column.
const COL_TOL: f64 = 4.0;
/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or
/// more columns doesn't open. It also bounds a table's size: every row is materialized to all
/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make
/// (blocks / 2)² cells.
pub(crate) const MAX_COLS: usize = 63;
/// Two blocks in a row this close (as a fraction of their font size) are one line of running
/// text that changed font or colour, such as a link inside a sentence, not two table cells.
const INLINE_GAP: f64 = 0.5;
/// How many blocks ending near a block's start are looked at to find the one it continues; a
/// page laid out so that many end at the same place stays quick.
const CONTINUATION_PROBE: usize = 64;
/// Per block, whether it continues another on the same line: it starts where that one ends (up to
/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running
/// on (a link inside a sentence, a superscript), not a cell of its own.
fn continuations(blocks: &[Block]) -> Vec<bool> {
continued_from(blocks).into_iter().map(|from| from.is_some()).collect()
}
/// Per block, the block it continues on the same line (see [`continuations`]): of those it could
/// continue, the one that ends nearest to where it starts.
fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect();
by_end.sort_by(|a, b| a.0.total_cmp(&b.0));
blocks
.iter()
.enumerate()
.map(|(bi, b)| {
let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size);
by_end
.get(k..)
.unwrap_or_default()
.iter()
.take_while(|(end, _)| *end <= b.rect[0] + 1.0)
.take(CONTINUATION_PROBE)
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a)))
.filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
.min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs()))
.map(|(ai, _)| ai)
})
.collect()
}
/// A gap between two runs wider than this (as a fraction of the font size) is a word space the
/// text left out. It is the gap the reading-block grouping uses for the same purpose.
const WORD_GAP: f64 = 0.12;
/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space
/// before the next run when the source had one there or the page shows a gap, but the text has
/// none).
pub(crate) struct Run<'a> {
pub(crate) block: &'a Block,
pub(crate) text: String,
}
/// A page's paragraphs, each a list of runs from left to right along its line. A block that
/// continues another on the same line ([`continued_from`]: an italic case name, a link, a
/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its
/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join
/// nothing. Paragraphs come in the order of their first runs in `blocks`.
pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec<Vec<Run<'a>>> {
let skipped = |i: usize| skip.get(i).copied().unwrap_or(false);
let from = continued_from(blocks);
// Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s
// order, NaN included), so that one is visited first and has its paragraph already, and the
// links cannot form a cycle.
let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect();
order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
// Each block's paragraph, named by its first block, and each paragraph's blocks in order.
let mut head: Vec<usize> = (0..blocks.len()).collect();
let mut members: Vec<Vec<usize>> = vec![Vec::new(); blocks.len()];
for &(x0, bi) in &order {
let Some(b) = blocks.get(bi) else { continue };
let joined = from
.get(bi)
.copied()
.flatten()
.filter(|ai| !skipped(*ai))
.filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1))
.and_then(|ai| head.get(ai).copied());
let h = joined.unwrap_or(bi);
if let Some(slot) = head.get_mut(bi) {
*slot = h;
}
if let Some(m) = members.get_mut(h) {
m.push(bi);
}
}
members
.into_iter()
.filter(|m| !m.is_empty())
.map(|m| {
let mut runs: Vec<Run<'a>> = Vec::with_capacity(m.len());
for b in m.into_iter().filter_map(|bi| blocks.get(bi)) {
if let Some(prev) = runs.last_mut()
&& !prev.text.ends_with(char::is_whitespace)
&& !b.text.starts_with(char::is_whitespace)
&& (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP)
{
prev.text.push(' ');
}
runs.push(Run { block: b, text: b.text.clone() });
}
runs
})
.collect()
}
/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold
/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run
/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker.
fn para_level(runs: &[Run], body: f64) -> u8 {
let Some(first) = runs.first() else { return 0 };
let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3;
runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0)
}
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
}
/// A rule at most this thick (points) is a line, not a filled area.
const RULE_THICK: f64 = 3.0;
/// Rule comparisons one page may spend deciding whether its tables are ruled; past it a table
/// counts as unruled, so a hostile page of many rules and cells stays quick.
const RULE_BUDGET: usize = 4_000_000;
/// Whether `rules` separate every pair of neighbouring cells of a table whose rows are `rows`
/// (block indices, top to bottom) and whose columns start at `edges` and end at `right`: between
/// two columns a vertical rule beside each row (unless a cell of that row spans across), and
/// between two rows a horizontal rule under each column. Every check spends `budget`. When they
/// do, says which outer edges (top, left, bottom, right) are ruled too: a rule over every column
/// above the first row or below the last, or beside every row left of the first column or right
/// of the last, no farther from the cells than the next row or column is.
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> Option<[bool; 4]> {
let vertical: Vec<&[f64; 4]> = rules.iter().filter(|r| r[2] - r[0] <= RULE_THICK && r[3] - r[1] > RULE_THICK).collect();
let horizontal: Vec<&[f64; 4]> = rules.iter().filter(|r| r[3] - r[1] <= RULE_THICK && r[2] - r[0] > RULE_THICK).collect();
let mut find = |set: &[&[f64; 4]], hit: &dyn Fn(&[f64; 4]) -> bool| -> bool {
set.iter().any(|r| {
*budget = budget.saturating_sub(1);
*budget > 0 && hit(r)
})
};
// Each row's vertical span.
let spans: Vec<[f64; 2]> = rows
.iter()
.map(|row| {
let bs = || row.iter().filter_map(|&i| blocks.get(i));
[bs().map(|b| b.rect[1]).fold(f64::INFINITY, f64::min), bs().map(|b| b.rect[3]).fold(f64::NEG_INFINITY, f64::max)]
})
.collect();
for (row, span) in rows.iter().zip(&spans) {
let mid = (span[0] + span[1]) / 2.0;
for pair in edges.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
let cells = || row.iter().filter_map(|&i| blocks.get(i));
if cells().any(|b| b.rect[0] < hi - COL_TOL && b.rect[2] > hi + COL_TOL) {
continue;
}
let clear = |x: f64| !cells().any(|b| b.rect[0] + COL_TOL < x && x < b.rect[2] - COL_TOL);
if !find(&vertical, &|r| {
let x = (r[0] + r[2]) / 2.0;
lo + COL_TOL < x && x <= hi + COL_TOL && r[1] <= mid && mid <= r[3] && clear(x)
}) {
return None;
}
}
}
let mids: Vec<f64> = edges.iter().zip(edges.iter().skip(1).chain(std::iter::once(&right))).map(|(a, b)| (a + b) / 2.0).collect();
for pair in spans.windows(2) {
let (below_top, above_bottom) = (pair[1][1], pair[0][0]);
for &x in &mids {
if !find(&horizontal, &|r| {
let y = (r[1] + r[3]) / 2.0;
below_top - COL_TOL <= y && y <= above_bottom + COL_TOL && r[0] <= x && x <= r[2]
}) {
return None;
}
}
}
let (Some(first), Some(last), Some(&left), Some(&last_col)) = (spans.first(), spans.last(), edges.first(), edges.last()) else {
return Some([false; 4]);
};
// How far the next row or column is: an outer rule farther out than that frames something else.
let row_gap = |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>, side: usize| a.zip(b).map_or(0.0, |(a, b)| (a[side] - b[side]).abs());
let top_gap = row_gap(Some(first), spans.get(1), 1);
let bottom_gap = row_gap(spans.len().checked_sub(2).and_then(|i| spans.get(i)), Some(last), 0);
let left_gap = edges.get(1).map_or(0.0, |e| e - left);
let right_gap = edges.len().checked_sub(2).and_then(|i| edges.get(i)).map_or(0.0, |e| last_col - e);
let mut over_columns = |lo: f64, hi: f64| {
mids.iter().all(|&x| {
find(&horizontal, &|r| {
let y = (r[1] + r[3]) / 2.0;
lo <= y && y <= hi && r[0] <= x && x <= r[2]
})
})
};
let top = over_columns(first[1] - COL_TOL, first[1] + top_gap + COL_TOL);
let bottom = over_columns(last[0] - bottom_gap - COL_TOL, last[0] + COL_TOL);
let mut beside_rows = |lo: f64, hi: f64| {
spans.iter().all(|span| {
let mid = (span[0] + span[1]) / 2.0;
find(&vertical, &|r| {
let x = (r[0] + r[2]) / 2.0;
lo <= x && x <= hi && r[1] <= mid && mid <= r[3]
})
})
};
let left_ruled = beside_rows(left - left_gap - COL_TOL, left + COL_TOL);
let right_ruled = beside_rows(right - COL_TOL, right.max(last_col + right_gap) + COL_TOL);
Some([top, left_ruled, bottom, right_ruled])
}
/// Detect tables in a page's blocks from their text layout: columns are left edges shared by
/// several short blocks, rows are blocks whose vertical spans overlap. Two columns of two rows
/// are a table only when `rules` (the page's ruled lines) draw its grid. Returns the tables and,
/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow).
pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<bool>) {
let mut budget = RULE_BUDGET;
let mut consumed = vec![false; blocks.len()];
// Candidate columns: cluster the left edges of cell-like blocks.
let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect();
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut cols: Vec<(f64, Vec<usize>)> = Vec::new(); // (left edge, member block indices)
for (x, i) in xs {
match cols.last_mut() {
Some((e, m)) if (x - *e).abs() <= COL_TOL => {
*e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64;
m.push(i);
}
_ => cols.push((x, vec![i])),
}
}
let cols: Vec<(f64, Vec<usize>)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect();
if cols.len() < 2 {
return (Vec::new(), consumed);
}
// Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks.
let mut col_of: Vec<Option<usize>> = vec![None; blocks.len()];
for (ci, (_, members)) in cols.iter().enumerate() {
for &i in members {
if let Some(c) = col_of.get_mut(i) {
*c = Some(ci);
}
}
}
// The column whose edge is within tolerance of `x` (edges are in ascending order).
let column_at = |x: f64| -> Option<usize> {
let k = cols.partition_point(|(e, _)| *e < x - COL_TOL);
cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k)
};
// Assign every cell-like block to its nearest column (within tolerance).
let mut rows: Vec<Vec<(usize, usize)>> = Vec::new(); // (column index, block index), top to bottom
let mut order: Vec<usize> = (0..blocks.len()).collect();
order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0])));
for &bi in &order {
let b = &blocks[bi];
if !is_cell_like(b) {
continue;
}
let Some(ci) = column_at(b.rect[0]) else { continue };
let joins = rows
.last()
.and_then(|row| row.first())
.map(|(_, fi)| {
let rb = &blocks[*fi];
let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]);
overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1])
})
.unwrap_or(false);
if joins {
if let Some(row) = rows.last_mut() {
row.push((ci, bi));
}
} else {
rows.push(vec![(ci, bi)]);
}
}
// Runs of consecutive rows that have two or more distinct columns are tables.
let mut out = Vec::new();
let flush = |run: &mut Vec<Vec<(usize, usize)>>, out: &mut Vec<Table>, consumed: &mut Vec<bool>, budget: &mut usize| {
if run.len() < 2 {
run.clear();
return;
}
let mut used: Vec<usize> = run.iter().flatten().map(|(_, i)| *i).collect();
used.sort_unstable();
used.dedup();
let mut edges: Vec<f64> = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect();
edges.sort_by(f64::total_cmp);
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
let ncols = edges.len();
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
let frame = if (2..=MAX_COLS).contains(&ncols) && !rules.is_empty() {
let row_blocks: Vec<Vec<usize>> = run.iter().map(|row| row.iter().map(|(_, i)| *i).collect()).collect();
ruled(blocks, rules, &row_blocks, &edges, right, budget)
} else {
None
};
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || frame.is_some()))) && ncols <= MAX_COLS;
if !ok {
run.clear();
return;
}
let mut rows_out = Vec::new();
let mut slots: Vec<Option<(Cell, Vec<usize>)>> = vec![None; ncols];
for row in run.drain(..) {
for slot in slots.iter_mut() {
*slot = None;
}
for (_ci, bi) in row {
let b = &blocks[bi];
let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue };
let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1;
let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span };
match slots.get_mut(slot) {
Some(Some((c, sources))) => {
c.text.push(' ');
c.text.push_str(&cell.text);
sources.push(bi);
}
Some(empty @ None) => *empty = Some((cell, vec![bi])),
None => continue,
}
}
let mut materialized = Vec::with_capacity(ncols);
let mut column = 0;
while column < ncols {
match slots.get_mut(column).and_then(Option::take) {
Some((mut cell, sources)) => {
// A spanning cell can skip later slots. Only consume source blocks
// when their text is emitted, leaving covered text in paragraph flow.
for bi in sources {
if let Some(taken) = consumed.get_mut(bi) {
*taken = true;
}
}
cell.span = cell.span.min(ncols - column).max(1);
column += cell.span;
materialized.push(cell);
}
None => {
column += 1;
materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 });
}
}
}
while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) {
materialized.pop();
}
rows_out.push(materialized);
}
let rect = [
edges[0],
used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min),
right,
used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max),
];
out.push(Table { rect, cols: edges, rows: rows_out, ruled: frame });
};
// Runs of consecutive multi-column rows are tables. Rows on either side of a run that
// hold a single cell wide enough to span two columns (a header band, a totals row) join
// the table; other single-column rows (captions, following text) stay paragraphs.
let spans_run = |edges: &[f64], bi: usize| -> bool {
let b = &blocks[bi];
edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL)
};
let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec<f64> {
let mut es: Vec<f64> =
run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect();
es.sort_by(f64::total_cmp);
es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
es
};
let cont = continuations(blocks);
let qual: Vec<bool> = rows
.iter()
.map(|row| {
// A block that continues another adds no column of its own, and the cells of a table row
// sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806).
let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect();
let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect();
spans.sort_by(|a, b| a[0].total_cmp(&b[0]));
let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL);
let mut cs: Vec<usize> = cells.iter().map(|(c, _)| *c).collect();
cs.sort_unstable();
cs.dedup();
apart && cs.len() >= 2
})
.collect();
let mut i = 0;
while i < rows.len() {
if !qual[i] {
i += 1;
continue;
}
let start = i;
while i < rows.len() && qual[i] {
i += 1;
}
let end = i; // run = rows[start..end]
let mut pieces: Vec<Vec<(usize, usize)>> = Vec::new();
if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) {
pieces.push(rows[start - 1].clone());
}
pieces.extend(rows[start..end].iter().cloned());
if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) {
pieces.push(rows[end].clone());
}
flush(&mut pieces, &mut out, &mut consumed, &mut budget);
}
(out, consumed)
}
pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
let body = body_size(pages);
let mut out = Vec::new();
for (i, p) in pages.iter().enumerate() {
if i > 0 {
out.push(Item::PageBreak);
}
let (tables, consumed) = tables(&p.blocks, &p.rules);
// Blocks and images by their top edge, top to bottom.
// A paragraph sits where its first run does: its other runs follow along the same line.
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
.into_iter()
.filter_map(|runs| {
let first = runs.first()?.block;
let lvl = para_level(&runs, body);
Some((first.rect[3], first.rect[0], Item::Para(runs, lvl)))
})
.collect();
parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t))));
parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im))));
parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1)));
out.extend(parts.into_iter().map(|x| x.2));
}
out
}