Skip to main content

csp_format/
cmc_pages.rs

1//! A work (.cmc) for given page files, made from a template work (from
2//! name-studio's clip-core `write_cmc`).
3//!
4//! Page files only become one work in CSP EX once a .cmc next to them lists
5//! them in order. A CSP-made .cmc is the template, so every work-wide
6//! setting (binding, start page, story info, default page) is kept; its page
7//! list (`CanvasNode`, a linked list under the root node) is replaced by one
8//! node per page file. Page nodes copy the template's first page node and
9//! get the new link, order and size (the `Link*` columns are CSP's cache of
10//! the page file's size and save time, written only when the template has
11//! them; readers do not use them, see `docs/spec/07-cmc.md`).
12
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use rusqlite::types::Value;
17
18use crate::clip::ClipFile;
19use crate::cmc::{CmcFile, NODE_PAGE};
20use crate::copy::insert_row;
21use crate::database::{Row, has_column, has_table, quote, select, select_all};
22use crate::error::{Error, Result, bail};
23
24/// What a page node records about its page file.
25#[derive(Debug, Clone, PartialEq)]
26pub struct PageLink {
27    /// File name, in the folder of the .cmc.
28    pub file_name: String,
29    /// A two-page spread (`Canvas.CanvasDoublePage`).
30    pub spread: bool,
31    /// `CanvasUnit`, `CanvasWidth`, `CanvasHeight`, `CanvasResolution`.
32    pub unit: i64,
33    /// `CanvasWidth`, in `unit`.
34    pub width: f64,
35    /// `CanvasHeight`, in `unit`.
36    pub height: f64,
37    /// `CanvasResolution`, in dpi.
38    pub resolution: f64,
39    /// Save time as year, month, day, hour, minute, second (CSP writes the
40    /// PC's local time; [`PageLink::read`] uses UTC, having no time zone).
41    pub modified: [i64; 6],
42}
43
44impl PageLink {
45    /// Read the size and modification time of a page file.
46    pub fn read(path: &Path) -> Result<PageLink> {
47        let file = ClipFile::open(path)?;
48        let row = select_all(file.database(), "Canvas")?
49            .into_iter()
50            .next()
51            .ok_or_else(|| Error::format(format!("No Canvas row in {}", path.display())))?;
52        let seconds = std::fs::metadata(path)?.modified()?.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
53        Ok(PageLink {
54            file_name: path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(),
55            spread: row.int("CanvasDoublePage") != 0,
56            unit: row.int("CanvasUnit"),
57            width: row.real("CanvasWidth"),
58            height: row.real("CanvasHeight"),
59            resolution: row.real("CanvasResolution"),
60            modified: civil_time(seconds as i64),
61        })
62    }
63}
64
65/// Seconds since 1970 as UTC year, month, day, hour, minute, second.
66pub fn civil_time(seconds: i64) -> [i64; 6] {
67    let (days, rest) = (seconds.div_euclid(86_400), seconds.rem_euclid(86_400));
68    // Howard Hinnant's days-to-civil.
69    let z = days + 719_468;
70    let era = z.div_euclid(146_097);
71    let doe = z - era * 146_097;
72    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
73    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
74    let mp = (5 * doy + 2) / 153;
75    let day = doy - (153 * mp + 2) / 5 + 1;
76    let month = if mp < 10 { mp + 3 } else { mp - 9 };
77    let year = yoe + era * 400 + (month <= 2) as i64;
78    [year, month, day, rest / 3600, rest / 60 % 60, rest % 60]
79}
80
81/// The highest number in the page file names (`page0012.clip` → 12).
82fn max_file_index(links: &[PageLink]) -> i64 {
83    links
84        .iter()
85        .filter_map(|link| {
86            let lower = link.file_name.to_lowercase();
87            let stem = lower.strip_suffix(".clip")?;
88            let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect();
89            digits.chars().rev().collect::<String>().parse().ok()
90        })
91        .max()
92        .unwrap_or(links.len() as i64)
93}
94
95fn node_values(link: &PageLink) -> Vec<(&'static str, Value)> {
96    let int = Value::Integer;
97    let [year, month, day, hour, minute, second] = link.modified;
98    vec![
99        ("FirstChildIndex", int(0)),
100        ("SelectedIndex", int(0)),
101        ("CanvasIndex", int(0)),
102        ("PageFlag", int(link.spread as i64)),
103        ("Memo", Value::Text(String::new())),
104        ("BookBindMemo", Value::Text(String::new())),
105        ("LinkPath", Value::Text(format!(".:{}", link.file_name))),
106        ("LinkWriteYear", int(year)),
107        ("LinkWriteMonth", int(month)),
108        ("LinkWriteDay", int(day)),
109        ("LinkWriteHour", int(hour)),
110        ("LinkWriteMinute", int(minute)),
111        ("LinkWriteSecond", int(second)),
112        ("LinkIsFolder", int(0)),
113        ("LinkUnit", int(link.unit)),
114        ("LinkWidth", Value::Real(link.width)),
115        ("LinkHeight", Value::Real(link.height)),
116        ("LinkResolution", Value::Real(link.resolution)),
117    ]
118}
119
120impl CmcFile {
121    /// Replace the page list with one page per link, in order, keeping
122    /// every work setting. The page files must be (or be put) in the
123    /// folder of the saved .cmc.
124    pub fn set_pages(&mut self, links: &[PageLink]) -> Result<()> {
125        if links.is_empty() {
126            bail!("A work needs at least one page");
127        }
128        let connection = &self.connection;
129        let root = self.project.root_node;
130        let base = select(connection, "SELECT * FROM CanvasNode WHERE Type = ?1 ORDER BY MainId LIMIT 1", [NODE_PAGE])?
131            .into_iter()
132            .next()
133            .unwrap_or_else(|| Row::new(Vec::new(), Vec::new()));
134        connection.execute("DELETE FROM CanvasNode WHERE MainId != ?1", [root])?;
135        let ids: Vec<i64> = (1..=links.len() as i64 + 1).filter(|id| *id != root).take(links.len()).collect();
136        for (position, (id, link)) in ids.iter().zip(links).enumerate() {
137            let mut columns: Vec<String> = base.columns().iter().filter(|c| *c != "_PW_ID").cloned().collect();
138            let mut values: Vec<Value> = columns.iter().map(|c| base.get(c).cloned().unwrap_or(Value::Null)).collect();
139            let next = ids.get(position + 1).copied().unwrap_or(0);
140            let mut set = |column: &str, value: Value| match columns.iter().position(|c| c == column) {
141                Some(i) => values[i] = value,
142                None => {
143                    columns.push(column.to_owned());
144                    values.push(value);
145                }
146            };
147            for (column, value) in node_values(link) {
148                set(column, value);
149            }
150            // CSP's own page rows have `_PW_ID` = `MainId`.
151            set("_PW_ID", Value::Integer(*id));
152            set("MainId", Value::Integer(*id));
153            set("Type", Value::Integer(NODE_PAGE));
154            set("NextIndex", Value::Integer(next));
155            insert_row(connection, "CanvasNode", &Row::new(columns, values))?;
156        }
157        for column in ["FirstChildIndex", "SelectedIndex"] {
158            if has_column(connection, "CanvasNode", column)? {
159                connection.execute(
160                    &format!("UPDATE CanvasNode SET {} = ?1 WHERE MainId = ?2", quote(column)),
161                    (ids[0], root),
162                )?;
163            }
164        }
165        if has_table(connection, "ElemScheme")? {
166            let top = ids.iter().copied().chain([root]).max().unwrap_or(root);
167            connection.execute("UPDATE ElemScheme SET MaxIndex = ?1 WHERE TableName = 'CanvasNode'", [top])?;
168        }
169        if has_column(connection, "Project", "MaxCanvasFileIndex")? {
170            connection.execute("UPDATE Project SET MaxCanvasFileIndex = ?1", [max_file_index(links)])?;
171        }
172        self.modified = true;
173        self.reload()
174    }
175
176    /// Set one `Project` column; false when the work file does not have it.
177    pub fn set_project_value(&mut self, column: &str, value: Value) -> Result<bool> {
178        if !has_column(&self.connection, "Project", column)? {
179            return Ok(false);
180        }
181        self.connection.execute(&format!("UPDATE Project SET {} = ?1", quote(column)), [value])?;
182        self.modified = true;
183        self.reload()?;
184        Ok(true)
185    }
186
187    /// The work settings the pages keep a copy of in their `Canvas` row:
188    /// the story info (`ComicStory*`: 作品名, 話数, 作者名, ノンブル) and
189    /// `ComicBindPosition`. Pages made from another work's template would
190    /// otherwise show that work's.
191    pub fn page_canvas_values(&self) -> Result<Vec<(String, Value)>> {
192        let project = select_all(&self.connection, "Project")?
193            .into_iter()
194            .next()
195            .ok_or_else(|| Error::format(".cmc has no Project row"))?;
196        Ok(project
197            .iter()
198            .filter(|(column, _)| column.starts_with("ComicStory") || *column == "ComicBindPosition")
199            .map(|(column, value)| (column.to_owned(), value.clone()))
200            .collect())
201    }
202
203    /// Save this work (a template) as `output` listing `pages` in order.
204    /// Every page file must already be in the folder of `output`, because
205    /// CSP finds pages relative to the .cmc.
206    pub fn save_with_pages(&mut self, output: &Path, pages: &[PathBuf]) -> Result<()> {
207        let folder = match output.parent() {
208            Some(parent) if !parent.as_os_str().is_empty() => parent.canonicalize()?,
209            _ => Path::new(".").canonicalize()?,
210        };
211        let mut links = Vec::with_capacity(pages.len());
212        for page in pages {
213            if page.canonicalize()?.parent() != Some(folder.as_path()) {
214                bail!("Page {} must be in the folder of {}", page.display(), output.display());
215            }
216            links.push(PageLink::read(page)?);
217        }
218        self.set_pages(&links)?;
219        self.save(output)
220    }
221}
222
223/// Seconds since 1970 of `time`, for [`civil_time`].
224pub fn unix_seconds(time: SystemTime) -> i64 {
225    time.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::work::NewWork;
232
233    fn link(name: &str, spread: bool) -> PageLink {
234        PageLink {
235            file_name: name.into(),
236            spread,
237            unit: 2,
238            width: 192.0,
239            height: 267.0,
240            resolution: 600.0,
241            modified: [2026, 10, 26, 12, 0, 0],
242        }
243    }
244
245    #[test]
246    fn civil_times() {
247        assert_eq!(civil_time(0), [1970, 1, 1, 0, 0, 0]);
248        assert_eq!(civil_time(951_782_400 + 3661), [2000, 2, 29, 1, 1, 1]);
249        assert_eq!(civil_time(1_792_972_800), [2026, 10, 26, 0, 0, 0]);
250    }
251
252    #[test]
253    fn pages_replace_the_list_and_keep_the_settings() {
254        let work = NewWork { pages: 4, title: "t".into(), ..Default::default() }.build().unwrap();
255        let mut file = CmcFile::from_bytes(work.cmc).unwrap();
256        let before = file.project.clone();
257        let links = [link("page0003.clip", false), link("page0010.clip", true), link("a.clip", false)];
258        file.set_pages(&links).unwrap();
259        assert!(file.set_project_value("ComicStoryNombreStart", Value::Integer(5)).unwrap());
260        assert!(!file.set_project_value("NoSuchColumn", Value::Integer(5)).unwrap());
261        let mut file = CmcFile::from_bytes(file.to_bytes().unwrap()).unwrap();
262        let pages = file.pages().unwrap();
263        let names: Vec<String> = pages.iter().map(|n| n.relative_path().unwrap().display().to_string()).collect();
264        assert_eq!(names, ["page0003.clip", "page0010.clip", "a.clip"]);
265        assert_eq!(pages.iter().map(|n| n.spread).collect::<Vec<_>>(), [false, true, false]);
266        assert_eq!(file.project.max_canvas_file_index, 10);
267        assert_eq!(file.project.bind_position, before.bind_position);
268        assert_eq!(file.root().unwrap().selected, pages[0].main_id);
269        assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
270        let values = file.page_canvas_values().unwrap();
271        assert!(values.iter().any(|(c, v)| c == "ComicStoryNombreStart" && *v == Value::Integer(5)));
272        assert!(values.iter().any(|(c, _)| c == "ComicBindPosition"));
273        assert!(file.set_pages(&[]).is_err());
274    }
275}