Skip to main content

csp_format/
work.rs

1//! New comic works (.cmc and its page files) without a template (R18).
2//!
3//! The .cmc gets the tables and schema registry of a work CSP created
4//! (`data/cmc_schema.sql`, `data/cmc_*_scheme.tsv`; structure only). The
5//! rows are written here from [`NewWork`]: the work settings, the page list
6//! and one page file per page (or spread), each made by [`ClipFile::create`]
7//! and given the comic settings CSP writes into a new work's pages
8//! (`docs/spec/07-cmc.md`).
9//!
10//! Page positions follow CSP (checked with works made in CSP EX):
11//!
12//! ```text
13//! position: pages counted in reading order from 0 (a spread counts 2)
14//! side:     left when the position is even, swapped when the work starts on the right
15//! spread:   two facing pages; it starts on the right page with right binding
16//!           and on the left page with left binding
17//! ```
18
19use std::path::{Path, PathBuf};
20
21use rusqlite::Connection;
22use rusqlite::types::Value;
23
24use crate::clip::ClipFile;
25use crate::cmc::NODE_PAGE;
26use crate::create::{NewCanvas, comic_story, fill_registry, insert};
27use crate::database;
28use crate::error::{Result, bail};
29
30const SCHEMA: &str = include_str!("data/cmc_schema.sql");
31const PARAM_SCHEME: &str = include_str!("data/cmc_param_scheme.tsv");
32const ELEM_SCHEME: &str = include_str!("data/cmc_elem_scheme.tsv");
33
34const ROOT_NODE: i64 = 1;
35/// `CanvasUnit` / `DefaultPageUnit` for millimetres.
36const UNIT_MM: i64 = 2;
37
38/// Where the pages are bound (`ComicBindPosition`).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Binding {
41    /// 右綴じ (1): pages are read from right to left.
42    Right,
43    /// 左綴じ (0).
44    Left,
45}
46
47impl Binding {
48    /// The stored `ComicBindPosition` value.
49    pub fn code(self) -> i64 {
50        match self {
51            Binding::Right => 1,
52            Binding::Left => 0,
53        }
54    }
55
56    /// Reads `ComicBindPosition` (anything but 1 is left binding).
57    pub fn from_code(code: i64) -> Self {
58        if code == 1 { Binding::Right } else { Binding::Left }
59    }
60}
61
62/// Which side the first page is on (`ComicStartPage`).
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum StartSide {
65    /// 左から (0).
66    Left,
67    /// 右から (1).
68    Right,
69}
70
71impl StartSide {
72    /// The stored `ComicStartPage` value.
73    pub fn code(self) -> i64 {
74        match self {
75            StartSide::Left => 0,
76            StartSide::Right => 1,
77        }
78    }
79
80    /// Reads `ComicStartPage` (anything but 1 starts on the left).
81    pub fn from_code(code: i64) -> Self {
82        if code == 1 { StartSide::Right } else { StartSide::Left }
83    }
84}
85
86/// Whether the page at `position` (pages counted from 0) is a left page.
87pub fn is_left_page(position: usize, start: StartSide) -> bool {
88    position.is_multiple_of(2) != (start == StartSide::Right)
89}
90
91/// One page file of a work: a single page or a spread.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct PageSlot {
94    /// Position of the (first) page, counted from 0.
95    pub position: usize,
96    /// The file is a two-page spread.
97    pub spread: bool,
98    /// Side of the page at `position`.
99    pub left: bool,
100}
101
102/// Settings of a new work (sizes in millimetres).
103#[derive(Debug, Clone, PartialEq)]
104pub struct NewWork {
105    /// Finished (trim) size of one page.
106    pub trim_width: f64,
107    /// Height of the finished page.
108    pub trim_height: f64,
109    /// Bleed around the trim (裁ち落とし幅).
110    pub bleed: f64,
111    /// Dots per inch.
112    pub resolution: f64,
113    /// Number of pages (a spread counts 2).
114    pub pages: usize,
115    /// Join facing pages into spreads (対向ページを見開きにする).
116    pub spreads: bool,
117    /// Binding side.
118    pub binding: Binding,
119    /// Side of the first page.
120    pub start: StartSide,
121    /// Title written into the story info (作品情報).
122    pub title: String,
123    /// Author written into the story info.
124    pub author: String,
125}
126
127impl Default for NewWork {
128    fn default() -> Self {
129        NewWork {
130            trim_width: 182.0,
131            trim_height: 257.0,
132            bleed: 5.0,
133            resolution: 600.0,
134            pages: 4,
135            spreads: false,
136            binding: Binding::Right,
137            start: StartSide::Left,
138            title: String::new(),
139            author: String::new(),
140        }
141    }
142}
143
144/// The bytes of a new work: the .cmc and its page files (names relative to
145/// the .cmc's folder).
146pub struct WorkFiles {
147    /// The .cmc file.
148    pub cmc: Vec<u8>,
149    /// Page files: name relative to the .cmc's folder ("page0001.clip") and bytes.
150    pub pages: Vec<(String, Vec<u8>)>,
151}
152
153impl NewWork {
154    /// The page files in reading order.
155    pub fn layout(&self) -> Vec<PageSlot> {
156        let mut slots = Vec::new();
157        let mut position = 0;
158        while position < self.pages {
159            let left = is_left_page(position, self.start);
160            // A spread starts on the side the reader meets first.
161            let first_side_left = self.binding == Binding::Left;
162            let spread = self.spreads && position + 1 < self.pages && left == first_side_left;
163            slots.push(PageSlot { position, spread, left });
164            position += if spread { 2 } else { 1 };
165        }
166        slots
167    }
168
169    /// Size of a page canvas (trim + bleed on both sides) in millimetres.
170    fn canvas_mm(&self, spread: bool) -> (f64, f64) {
171        let width = self.trim_width + 2.0 * self.bleed;
172        (if spread { width * 2.0 } else { width }, self.trim_height + 2.0 * self.bleed)
173    }
174
175    /// Builds the .cmc and the page files. Write the pages next to the .cmc.
176    pub fn build(&self) -> Result<WorkFiles> {
177        if self.pages == 0 {
178            bail!("A work needs at least one page");
179        }
180        if !(self.trim_width > 0.0 && self.trim_height > 0.0 && self.bleed >= 0.0 && self.resolution > 0.0) {
181            bail!("Page size, bleed and resolution must be positive");
182        }
183        let slots = self.layout();
184        let pages = slots
185            .iter()
186            .enumerate()
187            .map(|(i, slot)| Ok((format!("page{:04}.clip", i + 1), self.page(slot)?.to_bytes()?)))
188            .collect::<Result<Vec<_>>>()?;
189        Ok(WorkFiles { cmc: self.cmc(&slots)?, pages })
190    }
191
192    /// Write `folder/name.cmc` and the page files next to it; refuses to
193    /// replace existing files. Returns the .cmc's path.
194    pub fn write(&self, folder: &Path, name: &str) -> Result<PathBuf> {
195        let files = self.build()?;
196        let cmc = folder.join(format!("{name}.cmc"));
197        let paths: Vec<PathBuf> =
198            std::iter::once(cmc.clone()).chain(files.pages.iter().map(|(page, _)| folder.join(page))).collect();
199        if let Some(existing) = paths.iter().find(|path| path.exists()) {
200            bail!("{} already exists", existing.display());
201        }
202        std::fs::create_dir_all(folder)?;
203        for (page, bytes) in &files.pages {
204            std::fs::write(folder.join(page), bytes)?;
205        }
206        std::fs::write(&cmc, files.cmc)?;
207        Ok(cmc)
208    }
209
210    /// The work and story settings shared by the .cmc and every page.
211    fn story(&self) -> Vec<(&'static str, Value)> {
212        let text = |s: &str| Value::Text(s.to_owned());
213        let mut story = comic_story();
214        for (name, value) in story.iter_mut() {
215            *value = match *name {
216                "ComicStoryStoryName" => text(&self.title),
217                "ComicStoryAuthorName" => text(&self.author),
218                // CSP's defaults for a new comic.
219                "ComicStoryStoryIndex" => Value::Integer(0),
220                "ComicStoryNombreFont" => text("I-OTFアンチックStd B"),
221                "ComicStoryNombreFontPostScriptName" => text("IwaAntStd-Bd"),
222                "ComicStoryNombreEdgeWidth" => Value::Real(0.3),
223                _ => value.clone(),
224            };
225        }
226        story
227    }
228
229    fn cmc(&self, slots: &[PageSlot]) -> Result<Vec<u8>> {
230        let (int, text, real) = (Value::Integer, |s: &str| Value::Text(s.to_owned()), Value::Real);
231        let connection = Connection::open_in_memory()?;
232        connection.execute_batch(SCHEMA)?;
233        fill_registry(&connection, PARAM_SCHEME, ELEM_SCHEME)?;
234        let (width, height) = self.canvas_mm(false);
235        let mut project = vec![
236            ("ProjectInternalVersion", text("1.1.0")),
237            ("ProjectRootCanvasNode", int(ROOT_NODE)),
238            ("ComicBindPosition", int(self.binding.code())),
239            ("ComicStartPage", int(self.start.code())),
240            ("ComicBindAndStartPageIndeterminate", int(0)),
241            ("ComicShowStyle", int(0)),
242            ("ComicWebtoonWrapDirection", int(1)),
243            ("ComicCoverExist", int(0)),
244        ];
245        project.extend(self.story());
246        project.extend([
247            ("DefaultPageUnit", int(UNIT_MM)),
248            ("DefaultPageWidth", real(width)),
249            ("DefaultPageHeight", real(height)),
250            ("DefaultPageResolution", real(self.resolution)),
251            ("DefaultPageChannelBytes", int(1)),
252            ("DefaultPageChannelOrder", int(33)),
253            ("DefaultPageColorType", int(0)),
254            ("DefaultPageBlackChecked", int(1)),
255            ("DefaultPageWhiteChecked", int(1)),
256            ("DefaultPageToneLine", real(60.0)),
257            ("DefaultPageUsePaper", int(1)),
258            ("DefaultPagePaperRed", int(0xFFFF_FFFF)),
259            ("DefaultPagePaperGreen", int(0xFFFF_FFFF)),
260            ("DefaultPagePaperBlue", int(0xFFFF_FFFF)),
261            ("DefaultPageUseTemplate", int(0)),
262            ("DefaultPageTemplatePath", text("")),
263            ("DefaultPageTemplatePath2", text("")),
264            ("DefaultPageTemplateName", text("")),
265            ("DefaultPageIsUserTemplate", int(0)),
266            ("DefaultPageDoublePage", int(self.spreads as i64)),
267            ("DefaultPagePresetID", text("")),
268            ("DefaultPageCheckBookBinding", int(0)),
269            ("DefaultSrcPageColorProfile", Value::Blob(Vec::new())),
270            ("DefaultSimulatePageColorProfile", Value::Blob(Vec::new())),
271            ("DefaultPageCelUseTemplate", int(0)),
272            ("DefaultPageCelTemplatePath", text("")),
273            ("DefaultPageCelTemplatePath2", text("")),
274            ("DefaultPageCelTemplateName", text("")),
275            ("DefaultPageCelTemplateUUID", text("")),
276            ("DefaultPageCelIsUserTemplate", int(0)),
277        ]);
278        project.extend(self.crop_frame("DefaultPage"));
279        project.extend([
280            ("DefaultPageUseCropFrame", int(1)),
281            ("DefaultPageTemplateUUID", text("")),
282            ("DefaultPageUseDefaultCoverInfo", int(0)),
283            ("DefaultPageSettingType", text("Comic")),
284            ("DefaultPageRecordTimeLapse", int(0)),
285            ("DefaultPageTimeLapseCodec", int(0)),
286            ("MaxCanvasFileIndex", int(slots.len() as i64)),
287        ]);
288        insert(&connection, "Project", &project)?;
289        let page_id = |i: usize| ROOT_NODE + 1 + i as i64;
290        let node = |main_id: i64, kind: i64, next: i64, child: i64| {
291            vec![
292                ("MainId", int(main_id)),
293                ("Type", int(kind)),
294                ("NextIndex", int(next)),
295                ("FirstChildIndex", int(child)),
296                ("SelectedIndex", int(child)),
297                ("CanvasIndex", int(0)),
298                ("PageFlag", int(0)),
299                ("Memo", text("")),
300                ("BookBindMemo", text("")),
301                // Warnings are CSP's own checks; it fills them in.
302                ("BookBindWarning", int(0)),
303                ("BookBindWarningDef", int((kind == NODE_PAGE) as i64)),
304            ]
305        };
306        insert(&connection, "CanvasNode", &node(ROOT_NODE, crate::cmc::NODE_ROOT, 0, page_id(0)))?;
307        for (i, slot) in slots.iter().enumerate() {
308            let next = if i + 1 < slots.len() { page_id(i + 1) } else { 0 };
309            let mut row = node(page_id(i), NODE_PAGE, next, 0);
310            row[6].1 = int(slot.spread as i64);
311            row.push(("LinkPath", text(&format!(".:page{:04}.clip", i + 1))));
312            insert(&connection, "CanvasNode", &row)?;
313        }
314        for (table, max) in [("CanvasNode", page_id(slots.len() - 1)), ("Project", 1)] {
315            connection.execute("UPDATE ElemScheme SET MaxIndex = ?1 WHERE TableName = ?2", (max, table))?;
316        }
317        database::serialize(&connection)
318    }
319
320    /// The crop frame columns (`CropFrame*` in a page, `DefaultPageCrop*` in
321    /// the .cmc).
322    fn crop_frame(&self, prefix: &str) -> Vec<(&'static str, Value)> {
323        let real = Value::Real;
324        let page = prefix == "CropFrame";
325        let pick = |page_name: &'static str, cmc_name: &'static str| if page { page_name } else { cmc_name };
326        vec![
327            (pick("CropFrameWidth", "DefaultPageCropWidth"), real(self.trim_width)),
328            (pick("CropFrameHeight", "DefaultPageCropHeight"), real(self.trim_height)),
329            (pick("CropFrameDitch", "DefaultPageCropDitch"), real(self.bleed)),
330            (pick("CropFrameCropOffsetX", "DefaultPageCropOffsetX"), real(0.0)),
331            (pick("CropFrameCropOffsetY", "DefaultPageCropOffsetY"), real(0.0)),
332            (pick("CropFrameInnerWidth", "DefaultPageCropInnerWidth"), real(self.trim_width)),
333            (pick("CropFrameInnerHeight", "DefaultPageCropInnerHeight"), real(self.trim_height)),
334            (pick("CropFrameInnerOffsetX", "DefaultPageCropInnerOffsetX"), real(0.0)),
335            (pick("CropFrameInnerOffsetY", "DefaultPageCropInnerOffsetY"), real(0.0)),
336            (pick("CropFrameSafeMarginLeft", "DefaultPageSafeMarginLeft"), real(0.0)),
337            (pick("CropFrameSafeMarginTop", "DefaultPageSafeMarginTop"), real(0.0)),
338            (pick("CropFrameSafeMarginRight", "DefaultPageSafeMarginRight"), real(0.0)),
339            (pick("CropFrameSafeMarginBottom", "DefaultPageSafeMarginBottom"), real(0.0)),
340            (pick("CropFrameMergeDitch", "DefaultPageCropMergeDitch"), real(0.0)),
341        ]
342        .into_iter()
343        .chain((!page).then_some(("DefaultPageCropOptionFlag", Value::Integer(0))))
344        .collect()
345    }
346
347    fn page(&self, slot: &PageSlot) -> Result<ClipFile> {
348        let (width, height) = self.canvas_mm(slot.spread);
349        let pixels = |mm: f64| (mm * self.resolution / 25.4).round().max(1.0) as u32;
350        let canvas = NewCanvas {
351            width: pixels(width),
352            height: pixels(height),
353            resolution: self.resolution,
354            ..Default::default()
355        };
356        let mut file = ClipFile::create(&canvas)?;
357        let connection = file.database();
358        // Columns a comic page has and a new illustration does not.
359        let added = [
360            ("CropFrameOptionFlag", "INTEGER"),
361            ("CropFrameUnitKind", "INTEGER"),
362            ("CropFrameWidth", "REAL"),
363            ("CropFrameHeight", "REAL"),
364            ("CropFrameDitch", "REAL"),
365            ("CropFrameInnerWidth", "REAL"),
366            ("CropFrameInnerHeight", "REAL"),
367            ("CropFrameInnerOffsetX", "REAL"),
368            ("CropFrameInnerOffsetY", "REAL"),
369            ("CropFrameMergeDitch", "REAL"),
370            ("CropFrameCropOffsetX", "REAL"),
371            ("CropFrameCropOffsetY", "REAL"),
372            ("CropFrameSafeMarginLeft", "REAL"),
373            ("CropFrameSafeMarginTop", "REAL"),
374            ("CropFrameSafeMarginRight", "REAL"),
375            ("CropFrameSafeMarginBottom", "REAL"),
376            ("CropFrameInnerOffsetBasePosition", "INTEGER"),
377            ("CropFrameDesignationType", "INTEGER"),
378            ("CropFrameShow", "INTEGER"),
379            ("SafeFrameShow", "INTEGER"),
380        ];
381        for (column, kind) in added {
382            connection.execute_batch(&format!("ALTER TABLE Canvas ADD COLUMN {column} {kind} DEFAULT NULL"))?;
383        }
384        // Bits: 1 spread, 2 left page, 4 right page (as CSP writes a new work).
385        let option = if slot.spread {
386            3
387        } else if slot.left {
388            2
389        } else {
390            4
391        };
392        let mut values = vec![
393            ("CanvasUnit", Value::Integer(UNIT_MM)),
394            ("CanvasWidth", Value::Real(width)),
395            ("CanvasHeight", Value::Real(height)),
396            ("CanvasDoublePage", Value::Integer(slot.spread as i64)),
397            ("ComicBindPosition", Value::Integer(self.binding.code())),
398            ("ComicPageIndex", Value::Integer(slot.position as i64)),
399            ("ComicIsLeftPage", Value::Integer(slot.left as i64)),
400            ("CropFrameOptionFlag", Value::Integer(option)),
401            ("CropFrameUnitKind", Value::Integer(UNIT_MM)),
402            ("CropFrameInnerOffsetBasePosition", Value::Integer(0)),
403            ("CropFrameDesignationType", Value::Integer(1)),
404            ("CropFrameShow", Value::Integer(1)),
405            ("SafeFrameShow", Value::Integer(0)),
406        ];
407        values.extend(self.crop_frame("CropFrame"));
408        values.extend(self.story());
409        let assignments: Vec<String> =
410            values.iter().enumerate().map(|(i, (name, _))| format!("{} = ?{}", database::quote(name), i + 1)).collect();
411        connection.execute(
412            &format!("UPDATE Canvas SET {}", assignments.join(", ")),
413            rusqlite::params_from_iter(values.into_iter().map(|(_, value)| value)),
414        )?;
415        file.reload()?;
416        Ok(file)
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::cmc::CmcFile;
424
425    fn slots(work: &NewWork) -> Vec<(usize, bool, bool)> {
426        work.layout().iter().map(|slot| (slot.position, slot.spread, slot.left)).collect()
427    }
428
429    /// The page layouts CSP EX made for the same settings (works e1–e5).
430    #[test]
431    fn layouts_match_csp() {
432        let work = |binding, start, pages, spreads| NewWork { binding, start, pages, spreads, ..Default::default() };
433        use {Binding::*, StartSide as S};
434        assert_eq!(slots(&work(Right, S::Left, 4, true)), [(0, false, true), (1, true, false), (3, false, false)]);
435        assert_eq!(slots(&work(Left, S::Left, 4, true)), [(0, true, true), (2, true, true)]);
436        assert_eq!(slots(&work(Right, S::Right, 4, true)), [(0, true, false), (2, true, false)]);
437        assert_eq!(
438            slots(&work(Right, S::Left, 5, false)),
439            [(0, false, true), (1, false, false), (2, false, true), (3, false, false), (4, false, true)]
440        );
441    }
442
443    #[test]
444    fn new_works_read_back() {
445        let work = NewWork {
446            trim_width: 60.0,
447            trim_height: 80.0,
448            resolution: 150.0,
449            spreads: true,
450            title: "T".into(),
451            ..Default::default()
452        };
453        let files = work.build().unwrap();
454        let cmc = CmcFile::from_bytes(files.cmc).unwrap();
455        assert!(cmc.findings(None).is_empty(), "{:?}", cmc.findings(None));
456        assert_eq!(cmc.positions().unwrap(), work.layout());
457        let pages = cmc.pages().unwrap();
458        assert_eq!(pages.iter().map(|page| page.spread).collect::<Vec<_>>(), [false, true, false]);
459        assert_eq!(cmc.project.bind_position, 1);
460        assert_eq!(files.pages.len(), 3);
461        let spread = ClipFile::from_bytes(files.pages[1].1.clone()).unwrap();
462        // (60 + 2 × 5) × 2 mm at 150 dpi.
463        assert_eq!((spread.canvas.width_px, spread.canvas.height_px), (827, 531));
464        let row = spread.database().query_row(
465            "SELECT ComicPageIndex, ComicIsLeftPage, CropFrameOptionFlag, ComicStoryStoryName FROM Canvas",
466            [],
467            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?)),
468        );
469        assert_eq!(row.unwrap(), (1, 0, 3, "T".to_owned()));
470        let report = crate::verify::check_clip(files.pages[1].1.clone(), crate::verify::CheckOptions::default());
471        assert!(report.ok(), "{:?}", serde_json::to_string(&report).unwrap());
472    }
473}