Skip to main content

mdi_core/
publication_profile.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4const PUBLISHING_FONT_FAMILY: &str = "Yu Mincho, Hiragino Mincho ProN, Noto Serif JP, serif";
5const PUBLISHING_FONT_SIZE_PT: f64 = 10.5;
6const MM_PER_POINT: f64 = 25.4 / 72.0;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct ResolvedExportProfile {
11    pub layout: ResolvedLayout,
12    pub metadata: Map<String, Value>,
13    pub typesetting: ResolvedTypesetting,
14    pub pagination: ResolvedPagination,
15    pub epub: ResolvedEpub,
16    pub text: ResolvedText,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct ResolvedLayout {
22    pub system: String,
23    pub margin_mode: String,
24    pub binding_side: String,
25    pub gutter: f64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ResolvedTypesetting {
31    pub writing_mode: String,
32    pub font_family: String,
33    pub font_size: f64,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub line_spacing: Option<f64>,
36    pub text_indent_em: f64,
37    pub fullwidth_space_indent: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct ResolvedPagination {
43    pub page_size: String,
44    pub landscape: bool,
45    pub characters_per_line: f64,
46    pub lines_per_page: f64,
47    pub grid_mode: String,
48    pub margins: Margins,
49    pub page_numbers: PageNumbers,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
53pub struct Margins {
54    pub top: f64,
55    pub bottom: f64,
56    pub left: f64,
57    pub right: f64,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PageNumbers {
62    pub enabled: bool,
63    pub format: String,
64    pub position: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct ResolvedEpub {
70    pub chapter_split_level: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub cover_path: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ResolvedText {
78    pub fullwidth_space_indent: bool,
79    pub indent_count: f64,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct ChromiumPrintProfile {
85    pub html: String,
86    pub profile: ResolvedExportProfile,
87    pub page: ChromiumPrintPage,
88    pub page_numbers: ChromiumPrintPageNumbers,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ChromiumPrintPage {
94    pub width_mm: f64,
95    pub height_mm: f64,
96    pub margins_mm: Margins,
97    pub landscape: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ChromiumPrintPageNumbers {
103    pub enabled: bool,
104    pub format: String,
105    pub position: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub header_template: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub footer_template: Option<String>,
110}
111
112#[derive(Debug)]
113struct ModeDefaults {
114    font_family: &'static str,
115    font_size: f64,
116    characters_per_line: f64,
117    lines_per_page: f64,
118    margins: Margins,
119}
120
121pub fn resolve_export_profile_json(
122    source: &str,
123    source_writing_mode: Option<&str>,
124    require_layout: bool,
125) -> Result<String, String> {
126    let raw: Value =
127        serde_json::from_str(source).map_err(|_| "Export profile must be valid JSON".to_owned())?;
128    let profile = raw
129        .as_object()
130        .ok_or_else(|| "Export profile must be a JSON object".to_owned())?;
131    if require_layout
132        && profile
133            .get("layout")
134            .and_then(Value::as_object)
135            .and_then(|layout| layout.get("system"))
136            .is_none()
137    {
138        return Err(
139            "Configured exports require layout.system: japanese-publisher or word".to_owned(),
140        );
141    }
142    serde_json::to_string(&resolve_export_profile(profile, source_writing_mode)?)
143        .map_err(|error| error.to_string())
144}
145
146pub fn resolve_export_profile(
147    profile: &Map<String, Value>,
148    source_writing_mode: Option<&str>,
149) -> Result<ResolvedExportProfile, String> {
150    for (key, value) in profile {
151        if !value.is_object() {
152            return Err(format!("{key} must be an object"));
153        }
154    }
155    let layout = nested_object(profile, "layout", "layout")?;
156    let metadata = nested_object(profile, "metadata", "metadata")?;
157    let typesetting = nested_object(profile, "typesetting", "typesetting")?;
158    let pagination = nested_object(profile, "pagination", "pagination")?;
159    let epub = nested_object(profile, "epub", "epub")?;
160    let text = nested_object(profile, "text", "text")?;
161    let page_numbers = nested_object(pagination, "pageNumbers", "pagination.pageNumbers")?;
162    let margins = nested_object(pagination, "margins", "pagination.margins")?;
163
164    let writing_mode =
165        optional_string(typesetting, "writingMode", "writingMode")?.unwrap_or_else(|| {
166            if source_writing_mode == Some("vertical") {
167                "vertical".to_owned()
168            } else {
169                "horizontal".to_owned()
170            }
171        });
172    if writing_mode != "vertical" && writing_mode != "horizontal" {
173        return Err("writingMode must be vertical or horizontal".to_owned());
174    }
175    let layout_system = optional_string(layout, "system", "layout.system")?
176        .unwrap_or_else(|| "japanese-publisher".to_owned());
177    if layout_system != "japanese-publisher" && layout_system != "word" {
178        return Err("layout.system must be japanese-publisher or word".to_owned());
179    }
180    let margin_mode =
181        optional_string(layout, "marginMode", "layout.marginMode")?.unwrap_or_else(|| {
182            if layout_system == "japanese-publisher" {
183                "mirror".to_owned()
184            } else {
185                "single".to_owned()
186            }
187        });
188    if margin_mode != "single" && margin_mode != "mirror" {
189        return Err("layout.marginMode must be single or mirror".to_owned());
190    }
191    let binding_side = optional_string(layout, "bindingSide", "layout.bindingSide")?
192        .unwrap_or_else(|| {
193            if layout_system == "japanese-publisher" && writing_mode == "vertical" {
194                "right".to_owned()
195            } else {
196                "left".to_owned()
197            }
198        });
199    if binding_side != "right" && binding_side != "left" {
200        return Err("layout.bindingSide must be right or left".to_owned());
201    }
202    let page_size = optional_string(pagination, "pageSize", "pageSize")?.unwrap_or_else(|| {
203        if layout_system == "japanese-publisher" {
204            if writing_mode == "vertical" {
205                "A4".to_owned()
206            } else {
207                "Shirokuban".to_owned()
208            }
209        } else {
210            "A4".to_owned()
211        }
212    });
213    let (natural_width, natural_height) =
214        page_dimensions(&page_size).ok_or_else(|| format!("Unsupported page size: {page_size}"))?;
215
216    for (key, value) in metadata {
217        if !value.is_string() {
218            return Err(format!("metadata.{key} must be a string"));
219        }
220    }
221    let font_family = optional_string(typesetting, "fontFamily", "fontFamily")?;
222    let font_size = optional_number(typesetting, "fontSize", "fontSize")?;
223    let font_size_pt = optional_number(typesetting, "fontSizePt", "fontSizePt")?;
224    if font_size.is_some() && font_size_pt.is_some() && font_size != font_size_pt {
225        return Err("fontSize and fontSizePt must match when both are set".to_owned());
226    }
227    let line_spacing = optional_number(typesetting, "lineSpacing", "lineSpacing")?;
228    let line_height = optional_number(typesetting, "lineHeight", "lineHeight")?;
229    if line_spacing.is_some() && line_height.is_some() && line_spacing != line_height {
230        return Err("lineSpacing and lineHeight must match when both are set".to_owned());
231    }
232    let cover_path = optional_string(epub, "coverPath", "coverPath")?;
233    let landscape = optional_bool(pagination, "landscape", "landscape")?.unwrap_or(
234        layout_system == "japanese-publisher" && writing_mode == "vertical" && page_size == "A4",
235    );
236    let mode_defaults = if layout_system == "word" {
237        word_defaults(
238            &page_size,
239            &writing_mode,
240            landscape,
241            natural_width,
242            natural_height,
243        )
244    } else {
245        publisher_defaults(
246            &page_size,
247            &writing_mode,
248            landscape,
249            &binding_side,
250            natural_width,
251            natural_height,
252        )
253    };
254    let chapter_split_level = optional_string(epub, "chapterSplitLevel", "chapterSplitLevel")?
255        .unwrap_or_else(|| "h1".to_owned());
256    if !["h1", "h2", "h3", "none"].contains(&chapter_split_level.as_str()) {
257        return Err("chapterSplitLevel must be h1, h2, h3, or none".to_owned());
258    }
259    let page_number_format = optional_string(page_numbers, "format", "pageNumbers.format")?
260        .unwrap_or_else(|| "simple".to_owned());
261    if !["simple", "dash", "fraction"].contains(&page_number_format.as_str()) {
262        return Err("Unsupported page number format".to_owned());
263    }
264    let page_number_position = optional_string(page_numbers, "position", "pageNumbers.position")?
265        .unwrap_or_else(|| "bottom-center".to_owned());
266    if ![
267        "top-left",
268        "top-center",
269        "top-right",
270        "bottom-left",
271        "bottom-center",
272        "bottom-right",
273    ]
274    .contains(&page_number_position.as_str())
275    {
276        return Err("Unsupported page number position".to_owned());
277    }
278
279    let page_width = if landscape {
280        natural_height
281    } else {
282        natural_width
283    };
284    let page_height = if landscape {
285        natural_width
286    } else {
287        natural_height
288    };
289    let gutter = bounded_number(
290        optional_number(layout, "gutter", "layout.gutter")?,
291        0.0,
292        0.0,
293        page_width.min(page_height) / 3.0,
294        "layout.gutter",
295    )?;
296    if layout_system == "word" && gutter != 0.0 {
297        return Err("layout.gutter is only available with japanese-publisher layout".to_owned());
298    }
299    let mut resolved_margins = Margins {
300        top: bounded_number(
301            optional_number(margins, "top", "top margin")?,
302            mode_defaults.margins.top,
303            0.0,
304            page_height - 1.0,
305            "top margin",
306        )?,
307        bottom: bounded_number(
308            optional_number(margins, "bottom", "bottom margin")?,
309            mode_defaults.margins.bottom,
310            0.0,
311            page_height - 1.0,
312            "bottom margin",
313        )?,
314        left: bounded_number(
315            optional_number(margins, "left", "left margin")?,
316            mode_defaults.margins.left,
317            0.0,
318            page_width - 1.0,
319            "left margin",
320        )?,
321        right: bounded_number(
322            optional_number(margins, "right", "right margin")?,
323            mode_defaults.margins.right,
324            0.0,
325            page_width - 1.0,
326            "right margin",
327        )?,
328    };
329    if layout_system == "japanese-publisher" && gutter > 0.0 {
330        if binding_side == "right" {
331            resolved_margins.right += gutter;
332        } else {
333            resolved_margins.left += gutter;
334        }
335    }
336    let grid_mode = optional_string(pagination, "gridMode", "gridMode")?.unwrap_or_else(|| {
337        if layout_system == "word" {
338            "typographic".to_owned()
339        } else {
340            "strict".to_owned()
341        }
342    });
343    if grid_mode != "strict" && grid_mode != "typographic" {
344        return Err("gridMode must be strict or typographic".to_owned());
345    }
346    if layout_system == "word" && grid_mode == "strict" {
347        return Err(
348            "layout.system: word uses flowing typography, not a strict manuscript grid".to_owned(),
349        );
350    }
351    let resolved_line_spacing = line_spacing.or(line_height);
352    if grid_mode == "strict" && resolved_line_spacing.is_some() {
353        return Err("lineSpacing requires pagination.gridMode: typographic; strict grid preserves its physical manuscript cells".to_owned());
354    }
355    if resolved_margins.left + resolved_margins.right >= page_width {
356        return Err("left and right margins must leave printable page width".to_owned());
357    }
358    if resolved_margins.top + resolved_margins.bottom >= page_height {
359        return Err("top and bottom margins must leave printable page height".to_owned());
360    }
361
362    Ok(ResolvedExportProfile {
363        layout: ResolvedLayout {
364            system: layout_system,
365            margin_mode,
366            binding_side,
367            gutter,
368        },
369        metadata: metadata.clone(),
370        typesetting: ResolvedTypesetting {
371            writing_mode,
372            font_family: font_family
373                .filter(|value| !value.trim().is_empty())
374                .unwrap_or_else(|| mode_defaults.font_family.to_owned()),
375            font_size: bounded_number(
376                font_size.or(font_size_pt),
377                mode_defaults.font_size,
378                4.0,
379                72.0,
380                "fontSize",
381            )?,
382            line_spacing: resolved_line_spacing
383                .map(|value| bounded_number(Some(value), 0.0, 0.5, 3.0, "lineSpacing"))
384                .transpose()?,
385            text_indent_em: bounded_number(
386                optional_number(typesetting, "textIndentEm", "textIndentEm")?,
387                1.0,
388                0.0,
389                4.0,
390                "textIndentEm",
391            )?,
392            fullwidth_space_indent: optional_bool(
393                typesetting,
394                "fullwidthSpaceIndent",
395                "fullwidthSpaceIndent",
396            )?
397            .unwrap_or(false),
398        },
399        pagination: ResolvedPagination {
400            page_size,
401            landscape,
402            characters_per_line: bounded_number(
403                optional_number(pagination, "charactersPerLine", "charactersPerLine")?,
404                mode_defaults.characters_per_line,
405                10.0,
406                400.0,
407                "charactersPerLine",
408            )?,
409            lines_per_page: bounded_number(
410                optional_number(pagination, "linesPerPage", "linesPerPage")?,
411                mode_defaults.lines_per_page,
412                10.0,
413                400.0,
414                "linesPerPage",
415            )?,
416            grid_mode,
417            margins: resolved_margins,
418            page_numbers: PageNumbers {
419                enabled: optional_bool(page_numbers, "enabled", "pageNumbers.enabled")?
420                    .unwrap_or(true),
421                format: page_number_format,
422                position: page_number_position,
423            },
424        },
425        epub: ResolvedEpub {
426            chapter_split_level,
427            cover_path,
428        },
429        text: ResolvedText {
430            fullwidth_space_indent: optional_bool(
431                text,
432                "fullwidthSpaceIndent",
433                "text.fullwidthSpaceIndent",
434            )?
435            .unwrap_or(false),
436            indent_count: bounded_number(
437                optional_number(text, "indentCount", "text.indentCount")?,
438                1.0,
439                1.0,
440                4.0,
441                "text.indentCount",
442            )?,
443        },
444    })
445}
446
447/// Resolve a raw export profile and prepare all browser-independent Chromium
448/// print data in Rust. Browser bindings only launch the browser and pass these
449/// values to its native PDF API.
450pub fn prepare_chromium_print_profile(
451    html: &str,
452    profile: &Map<String, Value>,
453    source_writing_mode: Option<&str>,
454) -> Result<ChromiumPrintProfile, String> {
455    let profile = resolve_export_profile(profile, source_writing_mode)?;
456    prepare_chromium_print_profile_resolved(html, profile)
457}
458
459pub fn prepare_chromium_print_profile_json(
460    html: &str,
461    profile_json: &str,
462    source_writing_mode: Option<&str>,
463) -> Result<String, String> {
464    let raw: Value = serde_json::from_str(profile_json)
465        .map_err(|_| "Export profile must be valid JSON".to_owned())?;
466    let profile = raw
467        .as_object()
468        .ok_or_else(|| "Export profile must be a JSON object".to_owned())?;
469    serde_json::to_string(&prepare_chromium_print_profile(
470        html,
471        profile,
472        source_writing_mode,
473    )?)
474    .map_err(|error| error.to_string())
475}
476
477pub fn prepare_chromium_print_profile_resolved(
478    html: &str,
479    profile: ResolvedExportProfile,
480) -> Result<ChromiumPrintProfile, String> {
481    let (natural_width, natural_height) = page_dimensions(&profile.pagination.page_size)
482        .ok_or_else(|| format!("Unsupported page size: {}", profile.pagination.page_size))?;
483    let (width_mm, height_mm) = if profile.pagination.landscape {
484        (natural_height, natural_width)
485    } else {
486        (natural_width, natural_height)
487    };
488    let page_numbers = &profile.pagination.page_numbers;
489    let template = page_numbers
490        .enabled
491        .then(|| page_number_template(&page_numbers.format, &page_numbers.position));
492    let is_header = page_numbers.position.starts_with("top-");
493
494    Ok(ChromiumPrintProfile {
495        html: apply_pdf_profile(html, &profile)?,
496        page: ChromiumPrintPage {
497            width_mm,
498            height_mm,
499            margins_mm: profile.pagination.margins,
500            landscape: profile.pagination.landscape,
501        },
502        page_numbers: ChromiumPrintPageNumbers {
503            enabled: page_numbers.enabled,
504            format: page_numbers.format.clone(),
505            position: page_numbers.position.clone(),
506            header_template: if is_header { template.clone() } else { None },
507            footer_template: if is_header { None } else { template },
508        },
509        profile,
510    })
511}
512
513pub fn apply_pdf_profile_json(html: &str, profile_json: &str) -> Result<String, String> {
514    let profile: ResolvedExportProfile =
515        serde_json::from_str(profile_json).map_err(|error| error.to_string())?;
516    apply_pdf_profile(html, &profile)
517}
518
519/// Convert a resolved profile into isolated print CSS. This is kept in the
520/// language-neutral core so every present and future binding has identical
521/// page geometry and Japanese composition behavior.
522pub fn apply_pdf_profile(html: &str, profile: &ResolvedExportProfile) -> Result<String, String> {
523    let pagination = &profile.pagination;
524    let typesetting = &profile.typesetting;
525    let (natural_width, natural_height) = page_dimensions(&pagination.page_size)
526        .ok_or_else(|| format!("Unsupported page size: {}", pagination.page_size))?;
527    let (width, height) = if pagination.landscape {
528        (natural_height, natural_width)
529    } else {
530        (natural_width, natural_height)
531    };
532    let cross = if typesetting.writing_mode == "vertical" {
533        width - pagination.margins.left - pagination.margins.right
534    } else {
535        height - pagination.margins.top - pagination.margins.bottom
536    };
537    let font_size = typesetting.font_size / 72.0 * 25.4;
538    let strict_grid = pagination.grid_mode == "strict";
539    let inline = if typesetting.writing_mode == "vertical" {
540        height - pagination.margins.top - pagination.margins.bottom
541    } else {
542        width - pagination.margins.left - pagination.margins.right
543    };
544    let character_pitch = inline / pagination.characters_per_line;
545    let raw_character_spacing = if strict_grid {
546        (inline - font_size * pagination.characters_per_line)
547            / (pagination.characters_per_line - 1.0).max(1.0)
548    } else {
549        0.0
550    };
551    let character_spacing = if raw_character_spacing.abs() < 1e-9 {
552        0.0
553    } else {
554        raw_character_spacing
555    };
556    let line_pitch = cross / pagination.lines_per_page;
557    let line_height = if strict_grid {
558        format!("{line_pitch}mm")
559    } else if let Some(line_spacing) = typesetting.line_spacing {
560        line_spacing.to_string()
561    } else {
562        (cross / pagination.lines_per_page / font_size).to_string()
563    };
564    let fullwidth = if typesetting.fullwidth_space_indent {
565        " ".repeat(typesetting.text_indent_em.round() as usize)
566    } else {
567        String::new()
568    };
569    let writing_mode = if typesetting.writing_mode == "vertical" {
570        "vertical-rl"
571    } else {
572        "horizontal-tb"
573    };
574    let indent = if typesetting.fullwidth_space_indent {
575        "0".to_owned()
576    } else {
577        format!("{}em", typesetting.text_indent_em)
578    };
579    let block_css = if strict_grid {
580        format!(
581            "p{{margin:0;text-indent:{indent}}}h1,h2,h3,h4,h5,h6{{font-size:1em;line-height:inherit;color:#000;break-after:avoid;margin:0;font-weight:bold}}"
582        )
583    } else {
584        format!(
585            "p{{margin:0 0 .75em;text-indent:{indent}}}h1,h2,h3,h4,h5,h6{{color:#000;break-after:avoid;margin:0 0 .75em;line-height:1.25}}p+h1,p+h2,p+h3,p+h4,p+h5,p+h6{{padding-top:.75em}}h1{{font-size:1.6em}}h2{{font-size:1.35em}}h3{{font-size:1.15em}}"
586        )
587    };
588    let css = format!(
589        "<style id=\"mdi-export-profile\">{}html{{writing-mode:{writing_mode}!important;background:#fff;color:#000;--mdi-grid-mode:{};--mdi-character-pitch:{character_pitch}mm;--mdi-character-spacing:{character_spacing}mm;--mdi-line-pitch:{line_pitch}mm;--mdi-characters-per-line:{};--mdi-lines-per-page:{}}}html,body{{margin:0;box-sizing:border-box}}body{{font-family:{};font-size:{font_size}mm;line-height:{line_height};letter-spacing:{character_spacing}mm;writing-mode:{writing_mode};text-orientation:mixed;color:#000}}{block_css}a{{color:inherit;text-decoration:none}}.mdi-pagebreak,.mdi-pagebreak-right,.mdi-pagebreak-left{{background:transparent}}</style>",
590        page_rules(width, height, profile),
591        pagination.grid_mode,
592        pagination.characters_per_line,
593        pagination.lines_per_page,
594        css_value(&typesetting.font_family),
595    );
596    let styled = inject_profile_style(html, &css);
597    Ok(if fullwidth.is_empty() {
598        styled
599    } else {
600        prefix_nonempty_paragraphs(&styled, &fullwidth)
601    })
602}
603
604fn page_rules(width: f64, height: f64, profile: &ResolvedExportProfile) -> String {
605    let margins = profile.pagination.margins;
606    let base = format!(
607        "@page{{size:{width}mm {height}mm;margin:{}mm {}mm {}mm {}mm}}",
608        margins.top, margins.right, margins.bottom, margins.left
609    );
610    if profile.layout.margin_mode != "mirror" {
611        return base;
612    }
613    let (odd_right, odd_left, even_right, even_left) = if profile.layout.binding_side == "right" {
614        (margins.right, margins.left, margins.left, margins.right)
615    } else {
616        (margins.left, margins.right, margins.right, margins.left)
617    };
618    format!(
619        "{base}@page :right{{margin:{}mm {odd_right}mm {}mm {odd_left}mm}}@page :left{{margin:{}mm {even_right}mm {}mm {even_left}mm}}",
620        margins.top, margins.bottom, margins.top, margins.bottom
621    )
622}
623
624fn page_number_template(format: &str, position: &str) -> String {
625    let align = if position.ends_with("left") {
626        "left"
627    } else if position.ends_with("right") {
628        "right"
629    } else {
630        "center"
631    };
632    let page = r#"<span class="pageNumber"></span>"#;
633    let value = match format {
634        "dash" => format!("— {page} —"),
635        "fraction" => format!(r#"{page} / <span class="totalPages"></span>"#),
636        _ => page.to_owned(),
637    };
638    format!(
639        r#"<div style="width:100%;font-size:8pt;text-align:{align};padding:0 8mm">{value}</div>"#
640    )
641}
642
643fn inject_profile_style(html: &str, css: &str) -> String {
644    if let Some(index) = find_ascii_case_insensitive(html, "</head") {
645        return format!("{}{css}{}", &html[..index], &html[index..]);
646    }
647    if let Some(start) = find_ascii_case_insensitive(html, "<html")
648        && let Some(relative_end) = html[start..].find('>')
649    {
650        let end = start + relative_end + 1;
651        return format!("{}<head>{css}</head>{}", &html[..end], &html[end..]);
652    }
653    format!("{css}{html}")
654}
655
656fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
657    let haystack = haystack.as_bytes();
658    let needle = needle.as_bytes();
659    haystack
660        .windows(needle.len())
661        .position(|window| window.eq_ignore_ascii_case(needle))
662}
663
664fn prefix_nonempty_paragraphs(html: &str, prefix: &str) -> String {
665    let mut output = String::with_capacity(html.len() + prefix.len());
666    let mut cursor = 0;
667    while let Some(relative) = html[cursor..].find("<p") {
668        let start = cursor + relative;
669        output.push_str(&html[cursor..start]);
670        let Some(relative_end) = html[start..].find('>') else {
671            output.push_str(&html[start..]);
672            return output;
673        };
674        let end = start + relative_end + 1;
675        let opening = &html[start..end];
676        let valid_opening = opening == "<p>"
677            || opening
678                .as_bytes()
679                .get(2)
680                .is_some_and(u8::is_ascii_whitespace);
681        output.push_str(opening);
682        if valid_opening {
683            let rest = &html[end..];
684            let trimmed = rest.trim_start_matches(char::is_whitespace);
685            if !trimmed.starts_with("</p>") {
686                output.push_str(prefix);
687            }
688        }
689        cursor = end;
690    }
691    output.push_str(&html[cursor..]);
692    output
693}
694
695fn css_value(value: &str) -> String {
696    value
697        .chars()
698        .filter(|character| !matches!(character, '{' | '}' | '<' | '>' | ';'))
699        .collect()
700}
701
702fn nested_object<'a>(
703    parent: &'a Map<String, Value>,
704    key: &str,
705    label: &str,
706) -> Result<&'a Map<String, Value>, String> {
707    match parent.get(key) {
708        None => Ok(empty_object()),
709        Some(Value::Object(object)) => Ok(object),
710        Some(_) => Err(format!("{label} must be an object")),
711    }
712}
713
714fn empty_object() -> &'static Map<String, Value> {
715    static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
716    EMPTY.get_or_init(Map::new)
717}
718
719fn optional_string(
720    object: &Map<String, Value>,
721    key: &str,
722    label: &str,
723) -> Result<Option<String>, String> {
724    match object.get(key) {
725        None => Ok(None),
726        Some(Value::String(value)) => Ok(Some(value.clone())),
727        Some(_) => Err(format!("{label} must be a string")),
728    }
729}
730
731fn optional_number(
732    object: &Map<String, Value>,
733    key: &str,
734    label: &str,
735) -> Result<Option<f64>, String> {
736    match object.get(key) {
737        None => Ok(None),
738        Some(Value::Number(value)) => value
739            .as_f64()
740            .filter(|value| value.is_finite())
741            .map(Some)
742            .ok_or_else(|| format!("{label} must be a finite number")),
743        Some(_) => Err(format!("{label} must be a number")),
744    }
745}
746
747fn optional_bool(
748    object: &Map<String, Value>,
749    key: &str,
750    label: &str,
751) -> Result<Option<bool>, String> {
752    match object.get(key) {
753        None => Ok(None),
754        Some(Value::Bool(value)) => Ok(Some(*value)),
755        Some(_) => Err(format!("{label} must be a boolean")),
756    }
757}
758
759fn bounded_number(
760    value: Option<f64>,
761    fallback: f64,
762    minimum: f64,
763    maximum: f64,
764    label: &str,
765) -> Result<f64, String> {
766    let resolved = value.unwrap_or(fallback);
767    if !resolved.is_finite() || resolved < minimum || resolved > maximum {
768        return Err(format!("{label} must be between {minimum} and {maximum}"));
769    }
770    Ok(resolved)
771}
772
773fn publisher_defaults(
774    page_size: &str,
775    writing_mode: &str,
776    landscape: bool,
777    binding_side: &str,
778    natural_width: f64,
779    natural_height: f64,
780) -> ModeDefaults {
781    let width = if landscape {
782        natural_height
783    } else {
784        natural_width
785    };
786    let height = if landscape {
787        natural_width
788    } else {
789        natural_height
790    };
791    let base_glyph = PUBLISHING_FONT_SIZE_PT * MM_PER_POINT;
792    let minimum_outer = 18.0_f64.min(width.min(height) * 0.085);
793    let expands_beyond_a4 = width * height > 210.0 * 297.0;
794    let maximum_characters: f64 = if expands_beyond_a4 { 400.0 } else { 40.0 };
795
796    if page_size == "Shirokuban" && !landscape {
797        let node = 18.0;
798        let fore_edge = 15.5;
799        let margins = Margins {
800            top: 16.5,
801            bottom: 18.0,
802            left: if binding_side == "left" {
803                node
804            } else {
805                fore_edge
806            },
807            right: if binding_side == "right" {
808                node
809            } else {
810                fore_edge
811            },
812        };
813        return ModeDefaults {
814            font_family: PUBLISHING_FONT_FAMILY,
815            font_size: 10.0,
816            characters_per_line: if writing_mode == "vertical" {
817                40.0
818            } else {
819                27.0
820            },
821            lines_per_page: if writing_mode == "vertical" {
822                15.0
823            } else {
824                26.0
825            },
826            margins,
827        };
828    }
829    if page_size == "A4" && landscape && writing_mode == "vertical" {
830        return ModeDefaults {
831            font_family: PUBLISHING_FONT_FAMILY,
832            font_size: PUBLISHING_FONT_SIZE_PT,
833            characters_per_line: 40.0,
834            lines_per_page: 30.0,
835            margins: Margins {
836                top: 30.91666666666667,
837                bottom: 30.91666666666667,
838                left: 28.0,
839                right: 28.0,
840            },
841        };
842    }
843    if writing_mode == "vertical" {
844        let binding = (width * 0.22).min(8.0_f64.max(width * (28.0 / 210.0)));
845        let characters = 10.0_f64
846            .max(maximum_characters.min(((height - 2.0 * minimum_outer) / base_glyph).floor()));
847        let lines = 10.0_f64.max(
848            (if expands_beyond_a4 {
849                400.0_f64
850            } else {
851                30.0_f64
852            })
853            .min(((width - binding - minimum_outer) / base_glyph).floor()),
854        );
855        let glyph = base_glyph
856            .min((height - 2.0 * minimum_outer) / characters)
857            .min((width - binding - minimum_outer) / lines);
858        return ModeDefaults {
859            font_family: PUBLISHING_FONT_FAMILY,
860            font_size: glyph / MM_PER_POINT,
861            characters_per_line: characters,
862            lines_per_page: lines,
863            margins: Margins {
864                top: (height - characters * glyph) / 2.0,
865                bottom: (height - characters * glyph) / 2.0,
866                left: if binding_side == "left" {
867                    binding
868                } else {
869                    width - binding - lines * glyph
870                },
871                right: if binding_side == "right" {
872                    binding
873                } else {
874                    width - binding - lines * glyph
875                },
876            },
877        };
878    }
879    let characters =
880        10.0_f64.max(maximum_characters.min(((width - 2.0 * minimum_outer) / base_glyph).floor()));
881    let glyph = base_glyph.min((width - 2.0 * minimum_outer) / characters);
882    let horizontal_margin = (width - characters * glyph) / 2.0;
883    let vertical_margin = 6.0_f64.max(20.0_f64.min(height * 0.1));
884    let lines = 10.0_f64.max(
885        (if expands_beyond_a4 {
886            400.0_f64
887        } else {
888            50.0_f64
889        })
890        .min(((height - 2.0 * vertical_margin) / (glyph * 1.5)).floor()),
891    );
892    ModeDefaults {
893        font_family: PUBLISHING_FONT_FAMILY,
894        font_size: glyph / MM_PER_POINT,
895        characters_per_line: characters,
896        lines_per_page: lines,
897        margins: Margins {
898            top: vertical_margin,
899            bottom: vertical_margin,
900            left: horizontal_margin,
901            right: horizontal_margin,
902        },
903    }
904}
905
906fn word_defaults(
907    _page_size: &str,
908    writing_mode: &str,
909    landscape: bool,
910    natural_width: f64,
911    natural_height: f64,
912) -> ModeDefaults {
913    let width = if landscape {
914        natural_height
915    } else {
916        natural_width
917    };
918    let height = if landscape {
919        natural_width
920    } else {
921        natural_height
922    };
923    let margin = 25.4_f64.min(6.0_f64.max(width.min(height) / 2.0 - 1.0));
924    let glyph = PUBLISHING_FONT_SIZE_PT * MM_PER_POINT;
925    let inline_extent = if writing_mode == "vertical" {
926        height - 2.0 * margin
927    } else {
928        width - 2.0 * margin
929    };
930    let cross_extent = if writing_mode == "vertical" {
931        width - 2.0 * margin
932    } else {
933        height - 2.0 * margin
934    };
935    ModeDefaults {
936        font_family: PUBLISHING_FONT_FAMILY,
937        font_size: PUBLISHING_FONT_SIZE_PT,
938        characters_per_line: 10.0_f64.max(400.0_f64.min((inline_extent / glyph).floor())),
939        lines_per_page: 10.0_f64.max(400.0_f64.min((cross_extent / (glyph * 1.15)).floor())),
940        margins: Margins {
941            top: margin,
942            bottom: margin,
943            left: margin,
944            right: margin,
945        },
946    }
947}
948
949#[derive(Debug, Clone, Copy, Serialize)]
950#[serde(rename_all = "camelCase")]
951pub struct PageSizeDimensions {
952    pub key: &'static str,
953    pub width_mm: f64,
954    pub height_mm: f64,
955}
956
957const PAGE_DIMENSIONS: &[PageSizeDimensions] = &[
958    PageSizeDimensions {
959        key: "A0",
960        width_mm: 841.0,
961        height_mm: 1189.0,
962    },
963    PageSizeDimensions {
964        key: "A1",
965        width_mm: 594.0,
966        height_mm: 841.0,
967    },
968    PageSizeDimensions {
969        key: "A2",
970        width_mm: 420.0,
971        height_mm: 594.0,
972    },
973    PageSizeDimensions {
974        key: "A3",
975        width_mm: 297.0,
976        height_mm: 420.0,
977    },
978    PageSizeDimensions {
979        key: "A4",
980        width_mm: 210.0,
981        height_mm: 297.0,
982    },
983    PageSizeDimensions {
984        key: "A5",
985        width_mm: 148.0,
986        height_mm: 210.0,
987    },
988    PageSizeDimensions {
989        key: "A6",
990        width_mm: 105.0,
991        height_mm: 148.0,
992    },
993    PageSizeDimensions {
994        key: "A7",
995        width_mm: 74.0,
996        height_mm: 105.0,
997    },
998    PageSizeDimensions {
999        key: "A8",
1000        width_mm: 52.0,
1001        height_mm: 74.0,
1002    },
1003    PageSizeDimensions {
1004        key: "A9",
1005        width_mm: 37.0,
1006        height_mm: 52.0,
1007    },
1008    PageSizeDimensions {
1009        key: "A10",
1010        width_mm: 26.0,
1011        height_mm: 37.0,
1012    },
1013    PageSizeDimensions {
1014        key: "JIS-B0",
1015        width_mm: 1030.0,
1016        height_mm: 1456.0,
1017    },
1018    PageSizeDimensions {
1019        key: "JIS-B1",
1020        width_mm: 728.0,
1021        height_mm: 1030.0,
1022    },
1023    PageSizeDimensions {
1024        key: "JIS-B2",
1025        width_mm: 515.0,
1026        height_mm: 728.0,
1027    },
1028    PageSizeDimensions {
1029        key: "JIS-B3",
1030        width_mm: 364.0,
1031        height_mm: 515.0,
1032    },
1033    PageSizeDimensions {
1034        key: "JIS-B4",
1035        width_mm: 257.0,
1036        height_mm: 364.0,
1037    },
1038    PageSizeDimensions {
1039        key: "JIS-B5",
1040        width_mm: 182.0,
1041        height_mm: 257.0,
1042    },
1043    PageSizeDimensions {
1044        key: "JIS-B6",
1045        width_mm: 128.0,
1046        height_mm: 182.0,
1047    },
1048    PageSizeDimensions {
1049        key: "JIS-B7",
1050        width_mm: 91.0,
1051        height_mm: 128.0,
1052    },
1053    PageSizeDimensions {
1054        key: "JIS-B8",
1055        width_mm: 64.0,
1056        height_mm: 91.0,
1057    },
1058    PageSizeDimensions {
1059        key: "JIS-B9",
1060        width_mm: 45.0,
1061        height_mm: 64.0,
1062    },
1063    PageSizeDimensions {
1064        key: "JIS-B10",
1065        width_mm: 32.0,
1066        height_mm: 45.0,
1067    },
1068    PageSizeDimensions {
1069        key: "ISO-B0",
1070        width_mm: 1000.0,
1071        height_mm: 1414.0,
1072    },
1073    PageSizeDimensions {
1074        key: "ISO-B1",
1075        width_mm: 707.0,
1076        height_mm: 1000.0,
1077    },
1078    PageSizeDimensions {
1079        key: "ISO-B2",
1080        width_mm: 500.0,
1081        height_mm: 707.0,
1082    },
1083    PageSizeDimensions {
1084        key: "ISO-B3",
1085        width_mm: 353.0,
1086        height_mm: 500.0,
1087    },
1088    PageSizeDimensions {
1089        key: "ISO-B4",
1090        width_mm: 250.0,
1091        height_mm: 353.0,
1092    },
1093    PageSizeDimensions {
1094        key: "ISO-B5",
1095        width_mm: 176.0,
1096        height_mm: 250.0,
1097    },
1098    PageSizeDimensions {
1099        key: "ISO-B6",
1100        width_mm: 125.0,
1101        height_mm: 176.0,
1102    },
1103    PageSizeDimensions {
1104        key: "ISO-B7",
1105        width_mm: 88.0,
1106        height_mm: 125.0,
1107    },
1108    PageSizeDimensions {
1109        key: "ISO-B8",
1110        width_mm: 62.0,
1111        height_mm: 88.0,
1112    },
1113    PageSizeDimensions {
1114        key: "ISO-B9",
1115        width_mm: 44.0,
1116        height_mm: 62.0,
1117    },
1118    PageSizeDimensions {
1119        key: "ISO-B10",
1120        width_mm: 31.0,
1121        height_mm: 44.0,
1122    },
1123    PageSizeDimensions {
1124        key: "Bunko",
1125        width_mm: 105.0,
1126        height_mm: 148.0,
1127    },
1128    PageSizeDimensions {
1129        key: "Shinsho",
1130        width_mm: 103.0,
1131        height_mm: 182.0,
1132    },
1133    PageSizeDimensions {
1134        key: "Shirokuban",
1135        width_mm: 127.0,
1136        height_mm: 188.0,
1137    },
1138    PageSizeDimensions {
1139        key: "Kikuban",
1140        width_mm: 150.0,
1141        height_mm: 220.0,
1142    },
1143    PageSizeDimensions {
1144        key: "A5-ban",
1145        width_mm: 148.0,
1146        height_mm: 210.0,
1147    },
1148    PageSizeDimensions {
1149        key: "B6-ban",
1150        width_mm: 128.0,
1151        height_mm: 182.0,
1152    },
1153    PageSizeDimensions {
1154        key: "AB-ban",
1155        width_mm: 210.0,
1156        height_mm: 257.0,
1157    },
1158    PageSizeDimensions {
1159        key: "Ju-ban",
1160        width_mm: 182.0,
1161        height_mm: 206.0,
1162    },
1163    PageSizeDimensions {
1164        key: "Kiku-tate",
1165        width_mm: 152.0,
1166        height_mm: 218.0,
1167    },
1168    PageSizeDimensions {
1169        key: "Tankobon",
1170        width_mm: 130.0,
1171        height_mm: 188.0,
1172    },
1173    PageSizeDimensions {
1174        key: "Letter",
1175        width_mm: 216.0,
1176        height_mm: 279.0,
1177    },
1178    PageSizeDimensions {
1179        key: "Legal",
1180        width_mm: 216.0,
1181        height_mm: 356.0,
1182    },
1183    PageSizeDimensions {
1184        key: "Tabloid",
1185        width_mm: 279.0,
1186        height_mm: 432.0,
1187    },
1188    PageSizeDimensions {
1189        key: "Executive",
1190        width_mm: 184.0,
1191        height_mm: 267.0,
1192    },
1193    PageSizeDimensions {
1194        key: "Statement",
1195        width_mm: 140.0,
1196        height_mm: 216.0,
1197    },
1198    PageSizeDimensions {
1199        key: "Folio",
1200        width_mm: 210.0,
1201        height_mm: 330.0,
1202    },
1203    PageSizeDimensions {
1204        key: "Quarto",
1205        width_mm: 203.0,
1206        height_mm: 254.0,
1207    },
1208    PageSizeDimensions {
1209        key: "10x14",
1210        width_mm: 254.0,
1211        height_mm: 356.0,
1212    },
1213    PageSizeDimensions {
1214        key: "Naga-3",
1215        width_mm: 120.0,
1216        height_mm: 235.0,
1217    },
1218    PageSizeDimensions {
1219        key: "Naga-4",
1220        width_mm: 90.0,
1221        height_mm: 205.0,
1222    },
1223    PageSizeDimensions {
1224        key: "Kaku-2",
1225        width_mm: 240.0,
1226        height_mm: 332.0,
1227    },
1228    PageSizeDimensions {
1229        key: "Kaku-3",
1230        width_mm: 216.0,
1231        height_mm: 277.0,
1232    },
1233    PageSizeDimensions {
1234        key: "Kaku-6",
1235        width_mm: 162.0,
1236        height_mm: 229.0,
1237    },
1238    PageSizeDimensions {
1239        key: "Kaku-8",
1240        width_mm: 119.0,
1241        height_mm: 197.0,
1242    },
1243    PageSizeDimensions {
1244        key: "You-4",
1245        width_mm: 105.0,
1246        height_mm: 235.0,
1247    },
1248    PageSizeDimensions {
1249        key: "You-6",
1250        width_mm: 98.0,
1251        height_mm: 190.0,
1252    },
1253    PageSizeDimensions {
1254        key: "Hagaki",
1255        width_mm: 100.0,
1256        height_mm: 148.0,
1257    },
1258    PageSizeDimensions {
1259        key: "Ofuku-Hagaki",
1260        width_mm: 200.0,
1261        height_mm: 148.0,
1262    },
1263    PageSizeDimensions {
1264        key: "L-ban",
1265        width_mm: 89.0,
1266        height_mm: 127.0,
1267    },
1268    PageSizeDimensions {
1269        key: "2L-ban",
1270        width_mm: 127.0,
1271        height_mm: 178.0,
1272    },
1273    PageSizeDimensions {
1274        key: "KG",
1275        width_mm: 102.0,
1276        height_mm: 152.0,
1277    },
1278    PageSizeDimensions {
1279        key: "Cabinet",
1280        width_mm: 130.0,
1281        height_mm: 180.0,
1282    },
1283    PageSizeDimensions {
1284        key: "B5",
1285        width_mm: 176.0,
1286        height_mm: 250.0,
1287    },
1288    PageSizeDimensions {
1289        key: "B6",
1290        width_mm: 125.0,
1291        height_mm: 176.0,
1292    },
1293];
1294
1295pub fn page_dimensions(page_size: &str) -> Option<(f64, f64)> {
1296    PAGE_DIMENSIONS
1297        .iter()
1298        .find(|dimensions| dimensions.key == page_size)
1299        .map(|dimensions| (dimensions.width_mm, dimensions.height_mm))
1300}
1301
1302pub fn page_size_catalog_json() -> Result<String, String> {
1303    serde_json::to_string(PAGE_DIMENSIONS).map_err(|error| error.to_string())
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309
1310    fn resolve(source: &str, writing_mode: Option<&str>) -> ResolvedExportProfile {
1311        let value: Value = serde_json::from_str(source).unwrap();
1312        resolve_export_profile(value.as_object().unwrap(), writing_mode).unwrap()
1313    }
1314
1315    #[test]
1316    fn resolves_publisher_and_word_defaults_from_one_rust_authority() {
1317        let horizontal = resolve("{}", None);
1318        assert_eq!(horizontal.layout.system, "japanese-publisher");
1319        assert_eq!(horizontal.layout.margin_mode, "mirror");
1320        assert_eq!(horizontal.layout.binding_side, "left");
1321        assert_eq!(horizontal.typesetting.writing_mode, "horizontal");
1322        assert_eq!(horizontal.typesetting.font_size, 10.0);
1323        assert_eq!(horizontal.pagination.page_size, "Shirokuban");
1324        assert_eq!(horizontal.pagination.characters_per_line, 27.0);
1325        assert_eq!(horizontal.pagination.lines_per_page, 26.0);
1326        assert_eq!(
1327            horizontal.pagination.margins,
1328            Margins {
1329                top: 16.5,
1330                bottom: 18.0,
1331                left: 18.0,
1332                right: 15.5,
1333            }
1334        );
1335
1336        let vertical = resolve("{}", Some("vertical"));
1337        assert_eq!(vertical.layout.binding_side, "right");
1338        assert_eq!(vertical.pagination.page_size, "A4");
1339        assert!(vertical.pagination.landscape);
1340        assert_eq!(vertical.pagination.characters_per_line, 40.0);
1341        assert_eq!(vertical.pagination.lines_per_page, 30.0);
1342
1343        let word = resolve(r#"{"layout":{"system":"word"}}"#, None);
1344        assert_eq!(word.layout.margin_mode, "single");
1345        assert_eq!(word.pagination.grid_mode, "typographic");
1346        assert_eq!(word.pagination.margins.top, 25.4);
1347    }
1348
1349    #[test]
1350    fn resolves_every_public_page_size_in_both_writing_modes() {
1351        for dimensions in PAGE_DIMENSIONS {
1352            let page_size = dimensions.key;
1353            assert!(page_dimensions(page_size).is_some(), "{page_size}");
1354            for writing_mode in ["horizontal", "vertical"] {
1355                let profile = resolve(
1356                    &format!(
1357                        r#"{{"typesetting":{{"writingMode":"{writing_mode}"}},"pagination":{{"pageSize":"{page_size}"}}}}"#
1358                    ),
1359                    None,
1360                );
1361                let (natural_width, natural_height) = page_dimensions(page_size).unwrap();
1362                let (width, height) = if profile.pagination.landscape {
1363                    (natural_height, natural_width)
1364                } else {
1365                    (natural_width, natural_height)
1366                };
1367                assert!(profile.pagination.margins.left + profile.pagination.margins.right < width);
1368                assert!(
1369                    profile.pagination.margins.top + profile.pagination.margins.bottom < height
1370                );
1371            }
1372        }
1373        assert_eq!(PAGE_DIMENSIONS.len(), 67);
1374        let catalog: Vec<serde_json::Value> =
1375            serde_json::from_str(&page_size_catalog_json().unwrap()).unwrap();
1376        assert_eq!(catalog.len(), PAGE_DIMENSIONS.len());
1377        assert_eq!(catalog[4]["key"], "A4");
1378        assert_eq!(catalog[4]["widthMm"], 210.0);
1379        assert!(page_dimensions("A11").is_none());
1380    }
1381
1382    #[test]
1383    fn resolves_aliases_metadata_gutter_and_all_optional_groups() {
1384        let profile = resolve(
1385            r#"{
1386                "layout":{"system":"japanese-publisher","marginMode":"single","bindingSide":"right","gutter":6},
1387                "metadata":{"title":"Book","author":"Writer"},
1388                "typesetting":{"writingMode":"horizontal","fontFamily":"  ","fontSizePt":11,"lineHeight":1.5,"textIndentEm":2,"fullwidthSpaceIndent":true},
1389                "pagination":{"pageSize":"Letter","landscape":true,"charactersPerLine":32,"linesPerPage":28,"gridMode":"typographic","margins":{"top":10,"right":12,"bottom":11,"left":13},"pageNumbers":{"enabled":false,"format":"fraction","position":"top-right"}},
1390                "epub":{"chapterSplitLevel":"h3","coverPath":"cover.png"},
1391                "text":{"fullwidthSpaceIndent":true,"indentCount":4}
1392            }"#,
1393            None,
1394        );
1395        assert_eq!(profile.pagination.margins.right, 18.0);
1396        assert_eq!(profile.typesetting.font_size, 11.0);
1397        assert_eq!(profile.typesetting.line_spacing, Some(1.5));
1398        assert_eq!(profile.typesetting.font_family, PUBLISHING_FONT_FAMILY);
1399        assert!(!profile.pagination.page_numbers.enabled);
1400        assert_eq!(profile.pagination.page_numbers.format, "fraction");
1401        assert_eq!(profile.epub.cover_path.as_deref(), Some("cover.png"));
1402        assert_eq!(profile.text.indent_count, 4.0);
1403    }
1404
1405    #[test]
1406    fn rejects_malformed_json_and_configured_profiles_without_a_layout() {
1407        assert_eq!(
1408            resolve_export_profile_json("{", None, false).unwrap_err(),
1409            "Export profile must be valid JSON"
1410        );
1411        assert_eq!(
1412            resolve_export_profile_json("[]", None, false).unwrap_err(),
1413            "Export profile must be a JSON object"
1414        );
1415        assert!(
1416            resolve_export_profile_json(r#"{"pagination":{}}"#, None, true)
1417                .unwrap_err()
1418                .contains("Configured exports require layout.system")
1419        );
1420        assert!(
1421            resolve_export_profile_json(
1422                r#"{"layout":{"system":"word"},"pagination":{}}"#,
1423                None,
1424                true
1425            )
1426            .is_ok()
1427        );
1428    }
1429
1430    #[test]
1431    fn rejects_invalid_structures_enums_and_primitive_types() {
1432        for (source, message) in [
1433            (r#"{"typesetting":[]}"#, "typesetting must be an object"),
1434            (
1435                r#"{"metadata":{"title":42}}"#,
1436                "metadata.title must be a string",
1437            ),
1438            (
1439                r#"{"typesetting":{"fontFamily":1}}"#,
1440                "fontFamily must be a string",
1441            ),
1442            (
1443                r#"{"typesetting":{"writingMode":"sideways"}}"#,
1444                "writingMode must be vertical or horizontal",
1445            ),
1446            (
1447                r#"{"layout":{"system":"other"}}"#,
1448                "layout.system must be japanese-publisher or word",
1449            ),
1450            (
1451                r#"{"layout":{"marginMode":"fold"}}"#,
1452                "layout.marginMode must be single or mirror",
1453            ),
1454            (
1455                r#"{"layout":{"bindingSide":"top"}}"#,
1456                "layout.bindingSide must be right or left",
1457            ),
1458            (
1459                r#"{"pagination":{"pageSize":"A11"}}"#,
1460                "Unsupported page size",
1461            ),
1462            (
1463                r#"{"pagination":{"landscape":"yes"}}"#,
1464                "landscape must be a boolean",
1465            ),
1466            (
1467                r#"{"pagination":{"gridMode":"flexible"}}"#,
1468                "gridMode must be strict or typographic",
1469            ),
1470            (
1471                r#"{"pagination":{"pageNumbers":{"format":"roman"}}}"#,
1472                "Unsupported page number format",
1473            ),
1474            (
1475                r#"{"pagination":{"pageNumbers":{"position":"middle"}}}"#,
1476                "Unsupported page number position",
1477            ),
1478            (
1479                r#"{"epub":{"chapterSplitLevel":"h4"}}"#,
1480                "chapterSplitLevel must be h1, h2, h3, or none",
1481            ),
1482        ] {
1483            let error = resolve_export_profile_json(source, None, false).unwrap_err();
1484            assert!(error.contains(message), "{source}: {error}");
1485        }
1486    }
1487
1488    #[test]
1489    fn rejects_conflicts_out_of_range_values_and_impossible_geometry() {
1490        for (source, message) in [
1491            (
1492                r#"{"typesetting":{"fontSize":11,"fontSizePt":12}}"#,
1493                "fontSize and fontSizePt",
1494            ),
1495            (
1496                r#"{"typesetting":{"lineSpacing":1.2,"lineHeight":1.5},"pagination":{"gridMode":"typographic"}}"#,
1497                "lineSpacing and lineHeight",
1498            ),
1499            (
1500                r#"{"typesetting":{"lineSpacing":1.5}}"#,
1501                "gridMode: typographic",
1502            ),
1503            (
1504                r#"{"layout":{"system":"word","gutter":1}}"#,
1505                "only available with japanese-publisher",
1506            ),
1507            (
1508                r#"{"layout":{"system":"word"},"pagination":{"gridMode":"strict"}}"#,
1509                "flowing typography",
1510            ),
1511            (r#"{"typesetting":{"fontSize":3}}"#, "fontSize"),
1512            (
1513                r#"{"typesetting":{"lineSpacing":4},"pagination":{"gridMode":"typographic"}}"#,
1514                "lineSpacing",
1515            ),
1516            (r#"{"typesetting":{"textIndentEm":4.1}}"#, "textIndentEm"),
1517            (
1518                r#"{"pagination":{"charactersPerLine":9}}"#,
1519                "charactersPerLine",
1520            ),
1521            (
1522                r#"{"pagination":{"margins":{"left":100,"right":100}}}"#,
1523                "left and right margins",
1524            ),
1525            (
1526                r#"{"pagination":{"margins":{"top":100,"bottom":100}}}"#,
1527                "top and bottom margins",
1528            ),
1529            (r#"{"text":{"indentCount":0}}"#, "text.indentCount"),
1530        ] {
1531            let error = resolve_export_profile_json(source, None, false).unwrap_err();
1532            assert!(error.contains(message), "{source}: {error}");
1533        }
1534    }
1535
1536    #[test]
1537    fn prepares_vertical_mirrored_chromium_profile_and_fraction_header() {
1538        let prepared = prepare_chromium_print_profile_json(
1539            "<html><head></head><body><p>本文</p><p> </p></body></html>",
1540            r#"{
1541                "layout":{"system":"japanese-publisher","marginMode":"mirror","bindingSide":"right"},
1542                "typesetting":{"writingMode":"vertical","fontFamily":"Noto Serif JP","textIndentEm":2,"fullwidthSpaceIndent":true},
1543                "pagination":{"pageSize":"A4","landscape":true,"pageNumbers":{"enabled":true,"format":"fraction","position":"top-right"}}
1544            }"#,
1545            None,
1546        )
1547        .unwrap();
1548        let prepared: ChromiumPrintProfile = serde_json::from_str(&prepared).unwrap();
1549
1550        assert_eq!(prepared.page.width_mm, 297.0);
1551        assert_eq!(prepared.page.height_mm, 210.0);
1552        assert!(prepared.page.landscape);
1553        assert!(prepared.html.contains(r#"<style id="mdi-export-profile">"#));
1554        assert!(prepared.html.contains("writing-mode:vertical-rl"));
1555        assert!(prepared.html.contains("@page :right{margin:"));
1556        assert!(prepared.html.contains("@page :left{margin:"));
1557        assert!(prepared.html.contains("font-family:Noto Serif JP"));
1558        assert!(prepared.html.contains("<p>  本文</p>"));
1559        assert!(prepared.html.contains("<p> </p>"));
1560        let header = prepared.page_numbers.header_template.unwrap();
1561        assert!(header.contains("text-align:right"));
1562        assert!(header.contains(r#"class="totalPages""#));
1563        assert!(prepared.page_numbers.footer_template.is_none());
1564    }
1565
1566    #[test]
1567    fn prepares_word_typography_disabled_numbering_and_all_style_injection_shapes() {
1568        let raw = r#"{
1569            "layout":{"system":"word"},
1570            "typesetting":{"fontSize":12,"lineSpacing":1.5},
1571            "pagination":{"pageNumbers":{"enabled":false}}
1572        }"#;
1573        for (html, marker) in [
1574            (
1575                "<HTML><HEAD></HEAD><body><p>本文</p></body></HTML>",
1576                r#"<style id="mdi-export-profile">"#,
1577            ),
1578            (
1579                "<HTML lang=\"ja\"><body><p>本文</p></body></HTML>",
1580                r#"<HTML lang="ja"><head><style id="mdi-export-profile">"#,
1581            ),
1582            ("<p>本文</p>", r#"<style id="mdi-export-profile">"#),
1583        ] {
1584            let prepared = prepare_chromium_print_profile_json(html, raw, None).unwrap();
1585            let prepared: ChromiumPrintProfile = serde_json::from_str(&prepared).unwrap();
1586            assert!(prepared.html.contains(marker));
1587            assert!(prepared.html.contains("font-size:4.233333333333"));
1588            assert!(prepared.html.contains("line-height:1.5"));
1589            assert!(prepared.html.contains("p{margin:0 0 .75em"));
1590            assert!(prepared.page_numbers.header_template.is_none());
1591            assert!(prepared.page_numbers.footer_template.is_none());
1592        }
1593    }
1594
1595    #[test]
1596    fn applies_resolved_profile_json_without_re_resolving_it() {
1597        let profile = resolve(r#"{"layout":{"system":"japanese-publisher"}}"#, None);
1598        let html = apply_pdf_profile_json(
1599            "<html><head></head><body><p class=\"lead\">本文</p></body></html>",
1600            &serde_json::to_string(&profile).unwrap(),
1601        )
1602        .unwrap();
1603
1604        assert!(html.contains("@page{size:127mm 188mm"));
1605        assert!(html.contains("--mdi-grid-mode:strict"));
1606        assert!(html.contains("--mdi-characters-per-line:27"));
1607        assert!(html.contains("p{margin:0;text-indent:1em}"));
1608        assert!(html.contains(r#"<p class="lead">本文"#));
1609    }
1610}