Skip to main content

csp_format/
version.rs

1//! Which CSP version saved a file, and what older versions will make of it.
2//!
3//! CSP writes no version number of its own (`Project.ProjectInternalVersion`
4//! is "1.1.0" in every file from 2017 to 2026). What changes is the schema
5//! registry: each CSP release registers every column it knows in
6//! `ParamScheme`, and labels a file has are never removed. So the registered
7//! labels tell the newest CSP generation that saved the file.
8//!
9//! How an older file shows that a newer CSP saved it again depends on the
10//! saver: CSP up to 2.x (and .cmc saves up to 4.2) appended the new labels
11//! after the old rows, while CSP 3.0 and newer rewrite a .clip's registry in
12//! their own order, keeping labels they retired (CSP 5.1.4 was seen doing
13//! this). Appended blocks and retired labels both mark a resaved file.
14//!
15//! Since 1.12 CSP marks its own file format level with `Layer.Has1_0_N`
16//! columns (N = 6 … 19 so far). They are registered with lock type 4 and
17//! lock scope 2 (the canvas): a CSP that meets a set column it does not know
18//! locks that scope, or only the layer when the alternative lock (1, 0,
19//! from `Has1_0_8` on) is registered. Without the alternative it locks the
20//! whole canvas, unless the file carries version compatibility information
21//! (Preferences > File > Version compatibility, since 1.13.0).
22//!
23//! The generation table and the CSP versions are learnt from the corpus and
24//! CSP's release notes; see `docs/spec/09-versions.md`.
25
26use std::collections::{BTreeSet, HashSet};
27
28use rusqlite::Connection;
29
30use crate::database::{has_column, quote};
31use crate::error::Result;
32use crate::schema::Schema;
33
34/// A group of CSP releases that register the same new columns.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
36pub struct Generation {
37    /// CSP versions estimated for this generation (from release notes and
38    /// the earliest saves in the corpus).
39    pub csp: &'static str,
40    /// Month of the earliest file of this generation in the corpus.
41    pub first_seen: &'static str,
42    /// Labels (`table`, `column`) first registered by this generation.
43    pub markers: &'static [(&'static str, &'static str)],
44}
45
46/// Known generations, newest first. A file belongs to the first one whose
47/// markers are all registered; files without any marker are older than all.
48pub const GENERATIONS: &[Generation] = &[
49    Generation {
50        csp: "5.1",
51        first_seen: "2026-10",
52        markers: &[("Layer", "Has1_0_19"), ("BrushStyle", "WaterEdgeStyle")],
53    },
54    Generation { csp: "5.0", first_seen: "2026-05", markers: &[("Layer", "Has1_0_18"), ("BrushStyle", "BlurKind")] },
55    Generation { csp: "4.2", first_seen: "2025-12", markers: &[("Layer", "Has1_0_15"), ("Canvas", "TimeLapseCodec")] },
56    Generation {
57        csp: "3.0-4.1",
58        first_seen: "2024-12",
59        markers: &[("Layer", "Has1_0_12"), ("Canvas", "CanvasWorkTime")],
60    },
61    Generation {
62        csp: "2.1-2.x",
63        first_seen: "2024-01",
64        markers: &[("Layer", "Has1_0_8"), ("BrushStyle", "BrushColorMixingMode")],
65    },
66    Generation { csp: "2.0", first_seen: "2023-04", markers: &[("Layer", "WebCooperationLayerEditable")] },
67    Generation {
68        csp: "1.12-1.13",
69        first_seen: "2022-10",
70        markers: &[("Layer", "Has1_0_7"), ("CameraNodeInfo", "CameraNodeUUID")],
71    },
72    Generation {
73        csp: "1.11-1.12",
74        first_seen: "2022-02",
75        markers: &[("Layer", "HasPrimitive"), ("PrimitiveInfo", "PrimitiveUUID")],
76    },
77    Generation { csp: "1.10.10-1.11", first_seen: "2021-06", markers: &[("BrushStyle", "DualStyleIndex")] },
78    Generation { csp: "1.10.5-1.10.9", first_seen: "2020-12", markers: &[("Canvas", "TimeLapseManagerIndex")] },
79    Generation { csp: "1.8-1.10.3", first_seen: "2020-10", markers: &[("Layer", "TextLayerVersion")] },
80];
81
82/// Labels older CSP versions registered and newer ones no longer do, with the
83/// first generation that stopped registering them. They stay in files made
84/// before (saves never remove labels).
85pub const RETIRED_LABELS: &[(&str, &str, &str)] = &[("CameraInfo", "CameraZoomWithDolly", "3.0-4.1")];
86
87const KNOWN_SCHEME: &str = include_str!("data/param_scheme.tsv");
88
89/// Labels only .cmc files register (the work settings in `Project`).
90const CMC_LABELS: &[&str] = &[
91    "ComicBindPosition",
92    "ComicCoverExist",
93    "ComicShowStyle",
94    "ComicStartPage",
95    "ComicWebtoonWrapDirection",
96    "Cover1Prefix",
97    "Cover1Suffix",
98    "Cover4Prefix",
99    "Cover4Suffix",
100    "MaxCanvasFileIndex",
101    "PrintingCompanyID",
102    "SentencePrefix",
103    "SentenceSuffix",
104];
105
106/// What a file tells about the CSP versions that saved it.
107#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
108pub struct VersionInfo {
109    /// Newest generation whose labels are registered (the last saver, at least).
110    pub saved_by: Option<Generation>,
111    /// Generation of the first block of `ParamScheme` rows (the creator).
112    /// `None` when that block has no marker, or when a CSP that rewrites the
113    /// registry saved the file again (`resaved` and the creator is lost).
114    pub created_by: Option<Generation>,
115    /// Whether the file was made by an older CSP and saved again by a newer
116    /// one: rows appended after the first block, or a label the saving
117    /// generation no longer registers.
118    pub resaved: bool,
119    /// Highest N of the registered `Layer.Has1_0_N` columns (the format level
120    /// of the saving CSP), if any.
121    pub format_level: Option<u32>,
122    /// Columns with lock type 4 and no link whose value is set in some row:
123    /// a CSP that does not know such a column locks the scope its
124    /// registration names (the canvas for scope 2), or the layer when the
125    /// alternative lock is 1 (checked in CSP 5.1.4).
126    pub locking_features: Vec<String>,
127    /// Highest N among set `Has1_0_N` columns: CSP needs format level N or
128    /// newer to edit the file without a lock.
129    pub needs_format_level: Option<u32>,
130    /// Registered labels this library does not know (a newer CSP).
131    pub unknown_labels: Vec<String>,
132    /// Whether `ParamScheme` has the `AlternativeLock*` columns (since 2.x).
133    pub alternative_lock_columns: bool,
134    /// `SQLITE_VERSION_NUMBER` of the library that last wrote the database.
135    pub sqlite_version: Option<u32>,
136}
137
138impl VersionInfo {
139    /// Read the version facts of an open database. `image` is the raw SQLite
140    /// database (for the writer's SQLite version), when available.
141    pub fn read(connection: &Connection, image: Option<&[u8]>) -> Result<Self> {
142        let schema = Schema::read(connection)?;
143        let labels: HashSet<(&str, &str)> =
144            schema.params.iter().map(|param| (param.table.as_str(), param.column.as_str())).collect();
145        let saved_by = generation_of(&labels);
146        let blocks = registration_blocks(&schema);
147        let first: HashSet<(&str, &str)> = blocks
148            .first()
149            .map(|block| {
150                block.iter().map(|&i| (schema.params[i].table.as_str(), schema.params[i].column.as_str())).collect()
151            })
152            .unwrap_or_default();
153        let mut created_by = generation_of(&first);
154        let mut resaved = blocks.len() > 1;
155        if retired_label_kept(&labels, saved_by) {
156            resaved = true;
157            if created_by == saved_by {
158                created_by = None;
159            }
160        }
161        let format_level =
162            schema.params.iter().filter(|p| p.table == "Layer").filter_map(|p| has_level(&p.column)).max();
163
164        let mut locking_features = Vec::new();
165        let mut needs_format_level = None;
166        for param in schema.params.iter().filter(|p| p.lock_type == 4 && p.flag == 0 && p.link_table.is_none()) {
167            if !has_column(connection, &param.table, &param.column)? {
168                continue;
169            }
170            let sql = format!(
171                "SELECT COUNT(*) FROM {} WHERE {} IS NOT NULL AND {} != 0",
172                quote(&param.table),
173                quote(&param.column),
174                quote(&param.column)
175            );
176            let set: i64 = connection.query_row(&sql, [], |row| row.get(0))?;
177            if set > 0 {
178                locking_features.push(format!("{}.{}", param.table, param.column));
179                if let Some(level) = has_level(&param.column) {
180                    needs_format_level = needs_format_level.max(Some(level));
181                }
182            }
183        }
184
185        let known = known_labels();
186        let unknown_labels: BTreeSet<String> = schema
187            .params
188            .iter()
189            .filter(|p| !known.contains(&(p.table.clone(), p.column.clone())))
190            .filter(|p| !RETIRED_LABELS.iter().any(|&(t, c, _)| (t, c) == (p.table.as_str(), p.column.as_str())))
191            .map(|p| format!("{}.{}", p.table, p.column))
192            .collect();
193        let alternative_lock_columns = has_column(connection, "ParamScheme", "AlternativeLockType")?;
194        let sqlite_version = image
195            .filter(|bytes| bytes.len() >= 100 && bytes.starts_with(b"SQLite format 3\0"))
196            .map(|bytes| u32::from_be_bytes([bytes[96], bytes[97], bytes[98], bytes[99]]));
197        Ok(VersionInfo {
198            saved_by,
199            created_by,
200            resaved,
201            format_level,
202            locking_features,
203            needs_format_level,
204            unknown_labels: unknown_labels.into_iter().collect(),
205            alternative_lock_columns,
206            sqlite_version,
207        })
208    }
209}
210
211/// The newest generation whose markers are all in `labels`.
212pub fn generation_of(labels: &HashSet<(&str, &str)>) -> Option<Generation> {
213    GENERATIONS.iter().copied().find(|generation| generation.markers.iter().all(|marker| labels.contains(marker)))
214}
215
216/// Whether `labels` keep a label that `saved_by` (or an older generation it
217/// descends from) had already retired: the file was made before.
218fn retired_label_kept(labels: &HashSet<(&str, &str)>, saved_by: Option<Generation>) -> bool {
219    let Some(saved_by) = saved_by else { return false };
220    let age = |csp: &str| GENERATIONS.iter().position(|g| g.csp == csp);
221    RETIRED_LABELS
222        .iter()
223        .any(|&(table, column, retired_in)| labels.contains(&(table, column)) && age(saved_by.csp) <= age(retired_in))
224}
225
226/// N of a `Has1_0_N` / `LayerEffectHas1_0_N` column.
227fn has_level(column: &str) -> Option<u32> {
228    let rest = column.strip_prefix("Has1_0_").or_else(|| column.strip_prefix("LayerEffectHas1_0_"))?;
229    rest.parse().ok()
230}
231
232/// `ParamScheme` rows split where a table name comes back after other tables:
233/// CSP writes the registry table by table, and a newer CSP appends the
234/// labels it adds as a new run of tables.
235fn registration_blocks(schema: &Schema) -> Vec<Vec<usize>> {
236    let mut blocks: Vec<Vec<usize>> = vec![Vec::new()];
237    let mut seen: HashSet<&str> = HashSet::new();
238    let mut last: Option<&str> = None;
239    for (index, param) in schema.params.iter().enumerate() {
240        let table = param.table.as_str();
241        if last != Some(table) {
242            if seen.contains(table) {
243                blocks.push(Vec::new());
244                seen.clear();
245            }
246            seen.insert(table);
247        }
248        blocks.last_mut().expect("one block").push(index);
249        last = Some(table);
250    }
251    blocks.retain(|block| !block.is_empty());
252    blocks
253}
254
255/// The labels of the newest CSP this library was checked against.
256fn known_labels() -> HashSet<(String, String)> {
257    let mut known: HashSet<(String, String)> = KNOWN_SCHEME
258        .lines()
259        .filter_map(|line| {
260            let mut fields = line.split('\t');
261            Some((fields.next()?.to_owned(), fields.next()?.to_owned()))
262        })
263        .collect();
264    known.extend(CMC_LABELS.iter().map(|label| ("Project".to_owned(), (*label).to_owned())));
265    known
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn labels<'a>(items: &[(&'a str, &'a str)]) -> HashSet<(&'a str, &'a str)> {
273        items.iter().copied().collect()
274    }
275
276    #[test]
277    fn generation_needs_every_marker() {
278        let only_level = labels(&[("Layer", "Has1_0_19")]);
279        assert_eq!(generation_of(&only_level), None);
280        let both = labels(&[("Layer", "Has1_0_19"), ("BrushStyle", "WaterEdgeStyle")]);
281        assert_eq!(generation_of(&both).map(|g| g.csp), Some("5.1"));
282        let older = labels(&[("Layer", "WebCooperationLayerEditable"), ("Canvas", "TimeLapseManagerIndex")]);
283        assert_eq!(generation_of(&older).map(|g| g.csp), Some("2.0"));
284    }
285
286    #[test]
287    fn has_levels() {
288        assert_eq!(has_level("Has1_0_19"), Some(19));
289        assert_eq!(has_level("LayerEffectHas1_0_19"), Some(19));
290        assert_eq!(has_level("HasPrimitive"), None);
291    }
292
293    #[test]
294    fn new_files_are_the_newest_generation() {
295        let file = crate::ClipFile::create(&crate::NewCanvas { width: 16, height: 16, ..Default::default() }).unwrap();
296        let info = file.version_info().unwrap();
297        assert_eq!(info.saved_by.map(|g| g.csp), Some(GENERATIONS[0].csp));
298        assert_eq!(info.created_by, info.saved_by);
299        assert!(!info.resaved);
300        assert_eq!(info.format_level, Some(19));
301        assert!(info.locking_features.is_empty());
302        assert!(info.unknown_labels.is_empty());
303    }
304
305    #[test]
306    fn appended_rows_and_set_markers() {
307        let file = crate::ClipFile::create(&crate::NewCanvas { width: 16, height: 16, ..Default::default() }).unwrap();
308        let db = file.database();
309        db.execute(
310            "INSERT INTO ParamScheme(TableName, LabelName, DataType, Flag, OwnerType, LockType, LockSpecified, LinkTable) \
311             VALUES ('Layer', 'Has1_0_20', 1, 0, 0, 4, 2, '')",
312            [],
313        )
314        .unwrap();
315        db.execute("ALTER TABLE Layer ADD COLUMN Has1_0_20 INTEGER DEFAULT NULL", []).unwrap();
316        db.execute("UPDATE Layer SET Has1_0_20 = 1 WHERE MainId = (SELECT MAX(MainId) FROM Layer)", []).unwrap();
317        let info = file.version_info().unwrap();
318        assert!(info.resaved, "a Layer row after other tables starts a new block");
319        assert_eq!(info.format_level, Some(20));
320        assert_eq!(info.locking_features, vec!["Layer.Has1_0_20".to_owned()]);
321        assert_eq!(info.needs_format_level, Some(20));
322        assert_eq!(info.unknown_labels, vec!["Layer.Has1_0_20".to_owned()]);
323    }
324
325    #[test]
326    fn retired_label_marks_a_rewritten_older_file() {
327        let file = crate::ClipFile::create(&crate::NewCanvas { width: 16, height: 16, ..Default::default() }).unwrap();
328        // As CSP 5.1.4 leaves a 2020 file it saved again: the retired label kept.
329        file.database()
330            .execute(
331                "INSERT INTO ParamScheme(TableName, LabelName, DataType, Flag, OwnerType, LockType, LockSpecified, LinkTable) \
332                 VALUES ('CameraInfo', 'CameraZoomWithDolly', 1, 0, 0, 0, 0, '')",
333                [],
334            )
335            .unwrap();
336        let info = file.version_info().unwrap();
337        assert!(info.resaved);
338        assert_eq!(info.created_by, None);
339        assert!(info.unknown_labels.is_empty());
340
341        let old = labels(&[
342            ("Layer", "Has1_0_8"),
343            ("BrushStyle", "BrushColorMixingMode"),
344            ("CameraInfo", "CameraZoomWithDolly"),
345        ]);
346        assert!(!retired_label_kept(&old, generation_of(&old)), "2.x still registered it");
347    }
348
349    #[test]
350    fn newest_known_scheme_is_the_newest_generation() {
351        let known = known_labels();
352        let refs: HashSet<(&str, &str)> = known.iter().map(|(t, c)| (t.as_str(), c.as_str())).collect();
353        assert_eq!(generation_of(&refs).map(|g| g.csp), Some(GENERATIONS[0].csp));
354    }
355}