Skip to main content

secunit_core/wisp/
markdown.rs

1//! Markdown → Typst conversion, driven by `pulldown-cmark`.
2//!
3//! Policy documents are arbitrary prose: they contain `$`, `@`, `#`, tables,
4//! and other characters that are syntactically meaningful in Typst markup. So
5//! we don't pass markdown through as Typst — we parse it into events and emit
6//! Typst, escaping every text run. Headings become `=` so Typst's native
7//! `outline()` builds the table of contents and PDF bookmarks; emphasis,
8//! lists, code, blockquotes, links, rules, and tables map to their Typst forms.
9//!
10//! Block separation is explicit: every block element (heading, list, table,
11//! blockquote, code block) is preceded by a blank line so Typst never folds a
12//! list into the paragraph above it. The WISP also uses `**Bold**` lines as
13//! ad-hoc sub-headings; a paragraph that is nothing but one bold run is
14//! promoted to a real (level-4) heading so it gains weight and spacing instead
15//! of bleeding into the following list.
16
17use std::collections::{HashMap, HashSet};
18
19use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
20
21/// Convert a markdown document to Typst markup.
22///
23/// `sections` carries the relative names of the assembled source files. A link
24/// whose target is one of those files (e.g. the policy index's
25/// `[Access Control Policy](access-control-policy.md)`) is rewired into an
26/// internal cross-reference to that section's heading, so it jumps within the
27/// PDF instead of trying to open a sibling `.md` file. Section anchors are
28/// placed by the `<!--wisp:anchor …-->` markers that assembly injects ahead of
29/// each file.
30pub fn to_typst(markdown: &str, sections: &[String]) -> String {
31    // Per-section unique labels (collision-resolved, deterministic by order)
32    // and a basename→label map for resolving `.md` links to the right anchor.
33    let slugs = section_slugs(sections);
34    let known_slugs: HashSet<&str> = slugs.iter().map(String::as_str).collect();
35    let link_map: HashMap<&str, &str> = sections
36        .iter()
37        .map(|s| basename(s))
38        .zip(slugs.iter().map(String::as_str))
39        .collect();
40    // Anchors already emitted, so a duplicate/forged marker can't produce a
41    // second label with the same name (which Typst rejects).
42    let mut emitted_anchors: HashSet<String> = HashSet::new();
43
44    let mut opts = Options::empty();
45    opts.insert(Options::ENABLE_TABLES);
46    opts.insert(Options::ENABLE_STRIKETHROUGH);
47    opts.insert(Options::ENABLE_FOOTNOTES);
48
49    let mut out = String::with_capacity(markdown.len() * 2);
50    let mut list_stack: Vec<Option<u64>> = Vec::new();
51
52    // Table accumulation.
53    let mut row_cells: Vec<String> = Vec::new();
54    let mut cell_buf: Option<String> = None;
55    let mut table_rows: Vec<Vec<String>> = Vec::new();
56
57    // Top-level paragraph buffering, used to detect "bold-only" pseudo-headings.
58    let mut para_buf: Option<String> = None;
59    let mut strong_depth: u32 = 0;
60    let mut para_strong_runs: u32 = 0;
61    let mut para_has_plain: bool = false;
62
63    // Inside a fenced code block, text is emitted verbatim (never escaped).
64    let mut in_code = false;
65
66    for event in Parser::new_ext(markdown, opts) {
67        match event {
68            // ---- tables: capture cells, emit on table end ------------------
69            Event::Start(Tag::Table(_)) => table_rows.clear(),
70            Event::End(TagEnd::Table) => {
71                ensure_blank(&mut out);
72                emit_table(&mut out, &table_rows);
73                table_rows.clear();
74            }
75            Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => row_cells.clear(),
76            Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
77                table_rows.push(std::mem::take(&mut row_cells))
78            }
79            Event::Start(Tag::TableCell) => cell_buf = Some(String::new()),
80            Event::End(TagEnd::TableCell) => row_cells.push(cell_buf.take().unwrap_or_default()),
81
82            // ---- headings --------------------------------------------------
83            Event::Start(Tag::Heading { level, .. }) => {
84                ensure_blank(&mut out);
85                for _ in 0..heading_depth(level) {
86                    out.push('=');
87                }
88                out.push(' ');
89            }
90            Event::End(TagEnd::Heading(_)) => out.push_str("\n\n"),
91
92            // ---- paragraphs (buffered only at the top level) ---------------
93            Event::Start(Tag::Paragraph) if list_stack.is_empty() && cell_buf.is_none() => {
94                para_buf = Some(String::new());
95                para_strong_runs = 0;
96                para_has_plain = false;
97            }
98            Event::Start(Tag::Paragraph) => {}
99            Event::End(TagEnd::Paragraph) => {
100                if let Some(buf) = para_buf.take() {
101                    let trimmed = buf.trim();
102                    ensure_blank(&mut out);
103                    if para_strong_runs == 1 && !para_has_plain && is_wrapped_bold(trimmed) {
104                        // `**Sub-heading**` on its own line → real sub-heading.
105                        out.push_str("==== ");
106                        out.push_str(trimmed[1..trimmed.len() - 1].trim());
107                    } else {
108                        out.push_str(trimmed);
109                    }
110                    out.push_str("\n\n");
111                }
112                // Paragraphs inside list items emit nothing here; the item's
113                // inline text already went straight to `out`.
114            }
115
116            // ---- lists -----------------------------------------------------
117            Event::Start(Tag::List(start)) => {
118                if list_stack.is_empty() {
119                    ensure_blank(&mut out);
120                } else {
121                    ensure_newline(&mut out);
122                }
123                list_stack.push(start);
124            }
125            Event::End(TagEnd::List(_)) => {
126                list_stack.pop();
127                if list_stack.is_empty() {
128                    ensure_blank(&mut out);
129                }
130            }
131            Event::Start(Tag::Item) => {
132                ensure_newline(&mut out);
133                for _ in 0..list_stack.len().saturating_sub(1) {
134                    out.push_str("  ");
135                }
136                match list_stack.last() {
137                    // Typst auto-numbers `+`, so repeated `1.` in the source
138                    // still renders 1, 2, 3 — and nested enums get a, b / i, ii.
139                    Some(Some(_)) => out.push_str("+ "),
140                    _ => out.push_str("- "),
141                }
142            }
143            Event::End(TagEnd::Item) => ensure_newline(&mut out),
144
145            // ---- blockquote / code / rule ---------------------------------
146            Event::Start(Tag::BlockQuote(_)) => {
147                ensure_blank(&mut out);
148                out.push_str("#quote(block: true)[\n");
149            }
150            Event::End(TagEnd::BlockQuote(_)) => out.push_str("]\n\n"),
151            Event::Start(Tag::CodeBlock(kind)) => {
152                ensure_blank(&mut out);
153                let lang = match kind {
154                    CodeBlockKind::Fenced(l) if !l.is_empty() => l.to_string(),
155                    _ => String::new(),
156                };
157                out.push_str("```");
158                out.push_str(&lang);
159                out.push('\n');
160                in_code = true;
161            }
162            Event::End(TagEnd::CodeBlock) => {
163                in_code = false;
164                out.push_str("```\n\n");
165            }
166            Event::Rule => {
167                ensure_blank(&mut out);
168                out.push_str("#line(length: 100%)\n\n");
169            }
170
171            // ---- inline ----------------------------------------------------
172            Event::Start(Tag::Emphasis) | Event::End(TagEnd::Emphasis) => {
173                sink(&mut out, &mut cell_buf, &mut para_buf).push('_')
174            }
175            Event::Start(Tag::Strong) => {
176                strong_depth += 1;
177                if para_buf.is_some() && strong_depth == 1 {
178                    para_strong_runs += 1;
179                }
180                sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
181            }
182            Event::End(TagEnd::Strong) => {
183                strong_depth = strong_depth.saturating_sub(1);
184                sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
185            }
186            Event::Start(Tag::Strikethrough) => {
187                sink(&mut out, &mut cell_buf, &mut para_buf).push_str("#strike[")
188            }
189            Event::End(TagEnd::Strikethrough) => {
190                sink(&mut out, &mut cell_buf, &mut para_buf).push(']')
191            }
192            Event::Start(Tag::Link { dest_url, .. }) => {
193                let internal = link_target_slug(&dest_url, &link_map);
194                let s = sink(&mut out, &mut cell_buf, &mut para_buf);
195                match internal {
196                    // Internal cross-reference: link by label, no quotes.
197                    Some(slug) => {
198                        s.push_str("#link(<");
199                        s.push_str(slug);
200                        s.push_str(">)[");
201                    }
202                    None => {
203                        s.push_str("#link(\"");
204                        s.push_str(&escape_typst_string(&dest_url));
205                        s.push_str("\")[");
206                    }
207                }
208            }
209            Event::End(TagEnd::Link) => sink(&mut out, &mut cell_buf, &mut para_buf).push(']'),
210            Event::Code(text) => {
211                // Emit via `#raw("…")` rather than backtick markup: the string
212                // form carries any character faithfully, including interior
213                // backticks (which backtick-delimited raw cannot contain).
214                let s = sink(&mut out, &mut cell_buf, &mut para_buf);
215                s.push_str("#raw(\"");
216                s.push_str(&escape_typst_string(&text));
217                s.push_str("\")");
218            }
219            Event::Text(text) => {
220                if in_code {
221                    // Verbatim inside a raw block — Typst does not interpret
222                    // markup between the ``` fences.
223                    out.push_str(&text);
224                } else {
225                    if para_buf.is_some() && strong_depth == 0 && !text.trim().is_empty() {
226                        para_has_plain = true;
227                    }
228                    sink(&mut out, &mut cell_buf, &mut para_buf).push_str(&escape_markup(&text));
229                }
230            }
231            Event::SoftBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push(' '),
232            Event::HardBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push_str(" \\\n"),
233
234            Event::Html(text) | Event::InlineHtml(text) => {
235                if let Some(slug) = parse_anchor_marker(&text) {
236                    // Emit a zero-size labelled anchor at the section start, so
237                    // the label always exists even when the section does not
238                    // open with a heading. Unknown (forged) or already-emitted
239                    // slugs are ignored to avoid duplicate-label compile errors.
240                    if known_slugs.contains(slug.as_str()) && emitted_anchors.insert(slug.clone()) {
241                        ensure_blank(&mut out);
242                        out.push_str("#metadata(none) <");
243                        out.push_str(&slug);
244                        out.push_str(">\n\n");
245                    }
246                }
247            }
248            _ => {}
249        }
250    }
251
252    // Collapse any run of 3+ newlines to a single blank line.
253    normalize_blanks(&out)
254}
255
256/// Pick the active inline sink: a table cell, else a buffered paragraph, else
257/// the main output.
258fn sink<'a>(
259    out: &'a mut String,
260    cell: &'a mut Option<String>,
261    para: &'a mut Option<String>,
262) -> &'a mut String {
263    if let Some(c) = cell.as_mut() {
264        c
265    } else if let Some(p) = para.as_mut() {
266        p
267    } else {
268        out
269    }
270}
271
272fn heading_depth(level: HeadingLevel) -> usize {
273    match level {
274        HeadingLevel::H1 => 1,
275        HeadingLevel::H2 => 2,
276        HeadingLevel::H3 => 3,
277        HeadingLevel::H4 => 4,
278        HeadingLevel::H5 => 5,
279        HeadingLevel::H6 => 6,
280    }
281}
282
283/// True if `s` is `*...*` with no interior unescaped `*` — i.e. a single bold
284/// run wrapping the whole string.
285fn is_wrapped_bold(s: &str) -> bool {
286    s.len() > 2 && s.starts_with('*') && s.ends_with('*') && !s[1..s.len() - 1].contains('*')
287}
288
289/// Ensure `s` ends with a blank line (`\n\n`) unless it is empty.
290fn ensure_blank(s: &mut String) {
291    if s.is_empty() {
292        return;
293    }
294    while s.ends_with([' ', '\t', '\n']) {
295        s.pop();
296    }
297    if !s.is_empty() {
298        s.push_str("\n\n");
299    }
300}
301
302/// Ensure `s` ends with at least one newline (no trailing spaces) unless empty.
303fn ensure_newline(s: &mut String) {
304    while s.ends_with([' ', '\t']) {
305        s.pop();
306    }
307    if !s.is_empty() && !s.ends_with('\n') {
308        s.push('\n');
309    }
310}
311
312/// Collapse 3+ consecutive newlines into exactly two, and trim leading blanks.
313fn normalize_blanks(s: &str) -> String {
314    let mut out = String::with_capacity(s.len());
315    let mut newlines = 0u32;
316    for ch in s.chars() {
317        if ch == '\n' {
318            newlines += 1;
319            if newlines <= 2 {
320                out.push('\n');
321            }
322        } else {
323            newlines = 0;
324            out.push(ch);
325        }
326    }
327    out.trim_start_matches('\n').to_string()
328}
329
330/// Emit a Typst `table(...)` from collected rows (first row treated as header).
331fn emit_table(out: &mut String, rows: &[Vec<String>]) {
332    if rows.is_empty() {
333        return;
334    }
335    let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
336    use std::fmt::Write as _;
337    out.push_str("#table(\n");
338    let _ = writeln!(out, "  columns: {cols},");
339    out.push_str("  inset: 6pt,\n  align: left + top,\n  stroke: 0.5pt + luma(180),\n");
340    for (i, row) in rows.iter().enumerate() {
341        out.push_str("  ");
342        for c in 0..cols {
343            let cell = row.get(c).map(String::as_str).unwrap_or("").trim();
344            if i == 0 {
345                let _ = write!(out, "[*{cell}*], ");
346            } else {
347                let _ = write!(out, "[{cell}], ");
348            }
349        }
350        out.push('\n');
351    }
352    out.push_str(")\n\n");
353}
354
355/// Escape a text run for Typst *markup* mode: prefix every character that
356/// could start markup/code with a backslash. Conservative but safe for prose.
357fn escape_markup(s: &str) -> String {
358    let mut out = String::with_capacity(s.len() + 8);
359    for ch in s.chars() {
360        match ch {
361            '\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '=' | '[' | ']' => {
362                out.push('\\');
363                out.push(ch);
364            }
365            _ => out.push(ch),
366        }
367    }
368    out
369}
370
371/// Escape for a Typst double-quoted string literal (e.g. link URLs, `#raw`).
372/// Shared with `typst_emit` so both escape values identically.
373pub(crate) fn escape_typst_string(s: &str) -> String {
374    s.replace('\\', "\\\\").replace('"', "\\\"")
375}
376
377/// The final path segment of `name` (its basename), used to match a link's
378/// target file against the assembled sections.
379fn basename(name: &str) -> &str {
380    name.rsplit(['/', '\\']).next().unwrap_or(name)
381}
382
383/// Per-section unique Typst labels, one per entry in `sections`, in order.
384/// Derived from [`section_slug`] with collisions resolved by a `-2`, `-3`, …
385/// suffix so two files that normalise to the same base slug still get distinct,
386/// non-conflicting labels. Deterministic for a given ordered `sections`, so the
387/// anchor side (assembly) and the link side (conversion) agree.
388pub fn section_slugs(sections: &[String]) -> Vec<String> {
389    let mut used: HashSet<String> = HashSet::new();
390    let mut out = Vec::with_capacity(sections.len());
391    for name in sections {
392        let base = section_slug(name);
393        let mut slug = base.clone();
394        let mut n = 2u32;
395        while !used.insert(slug.clone()) {
396            slug = format!("{base}-{n}");
397            n += 1;
398        }
399        out.push(slug);
400    }
401    out
402}
403
404/// Stable Typst label for a section, derived from its file name: the stem,
405/// lowercased with every non-alphanumeric run collapsed to a single `-`, under
406/// a `wisp-` namespace (e.g. `access-control-policy.md` → `wisp-access-control-policy`).
407/// May collide for unusual names; [`section_slugs`] disambiguates.
408pub fn section_slug(name: &str) -> String {
409    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
410    let stem = base
411        .strip_suffix(".md")
412        .or_else(|| base.strip_suffix(".markdown"))
413        .unwrap_or(base);
414    let mut slug = String::from("wisp-");
415    let mut prev_dash = false;
416    for ch in stem.chars() {
417        if ch.is_ascii_alphanumeric() {
418            slug.push(ch.to_ascii_lowercase());
419            prev_dash = false;
420        } else if !prev_dash {
421            slug.push('-');
422            prev_dash = true;
423        }
424    }
425    slug.trim_end_matches('-').to_string()
426}
427
428/// If `dest` points at one of the assembled sections (matched by basename),
429/// return that section's label. Only same-document `.md` references qualify;
430/// absolute URLs, `mailto:`, and bare `#fragment` links stay external.
431fn link_target_slug<'a>(dest: &str, link_map: &HashMap<&str, &'a str>) -> Option<&'a str> {
432    if dest.contains("://") || dest.starts_with("mailto:") || dest.starts_with('#') {
433        return None;
434    }
435    let path = dest.split('#').next().unwrap_or(dest);
436    link_map.get(basename(path)).copied()
437}
438
439/// Parse a `<!--wisp:anchor SLUG-->` marker, returning the slug.
440fn parse_anchor_marker(html: &str) -> Option<String> {
441    let inner = html
442        .trim()
443        .strip_prefix("<!--wisp:anchor ")?
444        .strip_suffix("-->")?;
445    let slug = inner.trim();
446    (!slug.is_empty()).then(|| slug.to_string())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    /// Convert with no known sections (the common case for these unit tests).
454    fn to_typst_default(markdown: &str) -> String {
455        to_typst(markdown, &[])
456    }
457
458    #[test]
459    fn converts_atx_headings() {
460        let typ = to_typst_default("# Title\n\nbody\n\n## Section\n");
461        assert!(typ.contains("= Title"));
462        assert!(typ.contains("== Section"));
463        assert!(typ.contains("body"));
464    }
465
466    #[test]
467    fn escapes_typst_metacharacters_in_prose() {
468        let typ = to_typst_default("Email me @ test or pay $5 to #1\n");
469        assert!(typ.contains("\\@"));
470        assert!(typ.contains("\\$5"));
471        assert!(typ.contains("\\#1"));
472    }
473
474    #[test]
475    fn list_is_separated_from_preceding_paragraph() {
476        // The bug that produced inline "wall of text": a list right under a
477        // paragraph must be preceded by a blank line.
478        let typ = to_typst_default("Some intro.\n- one\n- two\n");
479        assert!(
480            typ.contains("intro.\n\n- one"),
481            "list not blank-separated: {typ:?}"
482        );
483    }
484
485    #[test]
486    fn bold_only_paragraph_becomes_subheading() {
487        let typ = to_typst_default("**General Use and Ownership**\n\n- a\n- b\n");
488        assert!(
489            typ.contains("==== General Use and Ownership"),
490            "bold pseudo-heading not promoted: {typ:?}"
491        );
492    }
493
494    #[test]
495    fn inline_emphasis_is_not_promoted() {
496        // `*important*` is CommonMark emphasis → Typst `_italic_`, and an inline
497        // run must never be promoted to a sub-heading.
498        let typ = to_typst_default("This is *important* text.\n");
499        assert!(
500            !typ.contains("===="),
501            "inline emphasis wrongly promoted: {typ:?}"
502        );
503        assert!(typ.contains("_important_"), "emphasis not mapped: {typ:?}");
504    }
505
506    #[test]
507    fn inline_strong_is_not_promoted() {
508        // `**bold**` mid-sentence has surrounding plain text, so it must stay
509        // inline (`*bold*`) and not become a `====` sub-heading.
510        let typ = to_typst_default("This is **bold** text.\n");
511        assert!(
512            !typ.contains("===="),
513            "inline strong wrongly promoted: {typ:?}"
514        );
515        assert!(typ.contains("*bold*"), "strong not mapped: {typ:?}");
516    }
517
518    #[test]
519    fn renders_a_table() {
520        let md = "| Field | Value |\n| --- | --- |\n| Owner | CTO |\n";
521        let typ = to_typst_default(md);
522        assert!(typ.contains("#table("));
523        assert!(typ.contains("columns: 2"));
524        assert!(typ.contains("[*Field*]"));
525        assert!(typ.contains("[Owner]"));
526    }
527
528    #[test]
529    fn preserves_code_blocks() {
530        let typ = to_typst_default("```rust\nlet x = 1;\n```\n");
531        assert!(typ.contains("```rust"));
532        assert!(typ.contains("let x = 1;"));
533    }
534
535    #[test]
536    fn no_triple_newlines() {
537        let typ = to_typst_default("# H\n\n\n\npara\n");
538        assert!(!typ.contains("\n\n\n"), "blank runs not collapsed: {typ:?}");
539    }
540
541    #[test]
542    fn section_slug_is_namespaced_and_stable() {
543        assert_eq!(
544            section_slug("access-control-policy.md"),
545            "wisp-access-control-policy"
546        );
547        assert_eq!(section_slug("security/README.md"), "wisp-readme");
548        assert_eq!(section_slug("foo_bar.markdown"), "wisp-foo-bar");
549    }
550
551    #[test]
552    fn rewrites_md_links_to_internal_anchors() {
553        // The policy index links to a sibling .md file that is also an assembled
554        // section: the link becomes an internal cross-reference, and the section
555        // gets a standalone label anchor from its marker.
556        let sections = vec!["access-control-policy.md".to_string()];
557        let body = "<!--wisp:anchor wisp-access-control-policy-->\n\n\
558                    # Access Control Policy\n\n\
559                    See the [ACP](access-control-policy.md#scope) for details.\n";
560        let typ = to_typst(body, &sections);
561        assert!(
562            typ.contains("#metadata(none) <wisp-access-control-policy>"),
563            "section anchor not emitted: {typ}"
564        );
565        assert!(
566            typ.contains("#link(<wisp-access-control-policy>)[ACP]"),
567            "md link not rewired to internal ref: {typ}"
568        );
569        assert!(
570            !typ.contains("#link(\"access-control-policy"),
571            "internal link still emitted as a file URL: {typ}"
572        );
573    }
574
575    #[test]
576    fn anchor_is_emitted_even_without_a_heading() {
577        // A section whose first block is not a heading must still get its label,
578        // otherwise a link to it would reference a non-existent Typst label.
579        let sections = vec!["intro.md".to_string()];
580        let body = "<!--wisp:anchor wisp-intro-->\n\nJust a paragraph, no heading.\n";
581        let typ = to_typst(body, &sections);
582        assert!(
583            typ.contains("#metadata(none) <wisp-intro>"),
584            "heading-less section lost its anchor: {typ}"
585        );
586    }
587
588    #[test]
589    fn forged_or_unknown_anchor_markers_are_ignored() {
590        // A marker whose slug is not a known section (e.g. authored in prose)
591        // must not emit a stray label.
592        let sections = vec!["intro.md".to_string()];
593        let body = "<!--wisp:anchor wisp-intro-->\n\n# Intro\n\n\
594                    <!--wisp:anchor wisp-not-a-section-->\n\nBody.\n";
595        let typ = to_typst(body, &sections);
596        assert!(typ.contains("#metadata(none) <wisp-intro>"));
597        assert!(
598            !typ.contains("wisp-not-a-section"),
599            "forged anchor leaked: {typ}"
600        );
601    }
602
603    #[test]
604    fn section_slugs_disambiguate_collisions() {
605        // `-` and `_` both collapse to the same base slug; the second gets a
606        // numeric suffix so the two labels never clash.
607        let s = section_slugs(&["access-control.md".into(), "access_control.md".into()]);
608        assert_eq!(s, vec!["wisp-access-control", "wisp-access-control-2"]);
609    }
610
611    #[test]
612    fn external_and_unknown_links_stay_literal() {
613        let sections = vec!["access-control-policy.md".to_string()];
614        // http(s) URLs and .md files that are NOT assembled sections stay as
615        // ordinary `#link("…")` targets.
616        let typ = to_typst(
617            "[site](https://example.com) and [rb](runbooks/restore.md)\n",
618            &sections,
619        );
620        assert!(
621            typ.contains("#link(\"https://example.com\")[site]"),
622            "{typ}"
623        );
624        assert!(typ.contains("#link(\"runbooks/restore.md\")[rb]"), "{typ}");
625    }
626}