diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 34c8f9ca76..aa52c20750 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -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 { +/// 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>> { + 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 { let mut run: Vec = 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 = None; let mut down: Option = 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, 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], n: usize, budget: &mut ImageBudget) -> Option<(Vec, 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::>()?; + // 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| { + 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::(); + 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::(); + 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 = 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::(); + 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::(); + 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)); + } + } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index c4fb865198..2b8f513a24 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3096,6 +3096,52 @@ fn drawn_images(doc: &Document) -> Vec { .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).