mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
fix(optimize): merge side-by-side and 2D grid image fragments (#4818)
This commit is contained in:
@@ -9,8 +9,9 @@
|
||||
//! between them, each drawing an image upright (no rotation, skew or flip);
|
||||
//! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array
|
||||
//! that changes anything, in a codec PdfCraft decodes;
|
||||
//! - they share their left and right edges and are stacked edge to edge with no gap or overlap
|
||||
//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them.
|
||||
//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed
|
||||
//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least
|
||||
//! [`MIN_FRAGMENTS`] of them.
|
||||
//!
|
||||
//! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost
|
||||
//! until the image settings downsample it; it is stored losslessly. A page whose content
|
||||
@@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe
|
||||
(w > 0 && h > 0).then_some((r, w, h, cs, n))
|
||||
}
|
||||
|
||||
/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge.
|
||||
/// Sorts it from the top down.
|
||||
fn stacked(run: &mut [Fragment]) -> bool {
|
||||
let Some(first) = run.first() else { return false };
|
||||
let [x0, _, x1, _] = first.rect;
|
||||
let width = x1 - x0;
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let tol = 1e-3 * width.max(1.0);
|
||||
if run.iter().any(|f| {
|
||||
(f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
run.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]),
|
||||
_ => true,
|
||||
})
|
||||
/// Whether `a` and `b` share their top and bottom edges within row tolerance.
|
||||
fn same_y(a: &Fragment, b: &Fragment) -> bool {
|
||||
let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]);
|
||||
(a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on while each is drawn right under (or right over) the one
|
||||
/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side,
|
||||
/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right
|
||||
/// within each row. Returns that exact row partition: tolerance-based membership is not
|
||||
/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor.
|
||||
fn stacked(run: &mut [Fragment]) -> Option<Vec<std::ops::Range<usize>>> {
|
||||
if run.is_empty()
|
||||
|| run.iter().any(|f| {
|
||||
let [x0, y0, x1, y1] = f.rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
!(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
let mut row_bounds: Vec<[f64; 4]> = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < run.len() {
|
||||
let mut end = start + 1;
|
||||
while end < run.len() && same_y(&run[start], &run[end]) {
|
||||
end += 1;
|
||||
}
|
||||
let row = &mut run[start..end];
|
||||
row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
if !row.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]),
|
||||
_ => true,
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let (Some(first), Some(last)) = (row.first(), row.last()) else { return None };
|
||||
row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]);
|
||||
rows.push(start..end);
|
||||
start = end;
|
||||
}
|
||||
let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None };
|
||||
let (width, height) = (x1 - x0, y1 - y0);
|
||||
if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol_x = 1e-3 * width.max(1.0);
|
||||
if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) {
|
||||
return None;
|
||||
}
|
||||
row_bounds
|
||||
.windows(2)
|
||||
.all(|w| match w {
|
||||
[a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]),
|
||||
_ => true,
|
||||
})
|
||||
.then_some(rows)
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row
|
||||
/// or strip by strip in one direction, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
let mut run: Vec<Fragment> = Vec::new();
|
||||
let mut row_x = [0.0f64, 0.0];
|
||||
let mut cur_x = [0.0f64, 0.0];
|
||||
let mut cur_y = [0.0f64, 0.0];
|
||||
let mut first_row = true;
|
||||
let mut right: Option<bool> = None;
|
||||
let mut down: Option<bool> = None;
|
||||
let mut complete = 0usize;
|
||||
let mut j = i;
|
||||
while let Some((next, m, name)) = group_at(ops, j) {
|
||||
while run.len() < limit {
|
||||
let Some((next, m, name)) = group_at(ops, j) else { break };
|
||||
let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break };
|
||||
let [a, _, _, d, e, f] = m.0;
|
||||
let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height };
|
||||
if let Some(prev) = run.last() {
|
||||
let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (prev.rect[1] - f2.rect[3]).abs() <= tol;
|
||||
let above = (f2.rect[1] - prev.rect[3]).abs() <= tol;
|
||||
let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0);
|
||||
match (down, below, above) {
|
||||
_ if !same_x => break,
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y;
|
||||
if same_row {
|
||||
let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]);
|
||||
let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x;
|
||||
let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x;
|
||||
match (right, step_right, step_left) {
|
||||
(None | Some(true), true, _) => {
|
||||
right = Some(true);
|
||||
cur_x[1] = f2.rect[2];
|
||||
}
|
||||
(None | Some(false), _, true) => {
|
||||
right = Some(false);
|
||||
cur_x[0] = f2.rect[0];
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
if first_row {
|
||||
row_x = cur_x;
|
||||
complete = run.len() + 1;
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span {
|
||||
break;
|
||||
}
|
||||
if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span {
|
||||
complete = run.len() + 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span {
|
||||
break;
|
||||
}
|
||||
let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v;
|
||||
let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v;
|
||||
match (down, below, above) {
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
}
|
||||
let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span;
|
||||
let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span;
|
||||
if starts_left && starts_right {
|
||||
right = None;
|
||||
complete = run.len() + 1;
|
||||
} else if starts_left {
|
||||
right = Some(true);
|
||||
} else if starts_right {
|
||||
right = Some(false);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
first_row = false;
|
||||
cur_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
}
|
||||
} else {
|
||||
row_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_x = row_x;
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
complete = 1;
|
||||
}
|
||||
run.push(f2);
|
||||
j = next;
|
||||
}
|
||||
run.truncate(complete);
|
||||
run
|
||||
}
|
||||
|
||||
@@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
|
||||
}
|
||||
|
||||
/// The run's pixels as one image, top row first: `(pixels, width, height)`.
|
||||
fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]);
|
||||
let width = run.iter().map(|f| f.width).max()?;
|
||||
// Pixel rows per unit of height: the finest fragment's.
|
||||
let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let height = ((top - bottom) * density).round();
|
||||
fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range<usize>], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (first, last) = (run.first()?, run.last()?);
|
||||
let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]);
|
||||
let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::<Option<_>>()?;
|
||||
// Pixels per unit along each axis: the finest fragment's.
|
||||
let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max);
|
||||
let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() };
|
||||
let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() };
|
||||
if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
// In range and whole, so the cast is exact.
|
||||
let height = height as u32;
|
||||
// In range and whole, so the casts are exact.
|
||||
let (width, height) = (width as u32, height as u32);
|
||||
if u64::from(width) * u64::from(height) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let row = (width as usize).checked_mul(n)?;
|
||||
let stride = (width as usize).checked_mul(n)?;
|
||||
// What this builds isn't what its strips cost to read: four small ones can ask for all of it.
|
||||
let bytes = row.checked_mul(height as usize)?;
|
||||
let bytes = stride.checked_mul(height as usize)?;
|
||||
if !budget.pay_pixels(u64::try_from(bytes).ok()?) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![255u8; bytes];
|
||||
for f in run {
|
||||
let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height);
|
||||
let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height);
|
||||
let mut r0 = 0u32;
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) };
|
||||
if r1 <= r0 {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? };
|
||||
out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?);
|
||||
let th = r1 - r0;
|
||||
let mut c0 = 0u32;
|
||||
for (col_idx, f) in row.iter().enumerate() {
|
||||
let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) };
|
||||
if c1 <= c0 {
|
||||
return None;
|
||||
}
|
||||
let tw = c1 - c0;
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? };
|
||||
let tile_row = (tw as usize).checked_mul(n)?;
|
||||
let col_off = (c0 as usize).checked_mul(n)?;
|
||||
for r in 0..th as usize {
|
||||
let dst = (r0 as usize + r) * stride + col_off;
|
||||
let src = r * tile_row;
|
||||
out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?);
|
||||
}
|
||||
c0 = c1;
|
||||
}
|
||||
r0 = r1;
|
||||
}
|
||||
Some((out, width, height))
|
||||
}
|
||||
@@ -288,13 +411,15 @@ fn merge_page(
|
||||
j = next;
|
||||
}
|
||||
scanned = scanned.saturating_add(run.len()).saturating_add(1);
|
||||
// Not one picture as a whole: the rows drawn one under the other from the start may be.
|
||||
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
|
||||
run = drawn_in_sequence(&ops, i, &xobjects, doc);
|
||||
// Not one picture as a whole: a prefix drawn row by row or strip by strip may be.
|
||||
let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None };
|
||||
if run.len() >= MIN_FRAGMENTS && rows.is_none() {
|
||||
run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc);
|
||||
j = run_end(&ops, i, run.len());
|
||||
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
|
||||
rows = stacked(&mut run);
|
||||
}
|
||||
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
|
||||
let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else {
|
||||
// Not a run: keep this op and look again from the next one.
|
||||
if let Some(op) = ops.get(i) {
|
||||
out.push(op.clone());
|
||||
@@ -305,7 +430,7 @@ fn merge_page(
|
||||
if !progress(Stage::Fragments) {
|
||||
return Err(OptimizeError::Cancelled);
|
||||
}
|
||||
let Some((px, w, h)) = merge(doc, &run, n, images) else {
|
||||
let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else {
|
||||
out.extend(ops.get(i..j).unwrap_or_default().iter().cloned());
|
||||
i = j;
|
||||
continue;
|
||||
@@ -322,7 +447,7 @@ fn merge_page(
|
||||
xobjects.set(name.clone(), Object::Ref(image));
|
||||
add_xobject(doc, page, &name, image)?;
|
||||
let (Some(first), Some(last)) = (run.first(), run.last()) else { break };
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]];
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]];
|
||||
let num = Object::Real;
|
||||
out.push(Op::new("q", Vec::new()));
|
||||
out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)]));
|
||||
@@ -339,6 +464,7 @@ fn merge_page(
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Merge consecutive tiled image fragments across `pages`.
|
||||
pub(crate) fn run(
|
||||
doc: &mut Document,
|
||||
pages: &[ObjRef],
|
||||
@@ -361,17 +487,45 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers.
|
||||
#[test]
|
||||
fn a_merged_height_beyond_u32_is_refused_before_allocating() {
|
||||
// Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be
|
||||
// 2^16 × 2^48 pixels, whose product wraps to 0 in u64.
|
||||
let image = ObjRef { num: 1, generation: 0 };
|
||||
let edge = 1.0 - (-48f64).exp2();
|
||||
let run = [
|
||||
let vertical = [
|
||||
Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 },
|
||||
Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
let horizontal = [
|
||||
Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 },
|
||||
Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Even an inconsistent partition must not silently omit a row or tile from its report.
|
||||
#[test]
|
||||
fn assembly_refuses_collapsed_output_rows_and_tiles() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(10));
|
||||
dict.set(b"Height".to_vec(), Object::Int(10));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100])));
|
||||
let mut run: Vec<_> = (0..4)
|
||||
.map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 })
|
||||
.collect();
|
||||
// The first supposed row consumes the entire height; the second cannot be discarded.
|
||||
assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
// Likewise, a tile whose right edge does not advance cannot simply vanish.
|
||||
if let Some(tile) = run.get_mut(1) {
|
||||
tile.rect = [0.0, 0.0, 10.0, 10.0];
|
||||
}
|
||||
assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Merging reads the images the pass after it reads, from the same allowance: with nothing
|
||||
@@ -474,4 +628,149 @@ mod tests {
|
||||
let reads = GROUP_READS.with(|c| c.get());
|
||||
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
|
||||
}
|
||||
|
||||
/// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid
|
||||
/// or separated runs are handled per run.
|
||||
#[test]
|
||||
fn side_by_side_and_grid_fragments_merge_into_one_image() {
|
||||
let tile = |doc: &mut Document, w: u32, h: u32, px: Vec<u8>| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
doc.add(Object::Stream(Stream::flate(d, &px)))
|
||||
};
|
||||
let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| {
|
||||
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes())));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
doc.add(Object::Dict(page))
|
||||
};
|
||||
|
||||
// Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3
|
||||
// pixels so the merged image keeps the finest horizontal resolution (8×3).
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for c in 0..4u8 {
|
||||
let w = if c % 2 == 0 { 1 } else { 2 };
|
||||
let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]);
|
||||
xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap();
|
||||
let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3)));
|
||||
assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3));
|
||||
let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||||
let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") };
|
||||
let parsed = parse(&cs.decoded().unwrap());
|
||||
let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap();
|
||||
assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]);
|
||||
|
||||
// A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for r in 0..2u8 {
|
||||
for c in 0..3u8 {
|
||||
let v = r * 100 + c * 20;
|
||||
let im = tile(&mut doc, 2, 2, vec![v; 4]);
|
||||
xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
}
|
||||
let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)]
|
||||
.into_iter()
|
||||
.map(|(r, c)| {
|
||||
let x = 5.0 + f64::from(c) * 10.0;
|
||||
let y = 50.0 + f64::from(1 - r) * 10.0;
|
||||
format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n")
|
||||
})
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (6, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4)));
|
||||
let mut expected = Vec::new();
|
||||
expected.extend([0, 0, 20, 20, 40, 40].repeat(2));
|
||||
expected.extend([100, 100, 120, 120, 140, 140].repeat(2));
|
||||
assert_eq!(s.decoded().unwrap(), expected);
|
||||
|
||||
// Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images,
|
||||
// whether drawn left to right or right to left.
|
||||
for right_to_left in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![80; 4]);
|
||||
xobjects.set(b"S".to_vec(), Object::Ref(im));
|
||||
let xs: Vec<f64> = if right_to_left {
|
||||
vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0]
|
||||
} else {
|
||||
vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0]
|
||||
};
|
||||
let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}");
|
||||
}
|
||||
|
||||
// A 2×2 grid followed immediately by a lone fifth tile below only the first column merges
|
||||
// the 4 grid tiles and leaves the fifth tile alone.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n"))
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
|
||||
// Overlapping side-by-side strips or rows of different widths are left alone.
|
||||
for bad_ops in [
|
||||
"q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n",
|
||||
"q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n",
|
||||
] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let page = build_page(&mut doc, xobjects, bad_ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3096,6 +3096,52 @@ fn drawn_images(doc: &Document) -> Vec<Stream> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tolerated row must not be regrouped after its highest tile moves during X sorting.
|
||||
#[test]
|
||||
fn fragment_row_tolerance_preserves_every_tiles_pixels() {
|
||||
for aligned in [true, false] {
|
||||
for merge_fragments in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut refs = Vec::new();
|
||||
for gray in [20u8, 80, 140, 220] {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(10));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100]))));
|
||||
}
|
||||
let content = if aligned {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q"
|
||||
} else {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q"
|
||||
};
|
||||
page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content);
|
||||
let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||||
let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap();
|
||||
// Save and reopen, then inspect only images still drawn by the optimized page.
|
||||
let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let drawn = drawn_images(&doc);
|
||||
if report.fragments_merged == 0 {
|
||||
assert_eq!(report.merged_images, 0);
|
||||
assert_eq!(drawn.len(), 4);
|
||||
for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) {
|
||||
assert_eq!(image.decoded().unwrap(), [gray; 100]);
|
||||
}
|
||||
} else {
|
||||
assert!(merge_fragments);
|
||||
assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1));
|
||||
let image = &drawn[0];
|
||||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10)));
|
||||
let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect();
|
||||
assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() {
|
||||
// 120 strips, every other one JPEG and half as wide (as one PDF printer writes them).
|
||||
|
||||
Reference in New Issue
Block a user