.ai import: Scatter brushes in Tints open in Tints, and brush art drops its frame

A Scatter brush in a .ai or EPS file's editing data always came into
the Brushes panel keeping its art's colors. A Scatter brush whose last
15 numbers start with 1 now opens in Tints, and one whose numbers start
with 0 keeps its art's colors. Other values are not read and keep the
art's colors. The field before the art's name does not decide it: an
object whose first field is 1 is read by its 15 numbers, and a Scatter
object with 8 numbers is still left out.

A brush's art usually comes in a group after its frame, an unpainted
rectangle around the art. The reader kept that rectangle as part of
the brush's art. It now leaves out a frame at the art's bounds (within
1/1000 pt): an unnamed, unpainted rectangle with its four corners in
order, closed, or open with its first corner again at the end, and no
settings of its own (shown, unlocked, at full opacity in Normal, with
no effects, note or other attributes). A wider frame stays in the art,
and so do a path that crosses the corners, an open path along three
sides and a rectangle with settings of its own. A group left with one
object becomes that object, unless it has a state or look of its own
(hidden, locked, isolated, knockout, an opacity, a blend mode, a name,
a mask, a note or another setting, or an appearance); then it stays a
group without the frame. The rule applies to every brush art section.

Existing files with such brushes now open with Tints and without the
extra frame path in the brush's art. With an exact frame, the pieces
that a Scatter or Art brush draws along a path stay where they were.

The Scatter, Art and Pattern brush colorization options, Tints among
them, are described on pages 281-282 of
https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=281

Tests cover Scatter brushes whose numbers start with 1 and 0, with a
first field of 1 and 2, and an 8-number object. They cover frames kept
or left out (exact, wider, open, filled, named, crossing the corners,
along three sides, with a note, Show Center or hidden), a group's own
state and note kept, and the pieces a Scatter and an Art brush draw
along a path with and without the frame. The import fuzz test reads
brush art after frames of any corners and paint.
This commit is contained in:
dotCooCoo
2026-11-08 13:23:23 +07:00
committed by bflatastic
parent 1ea4eefa80
commit c85b286994
6 changed files with 264 additions and 7 deletions
+12 -3
View File
@@ -3799,8 +3799,9 @@ proptest! {
}
/// Brush objects of every kind with any parameters, the brush manager's order with any names,
/// and brush art sections nested or cut short: read with the layers, or as a library without
/// them, the brushes kept within their ranges.
/// and brush art sections nested or cut short, their art alone or in a group after a frame of
/// any corners, painted or not, open or closed, the group ended or not: read with the layers,
/// or as a library without them, the brushes kept within their ranges.
#[test]
fn ai_brushes_never_panic(
kind in 0usize..7,
@@ -3809,6 +3810,7 @@ proptest! {
nest in 0usize..6,
cut in any::<bool>(),
layers in any::<bool>(),
frame in prop::option::of((prop::collection::vec(prop_oneof![arb_num(), Just("0".to_string()), Just("4".to_string())], 4), prop::sample::select(vec!["n", "N", "f", "S"]), any::<bool>())),
) {
use vectorcraft_testkit::ai;
let kinds = ["Adobe Calligraphic Brush Tool", "Adobe Scatter Brush Tool", "Adobe ArtOnPath Brush Tool", "Adobe PatternOnPath Brush Tool", "Adobe dBrush Brush Tool", "Adobe Brush Manager Order", "Other"];
@@ -3822,7 +3824,14 @@ proptest! {
for _ in 0..nest {
art.push_str("%AI8_BeginBrushPattern\n(A)\n");
}
art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n");
match &frame {
Some((c, op, ended)) => {
let (x0, y0, x1, y1) = (&c[0], &c[1], &c[2], &c[3]);
let end = if *ended { "U\n" } else { "" };
art.push_str(&format!("u\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\n{x0} {y0} L\n{op}\n0 g\n0 0 m\n4 0 L\n4 4 L\nf\n{end}"));
}
None => art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"),
}
for _ in 0..nest.saturating_sub(usize::from(cut)) {
art.push_str("%AI8_EndBrushPattern\n");
}
+81 -3
View File
@@ -31,14 +31,15 @@
//! after a space for Art and Pattern), more fields, and last 15 numbers.
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14).
//! rotation follows the path (14), and 1 Tints (1).
//! - Art: 1 Tints (2).
//! - Pattern: its scale as a factor (3).
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
//! thickness, density, paint opacity and bristle length as fractions and the size in
//! millimeters.
//! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by
//! [`super::read`]).
//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame
//! at the art's bounds isn't kept ([`without_frame`]); a wider one is.
//!
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
@@ -327,7 +328,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
scatter,
rotation,
rotation_relative_to_path: *p14 == 1.0,
colorization: Colorization::None,
// The first of the 15 numbers: 1 is Tints.
colorization: if n.first() == Some(&1.0) { Colorization::Tints } else { Colorization::None },
modes: Some(modes),
..Scatter::default()
};
@@ -471,3 +473,79 @@ pub(super) fn finish_brushes_within(
pub(super) fn left_out_note(n: usize) -> String {
format!("brushes VectorCraft can't read yet were left out of the Brushes panel ({n})")
}
/// Is `n` a frame: an unnamed, unpainted rectangle with nothing of its own (shown, unlocked, at full
/// opacity in Normal, with no effects, note, mask or other settings), a path of one subpath whose
/// anchors, without handles, are its four corners in order around it: closed, with the first again
/// at the end or not, or open with the first again at the end?
fn unpainted_rectangle(n: &Node) -> bool {
let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return false };
let ([sp], Some(b)) = (path.subpaths.as_slice(), n.geometric_bounds()) else { return false };
let corner = |p: kurbo::Point| (p.x == b.x0 || p.x == b.x1) && (p.y == b.y0 || p.y == b.y1);
let mut corners: Vec<kurbo::Point> = vec![];
for a in &sp.anchors {
if !corners.contains(&a.p) {
corners.push(a.p);
}
}
let last_repeats = sp.anchors.len() == 5 && sp.anchors.first().map(|a| a.p) == sp.anchors.last().map(|a| a.p);
// Each corner to the next goes along a side, not across: one coordinate stays.
let sides = corners.len() == 4
&& (0..4).all(|i| match (corners.get(i), corners.get((i + 1) % 4)) {
(Some(a), Some(b)) => (a.x == b.x) != (a.y == b.y),
_ => false,
});
n.name.is_none()
&& *n.extras() == vectorcraft_doc::NodeExtras::default()
&& n.visible
&& !n.locked
&& n.opacity >= 1.0
&& n.blend == vectorcraft_color::BlendMode::Normal
&& !n.isolate
&& n.knockout == vectorcraft_doc::Knockout::Neutral
&& !n.knockout_shape
&& n.appearance.effects.is_empty()
&& n.appearance.items.iter().all(|i| i.paint().is_none())
&& ((sp.closed && sp.anchors.len() == 4) || last_repeats)
&& sides
&& b.width() > 0.0
&& b.height() > 0.0
&& sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p && corner(a.p))
}
/// Brush art `art` without its frame: a group whose first object is an unpainted rectangle around
/// the rest, exactly (within 1/1000 pt), loses that rectangle, and a group without a look or state
/// of its own (hidden, locked, isolation, knockout, opacity, blend mode, name, appearance, or a mask,
/// note or other setting) that is left with one object is that object. A frame wider than the art
/// is kept.
pub(super) fn without_frame(art: Node) -> Node {
let NodeKind::Group { children, clip: false } = &art.kind else { return art };
let Some((first, rest)) = children.split_first() else { return art };
let around = rest.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b));
let exact = match (around, first.geometric_bounds()) {
(Some(a), Some(f)) => [a.x0 - f.x0, a.y0 - f.y0, a.x1 - f.x1, a.y1 - f.y1].iter().all(|d| d.abs() <= 1e-3),
_ => false,
};
if !exact || !unpainted_rectangle(first) {
return art;
}
let plain = art.opacity >= 1.0
&& art.visible
&& !art.locked
&& !art.isolate
&& art.knockout == vectorcraft_doc::Knockout::Neutral
&& !art.knockout_shape
&& art.blend == vectorcraft_color::BlendMode::Normal
&& art.name.is_none()
&& *art.extras() == vectorcraft_doc::NodeExtras::default()
&& art.appearance.effects.is_empty()
&& art.appearance.items.is_empty();
if let ([only], true) = (rest, plain) {
return Node::clone(only);
}
let mut out = art.clone();
if let Some(c) = out.children_mut() {
c.remove(0);
}
out
}
+2 -1
View File
@@ -761,7 +761,8 @@ impl<'a> Reader<'a> {
let node = match art.len() {
1 => art.pop().map(Arc::unwrap_or_clone),
_ => Some(Node::group(self.doc.alloc_id(), art)),
};
}
.map(brush::without_frame);
let Some(node) = node else { return };
self.brush_art.insert(name.clone(), node);
let mut colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors };
+166
View File
@@ -8371,6 +8371,172 @@ fn a_brushs_art_places_a_version_7_patterns_tiles_with_its_operands() {
assert!(!vectorcraft_brush::library(&r.doc).iter().any(|b| b.name == "Spray"), "{:?}", r.warnings);
}
/// A Scatter brush whose 15 numbers start with 1 colors its art with Tints of the stroke, and one
/// whose 15 numbers start with 0 keeps the art's colors. The field before the art's name doesn't
/// count: a brush whose first field is 1 is read by its 15 numbers, and one with 8 numbers, as older
/// files write their brushes, isn't read as a Scatter brush.
#[test]
fn a_scatter_brush_in_tints_comes_in_tinted() {
use vectorcraft_brush::{BrushKind, Colorization};
let read = |params: &str| {
let r = read_ok(&format!("{}{}{}", brush_art("Dot art", 4.0), plugin_object(SCATTER, "Spray", params), layer("")));
vectorcraft_brush::library(&r.doc).into_iter().find(|b| b.name == "Spray")
};
for (field, first, colorization) in
[("2", "1", Colorization::Tints), ("2", "0", Colorization::None), ("1", "1", Colorization::Tints), ("1", "0", Colorization::None)]
{
let params = format!("{field} /Dot art/ 5 0 0 0 / {first} 1 1 0 1 1 0 0 0 0 0 0 0 0 0");
let Some(b) = read(&params) else { panic!("{params}: not read") };
let BrushKind::Scatter(s) = b.kind else { panic!("scatter") };
assert_eq!(s.colorization, colorization, "{params}");
}
let older = "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0";
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", older), Err(brush::BrushError::Values));
assert!(read(older).is_none(), "8 numbers aren't a Scatter brush's");
}
/// A brush's art section `name`: in a group, a frame (a rectangle from `frame.0` to `frame.1` on
/// both axes, painted by `op` and followed by `after`) and a filled square from 0 to 4.
fn framed_art(name: &str, frame: (f64, f64), op: &str, after: &str) -> String {
format!(
"%AI8_BeginBrushPattern\n({name})\n0 A\n0 Xw\nu\n0 XR\n{}{op}\n{after}0 g\n{}f\nU\n%AI8_EndBrushPattern\n",
rect_path(frame.0, frame.0, frame.1, frame.1),
rect_path(0.0, 0.0, 4.0, 4.0)
)
}
/// The frame of a brush's art, an unnamed, unpainted rectangle at the art's bounds, isn't part of
/// the art, closed or open with its first corner again at the end. A frame wider than the art is
/// part of it, and so are a painted rectangle and a named one, which aren't frames.
#[test]
fn a_brushs_frame_is_left_out_only_at_the_arts_bounds() {
use vectorcraft_brush::BrushKind;
let named = "%_/ArtDictionary :\n%_/XMLUID : (Edge) ; (AI10_ArtUID) ,\n%_;\n%_\n";
for (why, frame, op, after, kept) in [
("exact", (0.0, 4.0), "n", "", false),
("open", (0.0, 4.0), "N", "", false),
("wider", (-1.0, 5.0), "n", "", true),
("filled", (0.0, 4.0), "f", "", true),
("named", (0.0, 4.0), "n", named, true),
] {
let params = "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0";
let r = read_ok(&format!("{}{}{}", framed_art("Dot art", frame, op, after), plugin_object(SCATTER, "Spray", params), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
let BrushKind::Scatter(s) = brush_named(&lib, "Spray") else { panic!("scatter") };
if kept {
assert!(matches!(s.art.kind, NodeKind::Group { .. }), "{why}: {:#?}", s.art);
assert_eq!(s.art.children().map(|c| c.len()), Some(2), "{why}");
} else {
assert_eq!(s.art.geometric_bounds().map(|b| b.width()), Some(4.0), "{why}: {:#?}", s.art);
assert!(matches!(s.art.kind, NodeKind::Path { .. }), "{why}: the square alone: {:#?}", s.art);
}
}
}
/// A brush's art group that is hidden, locked, isolated or has a note keeps that state when its
/// frame is left out: the group stays, without the frame, rather than becoming its one other object.
#[test]
fn a_brushs_frame_leaves_the_groups_own_state() {
use vectorcraft_doc::{Appearance, FillLayer, NodeId};
let square = Rect::new(0.0, 0.0, 4.0, 4.0);
let paint = FillLayer::new(Paint::solid(Color::BLACK));
let ap = Appearance { items: vec![AppearanceItem::Fill(paint)], effects: vec![], contents_index: None };
let dot = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(square), ap);
let frame = Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(square), Default::default());
let group = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(dot)]);
assert!(matches!(super::brush::without_frame(group.clone()).kind, NodeKind::Path { .. }), "a plain group is its one object");
// A closed path through the four corners across the square, not along its sides, isn't a frame.
let corners = [Point::new(0.0, 0.0), Point::new(4.0, 4.0), Point::new(4.0, 0.0), Point::new(0.0, 4.0)];
let crossing = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true));
let bowtie = Node::path(NodeId(4), crossing, Default::default());
let dot = group.children().and_then(|c| c.last().cloned()).unwrap();
let crossed = Node::group(NodeId(5), vec![Arc::new(bowtie), dot]);
assert_eq!(super::brush::without_frame(crossed).children().map(|c| c.len()), Some(2), "a path across the corners stays");
// An open path along three sides isn't a frame; one that comes back to its first corner is.
let sides = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0), Point::new(0.0, 4.0)];
let around = [sides.as_slice(), &sides[..1]].concat();
for (why, anchors, frame) in [("three sides", sides.as_slice(), false), ("back to the first corner", around.as_slice(), true)] {
let open = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(anchors, false));
let dot = group.children().and_then(|c| c.last().cloned()).unwrap();
let g = Node::group(NodeId(6), vec![Arc::new(Node::path(NodeId(7), open, Default::default())), dot]);
let left = if frame { None } else { Some(2) };
assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), left, "{why}");
}
// A rectangle with settings of its own (a note, Show Center, hidden) isn't a frame.
for why in ["note", "show center", "hidden"] {
let mut g = group.clone();
if let Some(Some(first)) = g.children_mut().map(|c| c.first_mut()) {
let first = Arc::make_mut(first);
match why {
"note" => first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })),
"show center" => {
first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { show_center: Some(true), ..Default::default() }))
}
_ => first.visible = false,
}
}
assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), Some(2), "{why}: the rectangle stays");
}
for why in ["hidden", "locked", "isolated", "note"] {
let mut g = group.clone();
(g.visible, g.locked, g.isolate) = (why != "hidden", why == "locked", why == "isolated");
if why == "note" {
g.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() }));
}
let out = super::brush::without_frame(g);
assert!(matches!(out.kind, NodeKind::Group { .. }), "{why}: {out:#?}");
assert_eq!(out.children().map(|c| c.len()), Some(1), "{why}: the frame left out");
assert_eq!((out.visible, out.locked, out.isolate), (why != "hidden", why == "locked", why == "isolated"), "{why}");
assert_eq!(out.extras().attrs.as_ref().map(|a| a.note.as_str()), (why == "note").then_some("edge"), "{why}");
}
}
/// Leaving out a frame that is exactly the art's bounds doesn't move what a Scatter or Art brush
/// draws: the pieces along a path are where they are with the frame kept.
#[test]
fn a_brushs_frame_at_the_arts_bounds_draws_the_same_without_it() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter};
let scatter = ("Spray", "Dot art", SCATTER, "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0");
for (name, art_name, tool, params) in [scatter, ("Ribbon", "Ribbon art", ART_BRUSH, RIBBON)] {
let r = read_ok(&format!("{}{}{}", framed_art(art_name, (0.0, 4.0), "n", ""), plugin_object(tool, name, params), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
let read = lib.iter().find(|b| b.name == name).cloned().unwrap();
let art = match &read.kind {
BrushKind::Scatter(s) => s.art.clone(),
BrushKind::Art(a) => a.art.clone(),
k => panic!("{name}: {k:?}"),
};
let bounds = art.geometric_bounds().unwrap();
assert_eq!((bounds.width(), bounds.height()), (4.0, 4.0), "{name}");
assert!(matches!(art.kind, NodeKind::Path { .. }), "{name}: the frame left out: {art:#?}");
let frame = Node::path(vectorcraft_doc::NodeId(90), vectorcraft_geom::shapes::rectangle(bounds), Default::default());
let framed_art = Node::group(vectorcraft_doc::NodeId(91), vec![Arc::new(frame), Arc::new(art)]);
let kind = match &read.kind {
BrushKind::Scatter(s) => BrushKind::Scatter(Scatter { art: framed_art, ..s.clone() }),
BrushKind::Art(a) => BrushKind::Art(ArtBrush { art: framed_art, ..a.clone() }),
k => panic!("{name}: {k:?}"),
};
let framed = Brush { kind, ..read.clone() };
let mut line = kurbo::BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((100.0, 30.0));
let stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0);
let at = |b: &Brush| -> Vec<[i64; 4]> {
vectorcraft_brush::stroke_pieces(&r.doc, b, &line, &stroke)
.iter()
.filter_map(|n| n.geometric_bounds())
.map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() as i64))
.collect()
};
let (without, with) = (at(&read), at(&framed));
assert!(!without.is_empty(), "{name}");
if name == "Spray" {
assert!(without.len() > 10, "{without:?}");
}
assert_eq!(without, with, "{name}");
}
}
/// A file without brush objects keeps the default brushes.
#[test]
fn a_file_without_brushes_keeps_the_default_ones() {
File diff suppressed because one or more lines are too long
+1
View File
@@ -1281,6 +1281,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.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`).
In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`).
In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.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).