Skip to main content

csp_format/
story.rs

1//! The texts of a page's story info layers (`Layer.ComicStoryInfoType`):
2//! (作品情報) holds 作品名・話数・サブタイトル in its first block, 作者名 and
3//! ページ番号 in the blocks of its attributes array; (ノンブル) holds the
4//! ノンブル. CSP marks each block with the part it is (record 0x2e) and
5//! draws them afresh from these blocks, so a part's text, style run length
6//! and block width are written together. Moved from name-studio's clip-core
7//! (`write/story.rs`, `write/page.rs`).
8
9use rusqlite::types::Value;
10use rusqlite::{Connection, OptionalExtension};
11
12use crate::database::{has_column, quote};
13use crate::error::{Error, Result};
14use crate::text::{Attributes, Record, ids, join_array, split_array};
15
16/// `Layer.ComicStoryInfoType` of the 作品情報 and (ノンブル) layers.
17pub const STORY_INFO: i64 = 1;
18/// `Layer.ComicStoryInfoType` of the (ノンブル) layer.
19pub const NOMBRE_INFO: i64 = 2;
20/// Layer column of a spread's story info layers: 1 for the right page's.
21pub const FOR_RIGHT_PAGE: &str = "ComicStoryInfoForRightPage";
22/// `CropFrameOptionFlag` bits CSP sets for a single left / right page (a spread has 17).
23pub const LEFT_PAGE_FLAG: i64 = 2;
24/// See [`LEFT_PAGE_FLAG`].
25pub const RIGHT_PAGE_FLAG: i64 = 4;
26
27/// Record 0x2e: which part of the story info a block is.
28pub const ATTR_STORY_PART: u32 = 0x2e;
29/// Record 0x0b: the style runs (a count, then per run its start and length).
30const ATTR_RUNS: u32 = ids::RUN_FONT_SIZE;
31/// Record 0x48: offset of the text from its frame (i32 × 2).
32pub const ATTR_ORIGIN: u32 = 0x48;
33/// Record 0x22: default text colour (per channel a u16, written twice).
34pub const ATTR_COLOR: u32 = 0x22;
35/// Record 0x1f: the font name CSP shows, in UTF-8.
36pub const ATTR_FONT_LABEL: u32 = 0x1f;
37
38const TEXT_BLOBS: [&str; 1] = ["TextLayerAttributes"];
39const TEXT_ARRAYS: [&str; 2] = ["TextLayerAttributesArray", "TextLayerAddAttributesV01"];
40
41/// A part of the story info texts.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum StoryPart {
44    /// 作品名・話数・サブタイトル.
45    Title,
46    /// 作者名.
47    Author,
48    /// ページ番号 (`7ページ`).
49    PageNumber,
50    /// ノンブル (visible or hidden).
51    Nombre,
52}
53
54impl StoryPart {
55    /// The record 0x2e value CSP gives the part (the hidden ノンブル is 32).
56    pub fn code(self) -> i64 {
57        match self {
58            StoryPart::Title => 1,
59            StoryPart::Author => 2,
60            StoryPart::PageNumber => 4,
61            StoryPart::Nombre => 16,
62        }
63    }
64
65    /// The part a block is marked as (record 0x2e), if marked.
66    pub fn of(attributes: &Attributes) -> Option<StoryPart> {
67        match attributes.get(ATTR_STORY_PART).map(i32s).as_deref() {
68            Some([1]) => Some(StoryPart::Title),
69            Some([2]) => Some(StoryPart::Author),
70            Some([4]) => Some(StoryPart::PageNumber),
71            Some([16] | [32]) => Some(StoryPart::Nombre),
72            _ => None,
73        }
74    }
75
76    /// The part of a block without a 0x2e mark, by where it is.
77    fn by_place(info: i64, column: &str, block: Option<usize>) -> StoryPart {
78        if info != STORY_INFO {
79            return StoryPart::Nombre;
80        }
81        match (column, block) {
82            ("TextLayerAttributesArray", Some(0)) | ("TextLayerAddAttributesV01", Some(1)) => StoryPart::Author,
83            ("TextLayerAttributesArray", Some(_)) | ("TextLayerAddAttributesV01", Some(_)) => StoryPart::PageNumber,
84            _ => StoryPart::Title,
85        }
86    }
87}
88
89/// Which edge of a text block stays where it is when its text changes.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum Align {
92    /// The left / top edge.
93    Start,
94    /// The centre.
95    Middle,
96    /// The right / bottom edge.
97    End,
98}
99
100impl Align {
101    /// Of CSP's 作品情報 positions: 1 左下, 2 下, 3 右下.
102    pub fn of_position(position: i64) -> Align {
103        match position {
104            2 => Align::Middle,
105            3 => Align::End,
106            _ => Align::Start,
107        }
108    }
109}
110
111/// Little-endian `i32`s of a record value.
112pub fn i32s(value: &[u8]) -> Vec<i64> {
113    value.as_chunks::<4>().0.iter().map(|bytes| i32::from_le_bytes(*bytes) as i64).collect()
114}
115
116/// `values` as little-endian `i32`s.
117pub fn i32_bytes(values: &[i64]) -> Vec<u8> {
118    values.iter().flat_map(|value| (*value as i32).to_le_bytes()).collect()
119}
120
121/// Rounds half to even, as CSP does for text positions.
122pub fn round_half_even(value: f64) -> i64 {
123    value.round_ties_even() as i64
124}
125
126/// `attributes` with every record changed by `change`.
127pub fn map_records(attributes: Attributes, change: impl Fn(u32, Vec<u8>) -> Vec<u8>) -> Attributes {
128    Attributes {
129        records: attributes
130            .records
131            .into_iter()
132            .map(|Record { id, data }| Record { id, data: change(id, data) })
133            .collect(),
134    }
135}
136
137/// A Canvas column, None when the canvas has no such column.
138pub fn canvas_value(db: &Connection, column: &str) -> Result<Option<Value>> {
139    if !has_column(db, "Canvas", column)? {
140        return Ok(None);
141    }
142    Ok(db.query_row(&format!("SELECT {} FROM Canvas", quote(column)), [], |row| row.get(0)).optional()?)
143}
144
145fn canvas_int(db: &Connection, column: &str) -> Result<Option<i64>> {
146    Ok(match canvas_value(db, column)? {
147        Some(Value::Integer(number)) => Some(number),
148        Some(Value::Real(number)) => Some(number as i64),
149        _ => None,
150    })
151}
152
153fn canvas_text(db: &Connection, column: &str) -> Result<String> {
154    Ok(match canvas_value(db, column)? {
155        Some(Value::Text(text)) => text,
156        Some(Value::Blob(bytes)) => String::from_utf8_lossy(&bytes).into_owned(),
157        _ => String::new(),
158    })
159}
160
161/// `Canvas.CanvasResolution` (dpi).
162pub fn resolution(db: &Connection) -> Result<f64> {
163    match canvas_value(db, "CanvasResolution")? {
164        Some(Value::Real(number)) => Ok(number),
165        Some(Value::Integer(number)) => Ok(number as f64),
166        _ => Err(Error::format("Canvas has no resolution")),
167    }
168}
169
170/// `Canvas.CanvasDoublePage` is set.
171pub fn is_spread(db: &Connection) -> Result<bool> {
172    Ok(matches!(canvas_value(db, "CanvasDoublePage")?, Some(Value::Integer(flag)) if flag != 0))
173}
174
175/// Canvas width in px, as CSP computes it from its unit.
176pub fn canvas_width_px(db: &Connection) -> Result<i64> {
177    let row = crate::database::select(db, "SELECT CanvasWidth, CanvasResolution, CanvasUnit FROM Canvas", [])?;
178    let row = row.first().ok_or_else(|| Error::format("No Canvas row"))?;
179    let unit = crate::model::Unit::from_code(row.int("CanvasUnit"));
180    Ok(round_half_even(unit.to_pixels(row.real("CanvasWidth"), row.real("CanvasResolution"))))
181}
182
183fn layer_blob(db: &Connection, layer_id: i64, column: &str) -> Result<Option<Vec<u8>>> {
184    let value: Value =
185        db.query_row(&format!("SELECT {} FROM Layer WHERE MainId = ?1", quote(column)), [layer_id], |row| row.get(0))?;
186    Ok(match value {
187        Value::Blob(blob) => Some(blob),
188        _ => None,
189    })
190}
191
192fn set_layer_column(db: &Connection, layer_id: i64, column: &str, value: impl rusqlite::ToSql) -> Result<()> {
193    db.execute(&format!("UPDATE Layer SET {} = ?1 WHERE MainId = ?2", quote(column)), (value, layer_id))?;
194    Ok(())
195}
196
197fn layer_offset_x(db: &Connection, layer_id: i64) -> Result<i64> {
198    let offset: Option<i64> =
199        db.query_row("SELECT LayerOffsetX FROM Layer WHERE MainId = ?1", [layer_id], |row| row.get(0))?;
200    Ok(offset.unwrap_or(0))
201}
202
203/// Story info layers of one kind as (id, for the right page of a spread).
204pub fn story_layers(db: &Connection, info: i64) -> Result<Vec<(i64, bool)>> {
205    if !has_column(db, "Layer", "ComicStoryInfoType")? {
206        return Ok(Vec::new());
207    }
208    let side = if has_column(db, "Layer", FOR_RIGHT_PAGE)? {
209        format!("IFNULL({}, 0)", quote(FOR_RIGHT_PAGE))
210    } else {
211        "0".to_owned()
212    };
213    let mut statement =
214        db.prepare(&format!("SELECT MainId, {side} FROM Layer WHERE ComicStoryInfoType = ?1 ORDER BY MainId"))?;
215    let ids = statement
216        .query_map([info], |row| Ok((row.get(0)?, row.get::<_, i64>(1)? != 0)))?
217        .collect::<std::result::Result<_, _>>()?;
218    Ok(ids)
219}
220
221/// The (ノンブル) layer of a single page. A spread has a second one for its
222/// right page.
223fn nombre_layer(db: &Connection) -> Result<Option<i64>> {
224    let ids = story_layers(db, NOMBRE_INFO)?;
225    Ok(if ids.len() == 1 { Some(ids[0].0) } else { None })
226}
227
228/// Apply `edit` to the attributes of each block of one story info layer,
229/// with the part the block is and whether its style runs count characters.
230pub fn edit_story_blocks(
231    db: &Connection,
232    layer_id: i64,
233    info: i64,
234    edit: &dyn Fn(StoryPart, bool, Attributes) -> Result<Attributes>,
235) -> Result<()> {
236    for column in TEXT_BLOBS.iter().chain(TEXT_ARRAYS.iter()) {
237        if !has_column(db, "Layer", column)? {
238            continue;
239        }
240        let Some(blob) = layer_blob(db, layer_id, column)? else { continue };
241        // The add-attributes runs carry no character counts.
242        let counted = *column != "TextLayerAddAttributesV01";
243        let one = |bytes: &[u8], block: Option<usize>| -> Result<Vec<u8>> {
244            let attributes = Attributes::parse(bytes)?;
245            let part = StoryPart::of(&attributes).unwrap_or_else(|| StoryPart::by_place(info, column, block));
246            Ok(edit(part, counted, attributes)?.to_bytes())
247        };
248        let value = if TEXT_BLOBS.contains(column) {
249            one(&blob, None)?
250        } else {
251            let blocks = split_array(&blob)?
252                .into_iter()
253                .enumerate()
254                .map(|(index, block)| one(block, Some(index)))
255                .collect::<Result<Vec<_>>>()?;
256            join_array(&blocks)
257        };
258        set_layer_column(db, layer_id, column, value)?;
259    }
260    Ok(())
261}
262
263/// Apply `edit` to the attributes of every non-empty attribute blob of one
264/// text layer.
265pub fn edit_text_blocks(db: &Connection, layer_id: i64, edit: &dyn Fn(Attributes) -> Attributes) -> Result<()> {
266    let one = |blob: &[u8]| -> Result<Vec<u8>> { Ok(edit(Attributes::parse(blob)?).to_bytes()) };
267    for column in TEXT_BLOBS.iter().chain(TEXT_ARRAYS.iter()) {
268        if !has_column(db, "Layer", column)? {
269            continue;
270        }
271        let Some(blob) = layer_blob(db, layer_id, column)? else { continue };
272        if blob.is_empty() {
273            continue;
274        }
275        let value = if TEXT_BLOBS.contains(column) {
276            one(&blob)?
277        } else {
278            join_array(&split_array(&blob)?.into_iter().map(one).collect::<Result<Vec<_>>>()?)
279        };
280        set_layer_column(db, layer_id, column, value)?;
281    }
282    Ok(())
283}
284
285/// Make CSP draw a story info / ノンブル text anew instead of the image the
286/// file caches for it (record 0x32 → Offscreen), which shows the old text.
287pub fn drop_render_cache(db: &Connection, layer_id: i64) -> Result<()> {
288    edit_text_blocks(db, layer_id, &|attributes| {
289        map_records(
290            attributes,
291            |id, data| if id == ids::RENDER_OFFSCREEN && data.len() == 4 { vec![0; 4] } else { data },
292        )
293    })
294}
295
296/// Font size in px of a story text (record 0x20 is pt × 100 at the canvas resolution).
297pub fn em_px(attributes: &Attributes, resolution: f64) -> f64 {
298    let points = attributes
299        .get(ids::FONT_SIZE)
300        .filter(|value| value.len() == 4)
301        .map(|value| i32::from_le_bytes(value.try_into().unwrap()) as f64 / 100.0)
302        .unwrap_or(6.0);
303    points * resolution / 72.0
304}
305
306/// The width of a story text's block: a full-width character is one em, the
307/// space, digits and other ASCII narrower. CSP does not lay the block out
308/// again when it opens the page, and breaks a text wider than its block
309/// (a shown ノンブル `- 1 -` lost its last ` -`), so the narrow characters are
310/// given as much as the widest fonts take.
311pub fn text_width(text: &str, em: f64) -> i64 {
312    let ems: f64 = text
313        .chars()
314        .map(|character| match character {
315            ' ' | '.' | ',' | '-' | ':' => 0.5,
316            character if character.is_ascii() => 0.62,
317            _ => 1.0,
318        })
319        .sum();
320    round_half_even(ems * em).max(1)
321}
322
323/// `attributes` with its one style run `units` UTF-16 units long (counted runs only).
324pub fn set_run_length(attributes: Attributes, units: usize, counted: bool) -> Attributes {
325    map_records(attributes, |id, mut data| {
326        if id == ATTR_RUNS && counted && data.len() >= 12 && data[0..4] == 1u32.to_le_bytes() {
327            data[8..12].copy_from_slice(&(units as u32).to_le_bytes());
328        }
329        data
330    })
331}
332
333/// A block made `width` px wide: its bbox (keeping the edge or middle
334/// `align` says), block size and quad.
335pub fn resize_block(attributes: Attributes, width: i64, align: Align) -> Attributes {
336    map_records(attributes, |id, data| match id {
337        ids::BBOX if data.len() == 16 => {
338            let [x0, y0, x1, y1] = i32s(&data)[..] else { unreachable!() };
339            let x0 = match align {
340                Align::Start => x0,
341                Align::Middle => (x0 + x1 - width).div_euclid(2),
342                Align::End => x1 - width,
343            };
344            i32_bytes(&[x0, y0, x0 + width, y1])
345        }
346        ids::BLOCK_SIZE if data.len() == 8 => i32_bytes(&[width, i32s(&data)[1]]),
347        ids::QUAD if data.len() == 32 => {
348            let mut quad = i32s(&data);
349            for index in [2, 4] {
350                quad[index] = quad[0] + width * 100;
351            }
352            i32_bytes(&quad)
353        }
354        _ => data,
355    })
356}
357
358/// Write `text` into one part's block(s): its style run and width.
359pub fn write_part(db: &Connection, layer_id: i64, info: i64, part: StoryPart, text: &str, align: Align) -> Result<()> {
360    let resolution = resolution(db)?;
361    let units = text.encode_utf16().count();
362    edit_story_blocks(db, layer_id, info, &|found, counted, attributes| {
363        if found != part {
364            return Ok(attributes);
365        }
366        let width = text_width(text, em_px(&attributes, resolution));
367        Ok(resize_block(set_run_length(attributes, units, counted), width, align))
368    })
369}
370
371/// The strings of a layer's `TextLayerStringArray`.
372pub fn string_array(db: &Connection, layer_id: i64) -> Result<Option<Vec<String>>> {
373    if !has_column(db, "Layer", "TextLayerStringArray")? {
374        return Ok(None);
375    }
376    let Some(blob) = layer_blob(db, layer_id, "TextLayerStringArray")? else { return Ok(None) };
377    Ok(Some(split_array(&blob)?.into_iter().map(|bytes| String::from_utf8_lossy(bytes).into_owned()).collect()))
378}
379
380/// Replaces a layer's `TextLayerStringArray` (the texts of its blocks after the first).
381pub fn set_string_array(db: &Connection, layer_id: i64, strings: &[String]) -> Result<()> {
382    set_layer_column(db, layer_id, "TextLayerStringArray", join_array(strings))
383}
384
385/// The parts of the blocks of a layer's attributes array, in order.
386pub fn array_parts(db: &Connection, layer_id: i64) -> Result<Vec<StoryPart>> {
387    if !has_column(db, "Layer", "TextLayerAttributesArray")? {
388        return Ok(Vec::new());
389    }
390    let Some(blob) = layer_blob(db, layer_id, "TextLayerAttributesArray")? else { return Ok(Vec::new()) };
391    split_array(&blob)?
392        .into_iter()
393        .enumerate()
394        .map(|(index, block)| {
395            let attributes = Attributes::parse(block)?;
396            Ok(StoryPart::of(&attributes)
397                .unwrap_or_else(|| StoryPart::by_place(STORY_INFO, "TextLayerAttributesArray", Some(index))))
398        })
399        .collect()
400}
401
402/// The ページ番号 text of the 作品情報.
403pub fn page_number_text(number: i64) -> String {
404    format!("{number}ページ")
405}
406
407/// Write the page's own ページ番号 into its 作品情報, when the work shows one:
408/// `ComicStoryPageNumberStart + ComicPageIndex` (and the next one for the
409/// other page of a spread). `first_right`: the spread's first page is its
410/// right one.
411pub fn update_page_number(db: &Connection, first_right: bool) -> Result<()> {
412    if canvas_int(db, "ComicStoryUsePageNumber")? != Some(1) {
413        return Ok(());
414    }
415    let first =
416        canvas_int(db, "ComicStoryPageNumberStart")?.unwrap_or(1) + canvas_int(db, "ComicPageIndex")?.unwrap_or(0);
417    let align = Align::of_position(canvas_int(db, "ComicStoryPageNumberPosition")?.unwrap_or(3));
418    let spread = is_spread(db)?;
419    for (layer_id, right) in story_layers(db, STORY_INFO)? {
420        let number = if spread && right != first_right { first + 1 } else { first };
421        let text = page_number_text(number);
422        let Some(mut strings) = string_array(db, layer_id)? else { continue };
423        let Some(index) = array_parts(db, layer_id)?.iter().position(|part| *part == StoryPart::PageNumber) else {
424            continue;
425        };
426        if strings.get(index).is_none_or(|old| *old == text) {
427            continue;
428        }
429        strings[index] = text.clone();
430        set_string_array(db, layer_id, &strings)?;
431        write_part(db, layer_id, STORY_INFO, StoryPart::PageNumber, &text, align)?;
432        drop_render_cache(db, layer_id)?;
433    }
434    Ok(())
435}
436
437/// How the work shows its ノンブル: the text around the number, and whether
438/// it stays centred (上部 / 下部) as the number changes.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct NombreStyle {
441    /// Text before the number.
442    pub prefix: String,
443    /// Text after the number.
444    pub suffix: String,
445    /// The ノンブル stays centred when the number changes.
446    pub centred: bool,
447}
448
449/// The ノンブル style of the page's work (none for the hidden ノンブル).
450pub fn nombre_style(db: &Connection) -> Result<Option<NombreStyle>> {
451    if canvas_int(db, "ComicStoryUseShownNombre")? != Some(1) {
452        return Ok(None);
453    }
454    let position = canvas_int(db, "ComicStoryNombrePosition")?.unwrap_or(5);
455    Ok(Some(NombreStyle {
456        prefix: canvas_text(db, "ComicStoryNombrePrefix")?,
457        suffix: canvas_text(db, "ComicStoryNombreSuffix")?,
458        centred: matches!(position, 2 | 5),
459    }))
460}
461
462/// Change the x of every block's bbox with `change(x0, x1)`.
463pub fn map_bbox_x(attributes: Attributes, change: &dyn Fn(i64, i64) -> (i64, i64)) -> Attributes {
464    map_records(attributes, |id, data| {
465        if id != ids::BBOX || data.len() != 16 {
466            return data;
467        }
468        let [x0, y0, x1, y1] = i32s(&data)[..] else { unreachable!() };
469        let (x0, x1) = change(x0, x1);
470        i32_bytes(&[x0, y0, x1, y1])
471    })
472}
473
474/// Move the (ノンブル) of a single page to the other side.
475///
476/// The hidden ノンブル sits at the binding edge, which is on the other side
477/// for left and right pages. CSP moves it when it opens a page whose side
478/// differs from where the file had it, and then marks the page as
479/// modified. The text block's bbox is mirrored across the canvas width.
480pub fn mirror_nombre(db: &Connection) -> Result<()> {
481    let Some(layer_id) = nombre_layer(db)? else { return Ok(()) };
482    let width = canvas_width_px(db)?;
483    let offset = layer_offset_x(db, layer_id)?;
484    let mirror = |x0: i64, x1: i64| (width - (x1 + offset) - offset, width - (x0 + offset) - offset);
485    edit_text_blocks(db, layer_id, &|attributes| map_bbox_x(attributes, &mirror))
486}
487
488/// `TextLayerAttributes` with its one style run `delta` characters longer.
489pub fn move_run_length(blob: &[u8], delta: i64) -> Result<Vec<u8>> {
490    if delta == 0 {
491        return Ok(blob.to_vec());
492    }
493    Ok(map_records(Attributes::parse(blob)?, |id, mut data| {
494        if id == ATTR_RUNS && data.len() >= 12 && u32::from_le_bytes(data[0..4].try_into().unwrap()) == 1 {
495            let length = u32::from_le_bytes(data[8..12].try_into().unwrap()) as i64;
496            data[8..12].copy_from_slice(&((length + delta).max(0) as u32).to_le_bytes());
497        }
498        data
499    })
500    .to_bytes())
501}
502
503/// Write the page's own number into the (ノンブル) text.
504///
505/// CSP writes `ComicStoryNombreStart + ComicPageIndex` there (a shown
506/// ノンブル with the work's prefix and suffix around it). The text and its
507/// style run change, and the block widens or narrows by its width per
508/// character, keeping the edge nearer to the page border (the binding edge),
509/// or its middle for a centred shown ノンブル. `first_right`: the spread's
510/// first page is its right one.
511pub fn update_nombre(db: &Connection, first_right: bool) -> Result<()> {
512    if !has_column(db, "Canvas", "ComicPageIndex")? || !has_column(db, "Canvas", "ComicStoryNombreStart")? {
513        return Ok(());
514    }
515    let first = canvas_int(db, "ComicStoryNombreStart")?.unwrap_or(1) + canvas_int(db, "ComicPageIndex")?.unwrap_or(0);
516    let style = nombre_style(db)?;
517    let text = |number: i64| match &style {
518        Some(style) => format!("{}{number}{}", style.prefix, style.suffix),
519        None => number.to_string(),
520    };
521    let centred = style.as_ref().is_some_and(|style| style.centred);
522    if is_spread(db)? {
523        for (layer_id, right) in story_layers(db, NOMBRE_INFO)? {
524            let number = if right == first_right { first } else { first + 1 };
525            set_nombre(db, layer_id, &text(number), centred)?;
526        }
527        return Ok(());
528    }
529    let Some(layer_id) = nombre_layer(db)? else { return Ok(()) };
530    set_nombre(db, layer_id, &text(first), centred)
531}
532
533/// Write `number` into one (ノンブル) layer.
534fn set_nombre(db: &Connection, layer_id: i64, number: &str, centred: bool) -> Result<()> {
535    let (string, attributes): (Value, Value) =
536        db.query_row("SELECT TextLayerString, TextLayerAttributes FROM Layer WHERE MainId = ?1", [layer_id], |row| {
537            Ok((row.get(0)?, row.get(1)?))
538        })?;
539    let old = match string {
540        Value::Blob(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
541        Value::Text(text) => text,
542        _ => String::new(),
543    };
544    if old.is_empty() || old == number {
545        return Ok(());
546    }
547    let (old_length, new_length) = (old.chars().count() as i64, number.chars().count() as i64);
548    let width = canvas_width_px(db)?;
549    let offset = layer_offset_x(db, layer_id)?;
550    let resize = |attributes: Attributes| -> Attributes {
551        let Some(block) = attributes.get(ids::BLOCK_SIZE).filter(|value| value.len() == 8).map(i32s) else {
552            return attributes;
553        };
554        let (block_width, height) = (block[0], block[1]);
555        let new_width = round_half_even(block_width as f64 * new_length as f64 / old_length as f64).max(1);
556        let grow = new_width - block_width;
557        let keep_border_edge = |x0: i64, x1: i64| {
558            if centred {
559                let x0 = (x0 + x1 - new_width).div_euclid(2);
560                (x0, x0 + new_width)
561            } else if x0 + offset < width - (x1 + offset) {
562                (x0, x1 + grow)
563            } else {
564                (x0 - grow, x1)
565            }
566        };
567        map_records(map_bbox_x(attributes, &keep_border_edge), |id, mut data| {
568            if id == ids::BLOCK_SIZE {
569                data = i32_bytes(&[new_width, height]);
570            } else if id == ids::QUAD && data.len() == 32 {
571                // The right-hand x of the quad, in 1/100 px.
572                for position in [2, 4] {
573                    let at = position * 4;
574                    let x = i32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as i64 + grow * 100;
575                    data[at..at + 4].copy_from_slice(&(x as i32).to_le_bytes());
576                }
577            }
578            data
579        })
580    };
581    set_layer_column(db, layer_id, "TextLayerString", number.as_bytes())?;
582    if let Value::Blob(attributes) = attributes {
583        set_layer_column(db, layer_id, "TextLayerAttributes", move_run_length(&attributes, new_length - old_length)?)?;
584    }
585    edit_text_blocks(db, layer_id, &resize)?;
586    drop_render_cache(db, layer_id)
587}
588
589impl crate::ClipFile {
590    /// Write the page's own ページ番号 and ノンブル into its story info texts
591    /// ([`update_page_number`], [`update_nombre`]), after its
592    /// `ComicPageIndex` or the work's story settings changed.
593    pub fn update_story_numbers(&mut self, first_right: bool) -> Result<()> {
594        update_page_number(self.database(), first_right)?;
595        update_nombre(self.database(), first_right)?;
596        self.reload()
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn gives_a_text_room_enough_not_to_break() {
606        // MS ゴシック: ASCII is half an em; `- 1 -` at 9 pt (75 px at 600 dpi).
607        assert!(text_width("- 1 -", 75.0) >= 188);
608        // Full-width characters are an em each.
609        assert_eq!(text_width("作品名", 50.0), 150);
610    }
611
612    #[test]
613    fn resizes_a_block_keeping_the_edge_asked_for() {
614        let mut attributes = Attributes::default();
615        attributes.set_bbox([100, 10, 200, 60]);
616        attributes.set(ids::BLOCK_SIZE, i32_bytes(&[100, 50]));
617        let resized = resize_block(attributes.clone(), 40, Align::End);
618        assert_eq!(resized.bbox(), Some([160, 10, 200, 60]));
619        assert_eq!(resized.block_size(), Some([40, 50]));
620        assert_eq!(resize_block(attributes, 40, Align::Middle).bbox(), Some([130, 10, 170, 60]));
621    }
622}