Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/AdocNet.Avalonia/AvaloniaRenderTheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ public sealed class AvaloniaRenderTheme
/// <summary>Returns the font size for the given 1-based heading level.</summary>
public double HeadingFontSize(int level) =>
level >= 1 && level <= HeadingFontSizes.Count ? HeadingFontSizes[level - 1] : FallbackHeadingFontSize;

/// <summary>
/// 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.
/// </summary>
public double BodyFontSize { get; set; } = 14;

/// <summary>
/// Fraction of <see cref="BodyFontSize"/> used for superscript/subscript glyphs.
/// </summary>
public double SubSuperscriptFontScale { get; set; } = 0.7;

/// <summary>Font size for superscript/subscript runs.</summary>
public double SubSuperscriptFontSize => BodyFontSize * SubSuperscriptFontScale;
}

/// <summary>
Expand Down
28 changes: 28 additions & 0 deletions src/AdocNet.Avalonia/AvaloniaRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,28 @@ private void AddInlines(InlineCollection target, IReadOnlyList<InlineLayout> 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
Expand Down Expand Up @@ -594,6 +616,12 @@ private static void AppendPlainText(StringBuilder sb, IReadOnlyList<InlineLayout
case MonoRun mono:
AppendPlainText(sb, mono.Children);
break;
case SuperscriptRun superscript:
AppendPlainText(sb, superscript.Children);
break;
case SubscriptRun subscript:
AppendPlainText(sb, subscript.Children);
break;
case LinkRun link:
AppendPlainText(sb, link.Children);
break;
Expand Down
34 changes: 24 additions & 10 deletions src/AdocNet.Layout/Builders/LayoutBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,10 @@ private static SourcePosition HeadingContentOrigin(SectionNode section)
return new TextRun(passthrough.Content);

case SuperscriptInlineNode superscript:
return new TextRun(superscript.Content);
return new SuperscriptRun(new InlineLayout[] { new TextRun(superscript.Content) });

case SubscriptInlineNode subscript:
return new TextRun(subscript.Content);
return new SubscriptRun(new InlineLayout[] { new TextRun(subscript.Content) });

case InlineAnchorNode:
return null;
Expand All @@ -473,27 +473,41 @@ private static SourcePosition HeadingContentOrigin(SectionNode section)
}

/// <summary>
/// Renders a footnote reference as a <c>[n]</c> 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 <c>[n]</c> 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).
/// </summary>
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 });

/// <summary>
/// Anchor href of a footnote's definition in the trailing footnotes area,
/// using the same <c>_footnotedef_N</c> convention as the HTML converter so a
/// host can resolve the marker's link to the note.
/// </summary>
private static string FootnoteDefHref(int number) => "#_footnotedef_" + number;

/// <summary>
/// Assigns document-wide footnote numbers during a single build, mirroring
/// the HTML converter's footnote state: anonymous footnotes get the next
Expand Down
24 changes: 24 additions & 0 deletions src/AdocNet.Layout/SubscriptRun.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;

namespace AdocNet.Layout;

/// <summary>
/// A subscript inline run (<c>~text~</c>) containing nested inline content.
/// Renderers draw it smaller and lowered below the baseline.
/// </summary>
public sealed class SubscriptRun : InlineLayout
{
/// <summary>
/// The nested inline content.
/// </summary>
public IReadOnlyList<InlineLayout> Children { get; }

/// <summary>
/// Creates a new subscript run.
/// </summary>
/// <param name="children">The nested inline content.</param>
public SubscriptRun(IReadOnlyList<InlineLayout> children)
{
Children = children;
}
}
25 changes: 25 additions & 0 deletions src/AdocNet.Layout/SuperscriptRun.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Collections.Generic;

namespace AdocNet.Layout;

/// <summary>
/// A superscript inline run (<c>^text^</c>, and footnote reference markers)
/// containing nested inline content. Renderers draw it smaller and raised above
/// the baseline.
/// </summary>
public sealed class SuperscriptRun : InlineLayout
{
/// <summary>
/// The nested inline content.
/// </summary>
public IReadOnlyList<InlineLayout> Children { get; }

/// <summary>
/// Creates a new superscript run.
/// </summary>
/// <param name="children">The nested inline content.</param>
public SuperscriptRun(IReadOnlyList<InlineLayout> children)
{
Children = children;
}
}
66 changes: 66 additions & 0 deletions tests/AdocNet.Avalonia.Editor.Tests/SuperscriptRenderingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<TextBlock>().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<Span>()
.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<Span>()
.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<Span>()
.First(s => s.BaselineAlignment == BaselineAlignment.Superscript);

// ... wrapping a clickable link (rendered as an InlineUIContainer hosting a TextBlock).
var container = span.Inlines.OfType<InlineUIContainer>().First();
var linkText = (TextBlock)container.Child!;
var run = linkText.Inlines!.OfType<Run>().First();
Assert.That(run.Text, Does.Contain("[1]"), "the marker text is the [n] reference");
}
}
69 changes: 65 additions & 4 deletions tests/AdocNet.Layout.Tests/LayoutBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextRun>().Select(t => t.Text));
/// <summary>Flattens an inline tree to plain text, recursing through wrapper runs.</summary>
private static string PlainText(IReadOnlyList<InlineLayout> inlines)
{
var sb = new System.Text.StringBuilder();
void Walk(IReadOnlyList<InlineLayout> 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()
Expand Down Expand Up @@ -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<TextRun>().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"));

Expand All @@ -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<SuperscriptRun>().FirstOrDefault();
Assert.That(sup, Is.Not.Null, "the footnote marker should be a superscript run");

var link = sup!.Children.OfType<LinkRun>().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<SuperscriptRun>().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<SubscriptRun>().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]
Expand Down
Loading