.ai import: type on a later page of a PDF of some artboards goes to its artboard

A PDF saved with some of its artboards has a page for each of them, while
the editing data keeps them all. The importer took the pages after the
first to show the artboards right after the first page's artboard. In a
PDF of artboards 2 and 4 of 4, the second page's type went to artboard 3.
In a PDF of artboards 1 and 3 of 4, the second page's type went to the
left edge of artboard 2.

Each page after the first now goes to an artboard of its size that comes
after the previous page's artboard and leaves an artboard for each later
page. The artboard chosen for the first page also leaves one for each
later page. When several artboards fit a later page, the importer
compares their art with the page and takes the closest one, or the first
of them on a tie. It compares at most 16 artboards for all the later
pages together, the same limit it uses for the first page. A later page
with no comparisons left takes the first artboard that fits it.

When the PDF has fewer pages than artboards and its first page shows the
first artboard, the first page's type and page art still go to the art's
box or to the editing data's artboard.
This commit is contained in:
dotCooCoo
2026-11-08 12:21:54 +07:00
committed by bflatastic
parent aa2aa40166
commit 0f39b54a05
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