1#![warn(clippy::print_stderr, clippy::print_stdout)]
35
36#[cfg(feature = "math")]
37mod ascii;
38mod inline;
39mod math;
40
41use std::{
42 fmt::Write as _,
43 path::{
44 Path,
45 PathBuf,
46 },
47};
48
49use anyhow::{
50 Context as _,
51 Result,
52};
53use asciidoc_parser::{
54 Document,
55 blocks::{
56 Block,
57 BreakType,
58 CompoundDelimitedContext,
59 FindBlocks,
60 IsBlock,
61 ListType,
62 QuoteType,
63 SectionType,
64 },
65 document::TocMode,
66};
67
68use adocers_render_core::numbering::Numbering;
69
70use crate::inline::{
71 string,
72 typst as inline_markup,
73};
74
75#[derive(Clone, Copy, Debug, Default)]
81#[expect(
82 clippy::struct_excessive_bools,
83 reason = "these mirror command line flags, and one field per flag is what reads clearly"
84)]
85pub struct Options {
86 pub icons: bool,
89
90 pub highlight: bool,
93
94 pub mermaid: bool,
97
98 pub math: bool,
101}
102
103#[derive(Clone, Debug)]
106pub struct Pdf {
107 pub bytes: Vec<u8>,
109
110 pub fallback: Option<String>,
118}
119
120pub fn pdf(document: &Document<'_>, base: &Path, options: &Options) -> Result<Pdf> {
125 let (source, images) = markup(document, base, options);
126
127 let error = match compile(&source, &images) {
128 Ok(bytes) => {
129 return Ok(Pdf {
130 bytes,
131 fallback: None,
132 });
133 }
134
135 Err(error) => error,
136 };
137
138 if !options.math {
144 return Err(error);
145 }
146
147 let plain = Options {
148 math: false,
149 ..*options
150 };
151
152 let (source, images) = markup(document, base, &plain);
153
154 match compile(&source, &images) {
155 Ok(bytes) => Ok(Pdf {
156 bytes,
157 fallback: Some(format!(
158 "an equation could not be typeset, so every equation is shown as its source \
159 ({error:#})"
160 )),
161 }),
162
163 Err(_) => Err(error),
165 }
166}
167
168pub fn markup(
170 document: &Document<'_>,
171 base: &Path,
172 options: &Options,
173) -> (String, Vec<(String, Vec<u8>)>) {
174 let mut out = Preamble::new(document, *options).to_string();
175
176 let mut emitter = Emitter {
177 out: String::new(),
178 base: base.to_path_buf(),
179 images_dir: attribute(document, "imagesdir"),
180 images: Vec::new(),
181 toc: Toc::of(document),
182 options: *options,
183 stem: attribute(document, "stem"),
184 figure: attribute(document, "figure-caption"),
185 figures: 0,
186 numbering: Numbering::of(document),
187 };
188
189 if emitter.toc.above() {
192 emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
193 }
194
195 emitter.blocks(document.child_blocks());
196
197 if emitter.toc.mode == Some(TocMode::Bottom) {
198 emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
199 }
200
201 out.push_str(&prune_links(&emitter.out));
202
203 (out, emitter.images)
204}
205
206fn compile(source: &str, images: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
208 use typst_as_lib::{
209 TypstEngine,
210 typst_kit_options::TypstKitFontOptions,
211 };
212
213 let engine = TypstEngine::builder()
214 .main_file(source.to_string())
215 .search_fonts_with(TypstKitFontOptions::default().include_system_fonts(false))
219 .with_static_file_resolver(
222 images
223 .iter()
224 .map(|(path, bytes)| (path.as_str(), bytes.as_slice())),
225 )
226 .build();
227
228 let document = engine
229 .compile()
230 .output
231 .map_err(|error| anyhow::anyhow!("{error}"))
232 .context("laying the document out")?;
233
234 typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
235 .map_err(|errors| anyhow::anyhow!(describe(&errors)))
236 .context("writing the PDF")
237}
238
239fn describe(errors: &[typst::diag::SourceDiagnostic]) -> String {
241 errors
242 .iter()
243 .map(|error| error.message.to_string())
244 .collect::<Vec<_>>()
245 .join("; ")
246}
247
248const FITTED: &str = "#let fitted(body) = layout(size => {\n\x20 let natural = \
256 measure(body).width\n\x20 let factor = size.width / natural * 100%\n\x20 if \
257 natural > size.width {\n\x20 scale(body, x: factor, y: factor, reflow: \
258 true, origin: top + left)\n\x20 } else { body }\n})";
259
260const CALLOUT: &str = "#let adoccallout(n) = if n <= 10 { str.from-unicode(0x2775 + n) } else if \
265 n <= 20 { str.from-unicode(0x24E0 + n) } else { numbering(\"(1)\", n) \
266 }\n#show regex(\"[\\u{2776}-\\u{277F}\\u{24EB}-\\u{24F4}]\"): it => \
267 text(fill: rgb(\"#1565a8\"), size: 1.35em, baseline: 0.12em)[#it]";
268
269fn footnotes(document: &Document<'_>, options: Options) -> String {
276 let texts: Vec<String> = document
277 .catalog()
278 .footnotes()
279 .iter()
280 .map(|footnote| format!("[{}]", prose(&footnote.text, options.math)))
281 .collect();
282
283 let array = match texts.len() {
286 0 => String::new(),
287 1 => format!("{},", texts[0]),
288 _ => texts.join(", "),
289 };
290
291 format!(
294 "#let adocfootnotes = ({array})\n#let adocfootnote(n) = if n <= adocfootnotes.len() {{ \
295 footnote(adocfootnotes.at(n - 1)) }}"
296 )
297}
298
299fn properties(document: &Document<'_>, title: &str) -> String {
305 let mut out = format!("title: {}", string(title));
306
307 let authors: Vec<String> = document
308 .authors()
309 .iter()
310 .map(|author| string(author.name()))
311 .collect();
312
313 if !authors.is_empty() {
314 let _ = write!(out, ", author: ({},)", authors.join(", "));
315 }
316
317 if let Some(description) = attribute(document, "description") {
318 let _ = write!(out, ", description: {}", string(&description));
319 }
320
321 let keywords: Vec<String> = ["keywords", "page-tags"]
322 .iter()
323 .filter_map(|name| attribute(document, name))
324 .flat_map(|value| {
325 value
326 .split(',')
327 .map(str::trim)
328 .filter(|word| !word.is_empty())
329 .map(string)
330 .collect::<Vec<_>>()
331 })
332 .collect();
333
334 if !keywords.is_empty() {
335 let _ = write!(out, ", keywords: ({},)", keywords.join(", "));
336 }
337
338 match attribute(document, "revdate").as_deref().and_then(date) {
341 Some(date) => {
342 let _ = write!(out, ", date: {date}");
343 }
344
345 None => out.push_str(", date: none"),
346 }
347
348 out
349}
350
351fn locale(document: &Document<'_>) -> String {
365 let Some(tag) = attribute(document, "lang") else {
366 return String::new();
367 };
368
369 let mut subtags = tag.trim().split('-');
370
371 let Some(language) = subtags.next().filter(|subtag| alphabetic(subtag, 2..=3)) else {
372 return String::new();
373 };
374
375 let mut out = format!(", lang: {}", string(&language.to_lowercase()));
376
377 if let Some(region) = subtags.find(|subtag| alphabetic(subtag, 2..=2)) {
378 let _ = write!(out, ", region: {}", string(®ion.to_uppercase()));
379 }
380
381 out
382}
383
384fn alphabetic(subtag: &str, length: std::ops::RangeInclusive<usize>) -> bool {
386 length.contains(&subtag.chars().count()) && subtag.chars().all(char::is_alphabetic)
387}
388
389fn date(value: &str) -> Option<String> {
391 let mut parts = value.trim().splitn(3, '-');
392
393 let year: i32 = parts.next()?.parse().ok()?;
394 let month: u8 = parts.next()?.parse().ok()?;
395 let day: u8 = parts.next()?.trim().parse().ok()?;
396
397 (1..=12).contains(&month).then_some(())?;
398 (1..=31).contains(&day).then_some(())?;
399
400 Some(format!(
401 "datetime(year: {year}, month: {month}, day: {day})"
402 ))
403}
404
405fn attribute(document: &Document<'_>, name: &str) -> Option<String> {
407 match document.attribute_value(name) {
408 asciidoc_parser::document::InterpretedValue::Value(value) if !value.is_empty() => {
409 Some(value.clone())
410 }
411
412 _ => None,
413 }
414}
415
416fn details(document: &Document<'_>) -> String {
422 let mut out = String::new();
423
424 let authors: Vec<String> = document
425 .authors()
426 .iter()
427 .map(|author| match author.email() {
428 Some(email) => format!(
432 "{} #link(\"mailto:{email}\")[\\<{}\\>]",
433 inline_markup(author.name()),
434 inline_markup(email)
435 ),
436
437 None => inline_markup(author.name()),
438 })
439 .collect();
440
441 if !authors.is_empty() {
442 let label = if authors.len() == 1 {
443 "Author"
444 } else {
445 "Authors"
446 };
447
448 let _ = writeln!(out, "*{label}:* {}\\", authors.join(", "));
449 }
450
451 for (name, label) in [("revnumber", "Version"), ("revdate", "Date")] {
452 if let Some(value) = attribute(document, name) {
453 let _ = writeln!(out, "*{label}:* {}\\", inline_markup(&value));
454 }
455 }
456
457 for attribute in document.header().attributes() {
458 let name = attribute.name().data();
459
460 let Some(shown) = adocers_render_core::metadata::displayed_as(name) else {
461 continue;
462 };
463
464 let asciidoc_parser::document::InterpretedValue::Value(value) = attribute.value() else {
465 continue;
466 };
467
468 if value.is_empty() {
469 continue;
470 }
471
472 let _ = writeln!(
473 out,
474 "*{}:* {}\\",
475 adocers_render_core::metadata::label_for(shown),
476 inline_markup(value)
477 );
478 }
479
480 let mut out = out.trim_end().trim_end_matches('\\').to_string();
481
482 if let Some(remark) = attribute(document, "revremark") {
487 let _ = write!(
488 out,
489 "\n#v(0.4em)\n#text(style: \"italic\")[{}]",
490 inline_markup(&remark)
491 );
492 }
493
494 out
495}
496
497fn prose(html: &str, math: bool) -> String {
507 let mut out = String::with_capacity(html.len());
508 let mut rest = html;
509
510 while let Some((at, open, close, latex)) = equation(rest) {
511 out.push_str(&inline_markup(&rest[..at]));
512
513 let body = &rest[at + open.len()..];
514
515 let Some(end) = body.find(close) else {
517 out.push_str(&inline_markup(&rest[at..at + open.len()]));
518 rest = &rest[at + open.len()..];
519
520 continue;
521 };
522
523 let source = inline::unescape(&body[..end]);
524
525 match typeset(&source, math, latex) {
526 Some(converted) => out.push_str(&converted),
527 None => {
528 let _ = write!(out, "#raw({})", string(source.trim()));
529 }
530 }
531
532 rest = &body[end + close.len()..];
533 }
534
535 out.push_str(&inline_markup(rest));
536 out
537}
538
539fn equation(html: &str) -> Option<(usize, &'static str, &'static str, bool)> {
541 let latex = html.find("\\(").map(|at| (at, "\\(", "\\)", true));
542 let ascii = html.find("\\$").map(|at| (at, "\\$", "\\$", false));
543
544 match (latex, ascii) {
545 (Some(l), Some(a)) if a.0 < l.0 => Some(a),
546 (Some(l), _) => Some(l),
547 (None, found) => found,
548 }
549}
550
551fn typeset(source: &str, math: bool, latex: bool) -> Option<String> {
553 math.then(|| converted(source, latex))
554 .flatten()
555 .map(|converted| format!("${converted}$"))
556}
557
558fn converted(source: &str, latex: bool) -> Option<String> {
560 if latex {
561 return math::typst(source);
562 }
563
564 asciimath(source)
565}
566
567#[cfg(feature = "math")]
570fn asciimath(source: &str) -> Option<String> {
571 ascii::typst(source)
572}
573
574#[cfg(not(feature = "math"))]
576fn asciimath(_source: &str) -> Option<String> {
577 None
578}
579
580struct Preamble(String);
582
583impl Preamble {
584 fn new(document: &Document<'_>, options: Options) -> Self {
585 let mut out = String::new();
586
587 let title = document
588 .doctitle_sanitized()
589 .unwrap_or_else(|| "Untitled".to_string());
590
591 let _ = writeln!(
592 out,
593 "#set document({})\n#set page(paper: \"a4\", margin: 2.2cm, numbering: \"1\")\n#set \
594 text(size: 10.5pt{})\n#set par(justify: true, leading: 0.62em)\n#show heading: it => \
595 block(above: 1.4em, below: 0.7em, it)\n#show link: it => text(fill: \
596 rgb(\"#1565a8\"), it)\n#show raw.where(block: true): it => block(\n\x20 width: 100%, \
597 fill: rgb(\"#f5f6f8\"), inset: 8pt, radius: 3pt, it,\n)\n{}\n{CALLOUT}\n{}\n{}\n",
598 properties(document, &title),
599 locale(document),
600 FITTED,
601 footnotes(document, options),
602 math::PRELUDE
603 );
604
605 if document.doctitle().is_some() {
606 let (heading, subtitle) = match (document.header().main_title(), document.subtitle()) {
611 (Some(main), Some(subtitle)) => {
612 (inline_markup(main), Some(inline_markup(subtitle)))
613 }
614
615 _ => (inline_markup(&title), None),
616 };
617
618 let _ = writeln!(
619 out,
620 "#align(center)[#text(size: 20pt, weight: \"bold\")[{heading}]]"
621 );
622
623 if let Some(subtitle) = subtitle {
624 let _ = writeln!(
625 out,
626 "#v(-0.6em)\n#align(center)[#text(size: 14pt)[{subtitle}]]"
627 );
628 }
629
630 let details = details(document);
631
632 if !details.is_empty() {
633 let _ = writeln!(
636 out,
637 "#v(0.4em)\n#align(center)[#block(width: 80%)[#set text(size: 9pt, fill: \
638 rgb(\"#656d77\"))\n#set align(center)\n{details}]]"
639 );
640 }
641
642 out.push('\n');
643 }
644
645 Self(out)
646 }
647}
648
649impl std::fmt::Display for Preamble {
650 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651 f.write_str(&self.0)
652 }
653}
654
655struct Emitter {
657 out: String,
659
660 base: PathBuf,
662
663 images_dir: Option<String>,
666
667 images: Vec<(String, Vec<u8>)>,
669
670 toc: Toc,
672
673 options: Options,
677
678 stem: Option<String>,
681
682 figure: Option<String>,
685 figures: usize,
686
687 numbering: Numbering,
690}
691
692#[derive(Clone, Debug)]
694struct Toc {
695 mode: Option<TocMode>,
697
698 title: String,
700
701 levels: usize,
703
704 placed: bool,
707}
708
709impl Toc {
710 fn of(document: &Document<'_>) -> Self {
712 let mode = document.toc_mode();
713
714 Self {
715 mode: (mode != TocMode::Disabled).then_some(mode),
716 title: document.toc_title().to_string(),
717 levels: document.toc_levels(),
718 placed: false,
719 }
720 }
721
722 fn above(&self) -> bool {
728 matches!(
729 self.mode,
730 Some(TocMode::Auto | TocMode::Left | TocMode::Right | TocMode::Top)
731 )
732 }
733}
734
735impl Emitter {
736 fn prose(&mut self, html: &str) -> String {
738 let converted = prose(html, self.options.math);
739
740 self.pictures(&converted)
741 }
742
743 fn pictures(&mut self, markup: &str) -> String {
753 let mut out = String::with_capacity(markup.len());
754 let mut rest = markup;
755
756 while let Some(at) = rest.find(inline::PICTURE) {
757 out.push_str(&rest[..at]);
758 rest = &rest[at + inline::PICTURE.len()..];
759
760 let Some((marker, tail)) = rest.split_once(inline::PICTURE) else {
761 out.push_str(inline::PICTURE);
762 break;
763 };
764
765 rest = tail;
766
767 let mut parts = marker.splitn(3, inline::FIELD);
770 let (Some(target), Some(height), Some(words)) =
771 (parts.next(), parts.next(), parts.next())
772 else {
773 continue;
774 };
775
776 match self.picture(target, height) {
777 Some(placed) => out.push_str(&placed),
778 None => out.push_str(words),
779 }
780 }
781
782 out.push_str(rest);
783 out
784 }
785
786 fn picture(&mut self, target: &str, height: &str) -> Option<String> {
788 let name = format!("/{target}");
789
790 if !self.images.iter().any(|(known, _)| *known == name) {
791 let bytes = std::fs::read(self.base.join(target)).ok()?;
792
793 self.images.push((name.clone(), bytes));
794 }
795
796 let height = height.parse::<f64>().map_or_else(
800 |_| "1em".to_string(),
801 |pixels| format!("{}pt", pixels * 0.75),
802 );
803
804 Some(format!("#box(image({}, height: {height}))", string(&name)))
805 }
806
807 fn outline(&mut self, levels: usize, title: &str) {
812 if self.toc.placed {
813 return;
814 }
815
816 self.toc.placed = true;
817
818 let title = if title.is_empty() {
819 "none".to_string()
820 } else {
821 format!("[{}]", inline_markup(title))
822 };
823
824 let _ = writeln!(
825 self.out,
826 "#outline(title: {title}, depth: {levels})\n#v(0.5em)\n"
827 );
828 }
829
830 fn blocks<'src>(&mut self, blocks: impl Iterator<Item = &'src Block<'src>>) {
832 for block in blocks {
833 self.block(block);
834 }
835 }
836
837 fn block(&mut self, block: &Block<'_>) {
839 if !matches!(block, Block::Section(_) | Block::Media(_)) && !is_diagram(block) {
842 self.title(block);
843 }
844
845 match block {
846 Block::Section(section) => {
847 let level = section.level().clamp(1, 6);
848
849 let title = format!(
853 "{}{}",
854 self.prose(&self.numbering.prefix(section)),
855 self.prose(section.section_title())
856 );
857
858 let label = section.id().map(anchor).unwrap_or_default();
863
864 if section.section_type() == SectionType::Discrete {
867 let _ = writeln!(
868 self.out,
869 "#heading(level: {level}, outlined: false)[{title}]{label}\n"
870 );
871 } else {
872 let _ = writeln!(self.out, "{} {title}{label}\n", "=".repeat(level));
873 }
874
875 self.blocks(section.child_blocks());
876 }
877
878 Block::Simple(simple) => {
879 let text = self.prose(simple.content().rendered_html());
880
881 if !text.trim().is_empty() {
882 let _ = writeln!(self.out, "{text}\n");
883 }
884 }
885
886 Block::List(list) => self.list(list),
887 Block::Table(table) => self.table(table),
888 Block::Preamble(preamble) => {
889 self.blocks(preamble.child_blocks());
890
891 if self.toc.mode == Some(TocMode::Preamble) {
892 self.outline(self.toc.levels, &self.toc.title.clone());
893 }
894 }
895
896 Block::Toc(toc) => {
899 if self.toc.mode == Some(TocMode::Macro) {
900 let attrlist = toc.macro_attrlist();
901
902 let named = |name: &str| {
903 attrlist
904 .named_attribute(name)
905 .map(asciidoc_parser::attributes::ElementAttribute::value)
906 .filter(|value| !value.is_empty())
907 };
908
909 let title =
910 named("title").map_or_else(|| self.toc.title.clone(), str::to_string);
911 let levels = named("levels")
912 .and_then(|levels| levels.parse().ok())
913 .unwrap_or(self.toc.levels);
914
915 self.outline(levels, &title);
916 }
917 }
918 Block::Admonition(admonition) => self.admonition(admonition),
919 Block::Quote(quote) => self.quote(quote),
920 Block::Media(media) => self.media(media),
921 Block::RawDelimited(raw) => self.raw(raw),
922 Block::CompoundDelimited(compound) => self.compound(compound),
923 Block::Break(r#break) => match r#break.type_() {
926 BreakType::Thematic => self.out.push_str("#line(length: 100%)\n\n"),
927 BreakType::Page => self.out.push_str("#pagebreak()\n\n"),
928 },
929
930 _ => {}
933 }
934 }
935
936 fn figure_caption(&mut self, titled: bool) -> String {
944 if !titled {
945 return String::new();
946 }
947
948 let Some(label) = self.figure.clone().filter(|label| !label.is_empty()) else {
949 return String::new();
950 };
951
952 self.figures += 1;
953
954 format!("{label} {}. ", self.figures)
955 }
956
957 fn title(&mut self, block: &Block<'_>) {
960 let Some(title) = block.title() else {
961 return;
962 };
963
964 let caption = block.caption().unwrap_or_default();
965
966 let caption = self.prose(caption);
967 let title = self.prose(title);
968
969 let _ = writeln!(
970 self.out,
971 "#block(above: 1em, below: 0.5em)[#text(weight: \"bold\", size: \
972 9.5pt)[{caption}{title}]]\n"
973 );
974 }
975
976 fn raw(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) {
979 let content = inline::text(&self.marks(raw.content().rendered_html()));
982
983 if self.diagram(raw, &content) {
984 return;
985 }
986
987 match raw.raw_context().as_ref() {
988 "comment" | "pass" => {}
991
992 "stem" => self.equation(raw, content.trim()),
993
994 _ => {
997 let language = (raw.raw_context().as_ref() == "listing")
998 .then(|| source_language(raw))
999 .flatten();
1000
1001 self.verbatim_as(&content, language.as_deref());
1002 }
1003 }
1004 }
1005
1006 fn equation(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>, source: &str) {
1011 if let Some(typeset) = self.mathematics(raw, source) {
1012 let _ = writeln!(self.out, "$ {typeset} $\n");
1013
1014 return;
1015 }
1016
1017 let _ = writeln!(self.out, "#align(center)[#raw({})]\n", string(source));
1018 }
1019
1020 fn mathematics(
1022 &self,
1023 raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1024 source: &str,
1025 ) -> Option<String> {
1026 self.options
1027 .math
1028 .then(|| converted(source, self.is_latex(raw)))
1029 .flatten()
1030 }
1031
1032 fn is_latex(&self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
1038 match raw.declared_style() {
1039 Some("latexmath") => true,
1040 Some("asciimath") => false,
1041 _ => self.stem.as_deref() == Some("latexmath"),
1042 }
1043 }
1044
1045 #[cfg(feature = "mermaid")]
1052 fn diagram(
1053 &mut self,
1054 raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1055 content: &str,
1056 ) -> bool {
1057 if !is_mermaid(raw) {
1058 return false;
1059 }
1060
1061 let Some(svg) = adocers_render_core::mermaid::printable(content) else {
1062 return false;
1063 };
1064
1065 let name = format!("/diagram-{}.svg", self.images.len());
1066 self.images.push((name.clone(), svg.into_bytes()));
1067
1068 self.figure(&name, raw, "");
1070
1071 true
1072 }
1073
1074 #[cfg(not(feature = "mermaid"))]
1076 #[expect(
1077 clippy::unused_self,
1078 reason = "the other half of this pair collects the drawing, and both are called the same \
1079 way"
1080 )]
1081 fn diagram(
1082 &mut self,
1083 _raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1084 _content: &str,
1085 ) -> bool {
1086 false
1087 }
1088
1089 fn verbatim(&mut self, content: &str) {
1091 self.verbatim_as(content, None);
1092 }
1093
1094 fn verbatim_as(&mut self, content: &str, language: Option<&str>) {
1102 let language = match language {
1103 Some(language) => format!(", lang: {}", string(language)),
1104 None => String::new(),
1105 };
1106
1107 let _ = writeln!(
1108 self.out,
1109 "#raw({}, block: true{language})\n",
1110 string(content)
1111 );
1112 }
1113
1114 fn compound(&mut self, compound: &asciidoc_parser::blocks::CompoundDelimitedBlock<'_>) {
1116 let abstracted = compound.declared_style() == Some("abstract");
1120
1121 let framed = !abstracted
1122 && matches!(
1123 compound.context_kind(),
1124 CompoundDelimitedContext::Example | CompoundDelimitedContext::Sidebar
1125 );
1126
1127 if framed {
1128 self.out.push_str(
1129 "#block(width: 100%, fill: rgb(\"#f2f4f7\"), inset: 10pt, radius: 3pt)[\n",
1130 );
1131 }
1132
1133 if abstracted {
1134 self.out
1135 .push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
1136 }
1137
1138 self.blocks(compound.child_blocks());
1139
1140 if framed || abstracted {
1141 self.out.push_str("]\n\n");
1142 }
1143 }
1144
1145 fn admonition(&mut self, admonition: &asciidoc_parser::blocks::AdmonitionBlock<'_>) {
1150 let name = admonition.name().to_string();
1151 let label = admonition.label().to_string().to_uppercase();
1152 let colour = admonition_colour(&name);
1153
1154 let mark = match self.icon(&name, &label) {
1155 Some(path) => format!("#image({}, width: 20pt)", string(&path)),
1156
1157 None => format!("#text(weight: \"bold\", size: 8pt, fill: rgb(\"{colour}\"))[{label}]"),
1160 };
1161
1162 let _ = write!(
1163 self.out,
1164 "#grid(\n columns: (34pt, 1fr),\n align: (center + horizon, left + horizon),\n \
1165 inset: (x, y) => if x == 1 {{ (left: 11pt, y: 2pt) }} else {{ (right: 10pt) }},\n \
1166 stroke: (x, y) => if x == 1 {{ (left: 1pt + rgb(\"#dcdfe4\")) }} else {{ none }},\n \
1167 [{mark}],\n [\n"
1168 );
1169
1170 match admonition.content() {
1177 Some(content) => {
1178 let converted = self.prose(content.rendered_html());
1179 self.out.push_str(&converted);
1180 self.out.push_str("\n\n");
1181 }
1182
1183 None => self.blocks(admonition.child_blocks()),
1184 }
1185
1186 self.out.push_str("],\n)\n\n");
1187 }
1188
1189 fn marks(&self, html: &str) -> String {
1196 if !self.options.icons {
1197 return html.to_string();
1198 }
1199
1200 let mut out = String::with_capacity(html.len());
1201 let mut rest = html;
1202
1203 while let Some(at) = rest.find(CONUM) {
1204 out.push_str(&rest[..at]);
1205 rest = &rest[at + CONUM.len()..];
1206
1207 let number: String = rest.chars().take_while(char::is_ascii_digit).collect();
1208
1209 let Some(mark) = number.parse().ok().and_then(circled) else {
1210 out.push_str(CONUM);
1211 continue;
1212 };
1213
1214 let Some(end) = rest.find("</b>") else {
1215 out.push_str(CONUM);
1216 continue;
1217 };
1218
1219 out.push(mark);
1220 rest = &rest[end + "</b>".len()..];
1221 }
1222
1223 out.push_str(rest);
1224 out
1225 }
1226
1227 fn icon(&mut self, name: &str, label: &str) -> Option<String> {
1233 if !self.options.icons {
1234 return None;
1235 }
1236
1237 let path = format!("/icon-{name}.svg");
1238
1239 if !self.images.iter().any(|(known, _)| *known == path) {
1240 let svg = adocers_render_core::icons::admonition(name, label)?
1241 .replace("currentColor", admonition_colour(name));
1242
1243 self.images.push((path.clone(), svg.into_bytes()));
1244 }
1245
1246 Some(path)
1247 }
1248
1249 fn quote(&mut self, quote: &asciidoc_parser::blocks::QuoteBlock<'_>) {
1251 self.out
1252 .push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
1253
1254 match quote.content() {
1255 Some(content) if quote.type_() == QuoteType::Verse => {
1258 self.verbatim(&inline::text(content.rendered_html()));
1259 }
1260
1261 Some(content) => {
1262 let converted = self.prose(content.rendered_html());
1263 self.out.push_str(&converted);
1264 self.out.push_str("\n\n");
1265 }
1266
1267 None => self.blocks(quote.child_blocks()),
1268 }
1269
1270 if let Some(attribution) = quote.attribution() {
1271 let attribution = self.prose(attribution);
1272
1273 let _ = writeln!(
1274 self.out,
1275 "#text(size: 9pt, fill: rgb(\"#656d77\"))[— {attribution}]"
1276 );
1277 }
1278
1279 self.out.push_str("]\n\n");
1280 }
1281
1282 fn in_images_dir(&self, target: &str) -> String {
1291 let Some(dir) = self.images_dir.as_deref() else {
1292 return target.to_string();
1293 };
1294
1295 if target.starts_with('/') || target.starts_with("data:") || target.contains("://") {
1296 return target.to_string();
1297 }
1298
1299 format!("{}/{target}", dir.trim_end_matches('/'))
1300 }
1301
1302 fn media(&mut self, media: &asciidoc_parser::blocks::MediaBlock<'_>) {
1304 let target = self.in_images_dir(media.resolved_target());
1305
1306 let Ok(bytes) = std::fs::read(self.base.join(&target)) else {
1310 let _ = writeln!(
1311 self.out,
1312 "#text(style: \"italic\", fill: rgb(\"#656d77\"))[[{}]]\n",
1313 inline_markup(&target)
1314 );
1315
1316 return;
1317 };
1318
1319 self.images.push((format!("/{target}"), bytes));
1320
1321 let width = attrlist_size(media, "width", 2);
1325 let height = attrlist_size(media, "height", 3);
1326
1327 self.figure(&format!("/{target}"), media, &size(width, height));
1328 }
1329
1330 fn figure<'src>(&mut self, name: &str, block: &impl IsBlock<'src>, size: &str) {
1332 let caption = match block.title() {
1333 Some(title) => {
1334 let label = self.figure_caption(block.title().is_some());
1335
1336 format!(
1337 ", caption: [{}{}], numbering: none",
1338 self.prose(&label),
1339 self.prose(title)
1340 )
1341 }
1342
1343 None => String::new(),
1344 };
1345
1346 let _ = writeln!(
1347 self.out,
1348 "#block(breakable: false, width: 100%)[#figure(fitted(image({}{size})){caption})]\n",
1349 string(name)
1350 );
1351 }
1352
1353 fn list(&mut self, list: &asciidoc_parser::blocks::ListBlock<'_>) {
1355 let items: Vec<_> = list
1356 .child_blocks()
1357 .filter_map(|block| match block {
1358 Block::ListItem(item) => Some(item),
1359 _ => None,
1360 })
1361 .collect();
1362
1363 let checklist = list.type_() == ListType::Unordered && list.is_checklist();
1368
1369 if checklist {
1370 self.out.push_str("#[\n#set list(marker: none)\n");
1371 }
1372
1373 let numbered = list.type_() == ListType::Ordered;
1376 let start = numbered.then(|| list.start()).flatten();
1377 let reversed = numbered && list.has_option("reversed");
1378
1379 let pattern = numbered
1380 .then(|| numbering(list))
1381 .flatten()
1382 .filter(|pattern| *pattern != ARABIC);
1383
1384 if let Some(pattern) = pattern {
1385 let _ = writeln!(self.out, "#set enum(numbering: {})", string(pattern));
1386 }
1387
1388 let marked = list.type_() == ListType::Callout && self.options.icons;
1391
1392 if marked {
1393 self.out
1394 .push_str("#set enum(numbering: n => adoccallout(n))\n");
1395 }
1396
1397 if let Some(start) = start {
1398 let _ = writeln!(self.out, "#set enum(start: {start})");
1399 }
1400
1401 if reversed {
1402 let highest = start.unwrap_or(1) + i64::try_from(items.len()).unwrap_or(1) - 1;
1407
1408 let _ = writeln!(
1409 self.out,
1410 "#set enum(numbering: n => numbering({}, {highest} - n + 1))",
1411 string(pattern.unwrap_or(ARABIC))
1412 );
1413 }
1414
1415 for item in items {
1416 let bullet = match list.type_() {
1417 ListType::Ordered | ListType::Callout => "+",
1418 ListType::Description => "/",
1419 ListType::Unordered => "-",
1420 };
1421
1422 let term = match (list.type_(), item.list_item_marker()) {
1425 (
1426 ListType::Description,
1427 asciidoc_parser::blocks::ListItemMarker::DefinedTerm { term, .. },
1428 ) => format!("{}: ", self.prose(term.rendered_html())),
1429
1430 (ListType::Description, _) => ": ".to_string(),
1431 _ => String::new(),
1432 };
1433
1434 let body = self.item(item);
1435 let _ = writeln!(self.out, "{bullet} {term}{}{body}", box_of(item, checklist));
1436 }
1437
1438 if start.is_some() {
1439 self.out.push_str("#set enum(start: 1)\n");
1440 }
1441
1442 if reversed || marked || pattern.is_some() {
1443 let _ = writeln!(self.out, "#set enum(numbering: {})", string(ARABIC));
1444 }
1445
1446 if checklist {
1447 self.out.push_str("]\n");
1448 }
1449
1450 self.out.push('\n');
1451 }
1452
1453 fn item(&mut self, item: &asciidoc_parser::blocks::ListItem<'_>) -> String {
1460 let taken = std::mem::take(&mut self.out);
1461 self.blocks(item.child_blocks());
1462 let body = std::mem::replace(&mut self.out, taken);
1463
1464 let mut lines = body.trim_end().lines();
1465 let mut out = lines.next().unwrap_or_default().to_string();
1466
1467 for line in lines {
1468 out.push('\n');
1469
1470 if !line.is_empty() {
1471 out.push_str(" ");
1472 }
1473
1474 out.push_str(line);
1475 }
1476
1477 out
1478 }
1479
1480 fn table(&mut self, table: &asciidoc_parser::blocks::TableBlock<'_>) {
1482 let columns = table.columns().len().max(1);
1483
1484 let _ = writeln!(
1485 self.out,
1486 "#table(\n columns: {columns},\n stroke: 0.5pt + rgb(\"#dcdfe4\"),"
1487 );
1488
1489 if let Some(header) = table.header_row() {
1490 self.out.push_str(" table.header(");
1491 self.cells(header, true);
1492 self.out.push_str("),\n");
1493 }
1494
1495 for row in table.body_rows() {
1496 self.out.push_str(" ");
1497 self.cells(row, false);
1498 self.out.push('\n');
1499 }
1500
1501 if let Some(footer) = table.footer_row() {
1504 self.out.push_str(" table.footer(");
1505 self.cells(footer, true);
1506 self.out.push_str("),\n");
1507 }
1508
1509 self.out.push_str(")\n\n");
1510 }
1511
1512 fn cells(&mut self, row: &asciidoc_parser::blocks::TableRow<'_>, heading: bool) {
1514 for cell in row.cells() {
1515 let text = match cell.content() {
1516 asciidoc_parser::blocks::TableCellContent::Simple(content) => {
1517 self.prose(content.rendered_html())
1518 }
1519
1520 asciidoc_parser::blocks::TableCellContent::AsciiDoc(_) => String::new(),
1523 };
1524
1525 let body = if heading {
1526 format!("[#text(weight: \"bold\")[{text}]]")
1527 } else {
1528 format!("[{text}]")
1529 };
1530
1531 let (columns, rows) = (cell.colspan(), cell.rowspan());
1534
1535 if columns > 1 || rows > 1 {
1536 let _ = write!(
1537 self.out,
1538 "table.cell(colspan: {columns}, rowspan: {rows}){body}, "
1539 );
1540
1541 continue;
1542 }
1543
1544 let _ = write!(self.out, "{body}, ");
1545 }
1546 }
1547}
1548
1549fn defined(markup: &str) -> std::collections::HashSet<String> {
1555 let mut labels = std::collections::HashSet::new();
1556 let mut rest = markup;
1557
1558 while let Some(at) = rest.find("#label(\"") {
1559 let before = &rest[..at];
1560 let after = &rest[at + "#label(\"".len()..];
1561
1562 if let Some(quote) = after.find("\")")
1563 && !before.ends_with("#link(")
1564 {
1565 labels.insert(after[..quote].to_string());
1566 }
1567
1568 rest = after;
1569 }
1570
1571 for line in markup.lines() {
1573 let Some(name) = line
1574 .strip_suffix('>')
1575 .and_then(|line| line.rsplit_once(" <"))
1576 else {
1577 continue;
1578 };
1579
1580 if !name.0.ends_with('\\') {
1582 labels.insert(name.1.to_string());
1583 }
1584 }
1585
1586 labels
1587}
1588
1589fn prune_links(markup: &str) -> String {
1597 let labels = defined(markup);
1598
1599 let mut out = String::with_capacity(markup.len());
1600 let mut rest = markup;
1601
1602 while let Some(at) = rest.find("#link(label(\"") {
1603 out.push_str(&rest[..at]);
1604
1605 let after = &rest[at + "#link(label(\"".len()..];
1606
1607 let Some(quote) = after.find("\")") else {
1608 out.push_str(&rest[at..]);
1609 return out;
1610 };
1611
1612 let name = &after[..quote];
1613 let body = &after[quote + "\")".len()..];
1614
1615 if labels.contains(name) {
1616 out.push_str(&rest[at..at + "#link(label(\"".len() + quote + "\")".len()]);
1618 rest = body;
1619 continue;
1620 }
1621
1622 let body = body.strip_prefix(')').unwrap_or(body);
1625
1626 match (body.strip_prefix('['), body.find(']')) {
1627 (Some(_), Some(end)) => {
1628 out.push_str(&body[1..end]);
1629 rest = &body[end + 1..];
1630 }
1631
1632 _ => {
1633 rest = body;
1634 }
1635 }
1636 }
1637
1638 out.push_str(rest);
1639 out
1640}
1641
1642#[cfg(feature = "mermaid")]
1644fn is_diagram(block: &Block<'_>) -> bool {
1645 matches!(block, Block::RawDelimited(raw) if is_mermaid(raw))
1646}
1647
1648#[cfg(not(feature = "mermaid"))]
1650fn is_diagram(_block: &Block<'_>) -> bool {
1651 false
1652}
1653
1654fn attrlist_size(
1656 media: &asciidoc_parser::blocks::MediaBlock<'_>,
1657 name: &str,
1658 index: usize,
1659) -> Option<f64> {
1660 let attrlist = media.macro_attrlist();
1661
1662 let value = attrlist
1663 .named_attribute(name)
1664 .or_else(|| {
1665 attrlist
1666 .nth_attribute(index)
1667 .filter(|attribute| attribute.name().is_none())
1668 })
1669 .map(asciidoc_parser::attributes::ElementAttribute::value)
1670 .filter(|value| !value.is_empty())?;
1671
1672 value.trim().trim_end_matches('%').parse().ok()
1673}
1674
1675fn size(width: Option<f64>, height: Option<f64>) -> String {
1680 let mut out = String::new();
1681
1682 if let Some(width) = width {
1683 let _ = write!(out, ", width: {}pt", width * 0.75);
1684 }
1685
1686 if let Some(height) = height {
1687 let _ = write!(out, ", height: {}pt", height * 0.75);
1688 }
1689
1690 out
1691}
1692
1693fn anchor(id: &str) -> String {
1700 let readable = !id.is_empty()
1701 && id
1702 .chars()
1703 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'));
1704
1705 if readable {
1706 return format!(" <{id}>");
1707 }
1708
1709 format!(" #label({})", string(id))
1710}
1711
1712fn box_of(item: &asciidoc_parser::blocks::ListItem<'_>, checklist: bool) -> &'static str {
1719 if !checklist {
1720 return "";
1721 }
1722
1723 match item.checkbox() {
1724 Some(true) => "\u{2713} ",
1725 Some(false) => "\u{274f} ",
1726 None => "",
1727 }
1728}
1729
1730const CONUM: &str = "<b class=\"conum\">(";
1732
1733fn circled(number: u32) -> Option<char> {
1742 let point = match number {
1743 1..=10 => 0x2775 + number,
1744 11..=20 => 0x24e0 + number,
1745 _ => return None,
1746 };
1747
1748 char::from_u32(point)
1749}
1750
1751fn admonition_colour(name: &str) -> &'static str {
1756 match name {
1757 "tip" => "#b58900",
1758 "important" => "#bf6900",
1759 "caution" => "#bf3400",
1760 "warning" => "#bf0000",
1761 _ => "#19407c",
1762 }
1763}
1764
1765const ARABIC: &str = "1.";
1768
1769fn numbering(list: &asciidoc_parser::blocks::ListBlock<'_>) -> Option<&'static str> {
1776 let style = list
1777 .declared_style()
1778 .filter(|style| {
1779 matches!(
1780 *style,
1781 "arabic"
1782 | "decimal"
1783 | "loweralpha"
1784 | "upperalpha"
1785 | "lowerroman"
1786 | "upperroman"
1787 | "lowergreek"
1788 )
1789 })
1790 .or_else(|| list.marker_style())?;
1791
1792 let pattern = match style {
1793 "loweralpha" => "a.",
1794 "upperalpha" => "A.",
1795 "lowerroman" => "i.",
1796 "upperroman" => "I.",
1797
1798 _ => ARABIC,
1801 };
1802
1803 Some(pattern)
1804}
1805
1806fn source_language(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> Option<String> {
1814 if raw.declared_style()? != "source" {
1815 return None;
1816 }
1817
1818 let declared = raw
1819 .attrlist()?
1820 .named_attribute("language")
1821 .or_else(|| raw.attrlist()?.nth_attribute(2))
1822 .map(asciidoc_parser::attributes::ElementAttribute::value)
1823 .filter(|language| !language.is_empty())?
1824 .to_lowercase();
1825
1826 let resolved = match declared.as_str() {
1827 "sh" | "shell" | "zsh" | "console" | "terminal" => "bash",
1828 "js" | "mjs" | "cjs" | "node" => "javascript",
1829 "ts" => "typescript",
1830 "py" | "python3" => "python",
1831 "rs" => "rust",
1832 "golang" => "go",
1833 "yml" => "yaml",
1834 "htm" | "xhtml" => "html",
1835 "jsonc" => "json",
1836 _ => return Some(declared),
1837 };
1838
1839 Some(resolved.to_string())
1840}
1841
1842#[cfg(feature = "mermaid")]
1847fn is_mermaid(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
1848 match raw.declared_style() {
1849 Some(style) if style.to_lowercase() == "mermaid" => true,
1850
1851 Some(style) if style.to_lowercase() == "source" => raw
1852 .attrlist()
1853 .and_then(|attrlist| {
1854 attrlist
1855 .named_attribute("language")
1856 .or_else(|| attrlist.nth_attribute(2))
1857 })
1858 .is_some_and(|language| language.value().to_lowercase() == "mermaid"),
1859
1860 _ => false,
1861 }
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866 use super::*;
1867 use asciidoc_parser::Parser;
1868
1869 fn options() -> Options {
1871 Options {
1872 icons: true,
1873 highlight: true,
1874 mermaid: true,
1875 math: true,
1876 }
1877 }
1878
1879 fn render(source: &str) -> String {
1880 let mut parser = Parser::default();
1881 let document = parser.parse(source);
1882
1883 markup(&document, Path::new("."), &options()).0
1884 }
1885
1886 #[test]
1887 fn emits_a_heading_and_a_paragraph() {
1888 let out = render("= Title\n\n== A section\n\nSome *bold* prose.\n");
1889
1890 assert!(out.contains("= A section"), "{out}");
1891 assert!(out.contains("#strong[bold]"), "{out}");
1892 }
1893
1894 #[test]
1895 fn emits_a_list() {
1896 let out = render("= T\n\n* one\n* two\n");
1897
1898 assert!(out.contains("- one"), "{out}");
1899 assert!(out.contains("- two"), "{out}");
1900 }
1901
1902 #[test]
1903 fn emits_a_table() {
1904 let out = render("= T\n\n|===\n|a |b\n|===\n");
1905
1906 assert!(out.contains("#table("), "{out}");
1907 assert!(out.contains("columns: 2"), "{out}");
1908 }
1909
1910 #[test]
1911 fn hands_a_listing_its_language_to_highlight() {
1912 let out = render("= T\n\n[source,rs]\n----\nfn main() {}\n----\n");
1913
1914 assert!(out.contains("lang: \"rust\""), "{out}");
1915 }
1916
1917 #[test]
1918 fn numbers_a_nested_list_the_way_its_depth_says() {
1919 let out = render("= T\n\n. one\n.. two\n");
1920
1921 assert!(out.contains("#set enum(numbering: \"a.\")"), "{out}");
1922 }
1923
1924 #[test]
1925 fn places_an_outline_when_the_document_asks_for_one() {
1926 let out = render("= T\n:toc:\n:toclevels: 3\n\n== A section\n");
1927
1928 assert!(
1929 out.contains("#outline(title: [Table of Contents], depth: 3)"),
1930 "{out}"
1931 );
1932 }
1933
1934 #[test]
1935 fn hands_typst_the_language_the_document_is_written_in() {
1936 let out = render("= T\n:lang: de\n\nText.\n");
1937
1938 assert!(
1939 out.contains("#set text(size: 10.5pt, lang: \"de\")"),
1940 "{out}"
1941 );
1942 }
1943
1944 #[test]
1945 fn splits_a_region_off_the_language_tag() {
1946 let out = render("= T\n:lang: pt-BR\n\nText.\n");
1947
1948 assert!(
1949 out.contains("#set text(size: 10.5pt, lang: \"pt\", region: \"BR\")"),
1950 "{out}"
1951 );
1952 }
1953
1954 #[test]
1955 fn reads_the_region_past_a_script_subtag() {
1956 let out = render("= T\n:lang: zh-Hans-CN\n\nText.\n");
1957
1958 assert!(
1959 out.contains("#set text(size: 10.5pt, lang: \"zh\", region: \"CN\")"),
1960 "{out}"
1961 );
1962 }
1963
1964 #[test]
1965 fn leaves_typst_its_own_default_when_the_document_names_no_language() {
1966 let out = render("= T\n\nText.\n");
1967
1968 assert!(out.contains("#set text(size: 10.5pt)"), "{out}");
1969 }
1970
1971 #[test]
1972 fn drops_a_language_tag_typst_could_not_read() {
1973 for tag in ["deutsch", "d", "1234", "de_DE"] {
1974 let out = render(&format!("= T\n:lang: {tag}\n\nText.\n"));
1975
1976 assert!(out.contains("#set text(size: 10.5pt)"), "{tag}: {out}");
1977 }
1978 }
1979
1980 #[test]
1981 fn places_no_outline_when_it_was_not_asked_for() {
1982 let out = render("= T\n\n== A section\n");
1983
1984 assert!(!out.contains("#outline"), "{out}");
1985 }
1986
1987 #[test]
1988 fn keeps_a_discrete_heading_out_of_the_outline() {
1989 let out = render("= T\n:toc:\n\n[discrete]\n== Aside\n\n== A section\n");
1990
1991 assert!(out.contains("outlined: false"), "{out}");
1992 }
1993
1994 #[test]
1995 fn keeps_an_items_own_blocks_inside_it() {
1996 let out = render("= T\n\n* one\n+\nMore about one.\n* two\n");
1997
1998 assert!(out.contains("- one\n\n More about one."), "{out}");
2001 }
2002
2003 #[test]
2004 fn numbers_a_list_from_where_it_says() {
2005 let out = render("= T\n\n[start=7]\n. seven\n. eight\n");
2006
2007 assert!(out.contains("#set enum(start: 7)"), "{out}");
2008 assert!(
2009 out.contains("#set enum(start: 1)"),
2010 "should be put back: {out}"
2011 );
2012 }
2013
2014 #[test]
2015 fn keeps_the_text_of_a_paragraph_admonition() {
2016 let out = render("= T\n\nTIP: Worth knowing.\n");
2017
2018 assert!(out.contains("/icon-tip.svg"), "{out}");
2019 assert!(out.contains("Worth knowing."), "{out}");
2020 }
2021
2022 #[test]
2023 fn labels_an_admonition_when_it_draws_no_icon() {
2024 let mut parser = Parser::default();
2025 let document = parser.parse("= T\n\nTIP: Worth knowing.\n");
2026
2027 let plain = Options {
2028 icons: false,
2029 ..options()
2030 };
2031
2032 let out = markup(&document, Path::new("."), &plain).0;
2033
2034 assert!(out.contains("TIP"), "{out}");
2035 assert!(!out.contains("icon-tip"), "{out}");
2036 }
2037
2038 #[test]
2039 fn marks_a_callout_and_its_item_the_same_way() {
2040 let out = render("= T\n\n----\ncode <1>\n----\n<1> Why.\n");
2041
2042 assert!(
2043 out.contains('\u{2776}'),
2044 "the listing keeps its mark: {out}"
2045 );
2046 assert!(out.contains("adoccallout"), "and so does its list: {out}");
2047 }
2048
2049 #[test]
2050 fn a_block_picture_is_looked_for_inside_imagesdir() {
2051 let out = render("= T\n:imagesdir: pics\n\nimage::figure.png[]\n");
2054
2055 assert!(out.contains("[pics\\/figure.png]"), "{out}");
2056 }
2057
2058 #[test]
2059 fn a_picture_that_says_where_it_lives_keeps_it() {
2060 let out = render("= T\n:imagesdir: pics\n\nimage::https://example.com/f.png[]\n");
2061
2062 assert!(out.contains("[https:\\/\\/example.com\\/f.png]"), "{out}");
2063 }
2064
2065 #[test]
2066 fn takes_a_page_break_literally() {
2067 let out = render("= T\n\none\n\n<<<\n\ntwo\n");
2068
2069 assert!(out.contains("#pagebreak()"), "{out}");
2070 }
2071
2072 #[test]
2073 fn keeps_a_cross_reference_and_unwraps_a_mark() {
2074 let out = render("= T\n\n[[here]]\n== Here\n\nSee <<here>>, and a note.footnote:[Why.]\n");
2075
2076 assert!(out.contains("#link(label(\"here\"))"), "{out}");
2077
2078 assert!(!out.contains("_footnotedef_"), "{out}");
2081 assert!(out.contains("Why."), "{out}");
2082 }
2083
2084 #[test]
2085 fn a_sections_label_belongs_to_its_heading() {
2086 let out = render("= T\n\nProse.\n\n[[here]]\n== Here\n");
2089
2090 assert!(out.contains("= Here <here>"), "{out}");
2091 assert!(!out.contains("#label(\"here\")\n= Here"), "{out}");
2092 }
2093
2094 #[test]
2095 fn an_anchored_term_can_be_reached() {
2096 let out =
2097 render("= T\n\nSee <<block,a block>>.\n\n[glossary]\n[[block]]block:: A shape.\n");
2098
2099 assert!(out.contains("#box[]#label(\"block\")"), "the anchor: {out}");
2100 assert!(
2101 out.contains("#link(label(\"block\"))[a block]"),
2102 "and the reference to it: {out}"
2103 );
2104 }
2105
2106 #[test]
2107 fn a_reference_to_nothing_keeps_its_words() {
2108 let out = render("= T\n\nSee <<nowhere>>.\n");
2109
2110 assert!(!out.contains("#link"), "{out}");
2111 assert!(out.contains("nowhere"), "{out}");
2112 }
2113
2114 #[test]
2115 fn typesets_both_notations() {
2116 let out = render("= T\n:stem: latexmath\n\n[stem]\n++++\n\\frac{a}{b}\n++++\n");
2117
2118 assert!(out.contains("$ frac(a ,b ) $"), "{out}");
2119
2120 let ascii = render("= T\n\n[stem]\n++++\nsqrt(4)\n++++\n");
2123
2124 if cfg!(feature = "math") {
2125 assert!(ascii.contains("$ sqrt(4) $"), "{ascii}");
2126 } else {
2127 assert!(ascii.contains("#raw(\"sqrt(4)\")"), "{ascii}");
2128 }
2129 }
2130
2131 #[test]
2132 fn typesets_an_equation_in_a_line_of_text() {
2133 let out = render("= T\n:stem: latexmath\n\nA line with stem:[x^2] in it.\n");
2134
2135 assert!(out.contains("$x ^(2 )$"), "{out}");
2136 }
2137
2138 #[test]
2139 fn falls_back_to_the_source_when_an_equation_will_not_typeset() {
2140 let mut parser = Parser::default();
2143 let document = parser.parse("= T\n:stem: latexmath\n\n[stem]\n++++\n\\hbar\\omega\n++++\n");
2144
2145 let pdf = pdf(&document, Path::new("."), &options()).expect("still makes a PDF");
2146
2147 assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
2148
2149 assert!(
2152 pdf.fallback.is_some_and(|said| said.contains("equation")),
2153 "the fallback should be reported"
2154 );
2155 }
2156
2157 #[test]
2158 fn compiles_to_a_pdf() {
2159 let mut parser = Parser::default();
2160 let document = parser.parse("= Title\n\n== Section\n\nProse with *bold*.\n\n* a\n* b\n");
2161
2162 let pdf = pdf(&document, Path::new("."), &options()).expect("compiles");
2163
2164 assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
2165 assert!(pdf.bytes.len() > 1000, "should have content");
2166 assert!(pdf.fallback.is_none(), "nothing should have been given up");
2167 }
2168}