Skip to main content

csp_format/
cmc.rs

1//! Comic / multi-page projects (.cmc): a plain SQLite file next to the pages.
2//!
3//! `Project` holds the work settings (binding, default page size, …) and
4//! `CanvasNode` the page list (spec: `docs/spec/07-cmc.md`):
5//!
6//! ```text
7//! root node (Type 0): FirstChildIndex → first page, SelectedIndex → selected page
8//! page node (Type 2): NextIndex → next page (0 ends the list),
9//!                     LinkPath ".:pageNNNN.clip" (relative to the .cmc folder),
10//!                     PageFlag 1 = two-page spread
11//! ```
12//!
13//! The order of the pages is the order of the linked list, not of the file
14//! names: page files keep their name when pages are moved. New page files
15//! take `Project.MaxCanvasFileIndex + 1`.
16
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use rusqlite::Connection;
21
22use crate::database::{self, Row, select_all};
23use crate::error::{Result, bail};
24
25/// `CanvasNode.Type` values.
26pub const NODE_ROOT: i64 = 0;
27/// `CanvasNode.Type` of a page.
28pub const NODE_PAGE: i64 = 2;
29
30/// One `CanvasNode` row.
31#[derive(Debug, Clone, PartialEq, serde::Serialize)]
32pub struct CanvasNode {
33    /// `MainId` of the row.
34    pub main_id: i64,
35    /// `Type`: [`NODE_ROOT`] or [`NODE_PAGE`].
36    pub kind: i64,
37    /// `MainId` of the next page (`NextIndex`, 0 ends the list).
38    pub next: i64,
39    /// `MainId` of the first page (`FirstChildIndex`, root node only).
40    pub first_child: i64,
41    /// `MainId` of the page selected in CSP (`SelectedIndex`, root node only).
42    pub selected: i64,
43    /// The page is a two-page spread (`PageFlag` bit 0).
44    pub spread: bool,
45    /// `LinkPath` as stored (".:page0001.clip"); see [`CanvasNode::relative_path`].
46    pub link_path: Option<String>,
47}
48
49impl CanvasNode {
50    fn from_row(row: &Row) -> Self {
51        CanvasNode {
52            main_id: row.int("MainId"),
53            kind: row.int("Type"),
54            next: row.int("NextIndex"),
55            first_child: row.int("FirstChildIndex"),
56            selected: row.int("SelectedIndex"),
57            spread: row.int("PageFlag") & 1 != 0,
58            link_path: row.text("LinkPath"),
59        }
60    }
61
62    /// The page file path relative to the .cmc folder ("page0001.clip").
63    pub fn relative_path(&self) -> Option<PathBuf> {
64        let link = self.link_path.as_deref()?;
65        let rest = link.strip_prefix(".:").unwrap_or(link);
66        Some(rest.split(':').collect())
67    }
68}
69
70/// Work settings from `Project` (the common subset; the row has more).
71#[derive(Debug, Clone, PartialEq, serde::Serialize)]
72pub struct CmcProject {
73    /// `ProjectInternalVersion`.
74    pub version: Option<String>,
75    /// `MainId` of the root `CanvasNode` (`ProjectRootCanvasNode`).
76    pub root_node: i64,
77    /// `ComicBindPosition`: 1 right binding (右綴じ), 0 left ([`crate::work::Binding`]).
78    pub bind_position: i64,
79    /// `ComicStartPage`: 0 the first page is on the left, 1 on the right.
80    pub start_page: i64,
81    /// The work has covers (`ComicCoverExist`).
82    pub cover: bool,
83    /// `DefaultPageUnit` (0 = px, 2 = mm in the samples).
84    pub page_unit: i64,
85    /// Default page width in `page_unit` (`DefaultPageWidth`).
86    pub page_width: f64,
87    /// Default page height in `page_unit` (`DefaultPageHeight`).
88    pub page_height: f64,
89    /// Default page resolution in dpi (`DefaultPageResolution`).
90    pub page_resolution: f64,
91    /// New pages are spreads (`DefaultPageDoublePage`).
92    pub double_page: bool,
93    /// Highest NNNN used by a page file (`MaxCanvasFileIndex`); new files take the next one.
94    pub max_canvas_file_index: i64,
95}
96
97impl CmcProject {
98    fn from_row(row: &Row) -> Self {
99        CmcProject {
100            version: row.text("ProjectInternalVersion"),
101            root_node: row.int("ProjectRootCanvasNode"),
102            bind_position: row.int("ComicBindPosition"),
103            start_page: row.int("ComicStartPage"),
104            cover: row.truthy("ComicCoverExist"),
105            page_unit: row.int("DefaultPageUnit"),
106            page_width: row.real("DefaultPageWidth"),
107            page_height: row.real("DefaultPageHeight"),
108            page_resolution: row.real("DefaultPageResolution"),
109            double_page: row.truthy("DefaultPageDoublePage"),
110            max_canvas_file_index: row.int("MaxCanvasFileIndex"),
111        }
112    }
113}
114
115/// An open .cmc. Changes stay in memory until [`CmcFile::save`].
116pub struct CmcFile {
117    original: Vec<u8>,
118    pub(crate) connection: Connection,
119    pub(crate) modified: bool,
120    /// The work settings.
121    pub project: CmcProject,
122    /// Every `CanvasNode` row, in table order (the page order is [`CmcFile::pages`]).
123    pub nodes: Vec<CanvasNode>,
124}
125
126impl CmcFile {
127    /// Reads a .cmc. The file is not kept open or changed.
128    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
129        Self::from_bytes(std::fs::read(path)?)
130    }
131
132    /// Parses the bytes of a .cmc (a plain SQLite file).
133    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
134        if !data.starts_with(b"SQLite format 3\0") {
135            bail!("Not a .cmc (SQLite) file");
136        }
137        let connection = database::open_bytes(&data)?;
138        let mut file =
139            CmcFile { original: data, connection, modified: false, project: empty_project(), nodes: Vec::new() };
140        file.reload()?;
141        Ok(file)
142    }
143
144    pub(crate) fn reload(&mut self) -> Result<()> {
145        let project = select_all(&self.connection, "Project")?;
146        let Some(project) = project.first() else { bail!(".cmc has no Project row") };
147        self.project = CmcProject::from_row(project);
148        self.nodes = select_all(&self.connection, "CanvasNode")?.iter().map(CanvasNode::from_row).collect();
149        Ok(())
150    }
151
152    /// The database (in memory; changes are saved by [`CmcFile::save`]).
153    pub fn database(&self) -> &Connection {
154        &self.connection
155    }
156
157    /// Which CSP generation saved the work file (see [`crate::version`]).
158    pub fn version_info(&self) -> Result<crate::VersionInfo> {
159        crate::VersionInfo::read(&self.connection, Some(&self.original))
160    }
161
162    /// The node with this `MainId`.
163    pub fn node(&self, main_id: i64) -> Option<&CanvasNode> {
164        self.nodes.iter().find(|node| node.main_id == main_id)
165    }
166
167    /// The root node (`Project.ProjectRootCanvasNode`, else the first node of type [`NODE_ROOT`]).
168    pub fn root(&self) -> Result<&CanvasNode> {
169        self.node(self.project.root_node)
170            .or_else(|| self.nodes.iter().find(|node| node.kind == NODE_ROOT))
171            .ok_or_else(|| crate::Error::format(".cmc has no root canvas node"))
172    }
173
174    /// Pages in reading order (the linked list from the root).
175    pub fn pages(&self) -> Result<Vec<&CanvasNode>> {
176        let mut pages = Vec::new();
177        let mut seen = HashSet::new();
178        let mut id = self.root()?.first_child;
179        while id != 0 {
180            if !seen.insert(id) {
181                bail!("Page list loops at canvas node {id}");
182            }
183            let Some(node) = self.node(id) else { bail!("Page list points to missing canvas node {id}") };
184            pages.push(node);
185            id = node.next;
186        }
187        Ok(pages)
188    }
189
190    /// Each page's position in the work (pages counted from 0, a spread
191    /// counts 2) and side, from the page list: what CSP writes into the
192    /// page files' `ComicPageIndex` / `ComicIsLeftPage` when it opens them.
193    /// Works with a cover number their 表3・表4 pages differently.
194    pub fn positions(&self) -> Result<Vec<crate::work::PageSlot>> {
195        let start = crate::work::StartSide::from_code(self.project.start_page);
196        let mut position = 0;
197        let mut slots = Vec::new();
198        for page in self.pages()? {
199            slots.push(crate::work::PageSlot {
200                position,
201                spread: page.spread,
202                left: crate::work::is_left_page(position, start),
203            });
204            position += if page.spread { 2 } else { 1 };
205        }
206        Ok(slots)
207    }
208
209    /// Absolute path of each page file, given the .cmc's folder.
210    pub fn page_paths(&self, folder: &Path) -> Result<Vec<PathBuf>> {
211        Ok(self.pages()?.iter().filter_map(|node| node.relative_path()).map(|path| folder.join(path)).collect())
212    }
213
214    /// The thumbnail CSP shows for a page in the work's page list: the page
215    /// file's `CanvasPreview` (`CanvasNode.CanvasThumbnail` is unset in every
216    /// .cmc seen). None when the page has no file or no preview.
217    pub fn page_thumbnail(&self, folder: &Path, node: &CanvasNode) -> Result<Option<crate::RgbaImage>> {
218        let Some(path) = node.relative_path().map(|path| folder.join(path)) else { return Ok(None) };
219        if !path.is_file() {
220            return Ok(None);
221        }
222        crate::ClipFile::open(path)?.preview()
223    }
224
225    /// Structural problems; with `folder`, also page files that are missing.
226    pub fn findings(&self, folder: Option<&Path>) -> Vec<String> {
227        let mut findings = Vec::new();
228        let pages = match self.pages() {
229            Ok(pages) => pages,
230            Err(error) => return vec![error.to_string()],
231        };
232        let listed: HashSet<i64> = pages.iter().map(|node| node.main_id).collect();
233        let root = self.root().map(|root| root.main_id).unwrap_or(0);
234        for node in &self.nodes {
235            if node.main_id != root && !listed.contains(&node.main_id) {
236                findings.push(format!("Canvas node {} is not in the page list", node.main_id));
237            }
238        }
239        let mut paths = HashSet::new();
240        for node in &pages {
241            match node.relative_path() {
242                None => findings.push(format!("Page node {} has no LinkPath", node.main_id)),
243                Some(path) => {
244                    if let Some(index) = file_index(&path)
245                        && index > self.project.max_canvas_file_index
246                    {
247                        findings.push(format!("{} is above MaxCanvasFileIndex", path.display()));
248                    }
249                    if !paths.insert(path.clone()) {
250                        findings.push(format!("{} is used by two pages", path.display()));
251                    }
252                    if let Some(folder) = folder
253                        && !folder.join(&path).is_file()
254                    {
255                        findings.push(format!("{} is missing", path.display()));
256                    }
257                }
258            }
259        }
260        if let Ok(selected) = self.root().map(|root| root.selected)
261            && selected != 0
262            && !listed.contains(&selected)
263        {
264            findings.push(format!("Selected node {selected} is not a page"));
265        }
266        let max_index = self
267            .connection
268            .query_row("SELECT MaxIndex FROM ElemScheme WHERE TableName = 'CanvasNode'", [], |row| row.get::<_, i64>(0))
269            .unwrap_or(i64::MAX);
270        if let Some(largest) = self.nodes.iter().map(|node| node.main_id).max()
271            && largest > max_index
272        {
273            findings.push(format!("CanvasNode MainId {largest} is above ElemScheme.MaxIndex {max_index}"));
274        }
275        findings
276    }
277
278    fn set_links(&mut self, order: &[i64]) -> Result<()> {
279        let root = self.root()?.main_id;
280        let first = order.first().copied().unwrap_or(0);
281        self.connection.execute("UPDATE CanvasNode SET FirstChildIndex = ?1 WHERE MainId = ?2", (first, root))?;
282        for (i, id) in order.iter().enumerate() {
283            let next = order.get(i + 1).copied().unwrap_or(0);
284            self.connection.execute("UPDATE CanvasNode SET NextIndex = ?1 WHERE MainId = ?2", (next, id))?;
285        }
286        let selected = self.root()?.selected;
287        if !order.contains(&selected) {
288            self.connection.execute("UPDATE CanvasNode SET SelectedIndex = ?1 WHERE MainId = ?2", (first, root))?;
289        }
290        self.modified = true;
291        self.reload()
292    }
293
294    /// Put the pages in the given order (every page exactly once).
295    pub fn reorder(&mut self, order: &[i64]) -> Result<()> {
296        let current: HashSet<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
297        let wanted: HashSet<i64> = order.iter().copied().collect();
298        if wanted.len() != order.len() || wanted != current {
299            bail!("The new order must list every page exactly once");
300        }
301        self.set_links(order)
302    }
303
304    /// Add a page after `after` (None = at the end). Returns the new node's
305    /// MainId and the page file name to create next to the .cmc (for
306    /// example a copy of another page).
307    pub fn add_page(&mut self, after: Option<i64>, spread: bool) -> Result<(i64, String)> {
308        let mut order: Vec<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
309        let position = match after {
310            None => order.len(),
311            Some(id) => order
312                .iter()
313                .position(|page| *page == id)
314                .map(|i| i + 1)
315                .ok_or_else(|| crate::Error::format(format!("Canvas node {id} is not a page")))?,
316        };
317        let main_id = crate::schema::allocate_main_id(&self.connection, "CanvasNode")?;
318        let file_index = self.project.max_canvas_file_index + 1;
319        let name = format!("page{file_index:04}.clip");
320        // Copy the settings columns from an existing page, as CSP's new pages
321        // share them.
322        let template = order.last().copied();
323        let warning: (i64, i64) = match template {
324            Some(id) => self.connection.query_row(
325                "SELECT COALESCE(BookBindWarning, 0), COALESCE(BookBindWarningDef, 1) FROM CanvasNode WHERE MainId = ?1",
326                [id],
327                |row| Ok((row.get(0)?, row.get(1)?)),
328            )?,
329            None => (0, 1),
330        };
331        self.connection.execute(
332            "INSERT INTO CanvasNode(MainId, Type, NextIndex, FirstChildIndex, SelectedIndex, CanvasIndex, PageFlag, \
333             Memo, BookBindMemo, BookBindWarning, BookBindWarningDef, LinkPath) \
334             VALUES (?1, ?2, 0, 0, 0, 0, ?3, '', '', ?4, ?5, ?6)",
335            (main_id, NODE_PAGE, spread as i64, warning.0, warning.1, format!(".:{name}")),
336        )?;
337        self.connection.execute("UPDATE Project SET MaxCanvasFileIndex = ?1", [file_index])?;
338        self.reload()?;
339        order.insert(position, main_id);
340        self.set_links(&order)?;
341        Ok((main_id, name))
342    }
343
344    /// Take a page out of the list and delete its node. The page file is
345    /// left alone (the caller decides whether to delete it).
346    pub fn remove_page(&mut self, main_id: i64) -> Result<Option<PathBuf>> {
347        let mut order: Vec<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
348        let Some(position) = order.iter().position(|id| *id == main_id) else {
349            bail!("Canvas node {main_id} is not a page");
350        };
351        let path = self.node(main_id).and_then(CanvasNode::relative_path);
352        order.remove(position);
353        self.connection.execute("DELETE FROM CanvasNode WHERE MainId = ?1", [main_id])?;
354        self.reload()?;
355        self.set_links(&order)?;
356        Ok(path)
357    }
358
359    /// Something was changed since the file was read.
360    pub fn is_modified(&self) -> bool {
361        self.modified
362    }
363
364    /// The file bytes: the original bytes when nothing changed.
365    pub fn to_bytes(&self) -> Result<Vec<u8>> {
366        if !self.modified {
367            return Ok(self.original.clone());
368        }
369        database::serialize(&self.connection)
370    }
371
372    /// Writes [`CmcFile::to_bytes`] to a file.
373    ///
374    /// `path` is replaced if it exists, and nothing checks whether it is
375    /// the file this was read from. Saving over the source is possible (its
376    /// bytes were read into memory), but a failed or interrupted write then
377    /// loses the original; keeping inputs intact is the caller's job. The
378    /// `cspf` tool refuses an output that is one of its inputs.
379    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
380        std::fs::write(path, self.to_bytes()?)?;
381        Ok(())
382    }
383}
384
385/// The NNNN of "pageNNNN.clip".
386fn file_index(path: &Path) -> Option<i64> {
387    let stem = path.file_stem()?.to_str()?;
388    stem.strip_prefix("page")?.parse().ok()
389}
390
391fn empty_project() -> CmcProject {
392    CmcProject {
393        version: None,
394        root_node: 0,
395        bind_position: 0,
396        start_page: 0,
397        cover: false,
398        page_unit: 0,
399        page_width: 0.0,
400        page_height: 0.0,
401        page_resolution: 0.0,
402        double_page: false,
403        max_canvas_file_index: 0,
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    /// A synthetic .cmc with the tables and columns CSP writes.
412    fn sample(pages: usize) -> Vec<u8> {
413        let connection = Connection::open_in_memory().unwrap();
414        connection
415            .execute_batch(
416                "CREATE TABLE Project(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, ProjectInternalVersion TEXT DEFAULT NULL, \
417                 ProjectRootCanvasNode INTEGER DEFAULT NULL, ComicBindPosition INTEGER DEFAULT NULL, ComicStartPage INTEGER DEFAULT NULL, \
418                 ComicCoverExist INTEGER DEFAULT NULL, DefaultPageUnit INTEGER DEFAULT NULL, DefaultPageWidth REAL DEFAULT NULL, \
419                 DefaultPageHeight REAL DEFAULT NULL, DefaultPageResolution REAL DEFAULT NULL, DefaultPageDoublePage INTEGER DEFAULT NULL, \
420                 MaxCanvasFileIndex INTEGER DEFAULT NULL);
421                 CREATE TABLE CanvasNode(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, MainId INTEGER DEFAULT NULL, Type INTEGER DEFAULT NULL, \
422                 NextIndex INTEGER DEFAULT NULL, FirstChildIndex INTEGER DEFAULT NULL, SelectedIndex INTEGER DEFAULT NULL, \
423                 CanvasIndex INTEGER DEFAULT NULL, PageFlag INTEGER DEFAULT NULL, Memo TEXT DEFAULT NULL, BookBindMemo TEXT DEFAULT NULL, \
424                 BookBindWarning INTEGER DEFAULT NULL, BookBindWarningDef INTEGER DEFAULT NULL, LinkPath TEXT DEFAULT NULL);
425                 CREATE TABLE ElemScheme(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, TableName TEXT DEFAULT NULL, ElemType INTEGER DEFAULT NULL, \
426                 MaxIndex INTEGER DEFAULT NULL);",
427            )
428            .unwrap();
429        connection
430            .execute(
431                "INSERT INTO Project VALUES (1, '1.1.0', 1, 0, 0, 0, 2, 257.0, 364.0, 350.0, 0, ?1)",
432                [pages as i64],
433            )
434            .unwrap();
435        let first = if pages > 0 { 2 } else { 0 };
436        connection
437            .execute("INSERT INTO CanvasNode VALUES (1, 1, 0, 0, ?1, ?1, 0, 0, '', '', 0, 0, NULL)", [first])
438            .unwrap();
439        for page in 1..=pages as i64 {
440            let next = if page < pages as i64 { page + 2 } else { 0 };
441            connection
442                .execute(
443                    "INSERT INTO CanvasNode VALUES (?1, ?1, 2, ?2, 0, 0, 0, 0, '', '', 0, 1, ?3)",
444                    (page + 1, next, format!(".:page{page:04}.clip")),
445                )
446                .unwrap();
447        }
448        connection.execute("INSERT INTO ElemScheme VALUES (1, 'CanvasNode', 0, ?1)", [pages as i64 + 1]).unwrap();
449        database::serialize(&connection).unwrap()
450    }
451
452    #[test]
453    fn reads_the_page_list() {
454        let file = CmcFile::from_bytes(sample(3)).unwrap();
455        let pages: Vec<String> =
456            file.pages().unwrap().iter().map(|node| node.relative_path().unwrap().display().to_string()).collect();
457        assert_eq!(pages, ["page0001.clip", "page0002.clip", "page0003.clip"]);
458        assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
459        assert_eq!(file.to_bytes().unwrap(), sample(3));
460    }
461
462    #[test]
463    fn edits_keep_the_list_consistent() {
464        let mut file = CmcFile::from_bytes(sample(3)).unwrap();
465        file.reorder(&[4, 2, 3]).unwrap();
466        let (id, name) = file.add_page(Some(2), false).unwrap();
467        assert_eq!((id, name.as_str()), (5, "page0004.clip"));
468        assert_eq!(file.remove_page(3).unwrap(), Some(PathBuf::from("page0002.clip")));
469        let file = CmcFile::from_bytes(file.to_bytes().unwrap()).unwrap();
470        let order: Vec<i64> = file.pages().unwrap().iter().map(|node| node.main_id).collect();
471        assert_eq!(order, [4, 2, 5]);
472        assert_eq!(file.project.max_canvas_file_index, 4);
473        assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
474        let mut file = file;
475        assert!(file.reorder(&[4, 2]).is_err());
476        assert!(file.reorder(&[4, 2, 2]).is_err());
477    }
478
479    #[test]
480    fn loops_are_reported() {
481        let mut file = CmcFile::from_bytes(sample(2)).unwrap();
482        file.connection.execute("UPDATE CanvasNode SET NextIndex = 2 WHERE MainId = 3", []).unwrap();
483        file.reload().unwrap();
484        assert!(file.pages().is_err());
485        assert!(!file.findings(None).is_empty());
486    }
487}