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
447pub 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
519pub 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}