Skip to main content

csp_format/
blank.rs

1//! Pages and works made from settings alone, without a CSP-made template
2//! of the work. Moved from name-studio's clip-core (`write/blank.rs`).
3//!
4//! The crate carries three files saved by CLIP STUDIO PAINT EX 5.1.4
5//! (`assets/`): an empty single page, a work (.cmc) listing it, and the page
6//! with one vector line and one text to copy editable layers from. A page
7//! is the empty one with the canvas, frames, binding and story info
8//! (作品名・話数・作者名・ノンブル) set from a [`PageSetup`]. Everything sized by
9//! the canvas is resized, and the 作品情報 / ノンブル texts are placed where
10//! CSP puts them, relative to the frames. The page is built from CSP's own
11//! rather than from [`crate::create`] so that the tables and columns CSP
12//! reads stay as CSP writes them.
13
14use rusqlite::Connection;
15use rusqlite::types::Value;
16
17use crate::ClipFile;
18use crate::cmc::CmcFile;
19use crate::database::{has_column, has_table, quote, select, value_as_text};
20use crate::error::{Error, Result, bail};
21use crate::image::{RgbaImage, decode_png};
22use crate::offscreen::{Attribute, BLOCK_SIZE, BlockData, Offscreen, PixelKind, encode};
23use crate::story::{
24    ATTR_COLOR, ATTR_FONT_LABEL, ATTR_ORIGIN, ATTR_STORY_PART, Align, LEFT_PAGE_FLAG, NOMBRE_INFO, RIGHT_PAGE_FLAG,
25    STORY_INFO, StoryPart, array_parts, canvas_value, drop_render_cache, edit_story_blocks, em_px, i32_bytes, i32s,
26    map_records, mirror_nombre, page_number_text, resize_block, resolution, round_half_even, set_run_length,
27    set_string_array, story_layers, string_array, text_width, update_nombre,
28};
29use crate::text::{Attributes, ids, join_array, split_array};
30
31/// An empty single page (A3 canvas, B4 仕上がり, 600 dpi, left page).
32pub const BLANK_PAGE: &[u8] = include_bytes!("../assets/blank-page.clip");
33/// The empty page with one vector layer (a line) and one text layer, to
34/// copy editable layers from ([`ClipFile::copy_layer_from`]).
35pub const BLANK_PARTS: &[u8] = include_bytes!("../assets/blank-parts.clip");
36/// A work listing the empty page.
37pub const BLANK_WORK: &[u8] = include_bytes!("../assets/blank-work.cmc");
38/// The layer effects CSP gave its (ノンブル) with フチ 0.5 mm at 600 dpi
39/// (`LayerEffectInfo`: the フチ on, the other effects off).
40pub const NOMBRE_EDGE: &[u8] = include_bytes!("../assets/nombre-edge.bin");
41
42/// The font of the 作品情報 and ノンブル texts.
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct StoryFont {
45    /// As CSP shows it (`MS ゴシック`).
46    pub name: String,
47    /// PostScript name of the font (`MS-Gothic`).
48    pub postscript: String,
49}
50
51fn first_number() -> i64 {
52    1
53}
54
55/// What a page and its work are made with. Lengths in mm.
56#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct PageSetup {
59    /// キャンバス (用紙) size.
60    pub canvas_mm: [f64; 2],
61    /// 解像度 (dpi).
62    pub resolution: f64,
63    /// 仕上がり (製本) size.
64    pub trim_mm: [f64; 2],
65    /// 裁ち落とし幅.
66    pub bleed_mm: f64,
67    /// 基本枠 size and its offset from the middle of the canvas.
68    pub basic_mm: [f64; 2],
69    /// Offset of the 基本枠 from the middle of the canvas (x, y).
70    #[serde(default)]
71    pub basic_offset_mm: [f64; 2],
72    /// 右綴じ (a manga) rather than 左綴じ.
73    pub right_binding: bool,
74    /// Whether the book's first page (index 0) is a left page.
75    pub first_page_left: bool,
76    /// 作品名.
77    pub title: String,
78    /// 話数; none leaves it out of the 作品情報.
79    #[serde(default)]
80    pub story_index: Option<i64>,
81    /// 作者名.
82    #[serde(default)]
83    pub author: String,
84    /// ノンブルの開始番号.
85    #[serde(default = "first_number")]
86    pub nombre_start: i64,
87    /// None keeps the built-in one (MS ゴシック).
88    #[serde(default)]
89    pub font: Option<StoryFont>,
90    /// サブタイトル, where the 作品情報 go, ページ番号 and a shown ノンブル.
91    #[serde(default)]
92    pub story: StoryLayout,
93}
94
95/// Where a 作品情報 text goes under the 仕上がり: CSP's 左下・下・右下.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum StoryPosition {
99    /// 左下 (1).
100    #[default]
101    Left,
102    /// 下 (2).
103    Center,
104    /// 右下 (3).
105    Right,
106}
107
108impl StoryPosition {
109    /// `ComicStory*Position`.
110    pub fn column(self) -> i64 {
111        match self {
112            StoryPosition::Left => 1,
113            StoryPosition::Center => 2,
114            StoryPosition::Right => 3,
115        }
116    }
117
118    fn align(self) -> Align {
119        Align::of_position(self.column())
120    }
121}
122
123fn center() -> StoryPosition {
124    StoryPosition::Center
125}
126
127fn right() -> StoryPosition {
128    StoryPosition::Right
129}
130
131/// The rest of CSP's 作品情報 and ノンブル settings. The default is what
132/// CSP's new works have: 作品名 左下, 作者名 下, no ページ番号, the hidden
133/// ノンブル.
134#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct StoryLayout {
137    /// サブタイトル, after 作品名 and 話数.
138    #[serde(default)]
139    pub subtitle: String,
140    /// Where the 作品名 goes.
141    #[serde(default)]
142    pub title_position: StoryPosition,
143    /// Where the 作者名 goes.
144    #[serde(default = "center")]
145    pub author_position: StoryPosition,
146    /// ページ番号 in the 作品情報 (`7ページ`); none shows none.
147    #[serde(default)]
148    pub page_number: Option<PageNumber>,
149    /// A ノンブル printed on the page, in place of the hidden one.
150    #[serde(default)]
151    pub nombre: Option<ShownNombre>,
152}
153
154impl Default for StoryLayout {
155    fn default() -> Self {
156        StoryLayout {
157            subtitle: String::new(),
158            title_position: StoryPosition::Left,
159            author_position: StoryPosition::Center,
160            page_number: None,
161            nombre: None,
162        }
163    }
164}
165
166/// ページ番号 in the 作品情報.
167#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct PageNumber {
170    /// The first page's number.
171    pub start: i64,
172    /// Where the ページ番号 goes.
173    #[serde(default = "right")]
174    pub position: StoryPosition,
175}
176
177/// Where a shown ノンブル goes, from the 基本枠: above or below it, on the
178/// binding side (内側), in the middle or on the other side (外側).
179#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum NombrePlace {
182    /// Above the 基本枠, binding side (1).
183    TopInner,
184    /// Above the 基本枠, in the middle (2).
185    Top,
186    /// Above the 基本枠, outer side (3).
187    TopOuter,
188    /// Below the 基本枠, binding side (4).
189    BottomInner,
190    /// Below the 基本枠, in the middle (5).
191    Bottom,
192    /// Below the 基本枠, outer side (6).
193    BottomOuter,
194}
195
196impl NombrePlace {
197    /// `ComicStoryNombrePosition`.
198    pub fn column(self) -> i64 {
199        match self {
200            NombrePlace::TopInner => 1,
201            NombrePlace::Top => 2,
202            NombrePlace::TopOuter => 3,
203            NombrePlace::BottomInner => 4,
204            NombrePlace::Bottom => 5,
205            NombrePlace::BottomOuter => 6,
206        }
207    }
208
209    fn top(self) -> bool {
210        self.column() <= 3
211    }
212
213    fn align(self) -> Align {
214        match self.column() {
215            1 | 4 => Align::End,
216            2 | 5 => Align::Middle,
217            _ => Align::Start,
218        }
219    }
220}
221
222fn nombre_offset() -> [f64; 2] {
223    [0.0, 5.0]
224}
225
226fn nombre_size() -> f64 {
227    9.0
228}
229
230/// A ノンブル printed on the page (CSP's ノンブル, not the hidden one).
231#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct ShownNombre {
234    /// Where the ノンブル goes.
235    pub place: NombrePlace,
236    /// 白 rather than 黒.
237    #[serde(default)]
238    pub white: bool,
239    /// フチ width (mm); none: no フチ.
240    #[serde(default)]
241    pub edge_mm: Option<f64>,
242    /// フォーマット: the text before and after the number.
243    #[serde(default)]
244    pub prefix: String,
245    /// Text after the number.
246    #[serde(default)]
247    pub suffix: String,
248    /// 基本枠との間隔: 横 (from the 基本枠's side, inwards) and 縦.
249    #[serde(default = "nombre_offset")]
250    pub offset_mm: [f64; 2],
251    /// サイズ (pt).
252    #[serde(default = "nombre_size")]
253    pub size_pt: f64,
254}
255
256impl ShownNombre {
257    fn text(&self, number: i64) -> String {
258        format!("{}{number}{}", self.prefix, self.suffix)
259    }
260}
261
262/// The largest side CSP takes, in px.
263pub const MAX_SIDE_PX: i64 = 30_000;
264
265fn mm_to_px(mm: f64, resolution: f64) -> i64 {
266    round_half_even(mm * resolution / 25.4)
267}
268
269impl PageSetup {
270    /// Whether the values make a page CSP opens (sizes, frames inside the
271    /// canvas, resolution 72–1200 dpi). Messages for people (and checks of
272    /// what a person may type) are the application's.
273    pub fn check(&self) -> Result<()> {
274        let positive = |values: &[f64]| values.iter().all(|value| value.is_finite() && *value > 0.0);
275        if !positive(&[self.canvas_mm[0], self.canvas_mm[1], self.trim_mm[0], self.trim_mm[1]])
276            || !positive(&[self.basic_mm[0], self.basic_mm[1]])
277        {
278            bail!("Canvas, trim and basic frame sizes must be positive");
279        }
280        if !(self.resolution.is_finite() && (72.0..=1200.0).contains(&self.resolution)) {
281            bail!("Resolution {} is outside 72–1200 dpi", self.resolution);
282        }
283        if self.trim_mm[0] > self.canvas_mm[0] + 1e-9 || self.trim_mm[1] > self.canvas_mm[1] + 1e-9 {
284            bail!("The trim frame is larger than the canvas");
285        }
286        if !(self.bleed_mm.is_finite() && self.bleed_mm >= 0.0) {
287            bail!("The bleed must be 0 or more");
288        }
289        let [ox, oy] = self.basic_offset_mm;
290        if !(ox.is_finite() && oy.is_finite())
291            || self.basic_mm[0] / 2.0 + ox.abs() > self.canvas_mm[0] / 2.0 + 1e-9
292            || self.basic_mm[1] / 2.0 + oy.abs() > self.canvas_mm[1] / 2.0 + 1e-9
293        {
294            bail!("The basic frame goes past the canvas");
295        }
296        let (width, height) = self.canvas_px();
297        if width > MAX_SIDE_PX || height > MAX_SIDE_PX {
298            bail!("The canvas is {width}×{height} px; CSP takes up to {MAX_SIDE_PX}");
299        }
300        if self.story_index.is_some_and(|index| index < 0)
301            || self.nombre_start < 0
302            || self.story.page_number.as_ref().is_some_and(|number| number.start < 0)
303        {
304            bail!("Story and page numbers must be 0 or more");
305        }
306        if let Some(nombre) = &self.story.nombre
307            && !(nombre.size_pt.is_finite()
308                && nombre.size_pt > 0.0
309                && nombre.edge_mm.is_none_or(|edge| edge.is_finite() && edge >= 0.0)
310                && nombre.offset_mm.iter().all(|offset| offset.is_finite()))
311        {
312            bail!("The shown nombre needs a positive size and finite offsets");
313        }
314        Ok(())
315    }
316
317    fn px(&self, mm: f64) -> i64 {
318        mm_to_px(mm, self.resolution)
319    }
320
321    /// Canvas size in pixels.
322    pub fn canvas_px(&self) -> (i64, i64) {
323        (self.px(self.canvas_mm[0]), self.px(self.canvas_mm[1]))
324    }
325
326    /// The 作品情報 line: 作品名, 話数 and サブタイトル as CSP writes them.
327    pub fn story_line(&self) -> String {
328        let mut line = self.title.trim().to_owned();
329        if let Some(index) = self.story_index {
330            line.push_str(&format!(" {index}話"));
331        }
332        let subtitle = self.story.subtitle.trim();
333        if !subtitle.is_empty() {
334            line.push(' ');
335            line.push_str(subtitle);
336        }
337        line
338    }
339}
340
341/// Canvas size and frames in px, as CSP lays them out (centred, rounded down).
342#[derive(Debug, Clone, Copy, PartialEq)]
343struct Frames {
344    width: i64,
345    height: i64,
346    resolution: f64,
347    trim: [i64; 4],
348    basic: [i64; 4],
349}
350
351impl Frames {
352    fn new(width: i64, height: i64, resolution: f64, trim: (i64, i64), basic: (i64, i64), offset: (i64, i64)) -> Self {
353        let centred = |(w, h): (i64, i64), (dx, dy): (i64, i64)| {
354            let x0 = (width - w).div_euclid(2) + dx;
355            let y0 = (height - h).div_euclid(2) + dy;
356            [x0, y0, x0 + w, y0 + h]
357        };
358        Frames { width, height, resolution, trim: centred(trim, (0, 0)), basic: centred(basic, offset) }
359    }
360
361    fn of_setup(setup: &PageSetup) -> Self {
362        let (width, height) = setup.canvas_px();
363        Frames::new(
364            width,
365            height,
366            setup.resolution,
367            (setup.px(setup.trim_mm[0]), setup.px(setup.trim_mm[1])),
368            (setup.px(setup.basic_mm[0]), setup.px(setup.basic_mm[1])),
369            (setup.px(setup.basic_offset_mm[0]), setup.px(setup.basic_offset_mm[1])),
370        )
371    }
372
373    /// The frames of a page whose sizes are in mm.
374    fn of_page(db: &Connection) -> Result<Self> {
375        let real = |column: &str| -> Result<f64> {
376            Ok(match canvas_value(db, column)? {
377                Some(Value::Real(number)) => number,
378                Some(Value::Integer(number)) => number as f64,
379                _ => 0.0,
380            })
381        };
382        let resolution = real("CanvasResolution")?;
383        let px = |column: &str| -> Result<i64> { Ok(mm_to_px(real(column)?, resolution)) };
384        Ok(Frames::new(
385            px("CanvasWidth")?,
386            px("CanvasHeight")?,
387            resolution,
388            (px("CropFrameWidth")?, px("CropFrameHeight")?),
389            (px("CropFrameInnerWidth")?, px("CropFrameInnerHeight")?),
390            (px("CropFrameInnerOffsetX")?, px("CropFrameInnerOffsetY")?),
391        ))
392    }
393}
394
395/// The built-in empty page made for `setup`, with new file and layer ids.
396pub fn blank_page(setup: &PageSetup) -> Result<ClipFile> {
397    setup.check()?;
398    let mut page = ClipFile::from_bytes(BLANK_PAGE.to_vec())?;
399    page.renew_file_id();
400    renew_layer_uuids(&page)?;
401    apply_setup(&mut page, setup)?;
402    Ok(page)
403}
404
405/// The built-in page with a vector and a text layer (to copy from), with a
406/// new file id.
407pub fn blank_parts() -> Result<ClipFile> {
408    let mut parts = ClipFile::from_bytes(BLANK_PARTS.to_vec())?;
409    parts.renew_file_id();
410    Ok(parts)
411}
412
413fn renew_layer_uuids(page: &ClipFile) -> Result<()> {
414    if !has_column(page.database(), "Layer", "LayerUuid")? {
415        return Ok(());
416    }
417    for row in select(page.database(), "SELECT MainId FROM Layer WHERE LayerUuid IS NOT NULL", [])? {
418        page.database().execute(
419            "UPDATE Layer SET LayerUuid = ?1 WHERE MainId = ?2",
420            (crate::edit::layer_uuid(), row.int("MainId")),
421        )?;
422    }
423    Ok(())
424}
425
426/// Give a CSP-made empty single page (a left page) the canvas, frames,
427/// binding and story info of `setup`.
428pub fn apply_setup(page: &mut ClipFile, setup: &PageSetup) -> Result<()> {
429    let db = page.database();
430    if matches!(canvas_value(db, "CanvasDoublePage")?, Some(Value::Integer(flag)) if flag != 0) {
431        bail!("A spread cannot be made into a single page");
432    }
433    if !matches!(canvas_value(db, "ComicIsLeftPage")?, Some(Value::Integer(1))) {
434        bail!("The empty page must be a left page");
435    }
436    let old = Frames::of_page(db)?;
437    let new = Frames::of_setup(setup);
438    if setup.story.page_number.is_some() {
439        add_page_number(db)?;
440    }
441    if let Some(nombre) = &setup.story.nombre {
442        show_nombre(db, nombre, setup.nombre_start, setup.resolution)?;
443    }
444    set_story_texts(db, setup)?;
445    place_story_texts(db, &old, &new, setup)?;
446    drop_text_images(db)?;
447    set_canvas(db, setup)?;
448    if let Some(font) = &setup.font {
449        set_story_font(db, font)?;
450    }
451    if (old.width, old.height) != (new.width, new.height) {
452        // The empty page's offscreens hold one colour (the paper's white) or
453        // nothing, so scaling keeps them as they are.
454        resize_offscreens(page, (old.width, old.height), (new.width, new.height))?;
455        set_thumbnail_canvas(page.database(), new.width, new.height)?;
456        blank_preview(page.database(), new.width, new.height)?;
457    }
458    let db = page.database();
459    if !setup.first_page_left {
460        db.execute("UPDATE Canvas SET ComicIsLeftPage = 0", [])?;
461        if let Some(Value::Integer(flag)) = canvas_value(db, "CropFrameOptionFlag")? {
462            let flag = (flag & !(LEFT_PAGE_FLAG | RIGHT_PAGE_FLAG)) | RIGHT_PAGE_FLAG;
463            db.execute("UPDATE Canvas SET CropFrameOptionFlag = ?1", [flag])?;
464        }
465        mirror_nombre(db)?;
466    }
467    // The page is the first one: its ノンブル is the start number.
468    update_nombre(db, !setup.first_page_left)?;
469    page.reload()
470}
471
472fn set_columns(db: &Connection, table: &str, values: &[(&str, Value)]) -> Result<()> {
473    for (column, value) in values {
474        if has_column(db, table, column)? {
475            db.execute(&format!("UPDATE {} SET {} = ?1", quote(table), quote(column)), [value])?;
476        }
477    }
478    Ok(())
479}
480
481/// The work-wide story settings, as both a page's Canvas and a work's
482/// Project row hold them.
483fn story_values(setup: &PageSetup) -> Vec<(&'static str, Value)> {
484    let mut values = vec![
485        ("ComicStoryStoryName", Value::Text(setup.title.trim().to_owned())),
486        ("ComicStoryUseStoryIndex", Value::Integer(setup.story_index.is_some() as i64)),
487        ("ComicStoryAuthorName", Value::Text(setup.author.trim().to_owned())),
488        ("ComicStoryNombreStart", Value::Integer(setup.nombre_start)),
489        // 1 in CSP's right-bound works.
490        ("ComicBindPosition", Value::Integer(setup.right_binding as i64)),
491    ];
492    if let Some(index) = setup.story_index {
493        values.push(("ComicStoryStoryIndex", Value::Integer(index)));
494    }
495    let story = &setup.story;
496    values.extend([
497        ("ComicStorySubTitle", Value::Text(story.subtitle.trim().to_owned())),
498        ("ComicStoryStoryPosition", Value::Integer(story.title_position.column())),
499        ("ComicStoryAuthorPosition", Value::Integer(story.author_position.column())),
500        ("ComicStoryUsePageNumber", Value::Integer(story.page_number.is_some() as i64)),
501        ("ComicStoryUseShownNombre", Value::Integer(story.nombre.is_some() as i64)),
502        ("ComicStoryUseHiddenNombre", Value::Integer(story.nombre.is_none() as i64)),
503    ]);
504    if let Some(number) = &story.page_number {
505        values.push(("ComicStoryPageNumberStart", Value::Integer(number.start)));
506        values.push(("ComicStoryPageNumberPosition", Value::Integer(number.position.column())));
507    }
508    if let Some(nombre) = &story.nombre {
509        values.extend([
510            ("ComicStoryNombrePosition", Value::Integer(nombre.place.column())),
511            ("ComicStoryNombrePrefix", Value::Text(nombre.prefix.clone())),
512            ("ComicStoryNombreSuffix", Value::Text(nombre.suffix.clone())),
513            ("ComicStoryNombreColor", Value::Integer(nombre.white as i64)),
514            ("ComicStoryNombreUseEdge", Value::Integer(nombre.edge_mm.is_some() as i64)),
515            ("ComicStoryNombreFontSize", Value::Real(nombre.size_pt)),
516            ("ComicStoryXPageNombreOffset", Value::Real(nombre.offset_mm[0])),
517            ("ComicStoryXPageNombreOffsetUnit", Value::Integer(2)),
518            ("ComicStoryYPageNombreOffset", Value::Real(nombre.offset_mm[1])),
519            ("ComicStoryYPageNombreOffsetUnit", Value::Integer(2)),
520        ]);
521        if let Some(edge) = nombre.edge_mm {
522            values.push(("ComicStoryNombreEdgeWidth", Value::Real(edge)));
523            values.push(("ComicStoryNombreEdgeUnit", Value::Integer(2)));
524        }
525    }
526    if let Some(font) = &setup.font {
527        values.push(("ComicStoryNombreFont", Value::Text(font.name.clone())));
528        values.push(("ComicStoryNombreFontPostScriptName", Value::Text(font.postscript.clone())));
529    }
530    values
531}
532
533fn set_canvas(db: &Connection, setup: &PageSetup) -> Result<()> {
534    let real = Value::Real;
535    let mut values = vec![
536        ("CanvasUnit", Value::Integer(2)),
537        ("CanvasWidth", real(setup.canvas_mm[0])),
538        ("CanvasHeight", real(setup.canvas_mm[1])),
539        ("CanvasResolution", real(setup.resolution)),
540        ("CropFrameUnitKind", Value::Integer(2)),
541        ("CropFrameWidth", real(setup.trim_mm[0])),
542        ("CropFrameHeight", real(setup.trim_mm[1])),
543        ("CropFrameDitch", real(setup.bleed_mm)),
544        ("CropFrameInnerWidth", real(setup.basic_mm[0])),
545        ("CropFrameInnerHeight", real(setup.basic_mm[1])),
546        ("CropFrameInnerOffsetX", real(setup.basic_offset_mm[0])),
547        ("CropFrameInnerOffsetY", real(setup.basic_offset_mm[1])),
548        ("CropFrameShow", Value::Integer(1)),
549        ("ComicPageIndex", Value::Integer(0)),
550    ];
551    values.extend(story_values(setup));
552    set_columns(db, "Canvas", &values)
553}
554
555/// The built-in work with `setup`'s settings, listing its one page (replace
556/// the list with [`CmcFile::save_with_pages`] once the pages are written).
557pub fn blank_work(setup: &PageSetup) -> Result<CmcFile> {
558    setup.check()?;
559    let mut work = CmcFile::from_bytes(BLANK_WORK.to_vec())?;
560    let real = Value::Real;
561    let mut values = vec![
562        // 0 in CSP's right-bound works whose first page is a left page (the usual manga).
563        ("ComicStartPage", Value::Integer((!setup.first_page_left) as i64)),
564        ("DefaultPageUnit", Value::Integer(2)),
565        ("DefaultPageWidth", real(setup.canvas_mm[0])),
566        ("DefaultPageHeight", real(setup.canvas_mm[1])),
567        ("DefaultPageResolution", real(setup.resolution)),
568        ("DefaultPageUseCropFrame", Value::Integer(1)),
569        ("DefaultPageCropWidth", real(setup.trim_mm[0])),
570        ("DefaultPageCropHeight", real(setup.trim_mm[1])),
571        ("DefaultPageCropDitch", real(setup.bleed_mm)),
572        ("DefaultPageCropInnerWidth", real(setup.basic_mm[0])),
573        ("DefaultPageCropInnerHeight", real(setup.basic_mm[1])),
574        ("DefaultPageCropInnerOffsetX", real(setup.basic_offset_mm[0])),
575        ("DefaultPageCropInnerOffsetY", real(setup.basic_offset_mm[1])),
576    ];
577    values.extend(story_values(setup));
578    set_columns(work.database(), "Project", &values)?;
579    work.database().execute(
580        "UPDATE CanvasNode SET LinkUnit = 2, LinkWidth = ?1, LinkHeight = ?2, LinkResolution = ?3 WHERE Type = 2",
581        (setup.canvas_mm[0], setup.canvas_mm[1], setup.resolution),
582    )?;
583    work.modified = true;
584    work.reload()?;
585    Ok(work)
586}
587
588/// Rewrite the blocks of one attributes array column.
589fn edit_array(db: &Connection, layer_id: i64, column: &str, edit: &dyn Fn(&mut Vec<Attributes>)) -> Result<()> {
590    if !has_column(db, "Layer", column)? {
591        return Ok(());
592    }
593    let blob: Value =
594        db.query_row(&format!("SELECT {} FROM Layer WHERE MainId = ?1", quote(column)), [layer_id], |row| row.get(0))?;
595    let Value::Blob(blob) = blob else { return Ok(()) };
596    let mut blocks = split_array(&blob)?.into_iter().map(Attributes::parse).collect::<Result<Vec<_>>>()?;
597    edit(&mut blocks);
598    let joined = join_array(&blocks.iter().map(Attributes::to_bytes).collect::<Vec<_>>());
599    db.execute(&format!("UPDATE Layer SET {} = ?1 WHERE MainId = ?2", quote(column)), (joined, layer_id))?;
600    Ok(())
601}
602
603fn with_part(attributes: &Attributes, part: StoryPart) -> Attributes {
604    map_records(attributes.clone(), |id, data| if id == ATTR_STORY_PART { i32_bytes(&[part.code()]) } else { data })
605}
606
607/// Give the 作品情報 a ページ番号: a block after the 作者名's, like it, as CSP
608/// adds it (its text and place are set with the others).
609fn add_page_number(db: &Connection) -> Result<()> {
610    for (layer_id, _) in story_layers(db, STORY_INFO)? {
611        if array_parts(db, layer_id)?.contains(&StoryPart::PageNumber) {
612            continue;
613        }
614        for column in ["TextLayerAttributesArray", "TextLayerAddAttributesV01"] {
615            edit_array(db, layer_id, column, &|blocks| {
616                let author = blocks.iter().rposition(|attributes| {
617                    attributes.get(ATTR_STORY_PART).map(i32s).as_deref() == Some(&[StoryPart::Author.code()])
618                });
619                if let Some(author) = author.or(blocks.len().checked_sub(1)) {
620                    let block = with_part(&blocks[author], StoryPart::PageNumber);
621                    blocks.push(block);
622                }
623            })?;
624        }
625        if let Some(mut strings) = string_array(db, layer_id)? {
626            strings.push(String::new());
627            set_string_array(db, layer_id, &strings)?;
628        }
629    }
630    Ok(())
631}
632
633/// Make the hidden ノンブル a shown one: its part, size and colour, with the
634/// first page's text (placed with the others).
635fn show_nombre(db: &Connection, nombre: &ShownNombre, start: i64, page_resolution: f64) -> Result<()> {
636    let resolution = resolution(db)?;
637    let text = nombre.text(start);
638    let units = text.encode_utf16().count();
639    let size = round_half_even(nombre.size_pt * 100.0);
640    for (layer_id, _) in story_layers(db, NOMBRE_INFO)? {
641        db.execute("UPDATE Layer SET TextLayerString = ?1 WHERE MainId = ?2", (text.as_bytes(), layer_id))?;
642        edit_story_blocks(db, layer_id, NOMBRE_INFO, &|_, counted, attributes| {
643            let old = attributes
644                .get(ids::FONT_SIZE)
645                .map(i32s)
646                .and_then(|values| values.first().copied())
647                .filter(|old| *old > 0)
648                .unwrap_or(600);
649            let ratio = size as f64 / old as f64;
650            let scale = |values: Vec<i64>| -> Vec<u8> {
651                i32_bytes(&values.into_iter().map(|value| round_half_even(value as f64 * ratio)).collect::<Vec<_>>())
652            };
653            let attributes = map_records(attributes, |id, data| match id {
654                ATTR_STORY_PART => i32_bytes(&[StoryPart::Nombre.code()]),
655                ids::FONT_SIZE if data.len() == 4 => i32_bytes(&[size]),
656                ATTR_COLOR if data.len() == 12 => vec![if nombre.white { 0xff } else { 0 }; 12],
657                ids::BLOCK_SIZE | ids::QUAD | ATTR_ORIGIN if data.len() % 4 == 0 => scale(i32s(&data)),
658                ids::BBOX if data.len() == 16 => {
659                    let [x0, y0, x1, y1] = i32s(&data)[..] else { unreachable!() };
660                    i32_bytes(&[x0, y0, x1, y0 + round_half_even((y1 - y0) as f64 * ratio)])
661                }
662                _ => data,
663            });
664            let width = text_width(&text, em_px(&attributes, resolution));
665            Ok(resize_block(set_run_length(attributes, units, counted), width, Align::Start))
666        })?;
667        drop_render_cache(db, layer_id)?;
668        if let Some(edge) = nombre.edge_mm {
669            // In px of the page made (the template's resolution changes after this).
670            set_edge(db, layer_id, edge / 25.4 * page_resolution, nombre.white)?;
671        }
672    }
673    Ok(())
674}
675
676/// Give a layer CSP's フチ (a layer effect, not a text attribute): `width`
677/// px, black around white text and white around black.
678fn set_edge(db: &Connection, layer_id: i64, width: f64, white_text: bool) -> Result<()> {
679    let mut info = NOMBRE_EDGE.to_vec();
680    // After the size, the count and the name `EffectEdge`: on, width, colour.
681    let at = 4 + 4 + 4 + "EffectEdge".len() * 2;
682    if info.get(at..at + 4) != Some(&[0, 0, 0, 1]) {
683        bail!("Unexpected layer effect template");
684    }
685    info[at + 4..at + 12].copy_from_slice(&width.to_be_bytes());
686    let channel: u32 = if white_text { 0 } else { 255 };
687    for index in 0..3 {
688        let start = at + 12 + index * 4;
689        info[start..start + 4].copy_from_slice(&channel.to_be_bytes());
690    }
691    let mut values: Vec<(&str, Value)> = [
692        "EffectLayerRenderType",
693        "EffectRenderType",
694        "EffectReferAreaType",
695        "EffectSetUpdateRectType",
696        "EffectOffscreenFixType",
697        "EffectOffscreenMoveType",
698    ]
699    .into_iter()
700    .map(|column| (column, Value::Integer(10)))
701    .collect();
702    values.push(("LayerEffectAttached", Value::Integer(1)));
703    values.push(("LayerEffectEdgeNewRender", Value::Integer(1)));
704    values.push(("LayerEffectInfo", Value::Blob(info)));
705    for (column, value) in values {
706        if !has_column(db, "Layer", column)? {
707            db.execute(&format!("ALTER TABLE Layer ADD COLUMN {}", quote(column)), [])?;
708        }
709        db.execute(&format!("UPDATE Layer SET {} = ?1 WHERE MainId = ?2", quote(column)), (value, layer_id))?;
710    }
711    Ok(())
712}
713
714/// Write the 作品名・話数・サブタイトル, 作者名 and ページ番号 into the 作品情報
715/// layer: the strings, their style runs' lengths and the blocks' widths.
716/// CSP draws them afresh.
717fn set_story_texts(db: &Connection, setup: &PageSetup) -> Result<()> {
718    let resolution = resolution(db)?;
719    let title = setup.story_line();
720    let author = setup.author.trim().to_owned();
721    // The page is the first one.
722    let page = setup.story.page_number.as_ref().map(|number| page_number_text(number.start));
723    for (layer_id, _) in story_layers(db, STORY_INFO)? {
724        db.execute("UPDATE Layer SET TextLayerString = ?1 WHERE MainId = ?2", (title.as_bytes(), layer_id))?;
725        if let Some(mut strings) = string_array(db, layer_id)? {
726            let parts = array_parts(db, layer_id)?;
727            strings.resize(parts.len().max(strings.len()), String::new());
728            for (index, part) in parts.iter().enumerate() {
729                match (part, &page) {
730                    (StoryPart::Author, _) => strings[index] = author.clone(),
731                    (StoryPart::PageNumber, Some(page)) => strings[index] = page.clone(),
732                    _ => {}
733                }
734            }
735            set_string_array(db, layer_id, &strings)?;
736        }
737        edit_story_blocks(db, layer_id, STORY_INFO, &|part, counted, attributes| {
738            let text = match (part, &page) {
739                (StoryPart::Title, _) => &title,
740                (StoryPart::Author, _) => &author,
741                (StoryPart::PageNumber, Some(page)) => page,
742                _ => return Ok(attributes),
743            };
744            let width = text_width(text, em_px(&attributes, resolution));
745            Ok(resize_block(set_run_length(attributes, text.encode_utf16().count(), counted), width, Align::Start))
746        })?;
747        drop_render_cache(db, layer_id)?;
748    }
749    Ok(())
750}
751
752fn layer_offset(db: &Connection, layer_id: i64) -> Result<(i64, i64)> {
753    let (x, y): (Option<i64>, Option<i64>) =
754        db.query_row("SELECT LayerOffsetX, LayerOffsetY FROM Layer WHERE MainId = ?1", [layer_id], |row| {
755            Ok((row.get(0)?, row.get(1)?))
756        })?;
757    Ok((x.unwrap_or(0), y.unwrap_or(0)))
758}
759
760/// Where a 作品情報 part goes: its position and its row there (items at one
761/// position stack up: ページ番号, 作品名, 作者名), and how many rows the
762/// tallest stack has.
763fn story_rows(story: &StoryLayout) -> (impl Fn(StoryPart) -> Option<(StoryPosition, i64)>, i64) {
764    let order = [
765        (StoryPart::PageNumber, story.page_number.as_ref().map(|number| number.position)),
766        (StoryPart::Title, Some(story.title_position)),
767        (StoryPart::Author, Some(story.author_position)),
768    ];
769    let row = move |part: StoryPart| {
770        let at = order.iter().position(|(one, _)| *one == part)?;
771        let position = order[at].1?;
772        let above = order[..at].iter().filter(|(_, other)| *other == Some(position)).count();
773        Some((position, above as i64))
774    };
775    let rows = order
776        .iter()
777        .filter_map(|(_, position)| *position)
778        .map(|position| order.iter().filter(|(_, other)| *other == Some(position)).count())
779        .max()
780        .unwrap_or(1) as i64;
781    (row, rows)
782}
783
784/// The least room left under 作品名 and 作者名, from the canvas edge.
785const STORY_MARGIN_MM: f64 = 0.5;
786
787/// Move the story info texts from the template's frames to the new ones,
788/// scaled by the change of resolution: the 作品情報 under the 仕上がり (at its
789/// left edge, middle or right edge), the hidden ノンブル at the binding edge
790/// of the 仕上がり, level with the bottom of the 基本枠, and a shown one above
791/// or below the 基本枠.
792fn place_story_texts(db: &Connection, old: &Frames, new: &Frames, setup: &PageSetup) -> Result<()> {
793    let scale = new.resolution / old.resolution;
794    let at = |anchor_old: i64, anchor_new: i64, value: i64| {
795        anchor_new + round_half_even((value - anchor_old) as f64 * scale)
796    };
797    let px = |mm: f64| mm_to_px(mm, new.resolution);
798    // CSP puts the first row about 6 mm under the 仕上がり (its pages of A3/B4
799    // and A4/B5 agree), a row a line high. When the canvas has no room there
800    // (a canvas of 仕上がり + 裁ち落とし), they go up, inside the canvas, so they
801    // can be seen.
802    let margin = round_half_even(STORY_MARGIN_MM / 25.4 * new.resolution);
803    let (row_of, rows) = story_rows(&setup.story);
804    let below = |y0: i64, height: i64, row: i64| {
805        let first = at(old.trim[3], new.trim[3], y0);
806        let lift = (first + rows * height - (new.height - margin)).max(0);
807        first + row * height - lift
808    };
809    for info in [STORY_INFO, NOMBRE_INFO] {
810        for (layer_id, _) in story_layers(db, info)? {
811            let (dx, dy) = layer_offset(db, layer_id)?;
812            edit_story_blocks(db, layer_id, info, &|part, _, attributes| {
813                Ok(map_records(attributes, |id, data| match id {
814                    ids::BBOX if data.len() == 16 => {
815                        let [x0, y0, x1, y1] = i32s(&data)[..] else { unreachable!() };
816                        let (x0, y0, x1, y1) = (x0 + dx, y0 + dy, x1 + dx, y1 + dy);
817                        let width = round_half_even((x1 - x0) as f64 * scale);
818                        let height = round_half_even((y1 - y0) as f64 * scale);
819                        let along = |align: Align, from: i64, to: i64| match align {
820                            Align::Start => from,
821                            Align::Middle => (from + to) / 2 - width / 2,
822                            Align::End => to - width,
823                        };
824                        let (x0, y0) = match (part, &setup.story.nombre) {
825                            (StoryPart::Nombre, None) => (
826                                // A left page: the binding edge is on the right.
827                                at(old.trim[2], new.trim[2], x1) - width,
828                                at(old.basic[3], new.basic[3], y1) - height,
829                            ),
830                            (StoryPart::Nombre, Some(nombre)) => {
831                                // 内側 is the binding side: the right of a left page.
832                                let [across, apart] = [px(nombre.offset_mm[0]), px(nombre.offset_mm[1])];
833                                let x = match nombre.place.align() {
834                                    Align::Start => new.basic[0] + across,
835                                    Align::Middle => along(Align::Middle, new.basic[0], new.basic[2]),
836                                    Align::End => new.basic[2] - across - width,
837                                };
838                                let y = if nombre.place.top() {
839                                    new.basic[1] - apart - height
840                                } else {
841                                    new.basic[3] + apart
842                                };
843                                (x, y)
844                            }
845                            (part, _) => match row_of(part) {
846                                Some((position, row)) => {
847                                    (along(position.align(), new.trim[0], new.trim[2]), below(y0, height, row))
848                                }
849                                None => (at(old.trim[0], new.trim[0], x0), below(y0, height, 0)),
850                            },
851                        };
852                        i32_bytes(&[x0 - dx, y0 - dy, x0 + width - dx, y0 + height - dy])
853                    }
854                    ids::BLOCK_SIZE | ids::QUAD | ATTR_ORIGIN if data.len() % 4 == 0 && scale != 1.0 => {
855                        let scaled: Vec<i64> =
856                            i32s(&data).into_iter().map(|number| round_half_even(number as f64 * scale)).collect();
857                        i32_bytes(&scaled)
858                    }
859                    _ => data,
860                }))
861            })?;
862            drop_render_cache(db, layer_id)?;
863        }
864    }
865    Ok(())
866}
867
868/// Remove the images CSP drew of the story info texts (record 0x32, cleared
869/// above): offscreens of those layers that no mipmap or thumbnail uses.
870/// They showed the template's texts.
871fn drop_text_images(db: &Connection) -> Result<()> {
872    let mut layers = story_layers(db, STORY_INFO)?;
873    layers.extend(story_layers(db, NOMBRE_INFO)?);
874    for (layer_id, _) in layers {
875        db.execute(
876            "DELETE FROM Offscreen WHERE LayerId = ?1 \
877             AND MainId NOT IN (SELECT Offscreen FROM MipmapInfo WHERE Offscreen IS NOT NULL) \
878             AND MainId NOT IN (SELECT ThumbnailOffscreen FROM LayerThumbnail WHERE ThumbnailOffscreen IS NOT NULL)",
879            [layer_id],
880        )?;
881    }
882    Ok(())
883}
884
885/// Set the font of the 作品情報 and ノンブル texts.
886fn set_story_font(db: &Connection, font: &StoryFont) -> Result<()> {
887    // Record 57: u16 1, then the shown and the PostScript name, each u16 length + UTF-8.
888    let mut attribute = 1u16.to_le_bytes().to_vec();
889    for text in [&font.name, &font.postscript] {
890        attribute.extend((text.len() as u16).to_le_bytes());
891        attribute.extend(text.as_bytes());
892    }
893    for info in [STORY_INFO, NOMBRE_INFO] {
894        for (layer_id, _) in story_layers(db, info)? {
895            edit_story_blocks(db, layer_id, info, &|_, _, attributes| {
896                Ok(map_records(attributes, |id, data| match id {
897                    ATTR_FONT_LABEL if !data.is_empty() => font.name.as_bytes().to_vec(),
898                    ids::FONT if !data.is_empty() => attribute.clone(),
899                    _ => data,
900                }))
901            })?;
902        }
903    }
904    Ok(())
905}
906
907/// Mipmap level sizes from a base size: each level is half of the one
908/// before, rounded down.
909fn levels(base: i64) -> Vec<i64> {
910    let mut result = vec![base];
911    while result.len() < 16 && *result.last().unwrap() > 1 {
912        result.push(result.last().unwrap() / 2);
913    }
914    result
915}
916
917fn padded(size: i64) -> i64 {
918    let block = BLOCK_SIZE as i64;
919    (size + block - 1) / block * block
920}
921
922/// Resize every offscreen sized by the canvas from `old` to `new` (px):
923/// render mipmaps (the root's at the exact canvas size, the layers' padded
924/// to whole blocks) at every level, their pixels scaled. Layer thumbnails
925/// keep their size. An offscreen stays without stored pixels when it had
926/// none (CSP makes those levels when it opens the page).
927fn resize_offscreens(
928    page: &mut ClipFile,
929    (width, height): (i64, i64),
930    (new_width, new_height): (i64, i64),
931) -> Result<()> {
932    let families = [
933        (levels(width), levels(height), levels(new_width), levels(new_height)),
934        (levels(padded(width)), levels(padded(height)), levels(padded(new_width)), levels(padded(new_height))),
935    ];
936    for row in select(page.database(), "SELECT MainId, Attribute, BlockData FROM Offscreen", [])? {
937        let Some(attribute) = row.blob("Attribute") else { continue };
938        let Ok(mut attribute) = Attribute::parse(attribute) else { continue };
939        let Ok(parameter) = attribute.parameter() else { continue };
940        let size = (parameter.width as i64, parameter.height as i64);
941        let Some((target_width, target_height)) = families.iter().find_map(|(widths, heights, new_w, new_h)| {
942            let count = widths.len().min(heights.len()).min(new_w.len()).min(new_h.len());
943            (0..count).find(|level| (widths[*level], heights[*level]) == size).map(|level| (new_w[level], new_h[level]))
944        }) else {
945            continue;
946        };
947        let external = row.get("BlockData").and_then(value_as_text).unwrap_or_default();
948        let stored = page.external(&external).map(<[u8]>::to_vec);
949        let old = match &stored {
950            Some(payload) => Some(Offscreen::parse(&attribute.to_bytes(), Some(payload))?.to_rgba()?),
951            None => None,
952        };
953        let resized = crate::offscreen::Parameter {
954            width: target_width as u32,
955            height: target_height as u32,
956            blocks_x: (target_width as u32).div_ceil(BLOCK_SIZE),
957            blocks_y: (target_height as u32).div_ceil(BLOCK_SIZE),
958            ..parameter
959        };
960        let image =
961            old.filter(|old| old.alpha_bbox().is_some()).map(|old| old.resize_area(resized.width, resized.height));
962        let data = match (&image, resized.kind()) {
963            (Some(image), PixelKind::Color | PixelKind::Gray) => encode(&resized, Some(image), None)?,
964            (Some(_), kind) => bail!("Resizing {kind:?} offscreens with pixels is not supported"),
965            (None, _) => BlockData::from_raw_blocks(&vec![None; resized.block_count()], resized.block_bytes())?,
966        };
967        let values = &mut attribute
968            .sections
969            .iter_mut()
970            .find(|section| section.name == "Parameter")
971            .ok_or_else(|| Error::format("Offscreen attribute has no Parameter section"))?
972            .values;
973        values[..4].copy_from_slice(&[resized.width, resized.height, resized.blocks_x, resized.blocks_y]);
974        attribute.set_block_sizes(&data.record_sizes());
975        page.database().execute(
976            "UPDATE Offscreen SET Attribute = ?1 WHERE MainId = ?2",
977            (attribute.to_bytes(), row.int("MainId")),
978        )?;
979        if stored.is_some() {
980            page.set_external(&external, data.to_bytes());
981        }
982    }
983    Ok(())
984}
985
986/// Record the canvas size (px) the layer thumbnails were drawn for.
987fn set_thumbnail_canvas(db: &Connection, width: i64, height: i64) -> Result<()> {
988    for (column, value) in [("ThumbnailCanvasWidth", width), ("ThumbnailCanvasHeight", height)] {
989        if has_column(db, "LayerThumbnail", column)? {
990            db.execute(&format!("UPDATE LayerThumbnail SET {} = ?1", quote(column)), [value])?;
991        }
992    }
993    Ok(())
994}
995
996/// CSP keeps the page preview at an eighth of the canvas.
997const PREVIEW_SCALE: f64 = 8.0;
998
999/// A blank (white) preview for a canvas of `width` × `height` px, at the
1000/// size CSP gives it.
1001fn blank_preview(db: &Connection, width: i64, height: i64) -> Result<()> {
1002    if !has_table(db, "CanvasPreview")? {
1003        return Ok(());
1004    }
1005    let Some(row) = select(db, "SELECT rowid AS id, ImageData FROM CanvasPreview ORDER BY rowid LIMIT 1", [])?.pop()
1006    else {
1007        return Ok(());
1008    };
1009    if row.blob("ImageData").is_none_or(|png| decode_png(png).is_err()) {
1010        return Ok(());
1011    }
1012    let size = |pixels: i64| ((pixels as f64 / PREVIEW_SCALE).round() as u32).max(1);
1013    let mut preview = RgbaImage::new(size(width), size(height));
1014    preview.fill([255, 255, 255, 255]);
1015    let id = row.int("id");
1016    db.execute("UPDATE CanvasPreview SET ImageData = ?1 WHERE rowid = ?2", (preview.to_png()?, id))?;
1017    for (column, value) in [("ImageWidth", preview.width), ("ImageHeight", preview.height)] {
1018        if has_column(db, "CanvasPreview", column)? {
1019            db.execute(&format!("UPDATE CanvasPreview SET {} = ?1 WHERE rowid = ?2", quote(column)), (value, id))?;
1020        }
1021    }
1022    Ok(())
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use crate::model::LayerKind;
1029
1030    /// The settings the built-in page was saved with in CSP (A3 canvas, B4
1031    /// 仕上がり, 600 dpi), with another work's story info.
1032    fn commercial() -> PageSetup {
1033        PageSetup {
1034            canvas_mm: [297.0, 420.0],
1035            resolution: 600.0,
1036            trim_mm: [257.0, 364.0],
1037            bleed_mm: 5.0,
1038            basic_mm: [212.0, 300.0],
1039            basic_offset_mm: [0.0, 0.0],
1040            right_binding: true,
1041            first_page_left: true,
1042            title: "ユメの話".into(),
1043            story_index: Some(4),
1044            author: "だれか".into(),
1045            nombre_start: 1,
1046            font: None,
1047            story: StoryLayout::default(),
1048        }
1049    }
1050
1051    /// G1 / G2, made in CSP EX 5.1.4: a 60 × 80 mm 仕上がり (= 基本枠) on a
1052    /// 70 × 90 mm canvas at 600 dpi, with サブタイトル, ページ番号 from 7 and a
1053    /// white ノンブル `p.1-` 3 mm from the 基本枠.
1054    fn shown(place: NombrePlace, position: StoryPosition) -> PageSetup {
1055        PageSetup {
1056            canvas_mm: [70.0, 90.0],
1057            trim_mm: [60.0, 80.0],
1058            basic_mm: [60.0, 80.0],
1059            title: "G1".into(),
1060            story_index: None,
1061            author: "作者".into(),
1062            story: StoryLayout {
1063                subtitle: "サブ".into(),
1064                title_position: position,
1065                author_position: position,
1066                page_number: Some(PageNumber { start: 7, position }),
1067                nombre: Some(ShownNombre {
1068                    place,
1069                    white: true,
1070                    edge_mm: Some(0.5),
1071                    prefix: "p.".into(),
1072                    suffix: "-".into(),
1073                    offset_mm: [0.0, 3.0],
1074                    size_pt: 9.0,
1075                }),
1076            },
1077            ..commercial()
1078        }
1079    }
1080
1081    /// (part code, text, canvas bbox) of every block of the story info layers of `info`.
1082    fn story_parts(page: &ClipFile, info: i64) -> Vec<(i64, String, [i32; 4])> {
1083        let mut parts = Vec::new();
1084        for (id, _) in story_layers(page.database(), info).unwrap() {
1085            let layer = page.layers.get(id).unwrap();
1086            let (dx, dy) = (layer.offset.0 as i32, layer.offset.1 as i32);
1087            for block in crate::text::read_blocks(&layer.row).unwrap() {
1088                let part = block.attributes.get(ATTR_STORY_PART).map(i32s).map_or(0, |values| values[0]);
1089                let [x0, y0, x1, y1] = block.attributes.bbox().unwrap();
1090                parts.push((part, block.text, [x0 + dx, y0 + dy, x1 + dx, y1 + dy]));
1091            }
1092        }
1093        parts
1094    }
1095
1096    fn canvas_ints(page: &ClipFile, columns: &[&str]) -> Vec<i64> {
1097        columns.iter().map(|column| page.canvas.row.int(column)).collect()
1098    }
1099
1100    fn reopened(page: ClipFile) -> ClipFile {
1101        ClipFile::from_bytes(page.to_bytes().unwrap()).unwrap()
1102    }
1103
1104    #[test]
1105    fn keeps_csp_layout_for_the_settings_it_was_saved_with() {
1106        let page = reopened(blank_page(&commercial()).unwrap());
1107        assert_eq!((page.canvas.width_px, page.canvas.height_px), (7016, 9921));
1108        // Where CSP put them on this page: the 作品名 below the 仕上がり at its
1109        // left edge, the 作者名 at its middle, the ノンブル at the gutter.
1110        let story = story_parts(&page, STORY_INFO);
1111        assert_eq!(story[0].1, "ユメの話 4話");
1112        assert_eq!(story[0].2[..2], [472, 9402]);
1113        assert_eq!(story[1].1, "だれか");
1114        assert!(((story[1].2[0] + story[1].2[2]) / 2 - 3507).abs() <= 1, "{story:?}");
1115        let nombre = story_parts(&page, NOMBRE_INFO);
1116        assert_eq!((nombre[0].1.as_str(), nombre[0].2), ("1", [6512, 8454, 6543, 8504]));
1117        assert_eq!(canvas_ints(&page, &["ComicIsLeftPage", "CropFrameOptionFlag", "ComicBindPosition"]), [1, 2, 1]);
1118        assert!(crate::verify::check_clip(page.to_bytes().unwrap(), Default::default()).ok());
1119        let work = blank_work(&commercial()).unwrap();
1120        assert_eq!(work.project.bind_position, 1);
1121        assert_eq!(work.project.start_page, 0);
1122        assert_eq!(work.pages().unwrap().len(), 1);
1123    }
1124
1125    #[test]
1126    fn makes_pages_of_another_size_and_binding() {
1127        let setup = PageSetup {
1128            canvas_mm: [158.0, 220.0],
1129            resolution: 350.0,
1130            trim_mm: [148.0, 210.0],
1131            bleed_mm: 3.0,
1132            basic_mm: [120.0, 180.0],
1133            basic_offset_mm: [2.0, -3.0],
1134            right_binding: false,
1135            first_page_left: false,
1136            title: "Short".into(),
1137            story_index: None,
1138            author: String::new(),
1139            nombre_start: 3,
1140            font: Some(StoryFont { name: "BIZ UDゴシック".into(), postscript: "BIZ-UDGothic".into() }),
1141            story: StoryLayout::default(),
1142        };
1143        let page = reopened(blank_page(&setup).unwrap());
1144        let (width, height) = (page.canvas.width_px as i64, page.canvas.height_px as i64);
1145        assert_eq!((width, height), (2177, 3031));
1146        // Every canvas-sized offscreen follows the new size at each mipmap level.
1147        for row in select(page.database(), "SELECT Attribute FROM Offscreen", []).unwrap() {
1148            let parameter = Attribute::parse(row.blob("Attribute").unwrap()).unwrap().parameter().unwrap();
1149            let size = (parameter.width as i64, parameter.height as i64);
1150            let fits = (0..8).any(|level| {
1151                size == (width >> level, height >> level) || size == (padded(width) >> level, padded(height) >> level)
1152            }) || size == (512, 512);
1153            assert!(fits, "offscreen {size:?} is not sized by the {width}x{height} canvas");
1154        }
1155        // A right page: ノンブル at its left edge, level with the bottom of the 基本枠.
1156        assert_eq!(canvas_ints(&page, &["ComicIsLeftPage", "CropFrameOptionFlag", "ComicBindPosition"]), [0, 4, 0]);
1157        let nombre = story_parts(&page, NOMBRE_INFO);
1158        assert!((nombre[0].2[0] as i64 - (width - 2039) / 2).abs() <= 1, "{nombre:?}");
1159        assert_eq!(nombre[0].2[3] as i64, (height - 2480) / 2 - 41 + 2480);
1160        assert_eq!(nombre[0].1, "3");
1161        let story = story_parts(&page, STORY_INFO);
1162        assert_eq!((story[0].1.as_str(), story[1].1.as_str()), ("Short", ""));
1163        let fonts = page
1164            .database()
1165            .query_row("SELECT TextLayerAttributes FROM Layer WHERE ComicStoryInfoType = 2", [], |row| {
1166                row.get::<_, Vec<u8>>(0)
1167            })
1168            .unwrap();
1169        assert!(fonts.windows(12).any(|window| window == b"BIZ-UDGothic"));
1170        let work = blank_work(&setup).unwrap();
1171        assert_eq!((work.project.bind_position, work.project.start_page), (0, 1));
1172        assert!(crate::verify::check_clip(page.to_bytes().unwrap(), Default::default()).ok());
1173    }
1174
1175    #[test]
1176    fn lays_out_a_shown_nombre_and_page_numbers_as_csp_does() {
1177        let close = |a: i32, b: i32| (a - b).abs() <= 1;
1178        // G1 on a canvas 20 mm taller: everything at 左下, the ノンブル 上部内側.
1179        let setup = PageSetup { canvas_mm: [70.0, 110.0], ..shown(NombrePlace::TopInner, StoryPosition::Left) };
1180        let page = reopened(blank_page(&setup).unwrap());
1181        assert_eq!((page.canvas.width_px, page.canvas.height_px), (1654, 2598));
1182        let lower = (2598 - 2126) / 2;
1183        let story = story_parts(&page, STORY_INFO);
1184        let texts: Vec<(i64, &str)> = story.iter().map(|(part, text, _)| (*part, text.as_str())).collect();
1185        assert_eq!(texts, [(1, "G1 サブ"), (2, "作者"), (4, "7ページ")]);
1186        let bbox = |part: i64| story.iter().find(|block| block.0 == part).unwrap().2;
1187        for (part, y) in [(4, 2150), (1, 2200), (2, 2250)] {
1188            let [x0, y0, _, y1] = bbox(part);
1189            assert!(x0 == 118 && close(y0, y + lower) && y1 - y0 == 50, "{part}: {:?}", bbox(part));
1190        }
1191        let nombre = story_parts(&page, NOMBRE_INFO);
1192        let (part, text, [_, y0, x1, y1]) = &nombre[0];
1193        assert_eq!((*part, text.as_str()), (16, "p.1-"));
1194        assert!(*x1 == 1535 && close(*y0, -29 + lower) && *y1 == 47 + lower, "{nombre:?}");
1195        let (attached, info): (i64, Vec<u8>) = page
1196            .database()
1197            .query_row(
1198                "SELECT LayerEffectAttached, LayerEffectInfo FROM Layer WHERE ComicStoryInfoType = 2",
1199                [],
1200                |row| Ok((row.get(0)?, row.get(1)?)),
1201            )
1202            .unwrap();
1203        assert_eq!((attached, info.as_slice()), (1, NOMBRE_EDGE));
1204        // The next page of the work: its ページ番号 and ノンブル follow.
1205        let mut next = page;
1206        next.database().execute("UPDATE Canvas SET ComicPageIndex = 1, ComicIsLeftPage = 0", []).unwrap();
1207        next.update_story_numbers(false).unwrap();
1208        let next = reopened(next);
1209        let numbers: Vec<String> = story_parts(&next, STORY_INFO)
1210            .into_iter()
1211            .chain(story_parts(&next, NOMBRE_INFO))
1212            .filter(|(part, ..)| *part == 4 || *part == 16)
1213            .map(|(_, text, _)| text)
1214            .collect();
1215        assert_eq!(numbers, ["8ページ", "p.2-"]);
1216        assert!(crate::verify::check_clip(next.to_bytes().unwrap(), Default::default()).ok());
1217        assert!(next.layers.flatten().iter().any(|(layer, _)| layer.kind == LayerKind::StoryInfo));
1218    }
1219
1220    #[test]
1221    fn refuses_settings_csp_cannot_make() {
1222        let check = |change: &dyn Fn(&mut PageSetup)| {
1223            let mut setup = commercial();
1224            change(&mut setup);
1225            setup.check().is_err()
1226        };
1227        assert!(check(&|setup| setup.trim_mm = [300.0, 364.0]));
1228        assert!(check(&|setup| setup.resolution = 2400.0));
1229        assert!(check(&|setup| setup.basic_offset_mm = [60.0, 0.0]));
1230        assert!(check(&|setup| setup.canvas_mm = [2000.0, 2000.0]));
1231        assert!(commercial().check().is_ok());
1232    }
1233}