diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37b2830..7d54aa3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
+### Added
+
+- **Layout: `SuperscriptRun` and `SubscriptRun` inline run types (#71).** The layout
+ inline model previously had no super/subscript run, so the Avalonia live preview
+ flattened `^sup^`, `~sub~`, and footnote markers to plain, full-size, baseline text.
+
+### Fixed
+
+- **Layout/Avalonia: superscript, subscript, and footnote markers now render correctly (#71).**
+ `LayoutBuilder` maps `^…^`/`~…~` to the new `SuperscriptRun`/`SubscriptRun`, and a
+ footnote reference to a superscript marker that is also a link to its definition
+ (`SuperscriptRun > LinkRun > TextRun("[n]")`, using the `_footnotedef_N` anchor
+ convention). `AvaloniaRenderer` draws super/subscript runs smaller and shifted off
+ the baseline (`BaselineAlignment.Superscript`/`Subscript`), so `H₂O`, `E=mc²`, and
+ footnote markers match the exported HTML/PDF instead of showing as ordinary inline
+ text. Footnote markers are clickable via the existing `LinkClicked` event. The new
+ `AvaloniaRenderTheme.BodyFontSize` is the reference size for sizing super/subscript.
+
## [1.0.18] - 2026-06-19
A PDF footnote follow-up: footnote markers referenced inside table cells now get
diff --git a/src/AdocNet.Avalonia/AvaloniaRenderTheme.cs b/src/AdocNet.Avalonia/AvaloniaRenderTheme.cs
index 6621a1d..7a2f49c 100644
--- a/src/AdocNet.Avalonia/AvaloniaRenderTheme.cs
+++ b/src/AdocNet.Avalonia/AvaloniaRenderTheme.cs
@@ -50,6 +50,22 @@ public sealed class AvaloniaRenderTheme
/// Returns the font size for the given 1-based heading level.
public double HeadingFontSize(int level) =>
level >= 1 && level <= HeadingFontSizes.Count ? HeadingFontSizes[level - 1] : FallbackHeadingFontSize;
+
+ ///
+ /// Reference body font size used to size superscript/subscript runs (including
+ /// footnote markers). Body text itself inherits its size from the host container
+ /// (so the host stays in control); set this to match when the host uses a
+ /// non-default body size, so super/subscript stay proportionally smaller.
+ ///
+ public double BodyFontSize { get; set; } = 14;
+
+ ///
+ /// Fraction of used for superscript/subscript glyphs.
+ ///
+ public double SubSuperscriptFontScale { get; set; } = 0.7;
+
+ /// Font size for superscript/subscript runs.
+ public double SubSuperscriptFontSize => BodyFontSize * SubSuperscriptFontScale;
}
///
diff --git a/src/AdocNet.Avalonia/AvaloniaRenderer.cs b/src/AdocNet.Avalonia/AvaloniaRenderer.cs
index 6f2260c..5232e8f 100644
--- a/src/AdocNet.Avalonia/AvaloniaRenderer.cs
+++ b/src/AdocNet.Avalonia/AvaloniaRenderer.cs
@@ -543,6 +543,28 @@ private void AddInlines(InlineCollection target, IReadOnlyList inl
return span;
}
+ case SuperscriptRun superscript:
+ {
+ var span = new Span
+ {
+ FontSize = Theme.SubSuperscriptFontSize,
+ BaselineAlignment = BaselineAlignment.Superscript,
+ };
+ AddInlines(span.Inlines, superscript.Children);
+ return span;
+ }
+
+ case SubscriptRun subscript:
+ {
+ var span = new Span
+ {
+ FontSize = Theme.SubSuperscriptFontSize,
+ BaselineAlignment = BaselineAlignment.Subscript,
+ };
+ AddInlines(span.Inlines, subscript.Children);
+ return span;
+ }
+
case LinkRun link:
{
var linkText = new TextBlock
@@ -594,6 +616,12 @@ private static void AppendPlainText(StringBuilder sb, IReadOnlyList
- /// Renders a footnote reference as a [n] marker (matching the HTML and
- /// PDF converters) and registers its body for the trailing footnotes area.
- /// When the collector has been cleared — i.e. we are already rendering a
- /// footnote body — a nested footnote falls back to its literal text so its
- /// content is never silently dropped (issue #63).
+ /// Renders a footnote reference as a superscript [n] marker that links to
+ /// its definition (matching the HTML/PDF converters: a raised, clickable marker)
+ /// and registers its body for the trailing footnotes area. When the collector has
+ /// been cleared — i.e. we are already rendering a footnote body — a nested footnote
+ /// falls back to a plain superscript marker (no link/number) so its content is
+ /// never silently dropped (issues #63, #71).
///
private InlineLayout? BuildFootnoteMarker(FootnoteInlineNode footnote)
{
if (_footnotes is null)
{
if (footnote.Text is not null)
- return new TextRun("[" + footnote.Text + "]");
+ return Superscript(new TextRun("[" + footnote.Text + "]"));
if (footnote.Id is not null)
- return new TextRun("[" + footnote.Id + "]");
+ return Superscript(new TextRun("[" + footnote.Id + "]"));
return null;
}
int number = _footnotes.Register(footnote);
- return new TextRun("[" + number + "]");
+ // A superscript marker wrapping a link to the footnote definition, so the
+ // Avalonia preview raises it and makes it navigable (LinkRun is clickable).
+ return Superscript(new LinkRun(FootnoteDefHref(number),
+ new InlineLayout[] { new TextRun("[" + number + "]") }));
}
+ private static SuperscriptRun Superscript(InlineLayout child) =>
+ new(new[] { child });
+
+ ///
+ /// Anchor href of a footnote's definition in the trailing footnotes area,
+ /// using the same _footnotedef_N convention as the HTML converter so a
+ /// host can resolve the marker's link to the note.
+ ///
+ private static string FootnoteDefHref(int number) => "#_footnotedef_" + number;
+
///
/// Assigns document-wide footnote numbers during a single build, mirroring
/// the HTML converter's footnote state: anonymous footnotes get the next
diff --git a/src/AdocNet.Layout/SubscriptRun.cs b/src/AdocNet.Layout/SubscriptRun.cs
new file mode 100644
index 0000000..c9e2870
--- /dev/null
+++ b/src/AdocNet.Layout/SubscriptRun.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace AdocNet.Layout;
+
+///
+/// A subscript inline run (~text~) containing nested inline content.
+/// Renderers draw it smaller and lowered below the baseline.
+///
+public sealed class SubscriptRun : InlineLayout
+{
+ ///
+ /// The nested inline content.
+ ///
+ public IReadOnlyList Children { get; }
+
+ ///
+ /// Creates a new subscript run.
+ ///
+ /// The nested inline content.
+ public SubscriptRun(IReadOnlyList children)
+ {
+ Children = children;
+ }
+}
diff --git a/src/AdocNet.Layout/SuperscriptRun.cs b/src/AdocNet.Layout/SuperscriptRun.cs
new file mode 100644
index 0000000..97059f7
--- /dev/null
+++ b/src/AdocNet.Layout/SuperscriptRun.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+
+namespace AdocNet.Layout;
+
+///
+/// A superscript inline run (^text^, and footnote reference markers)
+/// containing nested inline content. Renderers draw it smaller and raised above
+/// the baseline.
+///
+public sealed class SuperscriptRun : InlineLayout
+{
+ ///
+ /// The nested inline content.
+ ///
+ public IReadOnlyList Children { get; }
+
+ ///
+ /// Creates a new superscript run.
+ ///
+ /// The nested inline content.
+ public SuperscriptRun(IReadOnlyList children)
+ {
+ Children = children;
+ }
+}
diff --git a/tests/AdocNet.Avalonia.Editor.Tests/SuperscriptRenderingTests.cs b/tests/AdocNet.Avalonia.Editor.Tests/SuperscriptRenderingTests.cs
new file mode 100644
index 0000000..6533cf4
--- /dev/null
+++ b/tests/AdocNet.Avalonia.Editor.Tests/SuperscriptRenderingTests.cs
@@ -0,0 +1,66 @@
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.Documents;
+using global::Avalonia.Headless.NUnit;
+using global::Avalonia.Media;
+using AdocNet.Avalonia;
+using AdocNet.Parser;
+
+namespace AdocNet.Avalonia.Editor.Tests;
+
+///
+/// Rendering tests for issue #71: the Avalonia preview must draw superscript and
+/// subscript runs (incl. footnote markers) smaller and shifted off the baseline,
+/// and render the footnote marker as a clickable link — not as plain inline text.
+///
+[TestFixture]
+public class SuperscriptRenderingTests
+{
+ private static StackPanel Render(string adoc, AvaloniaRenderer renderer)
+ {
+ var layout = new AdocNet.Layout.Builders.LayoutBuilder().Build(AdocParser.Parse(adoc).Document);
+ return (StackPanel)renderer.Render(layout);
+ }
+
+ private static TextBlock FirstParagraph(StackPanel panel) =>
+ panel.Children.OfType().First();
+
+ [AvaloniaTest]
+ public void Superscript_renders_as_a_raised_smaller_span()
+ {
+ var theme = new AvaloniaRenderTheme();
+ var panel = Render("E=mc^2^.", new AvaloniaRenderer { Theme = theme, WrapInScrollViewer = false });
+
+ var span = FirstParagraph(panel).Inlines!.OfType()
+ .First(s => s.BaselineAlignment == BaselineAlignment.Superscript);
+ Assert.That(span.FontSize, Is.EqualTo(theme.SubSuperscriptFontSize));
+ Assert.That(span.FontSize, Is.LessThan(theme.BodyFontSize), "superscript should be smaller than body");
+ }
+
+ [AvaloniaTest]
+ public void Subscript_renders_as_a_lowered_smaller_span()
+ {
+ var theme = new AvaloniaRenderTheme();
+ var panel = Render("H~2~O.", new AvaloniaRenderer { Theme = theme, WrapInScrollViewer = false });
+
+ var span = FirstParagraph(panel).Inlines!.OfType()
+ .First(s => s.BaselineAlignment == BaselineAlignment.Subscript);
+ Assert.That(span.FontSize, Is.EqualTo(theme.SubSuperscriptFontSize));
+ Assert.That(span.FontSize, Is.LessThan(theme.BodyFontSize), "subscript should be smaller than body");
+ }
+
+ [AvaloniaTest]
+ public void Footnote_marker_renders_as_a_superscript_clickable_link()
+ {
+ var panel = Render("x footnote:[note body].", new AvaloniaRenderer { WrapInScrollViewer = false });
+
+ // The marker is a superscript span ...
+ var span = FirstParagraph(panel).Inlines!.OfType()
+ .First(s => s.BaselineAlignment == BaselineAlignment.Superscript);
+
+ // ... wrapping a clickable link (rendered as an InlineUIContainer hosting a TextBlock).
+ var container = span.Inlines.OfType().First();
+ var linkText = (TextBlock)container.Child!;
+ var run = linkText.Inlines!.OfType().First();
+ Assert.That(run.Text, Does.Contain("[1]"), "the marker text is the [n] reference");
+ }
+}
diff --git a/tests/AdocNet.Layout.Tests/LayoutBuilderTests.cs b/tests/AdocNet.Layout.Tests/LayoutBuilderTests.cs
index d144e43..230dad1 100644
--- a/tests/AdocNet.Layout.Tests/LayoutBuilderTests.cs
+++ b/tests/AdocNet.Layout.Tests/LayoutBuilderTests.cs
@@ -259,8 +259,31 @@ public void Cross_reference_renders_as_text()
// ── Footnotes (issue #63) ───────────────────────────────────────
- private static string PlainText(ParagraphLayout para) =>
- string.Concat(para.Inlines.OfType().Select(t => t.Text));
+ /// Flattens an inline tree to plain text, recursing through wrapper runs.
+ private static string PlainText(IReadOnlyList inlines)
+ {
+ var sb = new System.Text.StringBuilder();
+ void Walk(IReadOnlyList items)
+ {
+ foreach (var i in items)
+ {
+ switch (i)
+ {
+ case TextRun t: sb.Append(t.Text); break;
+ case BoldRun b: Walk(b.Children); break;
+ case ItalicRun it: Walk(it.Children); break;
+ case MonoRun m: Walk(m.Children); break;
+ case SuperscriptRun s: Walk(s.Children); break;
+ case SubscriptRun sub: Walk(sub.Children); break;
+ case LinkRun l: Walk(l.Children); break;
+ }
+ }
+ }
+ Walk(inlines);
+ return sb.ToString();
+ }
+
+ private static string PlainText(ParagraphLayout para) => PlainText(para.Inlines);
[Test]
public void Footnote_reference_renders_as_numbered_marker_not_body()
@@ -326,8 +349,7 @@ public void Footnote_in_table_cell_renders_marker_not_inlined_body()
var layout = Build("|===\n| Header footnote:[cell note] | B\n| x | y\n|===");
var table = (TableLayout)layout.Children.First(c => c is TableLayout);
- var cellText = string.Concat(
- table.Rows[0].Cells[0].Inlines.OfType().Select(t => t.Text));
+ var cellText = PlainText(table.Rows[0].Cells[0].Inlines);
Assert.That(cellText, Does.Contain("[1]"));
Assert.That(cellText, Does.Not.Contain("cell note"));
@@ -336,6 +358,45 @@ public void Footnote_in_table_cell_renders_marker_not_inlined_body()
Assert.That(PlainText(entry), Does.Contain("cell note"));
}
+ [Test]
+ public void Footnote_marker_is_a_superscript_link_to_its_definition()
+ {
+ var layout = Build("x footnote:[note body].");
+
+ var para = (ParagraphLayout)layout.Children.First(c => c is ParagraphLayout);
+ var sup = para.Inlines.OfType().FirstOrDefault();
+ Assert.That(sup, Is.Not.Null, "the footnote marker should be a superscript run");
+
+ var link = sup!.Children.OfType().FirstOrDefault();
+ Assert.That(link, Is.Not.Null, "the superscript marker should wrap a link to the definition");
+ Assert.That(link!.Href, Does.StartWith("#_footnotedef_"));
+ Assert.That(PlainText(link.Children), Is.EqualTo("[1]"));
+ }
+
+ // ── Superscript / subscript (issue #71) ─────────────────────────
+
+ [Test]
+ public void Superscript_produces_a_SuperscriptRun()
+ {
+ var layout = Build("E=mc^2^ is famous.");
+
+ var para = (ParagraphLayout)layout.Children.First(c => c is ParagraphLayout);
+ var sup = para.Inlines.OfType().FirstOrDefault();
+ Assert.That(sup, Is.Not.Null, "^2^ should map to a SuperscriptRun, not a plain TextRun");
+ Assert.That(PlainText(sup!.Children), Is.EqualTo("2"));
+ }
+
+ [Test]
+ public void Subscript_produces_a_SubscriptRun()
+ {
+ var layout = Build("H~2~O is water.");
+
+ var para = (ParagraphLayout)layout.Children.First(c => c is ParagraphLayout);
+ var sub = para.Inlines.OfType().FirstOrDefault();
+ Assert.That(sub, Is.Not.Null, "~2~ should map to a SubscriptRun, not a plain TextRun");
+ Assert.That(PlainText(sub!.Children), Is.EqualTo("2"));
+ }
+
// ── Passthrough ─────────────────────────────────────────────────
[Test]