diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index ce7eac542..916451a38 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -91,7 +91,7 @@ mod tests { ring(&mut layers, Some(MADE), red, 6.0, square); ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0)); let rect = page.artboards[0].rect; - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let mut view = PageView::new(&frame); assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw"); let widths: Vec = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); @@ -193,15 +193,15 @@ mod tests { let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let mut view = PageView::new(&frame); assert!(compare(&frame, &mut view, &vector, false).is_ok()); assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error"); let shifted = Rect::new(0.0, 0.0, 500.0, 500.0); - assert!(view.difference(&vector, shifted, false).any > MAX_DIFFERENCE, "wrong framing remains an error"); + assert!(view.difference_on_every_page(&vector, shifted, false).any > MAX_DIFFERENCE, "wrong framing remains an error"); } - /// The boxes `solid_regions` gives for a page of images are in the page's points: the + /// The boxes `solid_regions_on_every_page` gives for a page of images are in the page's points: the /// comparison is drawn at a multiple of the scale and resized, and the two sizes round /// differently on the short side, so a box at the far edge does not drift out of the object /// that makes it. @@ -218,7 +218,7 @@ mod tests { } } - /// The boxes `solid_regions` gives for a `width` × `height` page that is one image of a black + /// The boxes `solid_regions_on_every_page` gives for a `width` × `height` page that is one image of a black /// `object`, compared with layers that hold nothing. fn raster_page_regions(object: Rect, width: f64, height: f64) -> Vec { use vectorcraft_color::{Color, Paint}; @@ -256,9 +256,9 @@ mod tests { ); page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); assert_eq!(comparison_samples(&page), 4); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let mut view = PageView::new(&frame); - let (_, regions) = view.solid_regions(&Document::new(width, height), rect, false).unwrap(); + let (_, regions) = view.solid_regions_on_every_page(&Document::new(width, height), false).unwrap(); assert!(!regions.is_empty()); regions } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index e94dccfb5..4226ccaac 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,4 +1,6 @@ -use super::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, difference}; +use super::compare::{ + MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PageView, compare, comparison_samples, difference, pixels_compared, +}; use super::plan::{made_only, make_slots, plan, readable_all}; use super::slots::{ ART_LEFT_OUT, Content, DREW_DIFFERENTLY, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, SlotInfo, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, @@ -32,6 +34,10 @@ pub(super) struct Frame<'a> { pub(super) whole: bool, /// The PDF page areas and their moves into the editing document. pub(super) pages: Vec<(Rect, Affine)>, + /// The pages after the first that show artboards of the layers' document, each with that + /// artboard (as many as [`MAX_PIXELS_COMPARED`] allows): the layers are compared with them as + /// with the first. + pub(super) more: Vec<(Rect, Rect)>, } /// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the @@ -211,6 +217,9 @@ pub(super) fn build_from( Arc::make_mut(n).transform(shift, PAGE_MOVE); } } + // Pages cropped to the art are copies of the first, and a PDF saved with some of the + // artboards can hold any of them. + let more = more_pages(boards.filter(|b| !cropped && b.len() == doc.artboards.len()), &pages, comparison_samples(visible)); // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); @@ -224,7 +233,7 @@ pub(super) fn build_from( } drop_guides_off_boards(&mut doc); } - frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves }); + frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves, more }); } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); @@ -326,7 +335,8 @@ pub(super) fn build_from( Ok(note) => { let closer = other.and_then(|mut other| { let unseen = prune_unseen(&frame, &mut view, &mut other, reflow); - let closer = view.difference(&other, frame.rect, reflow).any < view.difference(&doc, frame.rect, reflow).any; + let closer = view.difference_on_every_page(&other, frame.rect, reflow).any + < view.difference_on_every_page(&doc, frame.rect, reflow).any; closer.then(|| compare(&frame, &mut view, &other, reflow).ok()).flatten().map(|note| (other, unseen, note)) }); match closer { @@ -801,7 +811,11 @@ fn closer_first( reflow: bool, ) -> Vec<(Document, usize)> { match other { - Some(other) if view.difference(&other.0, frame.rect, reflow).any < view.difference(&doc.0, frame.rect, reflow).any => vec![other, doc], + Some(other) + if view.difference_on_every_page(&other.0, frame.rect, reflow).any < view.difference_on_every_page(&doc.0, frame.rect, reflow).any => + { + vec![other, doc] + } Some(other) => vec![doc, other], None => vec![doc], } @@ -847,13 +861,14 @@ fn keep_differing( // Where only spread edges or type differ, or only the art around the page, no object is // replaced. - let (diff, regions) = view.solid_regions(doc, frame.rect, reflow)?; + let (diff, regions) = view.solid_regions_on_every_page(doc, reflow)?; if diff.solid <= MAX_SOLID_DIFFERENCE { return None; } let mut objects = vec![]; top_level(&doc.layers, "", &mut objects); - objects.retain(|(_, _, b)| !b.intersect(frame.rect).is_zero_area()); + let pages = view.regions(frame.rect); + objects.retain(|(_, _, b)| pages.iter().any(|page| !b.intersect(*page).is_zero_area())); if regions.len().saturating_mul(objects.len()) > MAX_PATCH_PAIRS { return None; } @@ -1200,6 +1215,32 @@ fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rec best.map_or(0, |(i, _)| i) } +/// The pages after the first, each with the artboard it shows ([`page_boards`]), as [`Frame::more`] +/// holds them: none when the pages don't show the artboards. They stop before the page that would +/// take the pixels the comparisons draw (`samples`, [`comparison_samples`]), the first page's +/// included, past [`MAX_PIXELS_COMPARED`], and after [`MAX_PAGES_COMPARED`] pages. +fn more_pages(boards: Option<&[vectorcraft_doc::Artboard]>, pages: &[Rect], samples: u32) -> Vec<(Rect, Rect)> { + let Some(boards) = boards else { return vec![] }; + let mut left = MAX_PIXELS_COMPARED; + pages + .iter() + .zip(boards) + .map_while(|(page, board)| { + left = left.checked_sub(pixels_compared(*page, samples))?; + Some((*page, board.rect)) + }) + .take(MAX_PAGES_COMPARED) + .skip(1) + .collect() +} + +/// The artboards (or other areas) of `pages` that `bounds` lies on, with `margin` (points) round it: +/// the pages whose drawings taking it out can change. +fn pages_touched(bounds: Rect, pages: &[Rect], margin: f64) -> Vec { + let b = bounds.inflate(margin, margin); + pages.iter().enumerate().filter(|(_, p)| !b.intersect(**p).is_zero_area()).map(|(i, _)| i).collect() +} + /// The artboards of the layers' document (`boards`) that the pages show, one a page from the /// first page's (`first`), when they are the pages' sizes: every artboard, or those of a PDF saved /// with some of them (`first` after the first artboard). @@ -1310,35 +1351,51 @@ fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) } -/// The text objects made from the file's text document that show and print on the page's area, -/// where the page has no type to match them, and that the layers look no worse without, are not -/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type -/// of what was turned to outlines): they are taken out. How many. `reflow`: the type is all laid -/// out again from the text document (see [`compare`]). +/// The text objects made from the file's text document that show and print on a page's area (the +/// frame's first page or another one, [`Frame::more`]), where the page has no type to match them, and +/// that the layers look no worse without on every page they lie on, are not drawn by the app that +/// wrote the file (which paints every shown text object; it keeps the type of what was turned to +/// outlines): they are taken out. How many. `reflow`: the type is all laid out again from the text +/// document (see [`compare`]). pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document, reflow: bool) -> usize { let mut found = vec![]; unmark(&mut doc.layers, true, true, &mut found); - let rect = frame.rect; if found.is_empty() || found.len() > MAX_WEIGHED { return 0; } - let with = view.difference(doc, rect, reflow).weighed(reflow); - // Taking a text object out changes only what is drawn in its box. When the art would still - // differ from the page by more than `compare` allows with every candidate gone, the layers - // aren't used whatever is pruned: don't draw them again for each one (#758). + let pages = view.regions(frame.rect); + // Taking a text object out changes only what is drawn in its box. When the art on a page would + // still differ from it by more than `compare` allows with every candidate gone, pruning doesn't + // make that page pass: the objects on it aren't drawn again one by one (#758). let margin = OUTLINE_REACH + 1.0; - let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum(); - if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE { - return 0; - } + let hopeless: Vec = view + .differences(doc, frame.rect, reflow) + .iter() + .zip(&pages) + .map(|(with, rect)| { + let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(*rect).area().max(0.0)).sum(); + with.weighed(reflow) - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE + }) + .collect(); // Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no - // less there for being about as light (#1102). - let mut with = view.colour_difference(doc, rect); + // less there for being about as light (#1102). The view keeps each page's drawing and share: a + // removal has only the pages in `on` drawn again. let mut removed = 0; for (id, bounds) in found { // A removal before it can have let its area type flow differently. let bounds = if removed > 0 { doc.node(id).and_then(|n| n.visual_bounds()) } else { bounds }; - if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { + let Some(b) = bounds else { continue }; + let mut lies = pages_touched(b, &pages, 0.0); + if lies.is_empty() { + continue; + } + // The pages its strokes and effects can reach past its box are drawn again too. + let mut on = pages_touched(b, &pages, margin); + // Area type that flows around it can reach other pages: they are all weighed. + if has_wraps(doc) { + (lies, on) = ((0..pages.len()).collect(), (0..pages.len()).collect()); + } + if lies.iter().any(|i| hopeless.get(*i).copied().unwrap_or(true)) { continue; } let mut without = doc.clone(); @@ -1347,11 +1404,12 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc } // Area type that wrapped around the text object flows without it. relayout_wrapped(&mut without); - let d = view.colour_difference(&without, rect); - if d - with <= UNSEEN_MARGIN { + let shares = view.weigh_without(doc, &without, &on); + if shares.len() == on.len() && shares.iter().all(|(_, before, after)| after - before <= UNSEEN_MARGIN) { take_out(&mut doc.layers, id, false); relayout_wrapped(doc); - with = d; + // Only the pages in `on` can draw differently (every page when type wraps). + view.adopt(doc); removed += 1; } } @@ -1364,6 +1422,42 @@ mod candidate_tests { use vectorcraft_doc::{ImageBlob, ImageObject}; + /// The pages compared stop where their drawings would take more than the pixels allowed. + #[test] + fn the_pages_compared_stop_at_the_pixel_bound() { + let pages: Vec = (0..1000).map(|i| Rect::new(f64::from(i) * 700.0, 0.0, f64::from(i) * 700.0 + 612.0, 792.0)).collect(); + let doc = Document::new(612.0, 792.0); + let boards: Vec = pages + .iter() + .map(|page| { + let mut board = doc.artboards[0].clone(); + board.rect = *page; + board + }) + .collect(); + let more = more_pages(Some(&boards), &pages, 1); + let each = pixels_compared(pages[0], 1); + assert_eq!(more.len() + 1, (MAX_PIXELS_COMPARED / each) as usize); + assert!(more.len() >= 64, "{}", more.len()); + assert_eq!(more_pages(Some(&boards[..3]), &pages[..3], 1).len(), 2); + // Pages drawn with 4 × 4 samples a pixel take 16 times the pixels. + assert_eq!(more_pages(Some(&boards), &pages, 4).len() + 1, (MAX_PIXELS_COMPARED / (each * 16)) as usize); + // Tiny pages stop at the page count. + let tiny: Vec = (0..1000).map(|i| Rect::new(f64::from(i) * 2.0, 0.0, f64::from(i) * 2.0 + 1.0, 1.0)).collect(); + let tiny_boards: Vec = + boards.iter().zip(&tiny).map(|(b, r)| vectorcraft_doc::Artboard { rect: *r, ..b.clone() }).collect(); + assert_eq!(more_pages(Some(&tiny_boards), &tiny, 1).len() + 1, MAX_PAGES_COMPARED); + } + + /// The pages a box touches with a margin round it include those it only comes near. + #[test] + fn the_pages_within_reach_of_a_box_count_as_touched() { + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(110.0, 0.0, 210.0, 100.0)]; + let near_the_second = Rect::new(60.0, 40.0, 106.0, 60.0); + assert_eq!(pages_touched(near_the_second, &pages, 0.0), vec![0]); + assert_eq!(pages_touched(near_the_second, &pages, OUTLINE_REACH + 1.0), vec![0, 1]); + } + /// Layers read in the units of a page scaled up to points take their wrap offsets with their art, /// within ±1000 points. #[test] @@ -1504,7 +1598,7 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), 0, image).unwrap(); let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let kinds = replace_unread( &mut doc, &frame, @@ -1546,8 +1640,14 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); } let (first, second) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)); - let frame = - Frame { page: &page, page_rect: first, rect: first, whole: false, pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)] }; + let frame = Frame { + page: &page, + page_rect: first, + rect: first, + whole: false, + pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)], + more: vec![], + }; let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); assert_eq!(result.unwrap_err(), "an unread object lies on more than one artboard"); } @@ -1572,7 +1672,7 @@ mod candidate_tests { ); page.insert(Some(page.layers[0].id), 0, art).unwrap(); let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page"); @@ -1603,7 +1703,7 @@ mod candidate_tests { ); page.insert(Some(page.layers[0].id), 0, art).unwrap(); let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let result = replace_unread( &mut doc, &frame, @@ -1641,7 +1741,7 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); } let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let kinds = replace_unread( &mut doc, &frame, @@ -1682,7 +1782,7 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); } let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); assert_eq!(result.unwrap_err(), "an unread object's appearance is not identifiable in the page art"); } @@ -1712,7 +1812,7 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); } let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let unread = [(first, vec!["placed files".into()]), (second, vec!["placed files".into()])]; let mut one_box_doc = doc.clone(); let kinds = replace_unread(&mut doc, &frame, &unread, &[(second, Rect::new(50.0, 50.0, 60.0, 60.0))], &BTreeSet::new(), &mut BTreeSet::new()) @@ -1756,7 +1856,7 @@ mod candidate_tests { page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); } let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; let unread = [(id, vec!["placed files".into()])]; let mut shown: Vec>> = nodes.iter().map(|n| vec![Arc::clone(n)]).collect(); let taken = take_unread_type(&doc, Some(&frame), &unread, &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], &mut shown); diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index c20ed8383..044dbd240 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -48,6 +48,9 @@ const WHITE: u32 = 8; /// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other /// is white? pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { + if p == q { + return false; + } let (p, q) = (on_white(p), on_white(q)); let (a, b) = (ink(p), ink(q)); lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) @@ -60,6 +63,9 @@ const SAME_COLOUR: u32 = 64; /// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same /// lightness to be alike)? pub(in crate::import) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { + if p == q { + return false; + } let (p, q) = (on_white(p), on_white(q)); p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) } @@ -73,6 +79,9 @@ pub(super) struct Difference { pub(super) any: f64, art: f64, pub(super) solid: f64, + /// The share that differs in the type's areas (`any` less `art`), kept on its own when the + /// shares of several regions are taken together ([`Self::max`]). + typed: f64, } impl Difference { @@ -81,6 +90,15 @@ impl Difference { pub(super) fn weighed(&self, reflow: bool) -> f64 { if reflow { self.art } else { self.any } } + + /// The larger of each share of `self` and `other`: how two regions differ, the one that differs + /// most for each. + fn max(self, other: Self) -> Self { + Self { any: self.any.max(other.any), art: self.art.max(other.art), solid: self.solid.max(other.solid), typed: self.typed.max(other.typed) } + } + + /// Two drawings that can't be compared: every pixel differs. + const ALL: Self = Self { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }; } /// The scale the comparisons draw a region at: about 400 pixels along its longer side. @@ -126,146 +144,506 @@ pub(super) fn comparison_render( vectorcraft_render::Rendered { width, height, pixels } } -/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers -/// with a renderer of their own, which keeps their decoded images between draws (the page's images -/// can share their keys and not their pixels, so the two don't share one). -pub(super) struct PageView { - page: vectorcraft_render::Rendered, +/// The most pixels the pages of a frame are drawn in for a comparison, all of them together +/// ([`pixels_compared`]): the pages after those that reach it aren't compared. It holds about 67 +/// letter-size pages drawn with one sample a pixel, and 4 drawn with 4 × 4 ([`comparison_samples`]). +pub(super) const MAX_PIXELS_COMPARED: u64 = 1 << 23; + +/// The most pages of a frame compared, the first included, however few pixels they take. +pub(super) const MAX_PAGES_COMPARED: usize = 128; + +/// How many pixels the comparisons draw the page `page` of the PDF part in, at [`compare_scale`] +/// with `samples` × `samples` samples a pixel ([`comparison_samples`]): `u64::MAX` for one that +/// can't be drawn. +pub(super) fn pixels_compared(page: Rect, samples: u32) -> u64 { + let samples = u64::from(samples) * u64::from(samples); + vectorcraft_render::raster_size(page, compare_scale(page)).map_or(u64::MAX, |(w, h)| (u64::from(w) * u64::from(h)).saturating_mul(samples)) +} + +/// The longest side (pixels) of a sheet ([`Sheet`]). +const MAX_SHEET_SIDE: f64 = 8192.0; + +/// How many times its pages' pixels a sheet may have, the gaps between them included. +const SHEET_SPREAD: f64 = 2.0; + +/// One page of the PDF part, drawn once. +struct Page { + drawn: vectorcraft_render::Rendered, /// Where the page's own type is, in its pixels. page_type: Vec, /// Where objects read with their art style lie, in its pixels: the page has Illustrator's /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't /// held to look alike. restyled: Vec, - /// Where the page draws anything (its type, or its own outlines of type) or has its type. - page_marks: Shares, - layers: vectorcraft_render::Renderer, + /// The column and row of its pixels that its move ([`Self::region`]) took partly off its area, + /// where the layers can draw a neighboring artboard's art and the page the space between pages: + /// they count as alike. + edges: Vec, + /// Where the page draws anything (its type, or its own outlines of type) or has its type, in its + /// pixels row by row. + page_marks: Vec, scale: f64, - samples: u32, + /// The page area's width and height ([`sized`]). + size: (f64, f64), + /// The area of the layers' document the page is compared with: its artboard ([`sized`]), moved + /// by up to half a pixel to start on a pixel of its sheet, the page's area with it. + region: Rect, } -impl PageView { - pub(super) fn new(frame: &Frame<'_>) -> Self { - let ra = frame.page_rect; - let scale = compare_scale(ra); - // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in - // the app that wrote the file. - let samples = comparison_samples(frame.page); - let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); +impl Page { + /// The area `ra` of `page`, `drawn` at `scale` ([`compare_scale`]), to be compared with the layers + /// on `region`, both moved by (`dx`, `dy`) pixels from where the artboard and page lie. + fn on(page: &Document, drawn: vectorcraft_render::Rendered, ra: Rect, region: Rect, scale: f64, (dx, dy): (f64, f64)) -> Self { let mut page_type = vec![]; - type_areas(&frame.page.layers, ra, scale, &mut page_type); - let (w, h) = (page.width as usize, page.height as usize); + type_areas(&page.layers, ra, scale, &mut page_type); + let (w, h) = (drawn.width as usize, drawn.height as usize); let typed = in_areas(&page_type, w, h); - let marks: Vec = page.pixels.as_chunks::<4>().0.iter().zip(&typed).map(|(p, t)| *t || ink(on_white(p)) > WHITE).collect(); - let page_marks = Shares::of(&marks, w, h); - Self { page, page_type, restyled: vec![], page_marks, layers: vectorcraft_render::Renderer::new(), scale, samples } + // A pixel with nothing drawn on it is white. + let page_marks: Vec = + drawn.pixels.as_chunks::<4>().0.iter().zip(&typed).map(|(p, t)| *t || (p[3] != 0 && ink(on_white(p)) > WHITE)).collect(); + let (wf, hf) = (f64::from(drawn.width), f64::from(drawn.height)); + let mut edges = vec![]; + // Moved left (or up), the first column (or row) starts before the area; moved right (or + // down), the last one ends past it. + match dx { + d if d > 1e-6 => edges.push(Rect::new(0.0, 0.0, 1.0, hf)), + d if d < -1e-6 => edges.push(Rect::new(wf - 1.0, 0.0, wf, hf)), + _ => {} + } + match dy { + d if d > 1e-6 => edges.push(Rect::new(0.0, 0.0, wf, 1.0)), + d if d < -1e-6 => edges.push(Rect::new(0.0, hf - 1.0, wf, hf)), + _ => {} + } + Self { drawn, page_type, restyled: vec![], edges, page_marks, scale, size: (ra.width(), ra.height()), region } } - /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out - /// of the comparisons ([`Structure::restyled`](ai::Structure)). - pub(super) fn restyled(&mut self, looks: &[Rect], region: Rect) { - let s = self.scale; - self.restyled = looks - .iter() - .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) - .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) - .collect(); + /// The pixels that count as alike: the restyled looks' and the edges'. + fn skipped(&self) -> Vec { + self.restyled.iter().chain(&self.edges).copied().collect() } - /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). - /// With `reflow` (`b`'s type laid out again from the file's text), type counts as type only where - /// both have something: `b`'s where the page has type or draws anything in at least [`MARKED`] - /// of it, the page's where at least half of it lies where `b` has a text object, shown or not, - /// or a placed file. Elsewhere it is art the other lacks. - pub(super) fn difference(&mut self, b: &Document, rb: Rect, reflow: bool) -> Difference { - // The page doesn't print what isn't printed. - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - let areas = self.counted_type(b, rb, reflow); - compared(&self.page, &y, &areas, &self.restyled) - } - - /// Where [`Self::difference`] counts `b`'s and the page's pixels as type, in the page's pixels. + /// Where [`PageView::difference_on_every_page`] counts `b`'s (on `rb`) and the page's pixels as + /// type, in the page's pixels. With `reflow` (`b`'s type laid out again from the file's text), + /// type counts as type only where both have something: `b`'s where the page has type or draws + /// anything in at least [`MARKED`] of it, the page's where at least half of it lies where `b` has + /// a text object, shown or not, or a placed file. Elsewhere it is art the other lacks. fn counted_type(&self, b: &Document, rb: Rect, reflow: bool) -> Vec { let mut areas = vec![]; type_areas(&b.layers, rb, self.scale, &mut areas); if reflow { - areas.retain(|r| self.page_marks.share(r) >= MARKED); + let (w, h) = (self.drawn.width as usize, self.drawn.height as usize); + let marked = shares(&self.page_marks, w, h, &areas); + areas = areas.into_iter().zip(marked).filter(|(_, share)| *share >= MARKED).map(|(r, _)| r).collect(); let mut texts = vec![]; all_type_areas(&b.layers, rb, self.scale, &mut texts); - let (w, h) = (self.page.width as usize, self.page.height as usize); - let on_layers = Shares::of(&in_areas(&texts, w, h), w, h); - areas.extend(self.page_type.iter().filter(|r| on_layers.share(r) >= 0.5)); + let on_layers = shares(&in_areas(&texts, w, h), w, h, &self.page_type); + areas.extend(self.page_type.iter().zip(on_layers).filter(|(_, share)| *share >= 0.5).map(|(r, _)| *r)); } else { areas.extend(&self.page_type); } areas } +} - /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page - /// ([`differ_in_colour`]), type included: what taking an object out of the layers changes - /// there, when it is covered by one of about the same lightness (#1102). - pub(super) fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - if (y.width, y.height) != (self.page.width, self.page.height) { - return 1.0; +/// `rb`, or when its sides are within half a point of `size` (a page area's, as an artboard taken for +/// its page is), the area of that size at its top-left: the layers are drawn on it, so that both +/// drawings have the same number of pixels. +fn sized((w, h): (f64, f64), rb: Rect) -> Rect { + if (rb.width() - w).abs() <= 0.5 && (rb.height() - h).abs() <= 0.5 { Rect::new(rb.x0, rb.y0, rb.x0 + w, rb.y0 + h) } else { rb } +} + +/// An area of the layers' document that holds the areas of some of the pages drawn at one scale, +/// each starting on one of its pixels: the layers are drawn on it once for all of them, and cut +/// apart. +struct Sheet { + rect: Rect, + scale: f64, + /// Its pages, as [`PageView::regions`] counts them. + pages: Vec, + /// How many pixels its pages take. + pixels: f64, +} + +impl Sheet { + /// The move (less than half a pixel) that makes `region` start on one of the sheet's pixels, and + /// the sheet's area with it: `None` when the sheet would then be too large ([`MAX_SHEET_SIDE`], + /// [`SHEET_SPREAD`]). + fn room(&self, region: Rect) -> Option<((f64, f64), Rect)> { + let s = self.scale; + let off = |v: f64, from: f64| { + let px = (v - from) * s; + (px - px.round()) / s + }; + let (dx, dy) = (off(region.x0, self.rect.x0), off(region.y0, self.rect.y0)); + let moved = Rect::new(region.x0 - dx, region.y0 - dy, region.x1 - dx, region.y1 - dy); + let all = self.rect.union(moved); + let (w, h) = (all.width() * s, all.height() * s); + let pixels = self.pixels + moved.width() * s * moved.height() * s; + (w.is_finite() && h.is_finite() && w.max(h) <= MAX_SHEET_SIDE && w * h <= SHEET_SPREAD * pixels).then_some(((dx, dy), all)) + } +} + +/// How many documents' drawings a [`PageView`] keeps. +const KEPT_DOCUMENTS: usize = 2; + +/// The layers of one document as drawn on the pages' areas, kept between comparisons. +struct Drawn { + /// The document's layers, held so that a document changed since never shares them. Only the + /// layers are compared: the import changes the documents it compares in their layers, and two + /// documents with the same layers and other symbols, patterns, swatches or color mode would + /// share their drawings. + layers: Vec>, + /// Each page's drawing, once drawn. + pages: Vec>, +} + +impl Drawn { + /// Are these the drawings of `b`, for `n` pages? + fn of(&self, b: &Document, n: usize) -> bool { + self.pages.len() == n && self.layers.len() == b.layers.len() && self.layers.iter().zip(&b.layers).all(|(x, y)| Arc::ptr_eq(x, y)) + } +} + +/// The layers drawn on one page's area, with how they differ from the page once weighed. +struct Layered { + pixels: vectorcraft_render::Rendered, + /// [`compared`] as [`PageView::difference_on_every_page`] weighs it, without and with `reflow`. + laid: Option, + reflowed: Option, + /// [`colour_share`]. + colour: Option, +} + +/// The pages drawn once, to weigh the layers' art against them, and the layers' drawings of the last +/// two documents weighed. The layers are drawn with a renderer of their own, which keeps their decoded +/// images between draws (the page's images can share their keys and not their pixels, so the two +/// don't share one). +pub(super) struct PageView { + /// The frame's first page, then its other pages ([`Frame::more`]). + pages: Vec, + /// The areas in the layers' document that the pages are compared on, as the frame gives them. + boards: Vec, + sheets: Vec, + samples: u32, + layers: vectorcraft_render::Renderer, + /// The drawings of the last [`KEPT_DOCUMENTS`] documents weighed, the last one weighed last (the + /// import weighs two readings of a file back and forth). + drawn: Vec, + /// The drawings of the last document [`Self::weigh_without`] weighed, with their shares. + without: Vec<(usize, vectorcraft_render::Rendered, f64)>, + /// How many times the layers were drawn. + draws: usize, +} + +impl PageView { + pub(super) fn new(frame: &Frame<'_>) -> Self { + let samples = comparison_samples(frame.page); + // Each page's area, the area of the layers' document it is compared with, and its scale. + let (mut planned, mut boards, mut sheets) = (vec![], vec![], Vec::::new()); + for (ra, rb) in std::iter::once((frame.page_rect, frame.rect)).chain(frame.more.iter().copied()) { + let (scale, i) = (compare_scale(ra), planned.len()); + let region = sized((ra.width(), ra.height()), rb); + let pixels = region.width() * scale * region.height() * scale; + // A page whose drawings are resampled ([`comparison_samples`]) is drawn on its own. + // Pages of one size can have scales a rounding error apart: they take the sheet's. + let room = sheets.last().filter(|s| samples == 1 && (s.scale - scale).abs() <= scale * 1e-9).and_then(|s| s.room(region)); + let (dx, dy, scale) = match (room, sheets.last_mut()) { + (Some(((dx, dy), all)), Some(sheet)) => { + sheet.rect = all; + sheet.pixels += pixels; + sheet.pages.push(i); + (dx, dy, sheet.scale) + } + _ => { + sheets.push(Sheet { rect: region, scale, pages: vec![i], pixels }); + (0.0, 0.0, scale) + } + }; + let moved = |r: Rect| Rect::new(r.x0 - dx, r.y0 - dy, r.x1 - dx, r.y1 - dy); + planned.push((moved(ra), moved(region), scale, (dx * scale, dy * scale))); + boards.push(rb); } - let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); - differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) + // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in + // the app that wrote the file. + let mut renderer = vectorcraft_render::Renderer::new(); + let pages = planned + .into_iter() + .map(|(ra, region, scale, moved)| { + Page::on(frame.page, comparison_render(&mut renderer, frame.page, ra, scale, samples), ra, region, scale, moved) + }) + .collect(); + Self { pages, boards, sheets, samples, layers: vectorcraft_render::Renderer::new(), drawn: vec![], without: vec![], draws: 0 } } - /// How `b` on `rb` draws differently from the page ([`Self::difference`]), and the boxes (in - /// `b`'s terms) around each patch of the pixels counted in its `solid` share (they and all - /// their neighbors differ, away from type), neighbors touching corner to corner counting as one patch. `None` when the two - /// drawings aren't the same size. - pub(super) fn solid_regions(&mut self, b: &Document, rb: Rect, reflow: bool) -> Option<(Difference, Vec)> { + /// The areas in the layers' document that the pages are compared on: `rb` for the first page, + /// then each other page's artboard. + pub(super) fn regions(&self, rb: Rect) -> Vec { + std::iter::once(rb).chain(self.boards.iter().skip(1).copied()).collect() + } + + /// Leave the drawn looks in `looks` (on `region`, the first page's area in the layers' document) + /// out of the comparisons ([`Structure::restyled`](ai::Structure)), on the other pages' artboards + /// too. + pub(super) fn restyled(&mut self, looks: &[Rect], region: Rect) { + for (i, page) in self.pages.iter_mut().enumerate() { + let (s, region) = (page.scale, if i == 0 { region } else { page.region }); + page.restyled = looks + .iter() + .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) + .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) + .collect(); + } + self.drawn.clear(); + } + + /// `b`, as printed, drawn on the areas of the pages `wanted`: on their sheet when they are more + /// than one and take at least half its pixels, else each on its own. + fn draw(&mut self, b: &Document, wanted: &[usize]) -> Vec<(usize, vectorcraft_render::Rendered)> { + let mut out = vec![]; + for sheet in &self.sheets { + let on: Vec = sheet.pages.iter().copied().filter(|i| wanted.contains(i)).collect(); + let s = sheet.scale; + let taken: f64 = on.iter().filter_map(|i| self.pages.get(*i)).map(|p| p.region.width() * s * p.region.height() * s).sum(); + // A pixel more on the right and at the bottom, for the pages' areas rounded up. + let whole = (on.len() > 1 && taken * 2.0 >= sheet.rect.width() * s * sheet.rect.height() * s).then(|| { + self.draws += 1; + let r = sheet.rect; + comparison_render(&mut self.layers, b, Rect::new(r.x0, r.y0, r.x1 + 1.0 / s, r.y1 + 1.0 / s), s, 1) + }); + for i in on { + let Some(page) = self.pages.get(i) else { continue }; + let r = page.region; + let part = whole + .as_ref() + .zip(vectorcraft_render::raster_size(r, s).ok()) + .and_then(|(whole, (w, h))| cut(whole, (r.x0 - sheet.rect.x0) * s, (r.y0 - sheet.rect.y0) * s, w, h)); + let drawn = match part { + Some(part) => part, + None => { + self.draws += 1; + comparison_render(&mut self.layers, b, r, page.scale, self.samples) + } + }; + out.push((i, drawn)); + } + } + out + } + + /// Have the layers of `b` drawn on the pages `wanted`, keeping those already drawn when `b` is the + /// document last drawn. + fn layered(&mut self, b: &Document, wanted: &[usize]) { + let n = self.pages.len(); + match self.drawn.iter().position(|d| d.of(b, n)) { + Some(at) => { + let kept = self.drawn.remove(at); + self.drawn.push(kept); + } + None => { + if self.drawn.len() >= KEPT_DOCUMENTS { + self.drawn.remove(0); + } + self.drawn.push(Drawn { layers: b.layers.clone(), pages: (0..n).map(|_| None).collect() }); + } + } + let missing: Vec = + wanted.iter().copied().filter(|i| self.drawn.last().and_then(|d| d.pages.get(*i)).is_some_and(Option::is_none)).collect(); + if missing.is_empty() { + return; + } + // The page doesn't print what isn't printed. + let printed = as_printed(b); + for (i, pixels) in self.draw(printed.as_ref().unwrap_or(b), &missing) { + if let Some(slot) = self.drawn.last_mut().and_then(|d| d.pages.get_mut(i)) { + *slot = Some(Layered { pixels, laid: None, reflowed: None, colour: None }); + } + } + } + + /// How `b` draws differently from each page ([`compared`]), the first on `rb` (its frame's area, + /// or another rectangle its size), the others on their artboards. + pub(super) fn differences(&mut self, b: &Document, rb: Rect, reflow: bool) -> Vec { + let all: Vec = (0..self.pages.len()).collect(); + self.layered(b, &all); + let printed = as_printed(b); + let printed = printed.as_ref().unwrap_or(b); + let mut out = vec![]; + for i in all { + let d = if i == 0 && self.boards.first() != Some(&rb) { self.elsewhere(printed, rb, reflow) } else { self.weighed(i, printed, reflow) }; + out.push(d); + } + out + } + + /// The first page compared with `b` (as printed) on `rb`, drawn for it alone. + fn elsewhere(&mut self, b: &Document, rb: Rect, reflow: bool) -> Difference { + let Some(page) = self.pages.first() else { return Difference::ALL }; + let rb = sized(page.size, rb); + self.draws += 1; + let y = comparison_render(&mut self.layers, b, rb, page.scale, self.samples); + compared(&page.drawn, &y, &page.counted_type(b, rb, reflow), &page.skipped()) + } + + /// Page `i` compared with its drawing of `b` (as printed, already drawn), weighed once. + fn weighed(&mut self, i: usize, b: &Document, reflow: bool) -> Difference { + let Some((page, Some(layered))) = self.pages.get(i).zip(self.drawn.last_mut().and_then(|d| d.pages.get_mut(i))) else { + return Difference::ALL; + }; + let memo = if reflow { &mut layered.reflowed } else { &mut layered.laid }; + *memo.get_or_insert_with(|| compared(&page.drawn, &layered.pixels, &page.counted_type(b, page.region, reflow), &page.skipped())) + } + + /// [`Self::differences`] taken together: each share is that of the page that differs most. + pub(super) fn difference_on_every_page(&mut self, b: &Document, rb: Rect, reflow: bool) -> Difference { + self.differences(b, rb, reflow).into_iter().reduce(Difference::max).unwrap_or(Difference::ALL) + } + + /// How `b` draws differently from the pages ([`Self::difference_on_every_page`]), and the boxes + /// (in `b`'s terms) around each patch of the pixels counted in its `solid` share (they and all + /// their neighbors differ, away from type), neighbors touching corner to corner counting as one + /// patch. `None` when the two drawings of a page aren't the same size. + pub(super) fn solid_regions_on_every_page(&mut self, b: &Document, reflow: bool) -> Option<(Difference, Vec)> { + let all: Vec = (0..self.pages.len()).collect(); + self.layered(b, &all); let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - let areas = self.counted_type(b, rb, reflow); - let (diff, mut solid) = compared_with_solid(&self.page, &y, &areas, &self.restyled); - let (w, h) = (y.width as usize, y.height as usize); - if solid.len() != w * h { - return None; - } - // A pixel's size in points: [`comparison_render`] draws at `scale * samples`, rounds that - // raster to whole pixels and resizes it to `w` × `h`, so the two sizes can round differently. - let k = f64::from(self.samples); - let drawn = vectorcraft_render::raster_size(rb, self.scale * k).map_or((w as f64, h as f64), |(dw, dh)| (f64::from(dw), f64::from(dh))); - let pitch = (drawn.0 / (w as f64 * k * self.scale), drawn.1 / (h as f64 * k * self.scale)); - let mut regions = vec![]; - let mut stack = vec![]; - for start in 0..solid.len() { - if !solid.get(start).copied().unwrap_or(false) { - continue; - } - // The patch's pixels are cleared as they are taken, so each is taken once. - if let Some(s) = solid.get_mut(start) { - *s = false; - } - stack.push(start); - let (mut i0, mut j0, mut i1, mut j1) = (start % w, start / w, start % w, start / w); - while let Some(k) = stack.pop() { - let (i, j) = (k % w, k / w); - (i0, j0, i1, j1) = (i0.min(i), j0.min(j), i1.max(i), j1.max(j)); - for n in j.saturating_sub(1)..=(j + 1).min(h - 1) { - for m in i.saturating_sub(1)..=(i + 1).min(w - 1) { - if let Some(s) = solid.get_mut(n * w + m).filter(|s| **s) { - *s = false; - stack.push(n * w + m); - } - } - } - } - let at = |i: usize, j: usize| (rb.x0 + i as f64 * pitch.0, rb.y0 + j as f64 * pitch.1); - let ((x0, y0), (x1, y1)) = (at(i0, j0), at(i1 + 1, j1 + 1)); - regions.push(Rect::new(x0, y0, x1, y1)); + let (mut diff, mut regions) = (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![]); + for (page, layered) in self.pages.iter().zip(&self.drawn.last()?.pages) { + let y = &layered.as_ref()?.pixels; + let (d, solid) = compared_with_solid(&page.drawn, y, &page.counted_type(b, page.region, reflow), &page.skipped()); + regions.extend(patches(solid, y.width as usize, y.height as usize, page.region, page.scale, self.samples)?); + diff = diff.max(d); } Some((diff, regions)) } + + /// The share (0 to 1) of the pixels where `b` on page `i`'s area draws another color than the + /// page ([`colour_share`]). + fn colour_difference(&mut self, i: usize, b: &Document) -> f64 { + self.layered(b, &[i]); + let Some((page, Some(layered))) = self.pages.get(i).zip(self.drawn.last_mut().and_then(|d| d.pages.get_mut(i))) else { return 1.0 }; + *layered.colour.get_or_insert_with(|| colour_share(&page.drawn, &layered.pixels, &page.edges)) + } + + /// `doc`'s and `without`'s shares ([`colour_share`]) on each of the pages `on`, `without` being + /// `doc` with an object taken out that lies on those pages only. `without`'s drawings are kept + /// for [`Self::adopt`]. + pub(super) fn weigh_without(&mut self, doc: &Document, without: &Document, on: &[usize]) -> Vec<(usize, f64, f64)> { + self.layered(doc, on); + let printed = as_printed(without); + let drawn = self.draw(printed.as_ref().unwrap_or(without), on); + let mut shares = vec![]; + self.without.clear(); + for (i, pixels) in drawn { + let before = self.colour_difference(i, doc); + let Some(page) = self.pages.get(i) else { continue }; + let after = colour_share(&page.drawn, &pixels, &page.edges); + shares.push((i, before, after)); + self.without.push((i, pixels, after)); + } + shares + } + + /// `doc` now has the object [`Self::weigh_without`] last took out taken out: its drawings are the + /// ones made without the object on the pages it lay on, and the ones kept on the others. + pub(super) fn adopt(&mut self, doc: &Document) { + let without = std::mem::take(&mut self.without); + let Some(drawn) = self.drawn.last_mut() else { return }; + drawn.layers = doc.layers.clone(); + // The type areas on a page can reach past the object's box. + for layered in drawn.pages.iter_mut().flatten() { + (layered.laid, layered.reflowed) = (None, None); + } + for (i, pixels, colour) in without { + if let Some(slot) = drawn.pages.get_mut(i) { + *slot = Some(Layered { pixels, laid: None, reflowed: None, colour: Some(colour) }); + } + } + } +} + +/// The `w` × `h` pixels of `from` from pixel (`x`, `y`): `None` unless they lie in it, from a pixel. +fn cut(from: &vectorcraft_render::Rendered, x: f64, y: f64, w: u32, h: u32) -> Option { + let (rx, ry) = (x.round(), y.round()); + if !((x - rx).abs() < 0.01 && (y - ry).abs() < 0.01 && rx >= 0.0 && ry >= 0.0) { + return None; + } + let (x, y, wu, hu) = (rx as usize, ry as usize, w as usize, h as usize); + let stride = from.width as usize * 4; + if x + wu > from.width as usize || y + hu > from.height as usize { + return None; + } + let mut pixels = Vec::with_capacity(wu * hu * 4); + for row in y..y + hu { + pixels.extend_from_slice(from.pixels.get(row * stride + x * 4..row * stride + (x + wu) * 4)?); + } + Some(vectorcraft_render::Rendered { width: w, height: h, pixels }) +} + +/// The boxes (in the terms of the document drawn on `rb` at `scale` with `samples` × `samples` +/// samples a pixel, [`comparison_render`]) around each patch of the `w` × `h` pixels `solid` marks, +/// neighbors touching corner to corner counting as one patch. `None` when `solid` doesn't have that +/// many pixels. +fn patches(mut solid: Vec, w: usize, h: usize, rb: Rect, scale: f64, samples: u32) -> Option> { + if solid.len() != w * h { + return None; + } + // A pixel's size in points: [`comparison_render`] draws at `scale * samples`, rounds that + // raster to whole pixels and resizes it to `w` × `h`, so the two sizes can round differently. + let k = f64::from(samples); + let drawn = vectorcraft_render::raster_size(rb, scale * k).map_or((w as f64, h as f64), |(dw, dh)| (f64::from(dw), f64::from(dh))); + let pitch = (drawn.0 / (w as f64 * k * scale), drawn.1 / (h as f64 * k * scale)); + let mut regions = vec![]; + let mut stack = vec![]; + for start in 0..solid.len() { + if !solid.get(start).copied().unwrap_or(false) { + continue; + } + // The patch's pixels are cleared as they are taken, so each is taken once. + if let Some(s) = solid.get_mut(start) { + *s = false; + } + stack.push(start); + let (mut i0, mut j0, mut i1, mut j1) = (start % w, start / w, start % w, start / w); + while let Some(k) = stack.pop() { + let (i, j) = (k % w, k / w); + (i0, j0, i1, j1) = (i0.min(i), j0.min(j), i1.max(i), j1.max(j)); + for n in j.saturating_sub(1)..=(j + 1).min(h - 1) { + for m in i.saturating_sub(1)..=(i + 1).min(w - 1) { + if let Some(s) = solid.get_mut(n * w + m).filter(|s| **s) { + *s = false; + stack.push(n * w + m); + } + } + } + } + let at = |i: usize, j: usize| (rb.x0 + i as f64 * pitch.0, rb.y0 + j as f64 * pitch.1); + let ((x0, y0), (x1, y1)) = (at(i0, j0), at(i1 + 1, j1 + 1)); + regions.push(Rect::new(x0, y0, x1, y1)); + } + Some(regions) +} + +/// The share (0 to 1) of the pixels where `y` draws another color than the page `x` +/// ([`differ_in_colour`]), type included and pixels in `skip` counting as alike: what taking an +/// object out of the layers changes there, when it is covered by one of about the same lightness +/// (#1102). +fn colour_share(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, skip: &[Rect]) -> f64 { + if (y.width, y.height) != (x.width, x.height) { + return 1.0; + } + let skipped = in_areas(skip, x.width as usize, x.height as usize); + let differing = x + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(y.pixels.as_chunks::<4>().0) + .zip(&skipped) + .filter(|((p, q), s)| !**s && differ_in_colour(p, q)) + .count(); + differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) } /// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), @@ -296,32 +674,33 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: /// the drawings can't be compared. fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec) { if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { - return (Difference { any: 1.0, art: 1.0, solid: 1.0 }, vec![]); + return (Difference { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }, vec![]); } let (w, h) = (x.width as usize, x.height as usize); // Pixels in `skip` count as alike. let skipped = in_areas(skip, w, h); let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); - let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); + if !differs.contains(&true) { + return (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![false; w * h]); + } let typed = in_areas(areas, w, h); let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); + // Which pixels differ with their left and right neighbors. + let mut across = vec![false; w * h]; + for (k, a) in across.iter_mut().enumerate() { + let i = k % w; + *a = i > 0 && i + 1 < w && differs.get(k - 1..=k + 1).is_some_and(|d| d.iter().all(|d| *d)); + } // A pixel on the region's edge has neighbours outside it, which don't count as differing. let mut solid = vec![false; w * h]; - for (i, j) in (1..h.saturating_sub(1)).flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) { - if !typed.get(j * w + i).copied().unwrap_or(true) - && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))) - && let Some(s) = solid.get_mut(j * w + i) - { - *s = true; - } + for (k, s) in solid.iter_mut().enumerate().skip(w).take(w * h.saturating_sub(2)) { + *s = !typed.get(k).copied().unwrap_or(true) && [k - w, k, k + w].iter().all(|n| across.get(*n).copied().unwrap_or(false)); } let area = (w as f64 * h as f64).max(1.0); - let shares = Difference { - any: differs.iter().filter(|d| **d).count() as f64 / area, - art: art as f64 / area, - solid: solid.iter().filter(|s| **s).count() as f64 / area, - }; + let any = differs.iter().filter(|d| **d).count() as f64 / area; + let art = art as f64 / area; + let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art }; (shares, solid) } @@ -371,14 +750,16 @@ impl Shares { pub(super) fn of(marks: &[bool], w: usize, h: usize) -> Self { let stride = w + 1; let mut sums = vec![0u32; stride * (h + 1)]; - for (k, marked) in marks.iter().enumerate().take(w * h) { - let (i, j) = (k % w.max(1), k / w.max(1)); - let left = sums.get((j + 1) * stride + i).copied().unwrap_or(0); - let up = sums.get(j * stride + i + 1).copied().unwrap_or(0); - let corner = sums.get(j * stride + i).copied().unwrap_or(0); - if let Some(s) = sums.get_mut((j + 1) * stride + i + 1) { - *s = (left + up + u32::from(*marked)).saturating_sub(corner); + // Row by row: the marked pixels left of each corner in its row, added to the row above's. + let mut above = vec![0u32; stride]; + for (j, out) in sums.chunks_exact_mut(stride).skip(1).enumerate() { + let row = marks.get(j * w..).unwrap_or(&[]).iter().copied().chain(std::iter::repeat(false)).take(w); + let mut run = 0u32; + for ((marked, s), up) in row.zip(out.iter_mut().skip(1)).zip(above.iter().skip(1)) { + run += u32::from(marked); + *s = up + run; } + above.copy_from_slice(out); } Self { sums, w, h } } @@ -386,8 +767,7 @@ impl Shares { /// The share (0 to 1) of `r`'s pixels that are marked: 0 for one with none of the pixels. pub(super) fn share(&self, r: &Rect) -> f64 { let (w, h, stride) = (self.w, self.h, self.w + 1); - let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; - let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + let (x0, y0, x1, y1) = pixel_box(r, w, h); if !r.is_finite() || x1 <= x0 || y1 <= y0 { return 0.0; } @@ -397,35 +777,78 @@ impl Shares { } } +/// The pixels `r` touches of `w` × `h` (first and last column and row, the last ones excluded). +fn pixel_box(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) { + let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; + (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)) +} + +/// [`Shares::share`] of each of `rects` for the `w` × `h` pixels `marks` marks: counted one by one +/// when the rectangles hold fewer pixels in all than there are, else with [`Shares`]. +fn shares(marks: &[bool], w: usize, h: usize, rects: &[Rect]) -> Vec { + let few = rects + .iter() + .map(|r| pixel_box(r, w, h)) + .try_fold(0usize, |n, (x0, y0, x1, y1)| n.checked_add(x1.saturating_sub(x0).checked_mul(y1.saturating_sub(y0))?)); + if !few.is_some_and(|n| n <= w * h) { + let counted = Shares::of(marks, w, h); + return rects.iter().map(|r| counted.share(r)).collect(); + } + let share = |r: &Rect| { + let (x0, y0, x1, y1) = pixel_box(r, w, h); + if !r.is_finite() || x1 <= x0 || y1 <= y0 { + return 0.0; + } + let inside: usize = (y0..y1).map(|y| marks.get(y * w + x0..y * w + x1).map_or(0, |row| row.iter().filter(|m| **m).count())).sum(); + inside as f64 / ((x1 - x0) as f64 * (y1 - y0) as f64) + }; + rects.iter().map(share).collect() +} + /// Which of `w` × `h` pixels (row by row) lie in one of `areas`. fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { + if areas.is_empty() { + return vec![false; w * h]; + } + let pixels: Vec<(usize, usize, usize, usize)> = areas.iter().map(|r| pixel_box(r, w, h)).collect(); + // Areas that cover fewer pixels in all than there are are marked one by one. + let covered = + pixels.iter().try_fold(0usize, |n, &(x0, y0, x1, y1)| if x1 < x0 || y1 < y0 { None } else { n.checked_add((x1 - x0).checked_mul(y1 - y0)?) }); + if covered.is_some_and(|n| n <= w * h) { + let mut inside = vec![false; w * h]; + for (x0, y0, x1, y1) in pixels { + for y in y0..y1 { + if let Some(row) = inside.get_mut(y * w + x0..y * w + x1) { + row.fill(true); + } + } + } + return inside; + } // Each area adds one inside it: corners marked, then summed along the rows and down the columns. let stride = w + 1; let mut count = vec![0i64; stride * (h + 1)]; - let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; - for r in areas { - let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + for (x0, y0, x1, y1) in pixels { for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { if let Some(c) = count.get_mut(y * stride + x) { *c += d; } } } - for row in count.chunks_mut(stride) { - let mut run = 0; - for c in row { - run += *c; - *c = run; - } + let mut inside = vec![false; w * h]; + if w == 0 { + return inside; } let mut column = vec![0i64; stride]; - for row in count.chunks_mut(stride) { - for (c, sum) in row.iter_mut().zip(column.iter_mut()) { - *sum += *c; - *c = *sum; + for (row, out) in count.chunks_exact(stride).zip(inside.chunks_exact_mut(w)) { + let mut run = 0; + for ((c, sum), o) in row.iter().zip(column.iter_mut()).zip(out) { + run += *c; + *sum += run; + *o = *sum > 0; } } - (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() + inside } /// The least share of a text object's area (with the margin round it) where the page draws anything @@ -447,13 +870,14 @@ pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001; const PAST_THE_PAGE: f64 = 1.0; /// Do the layers' art and the page's own look alike (where the page holds all the art that prints, -/// also round it)? An error says they don't; `Ok` has the notes to give when they differ a little: -/// with the share of the pixels that differ away from type when that is notable, otherwise with the -/// share of those that differ in the type's areas. With `type_reflow` (all the type laid out again -/// from the file's text, which may show in another font), type that differs doesn't make them -/// differ: only art away from type does, and the type's share is a note of its own. +/// also round it), on every page the frame has ([`Frame::more`])? An error says they don't; `Ok` has +/// the notes to give when they differ a little: with the share of the pixels that differ away from +/// type when that is notable, otherwise with the share of those that differ in the type's areas, on +/// the page that differs most. With `type_reflow` (all the type laid out again from the file's text, +/// which may show in another font), type that differs doesn't make them differ: only art away from +/// type does, and the type's share is a note of its own. pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document, type_reflow: bool) -> Result, String> { - let mut diff = view.difference(layered, frame.rect, type_reflow); + let mut diff = view.difference_on_every_page(layered, frame.rect, type_reflow); if frame.whole && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) @@ -461,8 +885,7 @@ pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document // The page and the art the layers print beyond it, each in its own document's place. let all = frame.rect.union(art); let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); - let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); - diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; + diff = diff.max(difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all)); } if diff.weighed(type_reflow) > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.weighed(type_reflow) * 100.0)); @@ -476,12 +899,189 @@ pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document ); let typed = format!( "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", - (diff.any - diff.art) * 100.0 + diff.typed * 100.0 ); let art_notable = diff.art > NOTABLE_DIFFERENCE; - Ok(match (art_notable, type_reflow && diff.any - diff.art > NOTABLE_DIFFERENCE) { + Ok(match (art_notable, type_reflow && diff.typed > NOTABLE_DIFFERENCE) { (true, true) => vec![art, typed], (true, false) => vec![art], (false, _) => vec![typed], }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// One page that differs only in its type and another that differs only in its art keep both + /// shares when they are taken together. + #[test] + fn the_type_share_of_one_page_stays_beside_the_art_share_of_another() { + let typed = Difference { any: 0.03, art: 0.0, solid: 0.0, typed: 0.03 }; + let art = Difference { any: 0.04, art: 0.04, solid: 0.0, typed: 0.0 }; + let both = typed.max(art); + assert_eq!((both.any, both.art, both.typed), (0.04, 0.04, 0.03)); + } + + /// Shares and areas counted pixel by pixel match those read from sums. + #[test] + fn pixels_counted_one_by_one_match_the_sums() { + let (w, h) = (37, 23); + let marks: Vec = (0..w * h).map(|k| (k * 7 + k / 5) % 3 == 0).collect(); + let rects = + [Rect::new(-2.0, 3.5, 10.2, 9.0), Rect::new(5.0, 5.0, 12.0, 12.0), Rect::new(3.0, 3.0, 3.0, 8.0), Rect::new(10.5, 2.2, 20.7, 22.9)]; + // 66 + 49 + 0 + 231 pixels, fewer than the 851 there are: counted one by one. + let held: usize = rects.iter().map(|r| pixel_box(r, w, h)).map(|(x0, y0, x1, y1)| x1.saturating_sub(x0) * y1.saturating_sub(y0)).sum(); + assert_eq!(held, 346); + let summed = Shares::of(&marks, w, h); + let one_by_one = shares(&marks, w, h, &rects); + assert_eq!(one_by_one, rects.iter().map(|r| summed.share(r)).collect::>()); + // So many rectangles that they hold more pixels than there are: read from sums. + let many: Vec = rects.iter().cycle().take(40).copied().collect(); + assert_eq!(shares(&marks, w, h, &many)[..4], one_by_one[..]); + assert_eq!(in_areas(&many, w, h), in_areas(&rects, w, h)); + assert!(in_areas(&rects, w, h).contains(&true)); + } + + /// A document with a 40-point gray square in each of `n` 100-point areas side by side, `gap` + /// apart (the one in area `nudged` 20 points to the right), the areas, and the squares' ids. + fn squares(n: usize, gap: f64, nudged: Option) -> (Document, Vec, Vec) { + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let (mut areas, mut ids) = (vec![], vec![]); + for i in 0..n { + let x = i as f64 * (100.0 + gap); + areas.push(Rect::new(x, 0.0, x + 100.0, 100.0)); + let x = if nudged == Some(i) { x + 20.0 } else { x }; + ids.push(square(&mut doc, layer, Rect::new(x + 30.0, 30.0, x + 70.0, 70.0))); + } + (doc, areas, ids) + } + + /// A gray square on `rect` added to `layer` of `doc`: its id. + fn square(doc: &mut Document, layer: vectorcraft_doc::NodeId, rect: Rect) -> vectorcraft_doc::NodeId { + filled(doc, layer, rect, 0.5) + } + + /// A rectangle on `rect` filled with `k` black added to `layer` of `doc`: its id. + fn filled(doc: &mut Document, layer: vectorcraft_doc::NodeId, rect: Rect, k: f32) -> vectorcraft_doc::NodeId { + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, k)); + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let node = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(rect), appearance); + let id = node.id; + doc.insert(Some(layer), 0, node).unwrap(); + id + } + + /// A frame of `page`'s `areas`, each its own artboard. + fn frame_of<'a>(page: &'a Document, areas: &[Rect]) -> Frame<'a> { + let first = areas[0]; + Frame { page, page_rect: first, rect: first, whole: false, pages: vec![], more: areas[1..].iter().map(|r| (*r, *r)).collect() } + } + + /// The layers are drawn once for all the pages of a sheet, a fraction of a pixel apart as they + /// are, and the drawings are kept until the document changes. + #[test] + fn the_layers_are_drawn_once_for_the_pages_and_kept() { + let (doc, areas, _) = squares(4, 20.3, None); + let frame = frame_of(&doc, &areas); + let mut view = PageView::new(&frame); + assert_eq!(view.sheets.len(), 1); + let each = view.differences(&doc, areas[0], false); + assert_eq!(each.len(), 4); + assert!(each.iter().all(|d| d.any == 0.0), "{:?}", each.iter().map(|d| d.any).collect::>()); + assert_eq!(view.draws, 1, "one drawing for the four pages"); + assert!(compare(&frame, &mut view, &doc, false).is_ok()); + assert_eq!(view.difference_on_every_page(&doc, areas[0], true).any, 0.0); + assert_eq!(view.draws, 1, "the same document isn't drawn again"); + let (nudged, _, _) = squares(4, 20.3, Some(3)); + let each = view.differences(&nudged, areas[0], false); + assert_eq!(view.draws, 2); + assert!(each[3].solid > MAX_SOLID_DIFFERENCE && each[..3].iter().all(|d| d.any == 0.0)); + } + + /// Taking an object out of the layers has only the pages it lies on drawn again; the other + /// pages' drawings are kept for the document without it. + #[test] + fn a_removal_draws_again_only_the_pages_it_lies_on() { + let (page, areas, _) = squares(4, 20.3, None); + let (mut layers, _, _) = squares(4, 20.3, None); + let layer = layers.layers[0].id; + let extra = square(&mut layers, layer, Rect::new(2.0 * 120.3 + 5.0, 5.0, 2.0 * 120.3 + 25.0, 25.0)); + let frame = frame_of(&page, &areas); + let mut view = PageView::new(&frame); + let before = view.differences(&layers, areas[0], false); + assert!(before[2].any > 0.0); + let mut without = layers.clone(); + without.node_mut(extra).unwrap().visible = false; + let shares = view.weigh_without(&layers, &without, &[2]); + assert_eq!(view.draws, 2, "only the third page is drawn without it"); + assert!(shares.len() == 1 && shares[0].2 < shares[0].1, "{shares:?}"); + view.adopt(&without); + let after = view.differences(&without, areas[0], false); + assert_eq!(view.draws, 2, "the other pages' drawings are kept"); + let again = PageView::new(&frame).differences(&without, areas[0], false); + for (a, b) in after.iter().zip(&again) { + assert!((a.any - b.any).abs() < 1e-3 && (a.solid - b.solid).abs() < 1e-3); + } + assert_eq!(after[2].any, 0.0); + } + + /// Pages far apart are drawn on sheets of their own, and a long row of them on sheets no longer + /// than the limit. + #[test] + fn sheets_stay_within_their_limits() { + let (doc, areas, _) = squares(3, 5000.0, None); + assert_eq!(PageView::new(&frame_of(&doc, &areas)).sheets.len(), 3); + let (doc, areas, _) = squares(40, 0.0, None); + let frame = frame_of(&doc, &areas); + let mut view = PageView::new(&frame); + assert!(view.sheets.len() > 1); + assert!(view.sheets.iter().all(|s| s.rect.width() * s.scale <= MAX_SHEET_SIDE)); + assert!(view.differences(&doc, areas[0], false).iter().all(|d| d.any == 0.0)); + assert_eq!(view.draws, view.sheets.len()); + let before = view.draws; + assert_eq!(view.draw(&doc, &[0, 1]).len(), 2); + assert_eq!(view.draws, before + 2, "two pages of a long sheet are drawn on their own"); + } + + /// A page moved by a fraction of a pixel onto its sheet doesn't count the column its move took + /// off its artboard, where the layers have art past the artboard before it and the page has none. + #[test] + fn the_column_a_page_moved_onto_doesnt_count() { + // The second area starts 400.4 pixels from the first at 4 pixels a point: it moves 0.4 pixels + // to the left, and its first column starts at 100 points. + let areas = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.1, 0.0, 200.1, 100.0)]; + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + filled(&mut page, layer, areas[0], 1.0); + let mut layers = page.clone(); + let layer = layers.layers[0].id; + // Black reaching past the first artboard, into the space between the two. + filled(&mut layers, layer, Rect::new(99.0, 0.0, 100.1, 100.0), 1.0); + let frame = frame_of(&page, &areas); + let mut view = PageView::new(&frame); + assert_eq!(view.sheets.len(), 1); + let each = view.differences(&layers, areas[0], false); + assert!(each.iter().all(|d| d.any == 0.0), "{:?}", each.iter().map(|d| d.any).collect::>()); + assert_eq!(compare(&frame, &mut view, &layers, false), Ok(vec![])); + assert_eq!(view.colour_difference(1, &layers), 0.0); + } + + /// The drawings of two documents weighed in turn are both kept. + #[test] + fn the_drawings_of_two_documents_are_kept() { + let (doc, areas, _) = squares(4, 20.3, None); + let (other, _, _) = squares(4, 20.3, Some(1)); + let frame = frame_of(&doc, &areas); + let mut view = PageView::new(&frame); + for _ in 0..3 { + view.differences(&doc, areas[0], false); + view.differences(&other, areas[0], false); + } + assert_eq!(view.draws, 2, "each document is drawn once"); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 9f7a8f4e7..dd1848266 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1580,6 +1580,197 @@ fn a_pdf_of_its_second_artboard_is_compared_on_that_artboard() { assert!((e - 190.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the second artboard: {:?}", texts[0].xf); } +/// Two 100-point artboards, the second 50 below the first (art space, y up), as the editing data +/// lists them. +const TWO_BOARDS: &str = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + +/// The PDF part of a file with [`TWO_BOARDS`]: a page for each artboard in a row, the first with a +/// square at (10, 86), and the second with one at (`x`, `y`) of its own when given. +fn two_pages(second: Option<(f64, f64)>) -> Document { + let mut page = pdf_part(10.0, 86.0); + let mut board = page.artboards[0].clone(); + (board.id, board.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + page.artboards.push(board); + if let Some((x, y)) = second { + let other = pdf_part(120.0 + x, y); + let square = page.reid(&other.layers[0].children().unwrap()[0]); + let layer = page.layers[0].id; + page.insert(Some(layer), 1, square).unwrap(); + } + page +} + +/// The layers are compared with every page: art that they draw on the second artboard and its page +/// doesn't sends the file to its PDF part, as it would on the first. +#[test] +fn art_only_the_layers_draw_on_the_second_artboard_makes_the_file_come_in_as_its_pdf_part() { + // A 60-point square on the second artboard, 20 from its left and 20 down. + let big = "0 0 1 0 k\n20 -130 m\n80 -130 L\n80 -70 L\n20 -70 L\nf"; + let art = native(&(TWO_BOARDS.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Second", true, big))); + let (d, w, read) = crate::layered_ai(&private(&art), two_pages(None), vec![], false, false, 1.0); + assert!(!read, "{w:?}"); + assert_ne!(names(&d), ["Live", "Second"], "{w:?}"); + assert!(w.iter().any(|w| w.contains("differs from the page's")), "{w:?}"); + // The same square on the first artboard, which its page doesn't draw either. + let big = "0 0 1 0 k\n20 20 m\n80 20 L\n80 80 L\n20 80 L\nf"; + let art = native(&(TWO_BOARDS.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Second", true, big))); + let (_, w, read) = crate::layered_ai(&private(&art), two_pages(None), vec![], false, false, 1.0); + assert!(!read && w.iter().any(|w| w.contains("differs from the page's")), "{w:?}"); +} + +/// Art that the second page draws and the layers don't sends the file to its PDF part too. +#[test] +fn art_only_the_second_page_draws_makes_the_file_come_in_as_its_pdf_part() { + let art = native(&(TWO_BOARDS.to_string() + &layer("Live", true, &square(10, 10)))); + // A 60-point square on the second page, 20 from its left and 20 down. + let mut page = two_pages(None); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(140.0, 20.0, 200.0, 80.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let node = Node::path(page.alloc_id(), path, appearance); + let layer_id = page.layers[0].id; + page.insert(Some(layer_id), 1, node).unwrap(); + let (_, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{w:?}"); + assert!(w.iter().any(|w| w.contains("differs from the page's")), "{w:?}"); +} + +/// A PDF saved with some of the artboards, not starting with the first, can hold any of the +/// others after it: its later pages aren't compared with the artboards that follow the first. +#[test] +fn the_later_pages_of_a_pdf_of_some_artboards_are_not_compared() { + // Four 100-point artboards side by side, 50 apart (art space, y up), a 4-point square on the + // first, second and fourth, and a 60-point square on the third. + let board = |x0: i32| { + format!( + "%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x0 + 100 + ) + }; + let boards = format!( + "%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", + board(0), + board(150), + board(300), + board(450) + ); + let big = "0 0 1 0 k\n320 20 m\n380 20 L\n380 80 L\n320 80 L\nf"; + let art = native(&(boards + &layer("Art", true, &(square(10, 10) + "\n" + &square(180, 50) + "\n" + big + "\n" + &square(460, 30))))); + // The PDF part: a page for the second artboard, then one for the fourth. + let mut pdf = pdf_part(30.0, 46.0); + let mut second = pdf.artboards[0].clone(); + (second.id, second.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(second); + let other = pdf_part(130.0, 66.0); + let fourth = pdf.reid(&other.layers[0].children().unwrap()[0]); + let layer_id = pdf.layers[0].id; + pdf.insert(Some(layer_id), 1, fourth).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 4, "{w:?}"); +} + +/// Layers that draw each page's art on its artboard come in. +#[test] +fn layers_that_match_every_page_come_in() { + // A square on the second artboard, 50 from its left and 46 down, which its page draws too. + let second = "0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf"; + let art = native(&(TWO_BOARDS.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Second", true, second))); + let (d, w, read) = crate::layered_ai(&private(&art), two_pages(Some((50.0, 46.0))), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Live", "Second"], "{w:?}"); + assert!(w.is_empty(), "{w:?}"); +} + +/// Layers that draw each page's art come in from [`TWO_BOARDS`] changed to `boards`. +fn two_boards_come_in(boards: String) { + let second = "0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf"; + let art = native(&(boards + &layer("Live", true, &square(10, 10)) + &layer("Second", true, second))); + let (d, w, read) = crate::layered_ai(&private(&art), two_pages(Some((50.0, 46.0))), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Live", "Second"], "{w:?}"); +} + +/// A first artboard up to half a point larger than its page is compared on an area of the page's +/// size. +#[test] +fn a_first_artboard_a_little_wider_than_its_page_is_compared_on_the_pages_size() { + two_boards_come_in(TWO_BOARDS.replace("%_100 0 /", "%_100.3 0 /")); +} + +/// A later artboard up to half a point smaller than its page is compared on an area of the page's +/// size. +#[test] +fn a_second_artboard_a_little_shorter_than_its_page_is_compared_on_the_pages_size() { + two_boards_come_in(TWO_BOARDS.replace("%_100 -150", "%_100 -149.7")); +} + +/// A text object the second page doesn't draw is left out even when the first page differs by more +/// than the comparison allows. +#[test] +fn an_unseen_text_object_on_the_second_page_is_weighed_while_the_first_page_differs() { + let doc = text_document(&[("Ghost\r", 0, (8200.0, 8330.0), (0.0, 0.0), 0.0)]); + let big = "0 0 1 0 k\n20 20 m\n80 20 L\n80 80 L\n20 80 L\nf"; + let art = native_with_text(&(TWO_BOARDS.to_string() + &layer("Art", true, &(square(10, 10) + "\n" + big + "\n" + &text_object(0)))), &doc); + // The first page draws the 60-point square in black. + let mut page = two_pages(None); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 20.0, 80.0, 80.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let node = Node::path(page.alloc_id(), path, appearance); + let layer_id = page.layers[0].id; + page.insert(Some(layer_id), 1, node).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let mut texts = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push(t.plain_text()); + } + }); + assert!(texts.is_empty(), "{w:?}"); + assert!(w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); +} + +/// A text object on the second artboard that its page doesn't draw is weighed as one on the first +/// is: it is taken out, unless its layer doesn't print. +#[test] +fn a_text_object_the_second_page_doesnt_draw_is_left_out_unless_its_layer_doesnt_print() { + // "Ghost" 38.5 down the second artboard (150 below the first's anchor in the canvas). + let doc = text_document(&[("Ghost\r", 0, (8200.0, 8330.0), (0.0, 0.0), 0.0)]); + let all_text = |d: &Document| { + let mut all = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + all + }; + let art = native_with_text(&(TWO_BOARDS.to_string() + &layer("Art", true, &(square(10, 10) + "\n" + &text_object(0)))), &doc); + let (d, w, read) = crate::layered_ai(&private(&art), two_pages(None), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert!(all_text(&d).is_empty(), "{w:?}"); + assert!(w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); + // On a layer that shows and doesn't print, it stays. + let tips = layer("Tips", true, &text_object(0)).replace("1 1 1 1 0 0 1 0 79", "1 1 1 0 0 0 1 0 79"); + let art = native_with_text(&(TWO_BOARDS.to_string() + &layer("Art", true, &square(10, 10)) + &tips), &doc); + let (d, w, read) = crate::layered_ai(&private(&art), two_pages(None), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Art", "Tips"], "{w:?}"); + assert_eq!(all_text(&d), ["Ghost"], "{w:?}"); + assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); +} + #[test] fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { let pdf = pdf_part(10.0, 86.0); diff --git a/docs/mcp.md b/docs/mcp.md index 5dc19c665..3f2b9333e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -772,9 +772,14 @@ layer, even where the page doesn't draw it. A file whose editing data can't be d pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain groups open ungrouped), with a warning saying why. Layers look different when they draw an object the page doesn't (or -miss one it draws), and, for an EPS whose page is the box of its art, when they print art outside it. A file whose -editing data is for a page of another size opens the same way, and its warning gives both sizes in points, with the -scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion. +miss one it draws), and, for an EPS whose page is the box of its art, when they print art outside it. When the PDF +content of a `.ai` has a page for each artboard, the layers are compared with each page on its artboard, until the +pages compared take 8,388,608 pixels at the comparison's scale (about 67 letter-size pages, or 4 when the PDF content +is only images) or number 128; when it has pages for some of the artboards, or its pages are cropped to the art, they +are compared with the first page only. An artboard, or the page of an EPS's editing data, whose sides are within half +a point of its page's is compared on an area of the page's size. A file whose editing data is for a page of another +size opens the same way, and its warning gives both sizes in points, with the scale of the editing data's page to the +file's page (1:10 for a tenth of its size) when their sides are in proportion. With `textAs: "outlines"` a `.ai` file whose type shows opens from its PDF content, which has the type's outlines. A PDF saved with Illustrator's editing data holds it in `AIPDFPrivateData` streams and opens from it as a `.ai` file does. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 92e478572..da687715a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1148,6 +1148,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). + When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).