From 741b67962358541e0e18f03b611bd774df976806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 13 Aug 2026 06:29:36 +0200 Subject: [PATCH 1/2] fix: ~~strike~~ no longer exports as bold + strike (#1373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadInlinesInto decided an emphasis span was strong from `DelimiterCount >= 2`. Markdig's EmphasisExtras — on here via UseAdvancedExtensions — folds SIX constructs into the one EmphasisInline node and tells them apart by the delimiter CHARACTER, not the count. `~~` is doubled, so strikethrough satisfied "strong" too and the run was emitted bold AND struck, in the PDF print (…) and in Word ( beside ) alike. The same slip mis-styled four more, all of them invisible until you look: ++ins++ -> bold (doubled delimiter) ==mark== -> bold (doubled delimiter) ~sub~ -> struck (shares '~' with strikethrough) ^sup^ -> italic (fell through to "not strong, not strike") Emphasis is now keyed on DelimiterChar. Subscript, superscript, inserted and marked have no representation in the document model (bold/italic/strike/code), so they render as their plain text — content kept, invented emphasis dropped. Giving them styles of their own is a model change, not this fix. Verified on the ARTEFACT, not just the model — the complaint is about a word on a page. Fail-before, read out of the produced files: PDF the struck word's glyphs were drawn with DAAAAA+Arial-BoldMT DOCX the struck run carried and by rasterising both prints and looking at them: before, "Retired" is bold and struck while sub/sup/ins/mark each wear a style; after, "Retired" is struck at normal weight, real bold is still bold, bold-containing-strike keeps both, and the four unrepresentable flavours print as plain text. Both artefact tests carry a genuinely bold word in the SAME document as their control, so "not bold" is a finding rather than an absence, and the DOCX assertions read run properties out first instead of through `?.` — a short-circuit there would have made them unfailable. MeshWeaver.Markdown.Export.Test 113/113 (8 red before, 0 after), the export e2e suites 16/16, MeshWeaver.Documentation.Test 20/20, -c Release -warnaserror clean. Co-Authored-By: Claude Opus 5 (1M context) --- ...crossed-out-text-is-no-longer-also-bold.md | 31 ++++ .../Ast/DocumentBuilder.cs | 44 ++++- .../DocumentBuilderTests.cs | 79 +++++++++ .../StrikethroughRenderTests.cs | 159 ++++++++++++++++++ 4 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-crossed-out-text-is-no-longer-also-bold.md create mode 100644 test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-crossed-out-text-is-no-longer-also-bold.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-crossed-out-text-is-no-longer-also-bold.md new file mode 100644 index 0000000000..252c97d0cc --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-crossed-out-text-is-no-longer-also-bold.md @@ -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. diff --git a/src/MeshWeaver.Markdown.Export/Ast/DocumentBuilder.cs b/src/MeshWeaver.Markdown.Export/Ast/DocumentBuilder.cs index 30503aec64..83dca75037 100644 --- a/src/MeshWeaver.Markdown.Export/Ast/DocumentBuilder.cs +++ b/src/MeshWeaver.Markdown.Export/Ast/DocumentBuilder.cs @@ -308,13 +308,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; @@ -351,6 +349,40 @@ private static void ReadInlinesInto( } } + /// + /// Which of the document model's styles an emphasis span carries. + /// + /// 🚨 The delimiter CHARACTER decides — never the count on its own. Markdig's + /// EmphasisExtras (enabled here by UseAdvancedExtensions) folds SIX different + /// constructs into the one node and tells them apart by + /// : **/__ strong, */_ + /// emphasis, ~~ strikethrough, ~ subscript, ^ superscript, ++ + /// inserted, == marked. + /// + /// Reading strength off DelimiterCount >= 2 alone therefore made EVERY doubled + /// delimiter strong, and ~~strike~~ is doubled: it printed as bold AND struck + /// through in both PDF and DOCX (#1373). The same slip gave ++ins++ and ==mark== + /// bold, ~sub~ a strikethrough, and ^sup^ italics — every one of them an emphasis + /// its author never wrote. + /// + /// 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. + /// + 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(); diff --git a/test/MeshWeaver.Markdown.Export.Test/DocumentBuilderTests.cs b/test/MeshWeaver.Markdown.Export.Test/DocumentBuilderTests.cs index c6c9040960..05e3ced1ac 100644 --- a/test/MeshWeaver.Markdown.Export.Test/DocumentBuilderTests.cs +++ b/test/MeshWeaver.Markdown.Export.Test/DocumentBuilderTests.cs @@ -98,4 +98,83 @@ public void Chapter_break_inserted_between_children() doc.Elements.OfType().Should().ContainSingle() .Which.Title.Should().Be("Chapter 2"); } + + /// + /// #1373 — ~~strike~~ is struck through and NOTHING else. + /// + /// Markdig folds every emphasis flavour into one EmphasisInline and distinguishes + /// them by the delimiter CHARACTER; the builder tested only the COUNT, so the doubled ~~ + /// satisfied "strong" as well and the run came out bold AND struck. It reached paper in both + /// formats — <strong><s>…</s></strong> in the PDF print, + /// <w:b/> beside <w:strike/> in Word. + /// + [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(); + } + + /// + /// The rest of the emphasis table, which the count-only test got wrong in four more ways: + /// ++ins++ and ==mark== came out bold, ~sub~ struck through, and + /// ^sup^ 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. + /// + [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); + } + + /// Triple asterisks still accumulate to bold+italic — nesting, not a special case. + [Fact] + public void Bold_italic_still_accumulates() + { + var run = SingleRun("***x***"); + + run.Bold.Should().BeTrue(); + run.Italic.Should().BeTrue(); + run.Strike.Should().BeFalse(); + } + + /// Strikethrough inside genuine bold keeps both — the fix removes a phantom, not a style. + [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().Single() + .Content.OfType().ToArray(); + + runs.Should().Contain(r => r.Text == "keep " && r.Bold && !r.Strike); + runs.Should().Contain(r => r.Text == "gone" && r.Bold && r.Strike); + } + + private static TextInline SingleRun(string markdown) + { + var doc = new DocumentBuilder().Build( + "T", markdown, new DocumentExportOptions(), BrandingOptions.Default); + return doc.Elements.OfType().Single() + .Content.OfType().Single(); + } } diff --git a/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs b/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs new file mode 100644 index 0000000000..236d71c8a3 --- /dev/null +++ b/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs @@ -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; + +/// +/// #1373, asserted where the reader sees it: on the produced DOCX and the printed PDF. +/// +/// The document model is pinned in DocumentBuilderTests, but a model assertion cannot +/// close this one on its own — the complaint is that a word came out bold 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 +/// <strong><s>…</s></strong>, and Word wrote <w:b/> +/// beside <w:strike/>. +/// +/// 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. +/// +public class StrikethroughRenderTests : IDisposable +{ + private readonly IoPoolRegistry pools = new(); + + private const string Struck = "Retired"; + private const string Emphasised = "Current"; + + /// Distinct single words: each survives PDF text extraction as one contiguous run. + 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); + + /// + /// Word says it outright: the struck run carries w:strike and must NOT carry w:b. + /// + [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().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().Should().NotBeNull("the word is struck through"); + struck.GetFirstChild().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().Should().NotBeNull(); + bold.GetFirstChild().Should().BeNull(); + } + + private static RunProperties PropertiesOf(IEnumerable 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!; + } + + /// + /// 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. + /// + /// Never skipped: with no browser the renderer must fail loudly, which is the + /// contract RendererOutputTests pins and this test honours rather than quietly passing. + /// + [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); + + if (await browser.Probe().FirstAsync().ToTask() is null) + { + var render = async () => await renderer.Render(Build()).FirstAsync().ToTask(); + (await render.Should().ThrowAsync()) + .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)}"); + } + + /// + /// The font names the glyphs of 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. + /// + private static string[] FontsOf( + IReadOnlyList 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 []; + } + + /// + /// 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. + /// + 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(); +} From a0f776afe2673e7647e80c6832dcdd6478d37c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 13 Aug 2026 13:37:43 +0200 Subject: [PATCH 2/2] merge: the PDF artefact test takes the IoPoolRegistry its renderer now requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A semantic conflict the 3-way merge could not see. #1372 gave PdfDocumentRenderer an IoPoolRegistry parameter for the two-pass contents-page-number print; this branch's artefact test predates it and constructed the renderer with the browser alone, so the merged tree failed to compile (CS7036) while every text hunk merged clean. The test already owns a pools field for HeadlessChromiumPdfRenderer, so it passes that one — the same shape RendererOutputTests and PdfHeaderLogoTests use. The artefact assertion itself is untouched and still real: Chrome resolves on this host, so Pdf_prints_the_word_struck_but_not_bold printed and read the fonts back out rather than taking the no-browser branch. MeshWeaver.Markdown.Export.Test 135/135, MeshWeaver.Documentation.Test 20/20, -c Release -warnaserror clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs b/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs index 236d71c8a3..49b39dc2b9 100644 --- a/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs +++ b/test/MeshWeaver.Markdown.Export.Test/StrikethroughRenderTests.cs @@ -101,7 +101,7 @@ 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); + var renderer = new PdfDocumentRenderer(browser, pools); if (await browser.Probe().FirstAsync().ToTask() is null) {