///|
pub fn markdown_block_to_rich_text(
  block : MarkdownBlock,
  base : @core.FontSpec,
  base_dir? : String? = None,
) -> RichTextBlock {
  match block.kind {
    Heading(level) => {
      let font = {
        ..base,
        size: base.size * markdown_heading_size_scale(level),
        weight: 700,
      }
      // GitHub-like rhythm: big titles get a full blank line above and half
      // below; smaller headings keep the tighter Typora-style spacing.
      let inset : @core.Insets = if level <= 2 {
        {
          top: base.size * 1.0,
          right: 0.0,
          bottom: base.size * 0.5,
          left: 0.0,
        }
      } else {
        {
          top: base.size * 0.6,
          right: 0.0,
          bottom: base.size * 0.3,
          left: 0.0,
        }
      }
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, font, base_dir~),
        font=Some(font),
        line_height=Some(base.size * markdown_heading_line_height_scale(level)),
        inset~,
      )
    }
    UnorderedListItem =>
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        prefix="• ",
        line_height=Some(base.size * 1.55),
        // Typora-style breathing room between list items; the last item of
        // a list gets paragraph spacing from the following blank-gap block.
        inset={ top: 0.0, right: 0.0, bottom: base.size * 0.25, left: 0.0, },
      )
    TaskListItem(checked) =>
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        // One placeholder char keeps prefix-indexed caret, selection, and
        // hit-test arithmetic intact; the painter draws the vector checkbox at
        // the lane width instead of this glyph.
        prefix=" ",
        marker=Some(TaskCheckbox(checked)),
        line_height=Some(base.size * 1.55),
        inset={ top: 0.0, right: 0.0, bottom: base.size * 0.25, left: 0.0, },
      )
    OrderedListItem(number) =>
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        prefix="\{number}. ",
        line_height=Some(base.size * 1.55),
        inset={ top: 0.0, right: 0.0, bottom: base.size * 0.25, left: 0.0, },
      )
    Blockquote =>
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        color=Some(@core.Color::black().multiply_alpha(0.82)),
        background=Some(
          @core.Brush::solid(@core.Color::rgba(r=0.12, g=0.13, b=0.15, a=0.05)),
        ),
        border_left=Some(
          @core.BorderStyle::new(
            brush=@core.Brush::solid(@core.Color::black().multiply_alpha(0.32)),
            width=4.0,
          ),
        ),
        inset={ top: 6.0, right: 0.0, bottom: 6.0, left: 14.0, },
        line_height=Some(base.size * 1.55),
        corner_radius=4.0,
      )
    CodeBlock =>
      RichTextBlock::new(
        runs=markdown_code_block_runs(block, base),
        background=Some(
          @core.Brush::solid(@core.Color::rgba(r=0.12, g=0.13, b=0.15, a=0.06)),
        ),
        inset={ top: 12.0, right: 14.0, bottom: 0.0, left: 14.0, },
        // 1.3x the base font is 1.4x the 0.92 code font, so descenders in code
        // stay inside the line box the painter clips to.
        line_height=Some(base.size * 1.3),
        corner_radius=8.0,
      )
    HorizontalRule =>
      RichTextBlock::new(runs=[], line_height=Some(base.size * 1.0), rule=true)
    Table =>
      RichTextBlock::new(
        runs=[
          RichTextRun::new(
            text=block.text,
            font=Some({
              ..base,
              families: @core.FontFamilyStack::monospace(),
              size: base.size * 0.92,
            }),
          ),
        ],
        table=markdown_table_preview(block, base),
        background=Some(
          @core.Brush::solid(@core.Color::rgba(r=0.08, g=0.12, b=0.16, a=0.05)),
        ),
        inset={ top: 6.0, right: 10.0, bottom: 6.0, left: 10.0, },
        line_height=Some(base.size * 1.35),
        corner_radius=6.0,
      )
    FootnoteDefinition(label) =>
      RichTextBlock::new(
        runs=[
          RichTextRun::new(
            text="[^" + label + "]: ",
            visual_text=Some(label),
            font=Some({ ..base, size: base.size * 0.78, weight: 700, }),
            color=Some(@core.Color::black().multiply_alpha(0.58)),
            background=Some(
              @core.Brush::solid(
                @core.Color::rgba(r=0.18, g=0.4, b=0.7, a=0.08),
              ),
            ),
            background_radius=4.0,
          ),
          ..markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        ],
        color=Some(@core.Color::black().multiply_alpha(0.82)),
        border_left=Some(
          @core.BorderStyle::new(
            brush=@core.Brush::solid(@core.Color::black().multiply_alpha(0.22)),
            width=3.0,
          ),
        ),
        inset={ top: 2.0, right: 0.0, bottom: 2.0, left: 12.0, },
        line_height=Some(base.size * 1.45),
      )
    HtmlBlock =>
      match markdown_block_view_project(block, base, base_dir~) {
        Some(projection) =>
          RichTextBlock::new(
            runs=projection.runs,
            // Generous outer insets so consecutive html blocks (a badge row
            // followed by another badge block) read as separate lines.
            inset={ top: 8.0, right: 0.0, bottom: 10.0, left: 0.0, },
            line_height=Some(base.size * 1.45),
            line_align=projection.line_align,
          )
        None => markdown_html_raw_block_rich_text(block.text, base)
      }
    FrontMatter =>
      RichTextBlock::new(
        runs=[
          RichTextRun::new(
            text=block.text,
            font=Some({
              ..base,
              families: @core.FontFamilyStack::monospace(),
              size: base.size * 0.9,
            }),
            color=Some(@core.Color::black().multiply_alpha(0.74)),
          ),
        ],
        background=Some(
          @core.Brush::solid(@core.Color::rgba(r=0.20, g=0.28, b=0.36, a=0.07)),
        ),
        inset={ top: 6.0, right: 10.0, bottom: 6.0, left: 10.0, },
        line_height=Some(base.size * 1.25),
        corner_radius=6.0,
      )
    Paragraph =>
      RichTextBlock::new(
        runs=markdown_inlines_to_rich_text(block.inlines, base, base_dir~),
        line_height=Some(base.size * 1.55),
        // Paragraph spacing below for readability between blocks.
        inset={ top: 0.0, right: 0.0, bottom: base.size * 0.4, left: 0.0, },
      )
  }
}

///|
/// Keep all HTML outside the explicit image-gallery subset visibly raw. This
/// preserves the existing safe fallback instead of turning the rich-text view
/// into a general HTML renderer.
fn markdown_html_raw_block_rich_text(
  html : String,
  base : @core.FontSpec,
) -> RichTextBlock {
  RichTextBlock::new(
    runs=[
      RichTextRun::new(
        text=html,
        font=Some({
          ..base,
          families: @core.FontFamilyStack::monospace(),
          size: base.size * 0.9,
        }),
        color=Some(@core.Color::black().multiply_alpha(0.7)),
      ),
    ],
    background=Some(
      @core.Brush::solid(@core.Color::rgba(r=0.12, g=0.12, b=0.12, a=0.06)),
    ),
    inset={ top: 6.0, right: 10.0, bottom: 6.0, left: 10.0, },
    // ~1.67x the monospace run size (editor-style leading) so multi-line
    // markup reads line by line instead of stacking on itself.
    line_height=Some(base.size * 1.5),
    corner_radius=6.0,
  )
}

///|
fn markdown_code_block_runs(
  block : MarkdownBlock,
  base : @core.FontSpec,
) -> Array[RichTextRun] {
  let font = {
    ..base,
    families: @core.FontFamilyStack::monospace(),
    size: base.size * 0.92,
  }
  match block.code_language {
    Some(language) => {
      let runs : Array[RichTextRun] = [
        RichTextRun::new(
          text=language,
          font=Some({ ..font, size: base.size * 0.78, weight: 700, }),
          visual_text=Some(language),
          color=Some(@core.Color::black().multiply_alpha(0.56)),
        ),
        RichTextRun::new(text="\n", font=Some(font)),
      ]
      // Apply syntax highlighting when the language is supported; otherwise emit
      // the source as a single plain run.
      if code_highlight_supports_language(language) {
        for span in code_highlight_tokenize(block.text, language) {
          let color = if span.kind is CodePlain {
            None
          } else {
            Some(code_highlight_color(span.kind))
          }
          runs.push(RichTextRun::new(text=span.text, font=Some(font), color~))
        }
      } else {
        runs.push(RichTextRun::new(text=block.text, font=Some(font)))
      }
      runs
    }
    None => [RichTextRun::new(text=block.text, font=Some(font))]
  }
}

///|
fn markdown_heading_size_scale(level : Int) -> Double {
  if level <= 1 {
    1.95
  } else if level == 2 {
    1.5
  } else if level == 3 {
    1.25
  } else if level == 4 {
    1.0
  } else if level == 5 {
    0.94
  } else {
    0.9
  }
}

///|
/// Editor-style leading for a heading line box, expressed against the heading's
/// own font size. The painter clips every run to its line box, so a box shorter
/// than the font's own line height slices the glyph bottoms: with the old
/// base-relative factors the level-2 heading box was 1.1x its font and the
/// descender of "Today" was cut off. Every heading therefore gets this leading
/// over its scaled size, which also covers CJK fallback faces whose ascent plus
/// descent is taller than a Latin font's.
fn markdown_heading_line_height_scale(level : Int) -> Double {
  markdown_heading_size_scale(level) * 1.4
}

///|
fn markdown_inlines_to_rich_text(
  inlines : Array[MarkdownInline],
  base : @core.FontSpec,
  base_dir? : String? = None,
) -> Array[RichTextRun] {
  let runs : Array[RichTextRun] = []
  for span in inlines {
    runs.push(
      RichTextRun::new(
        text=markdown_inline_source_text(span),
        visual_text=Some(markdown_inline_visual_text(span)),
        image_source=markdown_inline_image_source(span, base_dir~),
        image_size=markdown_inline_image_size(span, base, base_dir~),
        font=Some(markdown_inline_font(span.kind, base)),
        color=markdown_inline_color(span.kind),
        background=markdown_inline_background(span.kind),
        decoration=markdown_inline_decoration(span.kind),
      ),
    )
  }
  runs
}

///|
/// Visual text keeps soft breaks as real newlines: paint, block heights,
/// caret geometry, and hit-test all split on them, so a single newline in
/// the source shows as its own line instead of silently joining into one
/// run-on line with a space.
fn markdown_inline_visual_text(inline : MarkdownInline) -> String {
  match inline.kind {
    Image => markdown_inline_source_text(inline)
    FootnoteReference => inline.text
    _ => inline.text
  }
}

///|
fn markdown_inline_image_source(
  inline : MarkdownInline,
  base_dir? : String? = None,
) -> String? {
  match inline.kind {
    Image =>
      match inline.target {
        Some(target) => Some(markdown_resolve_image_source(target, base_dir))
        None => None
      }
    _ => None
  }
}

///|
/// Resolve a markdown image target against the document's directory. Absolute
/// paths, `data:`/`http:`/`https:` URIs and absolute POSIX paths are returned
/// unchanged; relative paths are joined to `base_dir` (the directory of the
/// open `.md` file) so the renderer — which resolves `DrawImage` sources
/// against the process cwd — can find images sitting next to the document.
fn markdown_resolve_image_source(target : String, base_dir : String?) -> String {
  match base_dir {
    Some(dir) =>
      if dir == "" || markdown_target_is_absolute(target) {
        target
      } else {
        markdown_join_path(dir, target)
      }
    None => target
  }
}

///|
/// True for sources that should not be re-rooted: data/URL URIs and absolute
/// POSIX/Windows paths.
fn markdown_target_is_absolute(target : String) -> Bool {
  target.has_prefix("data:") ||
  target.has_prefix("http://") ||
  target.has_prefix("https://") ||
  target.has_prefix("file://") ||
  target.has_prefix("/") ||
  target.has_prefix("\\") ||
  (target.length() >= 2 && target[1] == ':')
}

///|
/// Join a directory and a relative path with a single `/` separator. Does not
/// canonicalize `..` segments; the renderer/host fs resolves them.
fn markdown_join_path(dir : String, rel : String) -> String {
  let dir_chars = dir.to_array()
  let has_slash = dir_chars.length() > 0 &&
    (
      dir_chars[dir_chars.length() - 1] == '/' ||
      dir_chars[dir_chars.length() - 1] == '\\'
    )
  if has_slash {
    dir + rel
  } else {
    dir + "/" + rel
  }
}

///|
/// Box size for a plain markdown image. The intrinsic header size (when the
/// host can provide it) keeps the image's real shape; otherwise a
/// placeholder box reserves the space until layout learns more. The drawn
/// width is capped to the content column by the resolver, mirroring the
/// usual `max-width: 100%` behavior.
fn markdown_inline_image_size(
  inline : MarkdownInline,
  base : @core.FontSpec,
  base_dir~ : String?,
) -> @core.Size? {
  match inline.kind {
    Image => {
      let fallback = @core.Size::new(
        width=base.size * 8.0,
        height=base.size * 4.5,
      )
      Some(
        match inline.target {
          Some(target) if target.trim().to_owned() != "" =>
            markdown_image_metrics_size(
              markdown_resolve_image_source(target, base_dir),
            ).unwrap_or(fallback)
          _ => fallback
        },
      )
    }
    _ => None
  }
}

///|
fn markdown_inline_source_text(inline : MarkdownInline) -> String {
  if inline.kind is HtmlInline {
    return markdown_html_inline_source(inline.text)
  }
  if inline.kind is FootnoteReference {
    return "[^" + inline.text + "]"
  }
  if inline.text == "" {
    "image"
  } else {
    inline.text
  }
}

///|
fn markdown_inline_font(
  kind : MarkdownInlineKind,
  font : @core.FontSpec,
) -> @core.FontSpec {
  match kind {
    Bold => { ..font, weight: 700, }
    Italic => { ..font, weight: 500, }
    Strikethrough => { ..font, weight: 400, }
    Highlight => font
    Link | Autolink => { ..font, weight: 500, }
    Image => { ..font, weight: 600, }
    FootnoteReference => { ..font, size: font.size * 0.76, weight: 700, }
    HtmlInline =>
      {
        ..font,
        families: @core.FontFamilyStack::named("SFMono-Regular")
        .with_fallback(UiMonospace)
        .with_fallback(Monospace),
        size: font.size * 0.9,
      }
    Code =>
      {
        ..font,
        families: @core.FontFamilyStack::named("SFMono-Regular")
        .with_fallback(UiMonospace)
        .with_fallback(Monospace),
        size: font.size * 0.92,
      }
    Plain => font
  }
}

///|
/// Inline run colors. Links/autolinks render in Typora's blue so they read as
/// actionable; everything else inherits the block foreground (None).
fn markdown_inline_color(kind : MarkdownInlineKind) -> @core.Color? {
  match kind {
    Link | Autolink => Some(@core.Color::rgba(r=0.13, g=0.45, b=0.94, a=1.0))
    _ => None
  }
}

///|
fn markdown_inline_background(kind : MarkdownInlineKind) -> @core.Brush? {
  match kind {
    Code => Some(@core.Brush::solid(@core.Color::black().multiply_alpha(0.08)))
    Image =>
      Some(@core.Brush::solid(@core.Color::rgba(r=0.18, g=0.4, b=0.7, a=0.08)))
    FootnoteReference =>
      Some(@core.Brush::solid(@core.Color::rgba(r=0.18, g=0.4, b=0.7, a=0.08)))
    HtmlInline =>
      Some(@core.Brush::solid(@core.Color::black().multiply_alpha(0.06)))
    Highlight =>
      Some(@core.Brush::solid(@core.Color::rgba(r=1.0, g=0.83, b=0.2, a=0.4)))
    Plain | Bold | Italic | Strikethrough | Link | Autolink => None
  }
}

///|
fn markdown_inline_decoration(kind : MarkdownInlineKind) -> RichTextDecoration? {
  match kind {
    Strikethrough => Some(Strikethrough)
    Plain
    | Bold
    | Italic
    | Code
    | Highlight
    | Link
    | Autolink
    | Image
    | FootnoteReference
    | HtmlInline => None
  }
}