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]