Skip to main content

csp_format/
verify.rs

1//! Self-checks of a .clip file, used for corpus validation (V1) and for
2//! checking written files (V4). Each check records counts, not artwork.
3
4use std::collections::{BTreeMap, HashSet};
5
6use serde::Serialize;
7
8use crate::clip::ClipFile;
9use crate::container::{Container, ContainerWriter, FILE_ID_LENGTH};
10use crate::database::{self, select_all, value_as_text};
11use crate::error::Result;
12use crate::offscreen::{Attribute, BlockData, PixelKind, compress};
13use crate::text;
14use crate::vector::VectorData;
15
16/// Options for [`check_clip`].
17#[derive(Debug, Clone, Copy)]
18pub struct CheckOptions {
19    /// Decompress every block (R5).
20    pub decode_pixels: bool,
21    /// Recompress every block and compare with CSP's bytes (R6).
22    pub reencode_pixels: bool,
23}
24
25impl Default for CheckOptions {
26    fn default() -> Self {
27        CheckOptions { decode_pixels: true, reencode_pixels: true }
28    }
29}
30
31/// Checks of the CSFCHUNK container.
32#[derive(Debug, Clone, Default, Serialize)]
33pub struct ContainerReport {
34    /// Structural problems found while parsing (see [`crate::container::Container::findings`]).
35    pub findings: Vec<String>,
36    /// Rebuilding from the parsed chunks gives the same bytes (R1).
37    pub reassembles: bool,
38    /// The writer, fed the original chunks, gives the same bytes (R11).
39    pub writer_reproduces: bool,
40    /// Number of external chunks.
41    pub external_chunks: usize,
42    /// `ExternalChunk.Offset` points at each stored chunk.
43    pub external_offsets_match: bool,
44    /// Every `ExternalChunk.ExternalID` is stored as TEXT. CSP looks chunks
45    /// up by text, so a BLOB id makes the layer load as broken.
46    pub external_ids_text: bool,
47}
48
49/// Checks of the embedded database.
50#[derive(Debug, Clone, Default, Serialize)]
51pub struct DatabaseReport {
52    /// Number of tables.
53    pub tables: usize,
54    /// Rows over all tables.
55    pub rows: usize,
56    /// Tables that could not be read.
57    pub unreadable_tables: Vec<String>,
58    /// Rows of `ParamScheme`.
59    pub param_scheme_rows: usize,
60    /// Columns in tables that `ParamScheme` does not list.
61    pub unregistered_columns: Vec<String>,
62}
63
64/// The layer tree.
65#[derive(Debug, Clone, Default, Serialize)]
66pub struct LayerReport {
67    /// Number of `Layer` rows.
68    pub layers: usize,
69    /// Layers per [`crate::LayerKind`] name.
70    pub kinds: BTreeMap<String, usize>,
71    /// Layers not reachable from the root folder.
72    pub unreachable: usize,
73    /// Why the tree could not be built, if it could not.
74    pub tree_error: Option<String>,
75    /// Layers per `LayerComposite` value.
76    pub blend_modes: BTreeMap<i64, usize>,
77}
78
79/// Raster pixels (`Offscreen` rows and their block data).
80#[derive(Debug, Clone, Default, Serialize)]
81pub struct PixelReport {
82    /// `Offscreen` rows.
83    pub offscreens: usize,
84    /// Offscreens whose block data chunk is stored.
85    pub stored: usize,
86    /// Offscreens per pixel kind (`color`, `gray`, `monochrome`, `alpha`, `unknown`).
87    pub kinds: BTreeMap<String, usize>,
88    /// `Attribute` blobs that could not be parsed.
89    pub attribute_errors: usize,
90    /// `Attribute` blobs not written back byte for byte.
91    pub attribute_round_trip_failures: usize,
92    /// Block data that could not be parsed or has the wrong number of blocks.
93    pub payload_errors: usize,
94    /// Block data not written back byte for byte.
95    pub payload_round_trip_failures: usize,
96    /// Block data whose block sizes differ from the attribute's size table.
97    pub block_size_table_mismatches: usize,
98    /// Blocks in the grids of all offscreens.
99    pub blocks: usize,
100    /// Blocks with stored (compressed) pixels.
101    pub stored_blocks: usize,
102    /// Blocks whose stored checksum is wrong.
103    pub checksum_mismatches: usize,
104    /// Blocks that could not be inflated or have the wrong size.
105    pub decode_errors: usize,
106    /// Blocks compressed again to the same bytes, with CSP's zlib settings.
107    pub reencoded_identical: usize,
108    /// Blocks equal to zlib's default settings instead (material brush tips).
109    pub reencoded_identical_default: usize,
110    /// Blocks equal to neither.
111    pub reencoded_different: usize,
112    /// The first problem found, for diagnosis.
113    pub first_error: Option<String>,
114}
115
116/// External chunk references (`ExternalChunk` ids named by table columns).
117#[derive(Debug, Clone, Default, Serialize)]
118pub struct ReferenceReport {
119    /// Ids referred to by any column.
120    pub references: usize,
121    /// Referenced ids without a chunk (normal: CSP builds them on opening).
122    pub not_stored: usize,
123    /// Chunks no column refers to.
124    pub unreferenced_chunks: usize,
125    /// Per `Table.Column`: (references, of which stored).
126    pub by_column: BTreeMap<String, (usize, usize)>,
127}
128
129/// Vector data round trips (R7).
130#[derive(Debug, Clone, Default, Serialize)]
131pub struct VectorReport {
132    /// `VectorObjectList` payloads.
133    pub payloads: usize,
134    /// Vector objects.
135    pub objects: usize,
136    /// Points over all objects.
137    pub points: usize,
138    /// Objects per header size.
139    pub header_sizes: BTreeMap<usize, usize>,
140    /// Objects per point size.
141    pub point_sizes: BTreeMap<usize, usize>,
142    /// Payloads that could not be parsed.
143    pub parse_errors: usize,
144    /// Payloads not written back byte for byte.
145    pub round_trip_failures: usize,
146    /// The first problem found, for diagnosis.
147    pub first_error: Option<String>,
148}
149
150/// Text layer round trips (R8).
151#[derive(Debug, Clone, Default, Serialize)]
152pub struct TextReport {
153    /// Text layers.
154    pub layers: usize,
155    /// Text blocks.
156    pub blocks: usize,
157    /// Attribute records over all blocks.
158    pub records: usize,
159    /// Records per id.
160    pub record_ids: BTreeMap<u32, usize>,
161    /// Attribute lists that could not be parsed.
162    pub parse_errors: usize,
163    /// Texts or attribute lists not written back byte for byte.
164    pub round_trip_failures: usize,
165    /// The first problem found, for diagnosis.
166    pub first_error: Option<String>,
167}
168
169/// Layer effect settings (`LayerEffectInfo`) round trips.
170#[derive(Debug, Clone, Default, Serialize)]
171pub struct EffectReport {
172    /// `LayerEffectInfo` blobs.
173    pub blobs: usize,
174    /// Layers with `LayerEffectAttached`.
175    pub attached: usize,
176    /// Switched-on effects of attached layers, by section name.
177    pub enabled: BTreeMap<String, usize>,
178    /// Section names and how often they occur.
179    pub sections: BTreeMap<String, usize>,
180    /// Blobs that could not be parsed.
181    pub parse_errors: usize,
182    /// Blobs not written back byte for byte.
183    pub round_trip_failures: usize,
184    /// The first problem found, for diagnosis.
185    pub first_error: Option<String>,
186}
187
188/// Result of [`check_clip`]: every check of a .clip.
189#[derive(Debug, Clone, Default, Serialize)]
190pub struct ClipReport {
191    /// The container.
192    pub container: ContainerReport,
193    /// The database.
194    pub database: DatabaseReport,
195    /// The layer tree.
196    pub layers: LayerReport,
197    /// Raster pixels.
198    pub pixels: PixelReport,
199    /// External chunk references.
200    pub references: ReferenceReport,
201    /// Vector data.
202    pub vectors: VectorReport,
203    /// Text layers.
204    pub texts: TextReport,
205    /// Layer effects.
206    pub effects: EffectReport,
207    /// Size of the stored preview (`CanvasPreview`), if any.
208    pub preview: Option<(u32, u32)>,
209    /// Why the preview could not be decoded, if it could not.
210    pub preview_error: Option<String>,
211    /// Saving without changes gives the input bytes.
212    pub unchanged_save_identical: bool,
213    /// Checks that could not run, with why.
214    pub errors: Vec<String>,
215}
216
217impl ClipReport {
218    /// Whether every check passed.
219    pub fn ok(&self) -> bool {
220        self.errors.is_empty()
221            && self.container.findings.is_empty()
222            && self.container.reassembles
223            && self.container.writer_reproduces
224            && self.container.external_offsets_match
225            && self.container.external_ids_text
226            && self.database.unreadable_tables.is_empty()
227            && self.layers.unreachable == 0
228            && self.layers.tree_error.is_none()
229            && self.pixels.attribute_errors == 0
230            && self.pixels.attribute_round_trip_failures == 0
231            && self.pixels.payload_errors == 0
232            && self.pixels.payload_round_trip_failures == 0
233            && self.pixels.block_size_table_mismatches == 0
234            && self.pixels.checksum_mismatches == 0
235            && self.pixels.decode_errors == 0
236            && self.pixels.reencoded_different == 0
237            && self.references.unreferenced_chunks == 0
238            && self.vectors.parse_errors == 0
239            && self.vectors.round_trip_failures == 0
240            && self.texts.parse_errors == 0
241            && self.texts.round_trip_failures == 0
242            && self.effects.parse_errors == 0
243            && self.effects.round_trip_failures == 0
244            && self.preview_error.is_none()
245            && self.unchanged_save_identical
246    }
247}
248
249/// Check one .clip given as bytes.
250pub fn check_clip(data: Vec<u8>, options: CheckOptions) -> ClipReport {
251    let mut report = ClipReport::default();
252    let container = match Container::parse(data) {
253        Ok(container) => container,
254        Err(error) => {
255            report.errors.push(format!("container: {error}"));
256            return report;
257        }
258    };
259    check_container(&container, &mut report.container);
260    // The database report needs only the (small) database image, so it is
261    // made before the container moves into the typed view.
262    match container.sqlite().and_then(database::open_bytes) {
263        Ok(connection) => check_database(&connection, &mut report.database),
264        Err(error) => report.errors.push(format!("database: {error}")),
265    }
266    let file = match ClipFile::from_container(container) {
267        Ok(file) => file,
268        Err(error) => {
269            report.errors.push(format!("open: {error}"));
270            return report;
271        }
272    };
273    check_layers(&file, &mut report.layers);
274    if let Err(error) = check_pixels(&file, options, &mut report.pixels) {
275        report.errors.push(format!("pixels: {error}"));
276    }
277    if let Err(error) = check_references(&file, &mut report.references) {
278        report.errors.push(format!("references: {error}"));
279    }
280    if let Err(error) = check_vectors(&file, &mut report.vectors) {
281        report.errors.push(format!("vectors: {error}"));
282    }
283    if let Err(error) = check_texts(&file, &mut report.texts) {
284        report.errors.push(format!("texts: {error}"));
285    }
286    if let Err(error) = check_effects(&file, &mut report.effects) {
287        report.errors.push(format!("effects: {error}"));
288    }
289    match file.preview() {
290        Ok(Some(image)) => report.preview = Some((image.width, image.height)),
291        Ok(None) => {}
292        Err(error) => report.preview_error = Some(error.to_string()),
293    }
294    // Unchanged files are saved from the original bytes; together with
295    // `reassembles` / `writer_reproduces` this shows saving loses nothing.
296    report.unchanged_save_identical = !file.is_modified();
297    report
298}
299
300fn check_container(container: &Container, report: &mut ContainerReport) {
301    report.findings = container.findings();
302    report.reassembles = container.reassemble() == container.bytes();
303    report.external_chunks = container.external_chunks().count();
304    let file_id: [u8; FILE_ID_LENGTH] =
305        container.head.as_ref().and_then(|head| head.file_id.as_slice().try_into().ok()).unwrap_or([0; FILE_ID_LENGTH]);
306    let mut writer = ContainerWriter::new(file_id);
307    for chunk in container.external_chunks() {
308        writer.push_external(
309            chunk.external_id.clone().unwrap_or_default(),
310            &container.bytes()[chunk.payload_offset..chunk.body.end],
311        );
312    }
313    report.writer_reproduces = match container.sqlite() {
314        Ok(sqlite) => writer.assemble(sqlite).map(|bytes| bytes == container.bytes()).unwrap_or(false),
315        Err(_) => false,
316    };
317    // ExternalChunk.Offset must point at each chunk header.
318    report.external_offsets_match = (|| -> Result<bool> {
319        let connection = database::open_bytes(container.sqlite()?)?;
320        let rows = select_all(&connection, "ExternalChunk")?;
321        let mut matched = 0;
322        report.external_ids_text =
323            rows.iter().all(|row| matches!(row.get("ExternalID"), Some(rusqlite::types::Value::Text(_))));
324        for row in &rows {
325            let id = row.get("ExternalID").and_then(value_as_text).unwrap_or_default();
326            let chunk = container.external_chunk(&id);
327            if chunk.map(|chunk| chunk.header_offset as i64) == Some(row.int("Offset")) {
328                matched += 1;
329            }
330        }
331        Ok(matched == rows.len() && rows.len() == container.external_chunks().count())
332    })()
333    .unwrap_or(false);
334}
335
336fn check_database(connection: &rusqlite::Connection, report: &mut DatabaseReport) {
337    let tables = database::tables(connection).unwrap_or_default();
338    report.tables = tables.len();
339    let schema = crate::schema::Schema::read(connection).unwrap_or_default();
340    report.param_scheme_rows = schema.params.len();
341    let registered: HashSet<(String, String)> =
342        schema.params.iter().map(|param| (param.table.clone(), param.column.clone())).collect();
343    let bookkeeping = ["ExternalTableAndColumnName", "RemovedExternal", "ExternalChunk", "ElemScheme", "ParamScheme"];
344    for table in &tables {
345        match select_all(connection, table) {
346            Ok(rows) => report.rows += rows.len(),
347            Err(_) => report.unreadable_tables.push(table.clone()),
348        }
349        if table == "sqlite_sequence" || bookkeeping.contains(&table.as_str()) {
350            continue;
351        }
352        for (column, _) in database::columns(connection, table).unwrap_or_default() {
353            if column != "_PW_ID" && !registered.contains(&(table.clone(), column.clone())) {
354                report.unregistered_columns.push(format!("{table}.{column}"));
355            }
356        }
357    }
358}
359
360fn check_layers(file: &ClipFile, report: &mut LayerReport) {
361    report.layers = file.layers.layers.len();
362    report.unreachable = file.layers.unreachable.len();
363    for layer in &file.layers.layers {
364        *report.kinds.entry(layer.kind.name().to_owned()).or_default() += 1;
365        *report.blend_modes.entry(layer.blend).or_default() += 1;
366    }
367}
368
369fn check_pixels(file: &ClipFile, options: CheckOptions, report: &mut PixelReport) -> Result<()> {
370    let rows = select_all(file.database(), "Offscreen")?;
371    report.offscreens = rows.len();
372    let note = |report: &mut PixelReport, message: String| {
373        if report.first_error.is_none() {
374            report.first_error = Some(message);
375        }
376    };
377    for row in &rows {
378        let main_id = row.int("MainId");
379        let Some(raw_attribute) = row.blob("Attribute") else {
380            report.attribute_errors += 1;
381            continue;
382        };
383        let attribute = match Attribute::parse(raw_attribute) {
384            Ok(attribute) => attribute,
385            Err(error) => {
386                report.attribute_errors += 1;
387                note(report, format!("offscreen {main_id}: {error}"));
388                continue;
389            }
390        };
391        if attribute.to_bytes() != raw_attribute {
392            report.attribute_round_trip_failures += 1;
393        }
394        let parameter = match attribute.parameter() {
395            Ok(parameter) => parameter,
396            Err(error) => {
397                report.attribute_errors += 1;
398                note(report, format!("offscreen {main_id}: {error}"));
399                continue;
400            }
401        };
402        let kind = match parameter.kind() {
403            PixelKind::Color => "color",
404            PixelKind::Gray => "gray",
405            PixelKind::Monochrome => "monochrome",
406            PixelKind::Alpha => "alpha",
407            PixelKind::Unknown => "unknown",
408        };
409        *report.kinds.entry(kind.to_owned()).or_default() += 1;
410        report.blocks += parameter.block_count();
411        let Some(payload) = row.get("BlockData").and_then(value_as_text).and_then(|id| file.external(&id)) else {
412            continue;
413        };
414        report.stored += 1;
415        let data = match BlockData::parse(payload) {
416            Ok(data) => data,
417            Err(error) => {
418                report.payload_errors += 1;
419                note(report, format!("offscreen {main_id} blocks: {error}"));
420                continue;
421            }
422        };
423        if data.to_bytes() != payload {
424            report.payload_round_trip_failures += 1;
425        }
426        if data.blocks.len() != parameter.block_count() {
427            report.payload_errors += 1;
428            note(
429                report,
430                format!("offscreen {main_id}: {} blocks for a grid of {}", data.blocks.len(), parameter.block_count()),
431            );
432        }
433        if attribute.block_sizes() != Some(data.record_sizes().as_slice()) {
434            report.block_size_table_mismatches += 1;
435        }
436        report.checksum_mismatches += data.checksum_mismatches().len();
437        let expected = parameter.block_bytes();
438        for block in &data.blocks {
439            if block.zlib.is_none() {
440                continue;
441            }
442            report.stored_blocks += 1;
443            if !options.decode_pixels {
444                continue;
445            }
446            match block.inflate() {
447                Ok(Some(raw)) => {
448                    if raw.len() != expected {
449                        report.decode_errors += 1;
450                        note(
451                            report,
452                            format!(
453                                "offscreen {main_id} block {}: {} bytes, layout needs {expected}",
454                                block.index,
455                                raw.len()
456                            ),
457                        );
458                    }
459                    if options.reencode_pixels {
460                        if block.zlib.as_deref() == Some(compress(&raw).as_slice()) {
461                            report.reencoded_identical += 1;
462                        } else if block.zlib.as_deref() == crate::zlib::compress_default(&raw).ok().as_deref() {
463                            report.reencoded_identical_default += 1;
464                        } else {
465                            report.reencoded_different += 1;
466                            note(report, format!("offscreen {main_id} block {}: re-encoding differs", block.index));
467                        }
468                    }
469                }
470                Ok(None) => {}
471                Err(error) => {
472                    report.decode_errors += 1;
473                    note(report, format!("offscreen {main_id}: {error}"));
474                }
475            }
476        }
477    }
478    Ok(())
479}
480
481fn check_references(file: &ClipFile, report: &mut ReferenceReport) -> Result<()> {
482    let references = file.external_references()?;
483    let mut referenced = HashSet::new();
484    for (table, column, id) in &references {
485        let stored = file.external(id).is_some();
486        let entry = report.by_column.entry(format!("{table}.{column}")).or_default();
487        entry.0 += 1;
488        if stored {
489            entry.1 += 1;
490        } else {
491            report.not_stored += 1;
492        }
493        referenced.insert(id.clone());
494    }
495    report.references = references.len();
496    report.unreferenced_chunks = file.external_ids().filter(|id| !referenced.contains(*id)).count();
497    Ok(())
498}
499
500fn check_vectors(file: &ClipFile, report: &mut VectorReport) -> Result<()> {
501    if !database::table_exists(file.database(), "VectorObjectList")? {
502        return Ok(());
503    }
504    for row in select_all(file.database(), "VectorObjectList")? {
505        let Some(id) = row.get("VectorData").and_then(value_as_text) else { continue };
506        let Some(payload) = file.external(&id) else { continue };
507        report.payloads += 1;
508        match VectorData::parse(payload) {
509            Ok(data) => {
510                for object in &data.objects {
511                    report.objects += 1;
512                    report.points += object.points.len();
513                    *report.header_sizes.entry(object.header_size()).or_default() += 1;
514                    *report.point_sizes.entry(object.point_size).or_default() += 1;
515                }
516                if data.to_bytes() != payload {
517                    report.round_trip_failures += 1;
518                    report.first_error.get_or_insert_with(|| format!("{id}: round trip differs"));
519                }
520            }
521            Err(error) => {
522                report.parse_errors += 1;
523                report.first_error.get_or_insert_with(|| format!("{id}: {error}"));
524            }
525        }
526    }
527    Ok(())
528}
529
530fn check_effects(file: &ClipFile, report: &mut EffectReport) -> Result<()> {
531    if !database::has_column(file.database(), "Layer", "LayerEffectInfo")? {
532        return Ok(());
533    }
534    for layer in &file.layers.layers {
535        let Some(blob) = layer.row.blob("LayerEffectInfo") else { continue };
536        report.blobs += 1;
537        match crate::effect::LayerEffects::parse(blob) {
538            Ok(effects) => {
539                for (name, _) in &effects.sections {
540                    *report.sections.entry(name.clone()).or_default() += 1;
541                }
542                if layer.row.truthy("LayerEffectAttached") {
543                    report.attached += 1;
544                    for name in effects.enabled() {
545                        *report.enabled.entry(name.to_owned()).or_default() += 1;
546                    }
547                }
548                if effects.to_bytes() != blob {
549                    report.round_trip_failures += 1;
550                }
551            }
552            Err(error) => {
553                report.parse_errors += 1;
554                report.first_error.get_or_insert_with(|| format!("layer {}: {error}", layer.main_id));
555            }
556        }
557    }
558    Ok(())
559}
560
561fn check_texts(file: &ClipFile, report: &mut TextReport) -> Result<()> {
562    if !database::has_column(file.database(), "Layer", "TextLayerAttributes")? {
563        return Ok(());
564    }
565    for row in select_all(file.database(), "Layer")? {
566        let mut blobs: Vec<&[u8]> = Vec::new();
567        let mut arrays_ok = true;
568        if let Some(blob) = row.blob("TextLayerAttributes") {
569            blobs.push(blob);
570        }
571        if let Some(array) = row.blob("TextLayerAttributesArray") {
572            match text::split_array(array) {
573                Ok(parts) => {
574                    arrays_ok = text::join_array(&parts) == array;
575                    blobs.extend(parts);
576                }
577                Err(error) => {
578                    report.parse_errors += 1;
579                    report.first_error.get_or_insert_with(|| format!("layer {}: {error}", row.int("MainId")));
580                }
581            }
582        }
583        if let Some(array) = row.blob("TextLayerStringArray") {
584            match text::split_array(array) {
585                Ok(parts) => arrays_ok &= text::join_array(&parts) == array,
586                Err(error) => {
587                    report.parse_errors += 1;
588                    report.first_error.get_or_insert_with(|| format!("layer {}: {error}", row.int("MainId")));
589                }
590            }
591        }
592        if blobs.is_empty() {
593            continue;
594        }
595        report.layers += 1;
596        if !arrays_ok {
597            report.round_trip_failures += 1;
598        }
599        for blob in blobs {
600            report.blocks += 1;
601            match text::Attributes::parse(blob) {
602                Ok(attributes) => {
603                    report.records += attributes.records.len();
604                    for record in &attributes.records {
605                        *report.record_ids.entry(record.id).or_default() += 1;
606                    }
607                    if attributes.to_bytes() != blob {
608                        report.round_trip_failures += 1;
609                    }
610                }
611                Err(error) => {
612                    report.parse_errors += 1;
613                    report.first_error.get_or_insert_with(|| format!("layer {}: {error}", row.int("MainId")));
614                }
615            }
616        }
617    }
618    Ok(())
619}
620
621/// Self-check of a .cmc (R14).
622#[derive(Debug, Clone, Default, Serialize)]
623pub struct CmcReport {
624    /// `CanvasNode` rows.
625    pub nodes: usize,
626    /// Pages in the page list.
627    pub pages: usize,
628    /// Pages that are spreads.
629    pub spreads: usize,
630    /// Structural problems, and page files missing next to the .cmc.
631    pub findings: Vec<String>,
632    /// Saving without changes gives the input bytes.
633    pub unchanged_save_identical: bool,
634    /// Rewriting the page list in its own order and reading it back gives
635    /// the same pages without findings (the edit path, V4).
636    pub edit_round_trip: bool,
637    /// Checks that could not run, with why.
638    pub errors: Vec<String>,
639}
640
641impl CmcReport {
642    /// Whether every check passed.
643    pub fn ok(&self) -> bool {
644        self.errors.is_empty() && self.findings.is_empty() && self.unchanged_save_identical && self.edit_round_trip
645    }
646}
647
648/// Check one .cmc given as bytes; `folder` is where its pages live.
649pub fn check_cmc(data: Vec<u8>, folder: Option<&std::path::Path>) -> CmcReport {
650    let mut report = CmcReport::default();
651    let original = data.clone();
652    let mut file = match crate::cmc::CmcFile::from_bytes(data) {
653        Ok(file) => file,
654        Err(error) => {
655            report.errors.push(format!("open: {error}"));
656            return report;
657        }
658    };
659    report.nodes = file.nodes.len();
660    report.findings = file.findings(folder);
661    report.unchanged_save_identical = file.to_bytes().is_ok_and(|bytes| bytes == original);
662    let order: Vec<i64> = match file.pages() {
663        Ok(pages) => {
664            report.pages = pages.len();
665            report.spreads = pages.iter().filter(|node| node.spread).count();
666            pages.iter().map(|node| node.main_id).collect()
667        }
668        Err(error) => {
669            report.errors.push(format!("pages: {error}"));
670            return report;
671        }
672    };
673    let edited = file.reorder(&order).and_then(|()| file.to_bytes()).and_then(crate::cmc::CmcFile::from_bytes);
674    report.edit_round_trip = match edited {
675        Ok(again) => {
676            again.findings(None).is_empty()
677                && again.pages().is_ok_and(|pages| pages.iter().map(|node| node.main_id).eq(order.iter().copied()))
678        }
679        Err(error) => {
680            report.errors.push(format!("edit: {error}"));
681            false
682        }
683    };
684    report
685}