Merge pull request #6091 from dotCooCoo/fix/ai-pdf-later-page-artboard

.ai import: type on a later page of a PDF of some artboards goes to its artboard
This commit is contained in:
BFlat
2026-11-08 13:39:20 +08:00
committed by GitHub
3 changed files with 286 additions and 18 deletions
+196 -18
View File
@@ -284,16 +284,20 @@ pub(super) fn build_from(
lone.push(vec![Arc::new(n.clone())]);
}
});
// A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each
// goes where the layers have that artboard.
// A `.ai` read as its PDF part has a page for each artboard it was saved with, in a row:
// the type on each goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let boards = page_boards(&doc.artboards, first, &pages);
let boards = page_boards(&doc, visible, first, &pages);
// The first page's type goes to the first artboard only when that artboard is the page's
// (every artboard has a page); otherwise it goes to `rect`, the art's box or the editing
// data's artboard.
let first_board = first > 0 || pages.len() == doc.artboards.len();
let board_of = |i: usize| boards.as_ref()?.get(i).filter(|_| i > 0 || first_board);
let page_moves: Vec<(Rect, Affine)> = pages
.iter()
.enumerate()
.map(|(i, page)| {
let move_to_doc =
boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift);
let move_to_doc = board_of(i).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift);
(*page, move_to_doc)
})
.collect();
@@ -312,7 +316,7 @@ pub(super) fn build_from(
}
for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) {
let on = page_of(object);
let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) {
let shift = match on.and_then(|(i, page)| Some((board_of(i)?, page))) {
Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()),
None => shift,
};
@@ -322,7 +326,7 @@ pub(super) fn build_from(
}
// 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));
let more = more_pages(boards.as_deref().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));
@@ -1356,18 +1360,47 @@ fn in_units(board: Option<Rect>, page: &Document, unit: f64) -> bool {
unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0)
}
/// The most artboards [`shown_artboard`] weighs, a render each.
/// The most artboards [`shown_artboard`] weighs for the first page, and the most [`page_boards`]
/// weighs for all the pages after it together, a render each.
const MAX_BOARDS_WEIGHED: usize = 16;
/// Does artboard `a` have the size of `page`?
fn board_fits(a: &vectorcraft_doc::Artboard, page: &Rect) -> bool {
(a.rect.width() - page.width()).abs() <= 0.5 && (a.rect.height() - page.height()).abs() <= 0.5
}
/// The last of the artboards `boards` that each page of `pages` can show when every page shows
/// one, in the pages' order. A page after the first shows an artboard of its size. Working from
/// the last page back, each page's entry is the last artboard of its size before the next page's
/// entry. The first page's entry is the artboard before the second page's entry, or the last
/// artboard when there is one page. `None` when the pages after the first have no such artboards.
fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option<Vec<usize>> {
let mut last = vec![0; pages.len()];
let mut end = boards.len();
for (i, page) in pages.iter().enumerate().skip(1).rev() {
let j = (1..end).rev().find(|&j| boards.get(j).is_some_and(|a| board_fits(a, page)))?;
*last.get_mut(i)? = j;
end = j;
}
if let Some(l) = last.first_mut() {
*l = end.checked_sub(1)?;
}
Some(last)
}
/// Which of the artboards of `doc` (the layers' document) the page `page_rect` of `visible` shows,
/// when `visible` has fewer pages than `doc` has artboards (a PDF saved with a range of them): of
/// those of the page's size (`fits`), the one whose art draws most like the page, the first of
/// them on a tie. Else (and for an EPS, whose page is its art) the first artboard (0).
/// those of the page's size (`fits`) that leave an artboard for each later page ([`last_boards`]),
/// the one whose art draws most like the page, the first of them on a tie. Else (and for an EPS,
/// whose page is its art) the first artboard (0).
fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize {
if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() {
return 0;
}
let sized: Vec<usize> = doc.artboards.iter().enumerate().filter(|(_, a)| fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect();
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let last = last_boards(&doc.artboards, &pages).and_then(|l| l.first().copied()).unwrap_or(usize::MAX);
let sized: Vec<usize> =
doc.artboards.iter().enumerate().filter(|(i, a)| *i <= last && fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect();
if let [only] = sized.as_slice() {
return *only;
}
@@ -1407,13 +1440,46 @@ fn pages_touched(bounds: Rect, pages: &[Rect], margin: f64) -> Vec<usize> {
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).
fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: &[Rect]) -> Option<&'a [vectorcraft_doc::Artboard]> {
let same = |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5;
let shown = boards.get(first..first.saturating_add(pages.len()))?;
((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown)
/// The artboards of the layers' document (`doc`) that the pages of `visible` (`pages`) show, one a
/// page, the first page's being `first`, each of its page's size. The artboards are in the pages'
/// order. A page after the first shows an artboard after the previous page's, and only one that
/// leaves an artboard for each later page ([`last_boards`]). When several artboards can be the
/// page's (a PDF saved with some of them), the page shows the one whose art draws most like it,
/// the first of them on a tie. At most [`MAX_BOARDS_WEIGHED`] artboards are weighed for all the
/// later pages together, each page's candidates in order: a page takes the best of its candidates
/// that were weighed, or its first candidate when none of them were. `None` when the pages have no
/// such artboards.
fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) -> Option<Vec<vectorcraft_doc::Artboard>> {
let boards = &doc.artboards;
let same = |j: usize, p: &Rect| boards.get(j).is_some_and(|a| board_fits(a, p));
let last = last_boards(boards, pages)?;
if !pages.first().is_some_and(|p| same(first, p)) || last.first().is_none_or(|&l| first > l) {
return None;
}
let mut shown = vec![boards.get(first)?.clone()];
let (mut after, mut weighed) = (first + 1, 0);
for (i, page) in pages.iter().enumerate().skip(1) {
let sized: Vec<usize> = (after..=*last.get(i)?).filter(|&j| same(j, page)).collect();
let j = match sized.as_slice() {
[] => return None,
[only] => *only,
many => {
let weigh: Vec<usize> = many.iter().copied().take(MAX_BOARDS_WEIGHED.saturating_sub(weighed)).collect();
weighed += weigh.len();
let mut best: Option<(usize, f64)> = None;
for (j, a) in weigh.iter().filter_map(|&j| Some((j, boards.get(j)?))) {
let d = difference(visible, *page, doc, a.rect).any;
if best.is_none_or(|(_, b)| d < b) {
best = Some((j, d));
}
}
best.map_or(many.first().copied(), |(j, _)| Some(j))?
}
};
shown.push(boards.get(j)?.clone());
after = j + 1;
}
Some(shown)
}
/// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard.
@@ -1615,6 +1681,118 @@ mod candidate_tests {
assert_eq!(more_pages(Some(&tiny_boards), &tiny, 1).len() + 1, MAX_PAGES_COMPARED);
}
/// A page after the first doesn't take an artboard that a later page of another size needs,
/// even one that draws more like it.
#[test]
fn a_later_page_leaves_an_artboard_of_each_later_pages_size() {
let square = |doc: &mut Document, x: f64| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
let layer = doc.layers[0].id;
doc.insert(Some(layer), 0, art).unwrap();
};
let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard {
rect: Rect::new(x0, 0.0, x0 + width, 100.0),
..doc.artboards[0].clone()
};
// Artboards of 100, 100, 200, 100 and 100 points, a square on the fourth.
let mut doc = Document::new(100.0, 100.0);
doc.artboards = [(0.0, 100.0), (150.0, 100.0), (300.0, 200.0), (550.0, 100.0), (700.0, 100.0)].map(|(x, w)| board(&doc, x, w)).to_vec();
square(&mut doc, 560.0);
// Pages of 100, 100 and 200 points, the second with the fourth artboard's square.
let mut page = Document::new(100.0, 100.0);
let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)];
page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect();
square(&mut page, 130.0);
let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::<Vec<_>>());
assert_eq!(shown, Some(vec![0.0, 150.0, 300.0]));
}
/// The pages after the first weigh at most [`MAX_BOARDS_WEIGHED`] artboards together: a page
/// takes the best of its candidates that were weighed, and a page left with none to weigh
/// takes its first candidate, even when a later candidate draws like it.
#[test]
fn the_later_pages_weigh_a_bounded_number_of_artboards() {
let square = |doc: &mut Document, x: f64| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
let layer = doc.layers[0].id;
doc.insert(Some(layer), 0, art).unwrap();
};
let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard {
rect: Rect::new(x0, 0.0, x0 + width, 100.0),
..doc.artboards[0].clone()
};
// Artboards 0 to 17 are 100 points wide, 150 apart; artboards 18 and 19 are 200 points
// wide. The second page's square is on artboard 17 and the third page's on artboard 19.
let n = MAX_BOARDS_WEIGHED + 2;
let mut doc = Document::new(100.0, 100.0);
let mut boards: Vec<vectorcraft_doc::Artboard> = (0..n).map(|i| board(&doc, i as f64 * 150.0, 100.0)).collect();
let wide = n as f64 * 150.0;
boards.push(board(&doc, wide, 200.0));
boards.push(board(&doc, wide + 250.0, 200.0));
doc.artboards = boards;
square(&mut doc, (n - 1) as f64 * 150.0 + 10.0);
square(&mut doc, wide + 260.0);
let mut page = Document::new(100.0, 100.0);
let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)];
page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect();
square(&mut page, 130.0);
square(&mut page, 250.0);
// The second page weighs artboards 1 to 16, which draw alike, and takes artboard 1. The
// third page has no weighing left and takes artboard 18.
let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::<Vec<_>>());
assert_eq!(shown, Some(vec![0.0, 150.0, wide]));
}
/// The first page of a PDF of some artboards shows an artboard that leaves one for each later
/// page, even when a later artboard draws more like it.
#[test]
fn the_first_page_leaves_an_artboard_for_each_later_page() {
let square = |doc: &mut Document, x: f64| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
let layer = doc.layers[0].id;
doc.insert(Some(layer), 0, art).unwrap();
};
let board = |doc: &Document, x0: f64| vectorcraft_doc::Artboard { rect: Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..doc.artboards[0].clone() };
// Three artboards of one size, a square on the third.
let mut doc = Document::new(100.0, 100.0);
doc.artboards = [0.0, 150.0, 300.0].map(|x| board(&doc, x)).to_vec();
square(&mut doc, 310.0);
// Two pages, the square on the first.
let mut page = Document::new(100.0, 100.0);
let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0)];
page.artboards = pages.iter().map(|r| board(&page, r.x0)).collect();
square(&mut page, 10.0);
let fits = |r: &Rect| (r.width() - 100.0).abs() <= 0.5 && (r.height() - 100.0).abs() <= 0.5;
let first = shown_artboard(&doc, &page, Page::Artboard, pages[0], fits);
assert_eq!(first, 0);
let shown = page_boards(&doc, &page, first, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::<Vec<_>>());
assert_eq!(shown, Some(vec![0.0, 150.0]));
}
/// A linked image showing a placeholder (`XF`) waits on the page, whose PDF part draws its
/// pixels: the layers aren't used without it.
#[test]
+88
View File
@@ -1961,6 +1961,94 @@ fn the_later_pages_of_a_pdf_of_some_artboards_are_not_compared() {
assert_eq!(d.artboards.len(), 4, "{w:?}");
}
/// The editing data of four 100-point artboards side by side, 50 apart (art space, y up), with a
/// 4-point square on each at (`squares[i]`, 10 + 20 × i), and a text object with no story in the
/// file.
fn four_boards_with_type(squares: [u32; 4]) -> String {
let board = |x0: u32| {
format!(
"%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
x0 + 100
)
};
let boards = (0..4u32).map(|i| board(i * 150)).collect::<String>();
let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{boards}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n");
let art: Vec<String> = (0..4u32).map(|i| square(i * 150 + squares[i as usize], 10 + 20 * i)).collect();
native(&(boards + &layer("Art", true, &(art.join("\n") + "\n" + &text_object(0)))))
}
/// The PDF part of a file with [`four_boards_with_type`] saved with two of its artboards: a page
/// for each in a row, page `i` with a square `squares[i].0` from its left and `squares[i].1` up
/// from its bottom, and "Hi" 30 from the second page's left and 20 down.
fn two_pages_with_type(squares: [(f64, u32); 2]) -> Document {
let at = |(x, y): (f64, u32), page: f64| pdf_part(page + x, 100.0 - f64::from(y) - 4.0);
let mut pdf = at(squares[0], 0.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 = at(squares[1], 120.0);
let square = pdf.reid(&other.layers[0].children().unwrap()[0]);
let layer_id = pdf.layers[0].id;
pdf.insert(Some(layer_id), 1, square).unwrap();
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(150.0, 20.0), "Hi", style);
let id = pdf.alloc_id();
pdf.insert(Some(layer_id), 2, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
pdf
}
/// A PDF saved with artboards that don't follow one another has a page for each: the type on a
/// later page goes to the artboard that page shows, not to the one after the previous page's.
#[test]
fn type_on_a_later_page_of_a_pdf_of_some_artboards_goes_to_its_artboard() {
// The squares 10, 20, 30 and 40 from each artboard's left.
let art = four_boards_with_type([10, 20, 30, 40]);
// Pages for the second and fourth artboards. The fourth artboard starts at x 450, so the type
// 30 from its left is at x 480.
let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(20.0, 30), (40.0, 70)]), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(d.artboards.len(), 4, "{w:?}");
let texts = text_of(&d.layers[0]);
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi"], "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 480.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the fourth artboard: {:?}", texts[0].xf);
}
/// A PDF saved with the first and third of four artboards: the type on its second page goes to
/// the third artboard.
#[test]
fn type_on_the_second_page_of_a_pdf_of_the_first_and_third_artboards_goes_to_the_third() {
let art = four_boards_with_type([10, 20, 30, 40]);
// The third artboard starts at x 300, so the type 30 from its left is at x 330.
let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(10.0, 10), (30.0, 50)]), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(d.artboards.len(), 4, "{w:?}");
let texts = text_of(&d.layers[0]);
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi"], "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 330.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the third artboard: {:?}", texts[0].xf);
}
/// An EPS whose page is its art's box: an object the layers can't draw takes the page's art from
/// that box, when the editing data's first artboard has the page's size elsewhere.
#[test]
fn an_unread_object_of_an_eps_of_its_art_box_takes_the_page_art_on_that_box() {
let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
let board = |x0: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 100, 1), point(x0 + 100, 0, 2));
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(200), board(0));
let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n");
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let layers = layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10)));
let art = native(&format!("{boards}{symbol}{layers}"));
let cyan = page_square(50, 10).replace("0 0 1 0 setcmykcolor", "1 0 0 0 setcmykcolor");
let page = format!("{}\n{cyan}\n{}", page_square(10, 10), page_square(70, 10));
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Live", "Mixed"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[1]), ["group[1]", "path"], "{:?}", r.warnings);
replacement_is_cyan(&r.document, 1, 0);
}
/// Layers that draw each page's art on its artboard come in.
#[test]
fn layers_that_match_every_page_come_in() {
File diff suppressed because one or more lines are too long