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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
Name: Crossed-out text in an export is no longer also bold
Category: Fix
Description: Text you struck through came out bold as well in PDF and Word — as did highlighted and inserted text, while subscript came out struck. Each now prints as what it is.
Icon: Sparkle
Order: -20260813
---

# Crossed-out text in an export is no longer also bold

Striking a word out — writing `~~like this~~` — is how you show that something has been dropped or
superseded. Exported to PDF or to Word, that word came out **bold** as well as crossed out. It drew
the eye instead of retiring it, which is the opposite of what the author meant, and it was wrong in
both formats because both are drawn from the same reading of the document.

The reason was a small confusion about what makes text bold. Every kind of emphasis — bold, italic,
crossed out, highlighted — is written by wrapping the text in a symbol, and bold uses a *doubled*
one (`**bold**`). The export decided a passage was bold whenever its symbol was doubled, which is
true for `**` and also true for `~~`. So crossing something out made it bold as a side effect.

The same slip affected three more kinds of emphasis nobody had noticed:

- `==highlighted==` and `++inserted++` came out bold, for the same doubled-symbol reason.
- `~subscript~` came out crossed out, because it shares a symbol with strike-through.
- `^superscript^` came out italic.

An export now decides emphasis from *which* symbol was used, not how many of them there are.
Crossed-out text is crossed out. Bold is still bold, italic still italic, and bold with something
struck out inside it keeps both. Highlighting, insertion marks, subscript and superscript have no
counterpart in an exported document, so they now print as ordinary text — their words are all still
there, they simply no longer arrive wearing an emphasis you did not write.
44 changes: 38 additions & 6 deletions src/MeshWeaver.Markdown.Export/Ast/DocumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,11 @@ private static void ReadInlinesInto(
builder.Add(new TextInline(lit.Content.ToString(), bold, italic, strike));
break;
case EmphasisInline em:
var isStrong = em.DelimiterCount >= 2;
var isStrike = em.DelimiterChar is '~';
ReadInlinesInto(em, builder,
bold || isStrong,
italic || (!isStrong && !isStrike),
strike || isStrike);
{
var (emBold, emItalic, emStrike) = EmphasisStyle(em);
ReadInlinesInto(em, builder, bold || emBold, italic || emItalic, strike || emStrike);
break;
}
case CodeInline code:
builder.Add(new TextInline(code.Content, bold, italic, strike, Code: true));
break;
Expand Down Expand Up @@ -357,6 +355,40 @@ private static void ReadInlinesInto(
}
}

/// <summary>
/// Which of the document model's styles an emphasis span carries.
///
/// <para>🚨 The delimiter CHARACTER decides — never the count on its own. Markdig's
/// <c>EmphasisExtras</c> (enabled here by <c>UseAdvancedExtensions</c>) folds SIX different
/// constructs into the one <see cref="EmphasisInline"/> node and tells them apart by
/// <see cref="EmphasisInline.DelimiterChar"/>: <c>**</c>/<c>__</c> strong, <c>*</c>/<c>_</c>
/// emphasis, <c>~~</c> strikethrough, <c>~</c> subscript, <c>^</c> superscript, <c>++</c>
/// inserted, <c>==</c> marked.</para>
///
/// <para>Reading strength off <c>DelimiterCount >= 2</c> alone therefore made EVERY doubled
/// delimiter strong, and <c>~~strike~~</c> is doubled: it printed as <b>bold</b> AND struck
/// through in both PDF and DOCX (#1373). The same slip gave <c>++ins++</c> and <c>==mark==</c>
/// bold, <c>~sub~</c> a strikethrough, and <c>^sup^</c> italics — every one of them an emphasis
/// its author never wrote.</para>
///
/// <para>Subscript, superscript, inserted and marked have no representation in the document
/// model, which carries bold / italic / strike / code and nothing else. They render as their
/// plain text: no content is lost, and a construct the model cannot express is better shown
/// neutrally than dressed in the wrong style. Giving them styles of their own is a change to
/// the model, not a fix to this.</para>
/// </summary>
private static (bool Bold, bool Italic, bool Strike) EmphasisStyle(EmphasisInline em) =>
em.DelimiterChar switch
{
// `***both***` arrives as a count-1 span wrapping a count-2 span, so each level maps on
// its own and the two accumulate into bold+italic exactly as before.
'*' or '_' => (em.DelimiterCount >= 2, em.DelimiterCount == 1, false),
// A SINGLE '~' is subscript, not a strikethrough — the count matters here too, just not
// as a stand-in for strength.
'~' => (false, false, em.DelimiterCount >= 2),
_ => (false, false, false),
};

private static string ExtractPlainText(ContainerInline c)
{
var sb = new System.Text.StringBuilder();
Expand Down
79 changes: 79 additions & 0 deletions test/MeshWeaver.Markdown.Export.Test/DocumentBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,77 @@ public void Chapter_break_inserted_between_children()
.Which.Title.Should().Be("Chapter 2");
}

/// <summary>
/// #1373 — <c>~~strike~~</c> is struck through and NOTHING else.
///
/// <para>Markdig folds every emphasis flavour into one <c>EmphasisInline</c> and distinguishes
/// them by the delimiter CHARACTER; the builder tested only the COUNT, so the doubled <c>~~</c>
/// satisfied "strong" as well and the run came out bold AND struck. It reached paper in both
/// formats — <c>&lt;strong&gt;&lt;s&gt;…&lt;/s&gt;&lt;/strong&gt;</c> in the PDF print,
/// <c>&lt;w:b/&gt;</c> beside <c>&lt;w:strike/&gt;</c> in Word.</para>
/// </summary>
[Fact]
public void Strikethrough_is_struck_and_not_bold()
{
var run = SingleRun("~~gone~~");

run.Strike.Should().BeTrue();
run.Bold.Should().BeFalse("`~~` is strikethrough — the doubled delimiter is not strength");
run.Italic.Should().BeFalse();
}

/// <summary>
/// The rest of the emphasis table, which the count-only test got wrong in four more ways:
/// <c>++ins++</c> and <c>==mark==</c> came out bold, <c>~sub~</c> struck through, and
/// <c>^sup^</c> italic. The model has no style for those four, so they render as plain text —
/// their content is kept, their (unwritable) emphasis is not invented.
/// </summary>
[Theory]
// markdown bold italic strike
[InlineData("**x**", true, false, false)]
[InlineData("__x__", true, false, false)]
[InlineData("*x*", false, true, false)]
[InlineData("_x_", false, true, false)]
[InlineData("~~x~~", false, false, true)]
[InlineData("~x~", false, false, false)] // subscript
[InlineData("^x^", false, false, false)] // superscript
[InlineData("++x++", false, false, false)] // inserted
[InlineData("==x==", false, false, false)] // marked
public void Emphasis_is_keyed_on_the_delimiter_character(
string markdown, bool bold, bool italic, bool strike)
{
var run = SingleRun(markdown);

run.Text.Should().Be("x", "no emphasis flavour may swallow its own content");
run.Bold.Should().Be(bold);
run.Italic.Should().Be(italic);
run.Strike.Should().Be(strike);
}

/// <summary>Triple asterisks still accumulate to bold+italic — nesting, not a special case.</summary>
[Fact]
public void Bold_italic_still_accumulates()
{
var run = SingleRun("***x***");

run.Bold.Should().BeTrue();
run.Italic.Should().BeTrue();
run.Strike.Should().BeFalse();
}

/// <summary>Strikethrough inside genuine bold keeps both — the fix removes a phantom, not a style.</summary>
[Fact]
public void Strikethrough_nested_in_bold_keeps_both()
{
var doc = new DocumentBuilder().Build(
"T", "**keep ~~gone~~**", new DocumentExportOptions(), BrandingOptions.Default);
var runs = doc.Elements.OfType<ParagraphElement>().Single()
.Content.OfType<TextInline>().ToArray();

runs.Should().Contain(r => r.Text == "keep " && r.Bold && !r.Strike);
runs.Should().Contain(r => r.Text == "gone" && r.Bold && r.Strike);
}

/// <summary>
/// Two sections may share a title, and their anchors must not.
///
Expand Down Expand Up @@ -147,4 +218,12 @@ public void A_heading_with_no_slug_still_gets_a_usable_anchor()

doc.TocHeadings.Select(h => h.AnchorId).Should().Equal("section", "section-2");
}

private static TextInline SingleRun(string markdown)
{
var doc = new DocumentBuilder().Build(
"T", markdown, new DocumentExportOptions(), BrandingOptions.Default);
return doc.Elements.OfType<ParagraphElement>().Single()
.Content.OfType<TextInline>().Single();
}
}
159 changes: 159 additions & 0 deletions test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using MeshWeaver.Markdown.Export.Ast;
using MeshWeaver.Markdown.Export.Branding;
using MeshWeaver.Markdown.Export.Configuration;
using MeshWeaver.Markdown.Export.Docx;
using MeshWeaver.Markdown.Export.Pdf;
using MeshWeaver.Markdown.Export.Pixel;
using MeshWeaver.Mesh.Threading;
using Xunit;

namespace MeshWeaver.Markdown.Export.Test;

/// <summary>
/// #1373, asserted where the reader sees it: on the produced DOCX and the printed PDF.
///
/// <para>The document model is pinned in <c>DocumentBuilderTests</c>, but a model assertion cannot
/// close this one on its own — the complaint is that a word came out <b>bold</b> on the page, and
/// only the page can say it no longer does. Both renderers consume the same model, and both carried
/// the fault through: the PDF print wrapped the run in
/// <c>&lt;strong&gt;&lt;s&gt;…&lt;/s&gt;&lt;/strong&gt;</c>, and Word wrote <c>&lt;w:b/&gt;</c>
/// beside <c>&lt;w:strike/&gt;</c>.</para>
///
/// <para>Every document here carries a genuinely bold word next to the struck one, so each
/// assertion has its own control in the same artefact: "not bold" is only meaningful if bold is
/// visibly reachable by the same pipeline on the same page.</para>
/// </summary>
public class StrikethroughRenderTests : IDisposable
{
private readonly IoPoolRegistry pools = new();

private const string Struck = "Retired";
private const string Emphasised = "Current";

/// <summary>Distinct single words: each survives PDF text extraction as one contiguous run.</summary>
private const string Markdown = $"""
# Status

The **{Emphasised}** plan replaces the ~~{Struck}~~ one.
""";

private static Model.Document Build() => new DocumentBuilder().Build(
"Status",
Markdown,
new DocumentExportOptions { CoverPage = false, TableOfContents = false, PageBreakBeforeH1 = false },
BrandingOptions.Default);

/// <summary>
/// Word says it outright: the struck run carries <c>w:strike</c> and must NOT carry <c>w:b</c>.
/// </summary>
[Fact]
public void Docx_strikes_the_word_without_bolding_it()
{
var bytes = new DocxDocumentRenderer().Render(Build());

using var stream = new MemoryStream(bytes);
using var word = WordprocessingDocument.Open(stream, false);
var body = word.MainDocumentPart?.Document?.Body;
body.Should().NotBeNull("the rendered package must carry a document body");
var runs = body!.Descendants<Run>().ToArray();

// Read the properties off each run FIRST and assert them non-null. A `?.` chain here would
// short-circuit on a run with no RunProperties and skip the assertion entirely — an
// assertion that cannot fail is not an assertion.
var struck = PropertiesOf(runs, Struck);
struck.GetFirstChild<Strike>().Should().NotBeNull("the word is struck through");
struck.GetFirstChild<Bold>().Should().BeNull(
"`~~` is strikethrough only — it was reading as strong because the delimiter COUNT "
+ "was the whole test (#1373)");

// The control, in the same document: real bold still arrives as bold, and is not struck.
var bold = PropertiesOf(runs, Emphasised);
bold.GetFirstChild<Bold>().Should().NotBeNull();
bold.GetFirstChild<Strike>().Should().BeNull();
}

private static RunProperties PropertiesOf(IEnumerable<Run> runs, string text)
{
var run = runs.Single(r => r.InnerText == text);
run.RunProperties.Should().NotBeNull($"the run for '{text}' must carry run properties");
return run.RunProperties!;
}

/// <summary>
/// And on the printed page. A PDF records the FONT each glyph was drawn with, so the browser's
/// own answer to "did you print this bold?" is readable straight out of the file: the struck
/// word must be drawn in the same weight as ordinary body text, while the genuinely bold word
/// in the same sentence must not be — an independent control that makes a passing assertion
/// mean something.
///
/// <para><b>Never skipped</b>: with no browser the renderer must fail loudly, which is the
/// contract <c>RendererOutputTests</c> pins and this test honours rather than quietly passing.</para>
/// </summary>
[Fact]
public async Task Pdf_prints_the_word_struck_but_not_bold()
{
var browser = new HeadlessChromiumPdfRenderer(
new PixelRenderingOptions { NoSandbox = OperatingSystem.IsLinux() }, pools);
var renderer = new PdfDocumentRenderer(browser, pools);

if (await browser.Probe().FirstAsync().ToTask() is null)
{
var render = async () => await renderer.Render(Build()).FirstAsync().ToTask();
(await render.Should().ThrowAsync<PixelRendererUnavailableException>())
.WithMessage("*headless Chromium*");
return;
}

var bytes = await renderer.Render(Build()).FirstAsync().ToTask();

using var pdf = UglyToad.PdfPig.PdfDocument.Open(bytes);
var letters = pdf.GetPages().SelectMany(p => p.Letters).ToArray();

var struckFonts = FontsOf(letters, Struck);
var boldFonts = FontsOf(letters, Emphasised);

struckFonts.Should().NotBeEmpty("the struck word must actually be printed");
boldFonts.Should().NotBeEmpty("so must the bold one");

boldFonts.Should().OnlyContain(f => IsBold(f),
"the control: genuine `**bold**` still prints bold, so 'not bold' below is a real finding");
struckFonts.Should().OnlyContain(f => !IsBold(f),
$"`~~{Struck}~~` is strikethrough, and it was printing bold as well (#1373). "
+ $"Fonts seen: {string.Join(", ", struckFonts)}");
}

/// <summary>
/// The font names the glyphs of <paramref name="word"/> were drawn with. Matched by walking the
/// letters in order rather than by a text search, because that is the only reading that ties a
/// font back to a specific word.
/// </summary>
private static string[] FontsOf(
IReadOnlyList<UglyToad.PdfPig.Content.Letter> letters, string word)
{
for (var i = 0; i + word.Length <= letters.Count; i++)
{
var slice = letters.Skip(i).Take(word.Length).ToArray();
if (string.Concat(slice.Select(l => l.Value)) != word) continue;
return slice.Select(l => l.Font.Name).Distinct().ToArray();
}
return [];
}

/// <summary>
/// Chromium embeds a subset per weight and names it after the face it came from — the weight is
/// in the name (…-Bold, …,Bold, …-Semibold, …Black), never in a flag PdfPig exposes.
/// </summary>
private static bool IsBold(string fontName) =>
fontName.Contains("bold", StringComparison.OrdinalIgnoreCase)
|| fontName.Contains("black", StringComparison.OrdinalIgnoreCase)
|| fontName.Contains("heavy", StringComparison.OrdinalIgnoreCase);

public void Dispose() => pools.Dispose();
}
Loading