Simplify file import and export checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 16:44:05 +00:00
parent 62588fd080
commit 90ef99cb2c
40 changed files with 141 additions and 75 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ impl PrintService for SystemPrint {
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
mod system {
use super::*;
use super::{Command, PrintJob, Printer};
pub fn printers() -> Vec<Printer> {
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
}
+1 -1
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document};
use super::*;
use super::{Result, Session, bad};
const C: &str = "file.info";
/// Longest text field, in characters.
+1 -1
View File
@@ -92,7 +92,7 @@ pub use svg::options_map as svg_options;
#[cfg(not(target_arch = "wasm32"))]
pub use vectorcraft_format::write_atomic;
use super::*;
use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, has_selection};
use crate::EngineError;
mod access;
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use super::write_atomic;
use super::{EngineError, Result, Session, Value, bad, json};
// ---------- the file system (none on the web, where commands take and return bytes) ----------
//
+3 -1
View File
@@ -9,7 +9,9 @@ use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use super::super::*;
use super::super::{Result, Session, bad};
#[cfg(not(target_arch = "wasm32"))]
use super::super::{bool_or, find_command, str_param};
#[cfg(target_arch = "wasm32")]
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
@@ -1,7 +1,7 @@
use super::layout::{master_instances, master_prefix, page_masters};
use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost};
use super::nodes::rect_of;
use super::*;
use super::{Affine, Arc, Artboard, Blend, Builder, Document, Kind, Node, NodeKind, Result, af, err};
impl Builder {
pub(super) fn new(k: f64) -> Self {
@@ -1,4 +1,4 @@
use super::*;
use super::{Builder, Cursor, Document, Result, err, raster_doc};
/// What an Affinity file opened as.
pub(in super::super) struct Imported {
@@ -1,5 +1,5 @@
use super::nodes::MAX_DEPTH;
use super::*;
use super::{AfAlign, CharStyle, Cursor, Justify, Kind, Rect, TextKind, TextObject, VerticalAlign, af, shapes};
/// The master page instances among `nodes` (also inside layers and groups): (master, first page,
/// first master page, page count).
@@ -1,5 +1,5 @@
use super::nodes::MAX_DEPTH;
use super::*;
use super::{Kind, af};
/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels.
#[derive(Clone, Copy, Debug, Default)]
@@ -1,4 +1,4 @@
use super::*;
use super::{Affine, Arc, Blend, BlendMode, Builder, FillRule, Kind, LayerColor, Node, NodeKind, OpacityMask, Rect, af};
/// Deepest node tree mapped (the reader bounds trees well below this).
pub(super) const MAX_DEPTH: usize = 160;
@@ -1,7 +1,11 @@
use super::layout::png;
use super::nodes::affine;
use super::text::Placement;
use super::*;
use super::{
AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind,
GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer,
TextObject, Vec2, af, ap, json,
};
impl Builder {
/// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's
@@ -1,6 +1,9 @@
use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell};
use super::nodes::affine;
use super::*;
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, CharStyle, FillRule, FirstBaseline, Node, NodeKind, Paint, PathData, Point, Rect,
TextKind, TextObject, TextRun, Vec2, VerticalAlign, af, ap, shapes,
};
/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node
/// space into it (applied to the frame's geometry, not the type), plus the type's own scale.
+1 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use super::super::*;
use super::super::{Result, Session, bad, str_param};
use crate::{DocState, EngineError};
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::access::file_list;
use super::formats::formats;
use super::*;
use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, save, screens};
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -1
View File
@@ -10,7 +10,7 @@ use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat
use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
use super::super::*;
use super::super::{CommandSpec, Result, Session, bad, cmd, has_doc};
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
+1 -1
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_cad::ImportOptions;
use vectorcraft_doc::{Document, Unit};
use super::super::*;
use super::super::{CommandSpec, Result, Session, always, bad, cmd};
use super::{err, source};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -14,7 +14,7 @@ use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tga};
use super::super::*;
use super::super::{EngineError, Result, bad, color_value, parse_range};
use super::Format;
use super::imagemap::{self, MapKind};
+1 -1
View File
@@ -13,7 +13,7 @@ use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster};
use vectorcraft_geom::{Point, Rect};
use super::super::flatten::{FlattenOptions, flatten_document};
use super::super::*;
use super::super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, str_param};
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
+1 -1
View File
@@ -9,7 +9,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes};
use crate::DocState;
use super::super::*;
use super::super::{Result, Session, bad, bool_or, edit, f64_or, str_param};
use super::encode::single_artboard;
use super::{
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
+1 -1
View File
@@ -1,5 +1,5 @@
use super::access::write_file;
use super::*;
use super::{Encoded, EngineError, Result, Session, Value, json};
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, svg, text, unsupported};
/// One option a format's encoder reads from the export params.
#[derive(Clone, Copy, Debug)]
+1 -1
View File
@@ -37,7 +37,7 @@ use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
use super::super::*;
use super::super::{Affine, EngineError, NodeId, Result, bad};
use super::{ArtboardPick, Encoded, Format};
/// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown
+1 -1
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::*;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::cmd::links::Resolved;
+1 -1
View File
@@ -7,7 +7,7 @@ use serde_json::Value;
use vectorcraft_doc::Document;
use vectorcraft_metafile::Kind;
use super::super::*;
use super::super::{Result, bad};
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
+1 -1
View File
@@ -12,7 +12,7 @@ use vectorcraft_color::cms;
use vectorcraft_doc::Document;
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
use super::super::*;
use super::super::{EngineError, Result, bad};
use super::{Format, FormatOption};
/// The options `document.save` and the native encoder read.
+1 -1
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
use super::super::*;
use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
use super::{ArtboardPick, FormatOption, write_or_return};
use crate::EngineError;
+1 -1
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::colormgmt::Grays;
use super::super::*;
use super::super::{Result, Session, bad, f64_or, parse_range, str_param};
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
+1 -1
View File
@@ -12,7 +12,7 @@ use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use super::super::*;
use super::super::{CommandSpec, Result, Session, always, bad, bool_or, clock_date, cmd, date_param, has_doc, str_param};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref,
write_encoded,
+1 -1
View File
@@ -16,7 +16,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
use super::super::*;
use super::super::{NodeId, Result, Session, bad, str_param};
use super::encode::single_artboard;
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
+2 -2
View File
@@ -386,8 +386,8 @@ fn open_exts_cover_every_readable_format() {
#[test]
fn library_open_filters_list_library_formats_first() {
let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect();
assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS));
let filters: Vec<_> = library_open_filters("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS).collect();
assert_eq!(filters[0], ("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS));
assert!(filters[0].1.contains(&"ase"));
assert_eq!(filters[1], ("All readable files", OPEN_EXTS));
}
@@ -140,7 +140,7 @@ fn metafiles_open_and_place() {
let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone();
assert_eq!(placed.name.as_deref(), Some("pic.emf"));
assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. }));
let ext = fileio::PLACE_EXTS;
let ext = crate::cmd::fileio::PLACE_EXTS;
assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf"));
}
+1 -1
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use serde_json::Value;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::super::*;
use super::super::{Result, bad};
use super::FormatOption;
const C: &str = "document.export";
+1 -1
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_pdf::{PdfPreset, builtin_preset, builtin_presets};
use super::fileio::pdf::{find_preset, resolve};
use super::*;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param, unique_name};
/// The `format` of a PDF presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcpdfpresets";
+1 -1
View File
@@ -44,7 +44,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
use super::fileio::{self, Format, RasterImage};
use super::*;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name};
use crate::{EngineError, MAX_COORD};
const PLACE: &str = "file.place";
+1 -1
View File
@@ -25,7 +25,7 @@ use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::CropTo;
use super::super::fileio;
use super::super::*;
use super::super::{EngineError, NodeId, Result, bad};
/// The largest file placed (bytes).
pub(crate) const MAX_FILE: usize = 256 << 20;
+1 -1
View File
@@ -43,7 +43,7 @@ use vectorcraft_doc::text::Kashida;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use super::{EngineError, Paint, Result, bad, fileio};
use crate::cmd::fileio::zip::{self, Archive, ReadError};
/// Word documents as `file.place` reports them (not a format `document.open` reads).
+1 -1
View File
@@ -31,7 +31,7 @@ use std::collections::HashMap;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use super::{Paint, Result, bad, fileio};
/// RTF files as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
+1 -1
View File
@@ -3,7 +3,7 @@
use serde_json::Value;
use super::*;
use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param};
/// Text files as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
+1 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_render::encode::quantize::{self, Dither, Indexed, PaletteOptions
use vectorcraft_render::encode::web::{self, ColorSort};
use vectorcraft_render::encode::{gif, jpeg, png};
use super::*;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_or, has_doc, str_param, unique_name};
const C: &str = "document.exportForWeb";
+90 -35
View File
@@ -135,6 +135,16 @@ struct Linearised {
encrypted: bool,
}
/// The main cross-reference table at `main`, covering the objects before the first page's `first`.
fn check_main_table(pdf: &[u8], lin: &Indirect, main: usize, first: u32) {
// /T: the white space before the main table's first entry.
let entries = main + format!("xref\n0 {first}\n").len();
assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}");
assert_eq!(int(&lin.value, b"T"), entries - 1);
let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap();
assert_eq!(int(&main_trailer, b"Size") as u32, first);
}
/// Check `pdf` is linearised as ISO 32000-1 Annex F says, as a linearisation checker does: the
/// linearization dictionary, both cross-reference tables and their trailers, and (unless the file
/// is encrypted) the hint tables against where every page's objects are and what each page uses.
@@ -160,12 +170,7 @@ fn check_linearised(pdf: &[u8]) -> Linearised {
assert_eq!(all.len() as u32, size - 1, "the tables cover every object");
let first = lin.num;
assert!(first_part.iter().all(|n| (first..size).contains(n)) && first_part.len() as u32 == size - first);
// /T: the white space before the main table's first entry.
let entries = main + format!("xref\n0 {first}\n").len();
assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}");
assert_eq!(int(&lin.value, b"T"), entries - 1);
let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap();
assert_eq!(int(&main_trailer, b"Size") as u32, first);
check_main_table(pdf, &lin, main, first);
// Every object where its table says, read as the file's objects.
let objects: HashMap<u32, Indirect> = all.iter().map(|(n, off)| (*n, indirect(pdf, *off).unwrap())).collect();
@@ -205,7 +210,41 @@ fn check_linearised(pdf: &[u8]) -> Linearised {
let data = &pdf[data_at..data_at + int(&hint.value, b"Length")];
let mut r = BitReader { data, at: 0 };
// The page offset hint table.
let (counts, ids) = check_page_hints(&mut r, &pages, &adjusted, &span, first_page_objects);
// The shared object hint table.
let s = int(&hint.value, b"S");
let mut r = BitReader { data, at: 8 * s };
let (first_shared, first_shared_at, nfirst, ntotal) = read_shared_header(&mut r);
let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 };
if ntotal > nfirst {
assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location");
}
check_shared_hints(&mut r, ntotal, nfirst, first_page_objects, &shared_object, &span);
// What each page uses lies in its own objects, the first part or its shared objects.
let page_set: HashSet<u32> = pages.iter().copied().collect();
let references = PageReferenceHints {
pages: &pages,
page_set: &page_set,
first_part: &first_part,
objects: &objects,
nodes: &nodes,
shared_object: &shared_object,
};
for (i, &page) in pages.iter().enumerate() {
check_page_references(&references, i, page, counts[i], &ids[i]);
}
Linearised { pages, first_part, encrypted }
}
fn check_page_hints(
r: &mut BitReader<'_>,
pages: &[u32],
adjusted: &impl Fn(u32) -> usize,
span: &impl Fn(u32) -> usize,
first_page_objects: u32,
) -> (Vec<u64>, Vec<Vec<u64>>) {
let (min_objects, first_page_at, objects_bits) = (r.get(32), r.get(32) as usize, r.get(16));
let (min_len, len_bits) = (r.get(32), r.get(16));
let (_content_at, content_at_bits, _content_len, content_len_bits) = (r.get(32), r.get(16), r.get(32), r.get(16));
@@ -230,42 +269,58 @@ fn check_linearised(pdf: &[u8]) -> Linearised {
assert_eq!(len as u64, lengths[i], "page {i}'s length");
at += len;
}
(counts, ids)
}
// The shared object hint table.
let s = int(&hint.value, b"S");
let mut r = BitReader { data, at: 8 * s };
let (first_shared, first_shared_at, nfirst, ntotal) = (r.get(32) as u32, r.get(32) as usize, r.get(32), r.get(32));
let (group_bits, min_group, group_len_bits) = (r.get(16), r.get(32), r.get(16));
fn read_shared_header(r: &mut BitReader<'_>) -> (u32, usize, u64, u64) {
let first_shared = r.get(32) as u32;
let first_shared_at = r.get(32) as usize;
let nfirst = r.get(32);
let ntotal = r.get(32);
let group_bits = r.get(16);
assert_eq!(group_bits, 0, "one object a group");
(first_shared, first_shared_at, nfirst, ntotal)
}
fn check_shared_hints(
r: &mut BitReader<'_>,
ntotal: u64,
nfirst: u64,
first_page_objects: u32,
shared_object: &impl Fn(u64) -> u32,
span: &impl Fn(u32) -> usize,
) {
assert_eq!(nfirst as u32, first_page_objects);
let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 };
if ntotal > nfirst {
assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location");
}
let groups = r.column(ntotal as usize, group_len_bits);
for (id, g) in groups.iter().enumerate() {
let (min_group, group_len_bits) = (r.get(32), r.get(16));
for (id, g) in r.column(ntotal as usize, group_len_bits).iter().enumerate() {
assert_eq!((g + min_group) as usize, span(shared_object(id as u64)), "shared object {id}'s length");
}
assert!(r.column(ntotal as usize, 1).iter().all(|s| *s == 0), "no signatures");
}
// What each page uses lies in its own objects, the first part or its shared objects.
let page_set: HashSet<u32> = pages.iter().copied().collect();
for (i, &page) in pages.iter().enumerate() {
let own: HashSet<u32> = (page..page + counts[i] as u32).collect();
let listed: HashSet<u32> = ids[i].iter().map(|id| shared_object(*id)).collect();
let (mut seen, mut queue) = (HashSet::from([page]), vec![page]);
while let Some(o) = queue.pop() {
assert!(
own.contains(&o) || first_part.contains(&o) && (i == 0 || o < pages[0] || listed.contains(&o)) || listed.contains(&o),
"page {i} uses object {o}, which isn't among its objects or listed"
);
let mut links = vec![];
objects[&o].value.refs(&[b"Parent", b"Thumb"], &mut links);
queue.extend(links.into_iter().filter(|m| !page_set.contains(m) && !nodes.contains(m) && seen.insert(*m)));
}
assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses");
struct PageReferenceHints<'a> {
pages: &'a [u32],
page_set: &'a HashSet<u32>,
first_part: &'a HashSet<u32>,
objects: &'a HashMap<u32, Indirect<'a>>,
nodes: &'a HashSet<u32>,
shared_object: &'a dyn Fn(u64) -> u32,
}
fn check_page_references(hints: &PageReferenceHints<'_>, i: usize, page: u32, count: u64, ids: &[u64]) {
let own: HashSet<u32> = (page..page + count as u32).collect();
let listed: HashSet<u32> = ids.iter().map(|id| (hints.shared_object)(*id)).collect();
let (mut seen, mut queue) = (HashSet::from([page]), vec![page]);
while let Some(o) = queue.pop() {
assert!(
own.contains(&o) || hints.first_part.contains(&o) && (i == 0 || o < hints.pages[0] || listed.contains(&o)) || listed.contains(&o),
"page {i} uses object {o}, which isn't among its objects or listed"
);
let mut links = vec![];
hints.objects[&o].value.refs(&[&b"Parent"[..], &b"Thumb"[..]], &mut links);
queue.extend(links.into_iter().filter(|m| !hints.page_set.contains(m) && !hints.nodes.contains(m) && seen.insert(*m)));
}
Linearised { pages, first_part, encrypted }
assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses");
}
#[test]