Skip to content

Agents/markdown renderer native feature plan#81

Merged
zhuowcui merged 90 commits into
mainfrom
agents/markdown-renderer-native-feature-plan
May 13, 2026
Merged

Agents/markdown renderer native feature plan#81
zhuowcui merged 90 commits into
mainfrom
agents/markdown-renderer-native-feature-plan

Conversation

@Xueyang-Song

Copy link
Copy Markdown
Collaborator

No description provided.

Xueyang-Song and others added 30 commits May 8, 2026 06:10
Introduces a fully-native markdown rendering control stack built on Markdig + Win2D (DirectWrite), replacing the CommunityToolkit MarkdownTextBlock.

Projects added to JitHub.slnx:
- MarkdownRenderer: core library (parser, flow layout engine, Win2D painter, theming, selection, accessibility)
- MarkdownRenderer.Gfm: optional GFM extension package (tables, task lists, alerts, footnotes)
- MarkdownRenderer.Sample: WinUI 3 sample/demo app
- MarkdownRenderer.Tests: 95 xUnit tests (layout, parsing, GFM integration, perf benchmarks)

Key design decisions:
- Off-thread parse+layout with atomic LayoutSnapshot publish; UI thread only schedules repaints
- ThemeSnapshot captures all resolved ElementStyle values on UI thread before Task.Run to avoid cross-thread DP access
- AOT-safe renderer dispatch via IMarkdownNodeRendererErased (no reflection/GetMethod)
- volatile _snapshot field for safe cross-thread observation
- Win11 typography defaults with live light/dark theme switching (repaint-only, no re-parse)
- DOM-style text selection with source-accurate markdown clipboard copy via MarkdownSourceMap
- IMarkdownEmbedFactory hook for hosting arbitrary WinUI controls inline or as blocks
- MarkdownAutomationPeer wired up with AutomationControlType.Document

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CanvasVirtualControl only has a device after entering the visual tree and
CreateResources fires. Passing it as ICanvasResourceCreator to MarkdownLayoutContext
caused CanvasTextLayout creation to fail on the Task.Run background thread before
the first draw event.

Fix: use CanvasDevice.GetSharedDevice() as the resource creator for layout.
The shared device is always available and is the same underlying device that
CanvasVirtualControl uses, so DrawTextLayout on the draw session is compatible.

Also fix stale XML doc cref in LayoutSnapshot.cs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bug fix: ListItemBox — bullet and text were on separate lines
The old BuildDefaultListItem placed the marker in a vertical StackBox on top of
the content, causing bullet and text to stack. Introduced ListItemBox, a new
two-column layout box that measures and arranges a fixed-width marker gutter
(28px) side by side with a variable-width content StackBox.

Updated TaskListItemRenderer to use ListItemBox with the same pattern so
checkbox items render inline with their text.

Sample app improvements:
- Toolbar with 7 sample tabs: Typography, Lists, Tables, Code, GFM Alerts,
  Selection, Full Demo
- Light/Dark theme toggle button
- ScrollViewer wrapping the renderer panel
- Markdown editor on the left with auto-scroll
- Comprehensive sample content covering every feature: headings, inline
  formatting, blockquotes, ordered/unordered/task lists with nesting, tables,
  fenced code blocks, GFM alerts (all 5 types), footnotes, thematic breaks,
  links, and selection/copy instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…out, footnotes, selection

- ThemeResolver: ResolveBrush now looks up App.ThemeDictionaries[Dark|Light] based on
  host.ActualTheme so light/dark switching re-styles without reloading the control
- ThemeResolver.GetDefault: explicit Body key with bottom-only margin (0,0,0,8) so list
  item bullets vertically align with their text; Win11 heading sizes updated (32/26/22/18/15/14);
  all styles carry Segoe UI Variable/Emoji/Symbol font family; dark-aware fallback colors
- InlineRun.ElementKey default changed from 'Body' to string.Empty (inherit container)
  so plain TextRun inside a heading inherits heading font size/weight
- ElementStyle: default FontFamily includes emoji/symbol fallback fonts
- InlineContainerBox.ApplyRunStyles: delta-only — skip when ElementKey empty, never
  override FontSize; prevents Body style from clobbering heading typography
- InlineContainerBox.Measure: switch to CanvasLineSpacingMode.Default (font-native metrics)
  fixes inflated line boxes that pushed underline/strikethrough out of position and caused
  oversized inline-code backgrounds
- InlineContainerBox.DrawDecorations: fixed y-positions (82% for underline, 45% for
  strikethrough); use container style when run.ElementKey is empty
- LayoutBuilder.BuildEmphasis: check DelimiterChar == '~' for strikethrough so ~~text~~
  correctly creates StrikethroughRun instead of StrongRun
- LayoutBuilder.BuildLink: added IsImage branch rendering alt text for images
- LayoutBuilder.BuildInline: added FootnoteLink case producing superscript reference numbers
- ListItemBox: expose Marker and Content as public properties for SelectionController
- LayoutBuilder: reduce list marker gutter from 28 to 22 px
- SelectionController.EnumerateBlockRects: handle ListItemBox by traversing Marker+Content
- MarkdownRendererControl.FindLinkInBlock: handle ListItemBox case
- FootnoteRenderer: use ListItemBox (marker | content) for proper horizontal alignment
- GfmChildBuilder: remove explicit Body ElementKey from TextRuns, fix strikethrough check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ThemeResolver: use hardcoded isDark colors (no XAML resource lookup) so
  ActualThemeChanged correctly drives light/dark re-render; remove step-3
  global app resource fallback in ResolveBrush
- Font families changed to single DirectWrite-compatible names ('Segoe UI
  Variable', 'Consolas') — comma-separated stacks are not supported by
  IDWriteTextFormat; system font fallback handles emoji ranges automatically
- InlineContainerBox.DrawDecorations: only draw per-run background when run
  ElementKey differs from container key — prevents doubled background on
  code blocks (container Paint() already draws the block-level background)
- LayoutBuilder.BuildCodeBlock: remove explicit ElementKey from TextRun so
  it inherits the container CodeBlock style cleanly
- TableBox: full rewrite using InlineContainerBox[][] per cell — enables
  hit-testing, selection rects, and source-accurate copy for table content
- TableRenderer: create InlineContainerBox per cell via GfmChildBuilder
- SelectionController.EnumerateBlockRects: add TableBox case
- MarkdownRendererControl.FindLinkInBlock: add TableBox case

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…link hover

Implements the six remaining feature/bug-fix items:

1. WinUI control hosting (block + inline) — IMarkdownEmbedFactory now has a
   thread-safe MeasureHeight (background) + UI-thread CreateBlock contract.
   New EmbedBox reserves block space; new InlineEmbedRun reserves inline space
   via U+FFFC + SetCharacterSpacing. After layout, MarkdownRendererControl
   walks the box tree on the UI thread and realizes FrameworkElements onto
   the overlay Canvas.
2. Images — new ImageBox loads via CanvasBitmap.LoadAsync with a per-URL
   bitmap cache. Image-only paragraphs are detected in LayoutBuilder and
   promoted to ImageBox. Re-layout fires when bitmaps decode.
3. Color emoji — CanvasTextLayout.Options = EnableColorFont so DirectWrite
   uses the COLR/CPAL color path for Segoe UI Emoji.
4. GFM task lists — TaskListItemRenderer now produces a real
   Microsoft.UI.Xaml.Controls.CheckBox via InlineEmbedRun instead of ☑/☐.
5. Strikethrough centering — uses CanvasLineMetrics.Baseline to position the
   strike at baseline - xHeight/2 instead of a fixed line fraction.
6. Link hover — OnPointerMoved hit-tests inline runs and sets HoveredRun on
   the owning InlineContainerBox; ApplyHoverColor brightens the run's
   foreground; ProtectedCursor switches to Hand over LinkRun.

Sample app gains Images and Embeds tabs; Embeds tab demonstrates a
SampleEmbedFactory that intercepts \\\�utton:Label fences and renders real
WinUI Buttons with click handlers.

All three projects build clean with 0 errors / 0 warnings on x64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ion vibration

- Image/embed blocks now return whole-bounds rects from a new BlockBox.GetSelectionRects virtual, so dragging across images highlights and Ctrl+C copies the original markdown image syntax (source span registered).
- Detect GFM TaskList correctly: it is a LeafInline injected as the first inline of the first ParagraphBlock — not block-level data — so the renderer now walks into the paragraph and skips the marker inline when emitting body content. CheckBox embed now actually appears.
- ApplyHoverColor resets every LinkRun back to its theme foreground before brightening the hovered run, so the link color reverts when the pointer leaves.
- ProtectedCursor is only re-assigned when the hovered run actually changes; this stops the per-frame cursor reset that was causing selected text to vibrate vertically during a drag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Disposing the CancellationTokenSource immediately after Cancel() was racy: the background Task.Run lambda could still be accessing the token after disposal, causing ObjectDisposedException (not OCE) to escape the catch blocks and surface as an unhandled exception dialog.

Changes:
- RequestRebuild no longer calls Dispose() on the old CTS inline; instead the CTS is disposed by a ContinueWith callback after the task it owned finishes.
- RebuildAsync is split into a public shell that swallows OCE/unexpected exceptions and a private RebuildInternalAsync that propagates them cleanly via ThrowIfCancellationRequested at each checkpoint — no more duplicate try/catch pairs.
- OnUnloaded cancels and disposes the current CTS as part of teardown.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…flag

CanvasTextLayout.SetColor (IDWriteTextLayout::SetDrawingEffect) was called
on every paint frame when processing link hover, even when no hover was
active.  This forced DirectWrite to re-validate cached glyph-run metrics
on every frame, causing GetCharacterRegions to return slightly different
Y values each frame — visible as vertical text vibration during drag-select.

Fix: introduce _hoverColorsDirty flag on InlineContainerBox.
- ApplyHoverColor only runs when the flag is set, then clears it.
- Flag is set to true on construction, after Measure creates a new layout,
  and in the HoveredRun setter only when the value actually changes.
- ClearHover via the HoveredRun = null property path correctly triggers
  the flag so the reset (un-highlight) still runs once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both MarkdownRenderer and MarkdownRenderer.Gfm already had
IsAotCompatible=true (which enables the IL2xxx/IL3xxx analyzers).
Make the contract permanent by promoting all trim, single-file, and
AOT-analyzer warnings to build errors via WarningsAsErrors.

Verified: clean Release build (0 warnings, 0 errors) on x64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Triggered by parallel reviews from Opus-4.7, GPT-5.5 and GPT-5.3-Codex.
Each item below was flagged by at least one reviewer.

Hit-test correctness
- InlineContainerBox.HitTest: change boundary predicate from <= to <  so
  HitTest and RunAt agree on which run owns the boundary character.
- StackBox.HitTest: return false on padding-area hits instead of
  fabricating a position with the StackBox's own BlockIndex (no
  source-map entry exists for the StackBox itself).
- LayoutSnapshot.HitTest: use 'continue' instead of 'break' when a
  block's bounds top is below the hit Y so out-of-vertical-order
  blocks (custom renderers, footnote groups) still hit-test correctly.

Image lifecycle
- ImageBox: do not honour cached failure entries; let next instance
  retry.  Stop writing nulls into the static cache on failure.
- MarkdownRendererControl.OnImageLoadCompleted: always marshal to the
  UI thread via DispatcherQueue.TryEnqueue.  The async load completes
  on the thread pool; the previous else-branch could re-enter
  RequestRebuild off-thread and mutate UI-only state.

Pointer / hover
- Wire PointerExited, PointerCanceled and PointerCaptureLost to clear
  hover state and reset the cursor when the pointer leaves the canvas.

Layout / source-map
- LayoutBuilder.BuildList: parse Markdig's ListBlock.OrderedStart so
  '5. item' renders with marker '5.' rather than always '1.'.
- LayoutBuilder.BuildBlock: register a source-map entry for EmbedBox
  so Ctrl+C across an embed copies its original markdown.
- AlertRenderer: compute alert body source span by skipping past
  '[!TAG]' and trailing whitespace in the original source text
  instead of inheriting the full paragraph span (which included the
  tag and produced incorrect partial-selection copy).

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Multi-agent review (Opus + GPT-5.3-Codex) flagged that
CanvasTextLayout, CanvasBitmap placeholder layouts and the entire
LayoutSnapshot tree were never disposed.  On heavy edits the renderer
leaked native handles for every superseded snapshot.

Changes
- BlockBox: add virtual Dispose() so concrete boxes can release
  native resources when a snapshot is replaced.
- InlineContainerBox: dispose cached CanvasTextLayout.
- ImageBox: dispose placeholder CanvasTextLayout. Cached CanvasBitmap
  remains owned by the static cache (shared across boxes) and is
  intentionally not disposed here.
- StackBox / ListItemBox / TableBox: recurse into children.
- LayoutSnapshot: implement IDisposable, recursively disposing every
  block.
- MarkdownRendererControl.RebuildInternalAsync: capture old snapshot,
  publish the new one, then dispose the old one.
- MarkdownRendererControl.OnUnloaded: clear the embed overlay and
  dispose the current snapshot so re-attaching the control to a new
  parent doesn't leak DirectWrite layouts or stale FrameworkElements.

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
….Changed wiring

Reviewers (GPT-5.5, GPT-5.3-Codex) flagged that ElementStyle overrides
could not selectively unset properties:
* Margin / Padding always replaced defaults wholesale; an override
  with a single non-default field clobbered the resolver's spacing.
* Underline / Strikethrough were OR-ed against defaults, so an
  override could only enable, never disable (e.g. could not strip
  the default underline from links).
* MarkdownTheme exposed a 'Changed' event the control never
  subscribed to, so external mutations silently failed to repaint.
* EmbedFactory dependency-property changes did not trigger a rebuild.

Changes
- New ElementStyleOverride type with all-nullable fields. Consumers
  now express partial overrides naturally; unset fields fall through
  to defaults.  ElementStyle (resolved style consumed by the layout
  engine) keeps its non-nullable shape.
- MarkdownTheme.Overrides retyped to
  IDictionary<string, ElementStyleOverride>.
- ThemeResolver.GetEffectiveStyle merges with '??' across every
  field, including Margin/Padding/Underline/Strikethrough.
- MarkdownRendererControl: Theme DP changed callback now
  subscribes/unsubscribes to MarkdownTheme.Changed and rebuilds on
  external mutations.  Subscription is cleared in OnUnloaded so the
  control is not pinned by a long-lived theme instance.
- EmbedFactory DP changed callback now calls RequestRebuild so
  swapping factories at runtime takes effect immediately.

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Re-review caught a lifecycle bug introduced by 0cb263d: OnUnloaded
unhooks the Theme.Changed handler, but if the control is later
re-attached without the Theme DP being reassigned (TabView/ListView
recycling, navigation-hide-then-show), the subscription was never
restored and external mutations like theme.Invalidate() silently
stopped repainting.

OnLoadedInternal now re-binds the handler with an unsubscribe-first
pattern for idempotency in case Loaded fires without a prior Unload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previous implementation walked entries one at a time, synthesizing
'\n\n' between distinct BlockIndex values.  Each table cell has its
own BlockIndex, so selections that crossed cells in a single row
copied as a column with double-newline separators instead of the
original '| cell1 | cell2 |' markdown.

New implementation finds the first and last entries overlapping the
selection, projects each end-offset back to a source-text byte
position, and returns the verbatim slice between them.  This
preserves every delimiter that already exists in the original source
(table pipes, list newlines, blockquote markers, footnote refs)
without us having to reconstruct them from rendered structure.

Trade-off: selections that cross hidden source regions (extension-
stripped tags) will now include those bytes too — which is what
'copy as markdown' should do anyway.

Build verified: 0 errors, 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d shake

User report: selection vibration was still visible — but only on text
that contained inline links.  Root cause:

OnPointerMoved updates two things on every move event:
1. selection range (when _selectionAnchor is set), and
2. hover state (HoveredRun on the InlineContainerBox under cursor).

While dragging across body text that includes a link, the pointer
crosses the link↔body run boundary on essentially every move event.
HoveredRun therefore toggles each move, dirtying _hoverColorsDirty,
which forces ApplyHoverColor to call SetColor on the cached
CanvasTextLayout each frame.  SetColor invalidates DirectWrite's
internal glyph metrics cache, which re-rounds vertical baselines
sub-pixel — visible as the text 'vibrating' beneath the selection
highlight.

Fix: short-circuit OnPointerMoved after the selection-extend branch
when _selectionAnchor is non-null.  No hover/cursor changes run
mid-drag.  When the user releases (_selectionAnchor cleared in
OnPointerReleased), the next move resumes normal hover behaviour, so
links still light up correctly outside of an active drag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MarkdigParser.ParseAsync was throwing OperationCanceledException from inside the Task.Run lambda whenever a rebuild was superseded mid-parse. Although RebuildAsync catches the OCE, the throw still appeared in the debugger as a first-chance exception originating at MarkdigParser.cs:22, which the user was hitting on the image example page.

Replace ThrowIfCancellationRequested with cooperative IsCancellationRequested checks: bail with Task.FromCanceled before scheduling, and return null inside the lambda when cancellation has already been observed. The post-await ct.ThrowIfCancellationRequested() in RebuildInternalAsync still drives the cancellation flow, but the throw now happens at the call site (caught by RebuildAsync) rather than on a thread-pool stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds MarkdownRenderer.Diagnostics.ShakeLogger — a lock-free producer / single-background-task drain that flushes via Debug.WriteLine. Hooked at the four sites that matter for the shake bug:

- OnRegionsInvalidated: per-frame counter + per-region rect + frame-end summary (regions, hovered run type, dragging flag).
- InlineContainerBox.Paint: x/y and CanvasTextLayout.LayoutBounds for every inline run paint.
- SelectionController.PaintHighlight: first selection rect coordinates per frame.
- OnPointerMoved (drag branch): pointer position + resolved DocumentPosition.

Enabled from MarkdownRenderer.Sample.App.OnLaunched. The drain task batches up to 256 entries every 50ms and never blocks producers, so the instrumentation does not perturb the selection-drag timing we are trying to characterise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Diagnostics added in dcddf40 captured per-frame paint coordinates during a selection drag and proved that text positions are pixel-perfect identical across every frame (e.g. block 2 always renders at y=144.4258, block 0 at y=16.0000). The text was never actually moving.

The perceived 'vibration' was a ClearType artefact. ClearType is colour-aware: the same glyph rendered onto a white background and onto an alpha-blended selection-tinted background produces subtly different sub-pixel RGB values. As a selection drag sweeps the highlight edge through a glyph, the background tint under that glyph alternates per frame and ClearType re-tunes the glyph, which reads to the eye as a sub-pixel shake.

Force CanvasTextAntialiasing.Grayscale on the drawing session before painting the snapshot. Glyph edges are now background-independent, so the alpha-blended selection highlight no longer perturbs glyph rendering. Paint coordinates were already stable, so this is a one-line fix.

Also: turn the ShakeLogger off by default in the sample app — keep the infrastructure available behind a single Enabled flag for future regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… change

OnPointerMoved was calling Canvas.Invalidate() and reassigning ProtectedCursor on every transition between any two inline runs (including TextRun -> TextRun within the same paragraph as the pointer crossed character or line boundaries). Each ProtectedCursor reassignment plus full canvas invalidate per pointer-move event nudged the visual host by a sub-pixel, producing visible text 'shake' on plain hover with no selection drag active.

Now we track the hovered LINK identity (not arbitrary run identity) and only invalidate when that changes, and only set ProtectedCursor when its shape would actually flip Hand<->IBeam. Hover transitions between two non-link runs are a no-op.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Inline text was painted at fractional sub-pixel y values (e.g. y=144.4258). CanvasVirtualControl invalidates dirty regions on tile-aligned bounds, so as a selection-drag extends a highlight rect across the text, the dirty rect's shape changes per frame and DirectWrite re-rasterises glyphs straddling those tile boundaries at slightly different sub-pixel offsets — producing the residual selection-drag 'shake' the user reported even after hover-shake was eliminated.

Introduce InlineContainerBox.GetSnappedOrigin which rounds the layout origin to integer pixels, and route Paint, HitTest, RunAt, GetRangeRects and EnumerateEmbedRects through it so all five stay in sync. Glyph rasterisation is now position-stable across frames regardless of which dirty region is being repainted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Embedded inline WinUI elements (buttons, textboxes, checkboxes, etc.) now own pointer interactions when the pointer is directly over them, while still being included as atomic units in selection drags that pass through them. This matches how browsers treat <input>/<textarea> inside contenteditable text.

Specifically:

* On PointerPressed *over* an embed: do not start a selection and do not capture the pointer. XAML pointer routing delivers the click to the embedded element so its own click/focus handlers run. Any prior selection is cleared.

* On PointerMoved *hovering* an embed (no drag): do nothing. We do not override ProtectedCursor, so the embed's own cursor (Hand for Button, IBeam for TextBox, Arrow for Slider, …) takes effect via XAML routing. Stale link-hover state on our text is cleared.

* On PointerMoved *during a drag* through an embed: snap the pointer's DocumentPosition to either the start or end of the InlineEmbedRun based on which half of the rect the pointer is in. The embed is included in the selection as a single, indivisible unit — the user can never have a selection that ends halfway through an embedded button or textbox.

Implementation: cache (InlineContainerBox, InlineEmbedRun, Rect) tuples during PlaceEmbeds; new IsPointOverEmbed and TryHitTestEmbed helpers consult that cache from the pointer handlers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eam over embeds

Root cause: PointerExited on _canvas fires whenever the pointer moves from the canvas to any sibling element, including the _overlay that hosts embedded WinUI elements (Button, CheckBox, etc.). The old code set ProtectedCursor = IBeam on PointerExited. WinUI cursor resolution walks up from the hit-tested element; if the embed has no ProtectedCursor of its own, it inherits IBeam from the UserControl parent — so IBeam appeared over all embedded buttons.

Fix: replace the two-state _cursorIsHand bool with a nullable InputSystemCursorShape? tracking three states: null (system default), IBeam (over text), Hand (over link). Setting ProtectedCursor = null removes the UserControl override, allowing XAML to walk up to the system default (Arrow), which lets each embed element use its own cursor without conflict.

SetCursorShape(null) is now called from: OnPointerExited (always, not just when a link was hovered), the IsPointOverEmbed early-return branch in OnPointerMoved, and from the new SetCursorShape helper which no-ops if the shape has not changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ners

Sub-pixel rect edges combined with rounded corners produced per-frame anti-aliasing variation around selection corners during a drag. Neighbouring glyph dirty rects then re-rasterised at slightly different sub-pixel offsets, manifesting as text shake on the embed page (and elsewhere with mixed inline content).

Floor/Ceiling the rect to integer DIPs and use FillRectangle with square corners (browser-style).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…repaint during drag

Root cause: _canvas.Invalidate() was called on every PointerMoved during a selection drag. CanvasVirtualControl re-rasterised all visible tiles each frame; DirectWrite glyph positions depend on the sub-pixel offset of each tile's origin, which can vary slightly as the dirty-rect shape changes frame-to-frame. This produced the persistent 'selection shake' despite earlier integer-pixel snapping fixes.

Fix: render the selection highlight as XAML Rectangle elements on the existing _overlay Canvas instead of painting it onto the DirectWrite surface. Consequences:
- No _canvas.Invalidate() needed during drag — DirectWrite tiles are never touched.
- _selection.Changed → UpdateSelectionOverlay() replaces the rectangles; XAML compositor handles the visual update with zero glyph rasterisation.
- Ctrl+A and PointerPressed selection-start no longer invalidate the canvas for selection (hover-clear still does, as before).
- On new layout (RequestRebuild), the overlay is cleared and selection is reset to keep state consistent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nd targeted canvas invalidation

Two root causes addressed:

1. Block embed (Button) positions were placed at fractional DIPs (e.g. Y=144.3) because StackBox accumulates float heights. A 1-px border at a fractional coordinate is anti-aliased differently when its colour changes between hover/normal states, producing a perceived 0.5-1px size change that made surrounding text appear to shift. Fix: Math.Round all Canvas.SetLeft/SetTop and Width/Height in PlaceEmbeds for both EmbedBox (block) and InlineContainerBox (inline) embeds.

2. _canvas.Invalidate() (full repaint) was called in OnPointerExited and in the IsPointOverEmbed branch of OnPointerMoved whenever a link had been hovered. PointerExited on the DirectWrite canvas fires whenever the pointer crosses into an embed (button/checkbox) sibling — at fractional boundaries this can oscillate rapidly, triggering repeated full-canvas repaints → tile-offset glyph shake. Fix: track _lastHoveredBox alongside _lastHoveredRun, and replace full Invalidate() with targeted Invalidate(box.Bounds) — only the paragraph containing the link is repainted. Also extend IsPointOverEmbed to cover block-embed rects (tracked in new _blockEmbedRects list) so the embed-hover early-return correctly suppresses IBeam cursor and link-hover work over Button elements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…essibility peer

Implements four major features for the native markdown renderer:

- ImageBox now renders alt text as a centered caption beneath the image (new ImageCaption theme key, FontSize 12, italic, secondary fg) and respects FlowDirection for caption text direction.

- ImageBox supports SVG via Win2D CanvasSvgDocument. Handles both http(s) and data: URIs; intrinsic size extracted from width/height/viewBox attributes; SVG bytes cached, parsed lazily against the drawing-session device.

- StackBox (used by blockquotes/alerts) flips the accent bar to the right edge in RTL; ListItemBox flips marker/content columns in RTL; TableBox reverses column order in RTL. Flow direction plumbed through MarkdownLayoutContext to the boxes.

- Embed virtualization: MarkdownRendererControl now collects EmbedPlans during layout and realises only those whose rect intersects viewport ± 400 px. Embeds beyond ± 1200 px are derealised. IMarkdownEmbedFactory gains an optional RecycleBlock hook. Realisation is re-evaluated on every ViewChanged tick.

- MarkdownAutomationPeer now aggregates document text into GetNameCore, exposes per-block MarkdownBlockPeer children with appropriate Header control type and AutomationHeadingLevel for h1-h6, falling back to Text for paragraphs/list items.

- Sample app: RTL toggle in toolbar, new RTL sample (Arabic+English mixed bidi, blockquote, list, table), new Virtualization sample with 300 hosted Button embeds, richer Images sample exercising PNG with caption, data: SVG, remote SVG, and graceful broken-image fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add 18 unit tests across SvgIntrinsics and EmbedVisibility, exercising
the pure-logic helpers extracted from ImageBox + the embed virtualization
realize/derealize hysteresis bands.

Add MarkdownRenderer.Sample.Automation: a FlaUI-driven harness that
launches the sample app, exercises the automation tree, RTL toggle,
sample switching, embed virtualization (verifies bounded realization on
a 300-button document), and image sample loading. All 5 probes pass
end-to-end against the running app.

Fix: SvgIntrinsics.ParseStringAttribute treated whitespace as a value
terminator, breaking viewBox parsing (values contain spaces). Rewritten
to be quote-aware; tests caught the regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ualization probe)

Addresses findings from round-1 multi-agent code review.

- FlowDirection runtime callback (gated on IsLoaded) now triggers
  RequestRebuild so RTL toggles re-layout without a full reload.
- OnUnloaded derealises all hosted embeds and clears plan/rect caches
  before tearing down the overlay, preventing leaked FrameworkElements.
- InlineEmbedRun gains a Recycle Action; InlineEmbedPlan.Derealize
  invokes it so factories can pool/reset their hosted elements.
- MarkdownAutomationPeer caches per-block peers via ConditionalWeakTable
  so AT clients see stable peer identity across enumerations.
- MarkdownBlockPeer.GetBoundingRectangleCore reports per-block bounds in
  screen coords instead of inheriting the renderer's full rect.
- TableBox.Arrange anchors header and body cells to the right edge in
  RTL flow direction.
- ImageBox caches SVG intrinsic size on the loaded path so reuse keeps
  metadata; SvgIntrinsics rejects percent-only dimensions and tolerates
  comma-separated viewBox values, with regression tests.
- MarkdownRendererControl publishes RealizedEmbedCount via
  AutomationProperties.HelpText ("realized:N") so the UI-automation
  virtualisation probe can validate counts directly instead of walking
  the UIA tree (which is sensitive to peer caching). Probe updated to
  parse HelpText.

Verified: 120 unit tests pass; all 5 UI-automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eer, RTL hit-test)

Addresses findings from round-2 multi-agent code review (GPT-5.5 + Opus-4.7).

HIGH:
- MarkdownRendererControl no longer writes "realized:N" to its own
  AutomationProperties.HelpText. HelpText is read aloud by Narrator and
  raises UIA property-change events on every scroll tick; that's hostile
  to assistive tech and RPA tools. Replaced with a public
  EmbedsRealizationChanged event. The sample app subscribes to it and
  mirrors the count into a hidden, off-screen TextBlock with AutomationId
  "RealizedEmbedCount". The UI automation probe reads the TextBlock's
  Name, keeping the test signal external to the control's UIA surface.

MEDIUM:
- MarkdownBlockPeer.GetBoundingRectangleCore now composes the base
  FrameworkElementAutomationPeer's screen rect with the block's
  renderer-local offset (scaled by XamlRoot.RasterizationScale), so
  Narrator's per-element scan navigation lands on the correct screen
  position even when the host window isn't at desktop (0,0).
- New MarkdownLinkPeer exposes inline LinkRuns as UIA Hyperlinks with
  the link text as Name and the URL as HelpText. MarkdownBlockPeer
  surfaces them via GetChildrenCore so screen readers can discover and
  navigate links inside a paragraph or heading.
- MarkdownAutomationPeer.AppendText now includes ImageBox.Alt so
  documents whose only block is an image still expose accessible text
  instead of falling back to the "Markdown document" placeholder.
- ImageBox no longer parses SVG synchronously inside Paint. When bytes
  are present but the document hasn't pre-parsed yet (e.g. shared-device
  reparse needed), we kick a one-shot async re-parse on a background
  task and skip drawing this frame; LoadCompleted triggers a repaint
  when ready. The paint thread is never blocked on
  CanvasSvgDocument.LoadAsync.
- TryHitTestEmbed (inline embed selection snap) now flips its half-rect
  mapping in RTL so the visual right edge maps to logical offset 0,
  matching surrounding bidi text.

LOW:
- LayoutBuilder block-image caption uses FlattenContainer so alt text
  with inline formatting (e.g. ![**bold** caption](url)) is preserved.
- SvgIntrinsics ParseStringAttribute also accepts '\r' as an
  attribute-name separator so SVGs with CRLF line endings parse
  correctly. New regression test added.
- ImageBox removes the redundant per-call 30s CancellationTokenSource;
  HttpClient.Timeout already bounds the download.
- Sample app exposes the renderer's current FlowDirection through a
  hidden "FlowDirectionStatus" TextBlock so the rtl-toggle UI probe
  asserts the renderer actually flipped, not just the toggle button.

Verified: 121 unit tests pass; all 5 UI-automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Xueyang-Song and others added 28 commits May 11, 2026 12:31
- RequestRebuild: defer CTS disposal to ContinueWith after task completes to
  prevent ObjectDisposedException in ct.Register() inside Task.Run/scheduler
  internals; capture old CTS in closure, dispose it when task finishes
- GetSystemDoubleClickTimeMs: clamp GetDoubleClickTime() with Math.Min before
  (int) cast to prevent negative result if registry value > int.MaxValue, which
  would silently disable double/triple-click selection for the process lifetime
- RebuildInternalAsync: move committed flag declaration before try block so
  catch can reference it; only dispose snapshot in catch if not yet committed —
  after commit, _snapshot owns it and disposing would cause use-after-free
- InlineContainerBox.Add: add Debug.Assert that BlockIndex is set before
  first Add() call; silent violation registered all runs under block 0,
  corrupting source-map entries for selection/copy/footnote navigation
- InlineContainerBox.GetRangeRects: add EnsureBuffer() before ToBufferIndex
  calls to ensure buffer is current; stale buffer produces wrong character
  offsets and incorrect selection rectangles during mid-layout selection
- Program.cs ProbeContextMenu: change menuItems from dead code to an
  informational warning when no MenuItems found in UIA tree

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ed bounds, dead vars, CapturePointer, FootnoteRenderer loop

- OnThemeRevisionChanged: guard against cross-thread calls with DispatcherQueue.HasThreadAccess/TryEnqueue
- RebuildInternalAsync: move _themeSnapshot assignment to after committed=true so it always reflects committed state
- IsPointOverEmbed/TryHitTestEmbed: use exclusive right/bottom edge (< instead of <=) to match Rect.Contains semantics
- RealizeVisibleEmbeds: remove 4 dead local variables (realizeTop/Bottom, derealizeTop/Bottom)
- Program.cs: wrap window.Close() in try-catch to prevent exception bypassing results summary
- OnPointerPressed: clear _selectionAnchor when CapturePointer fails to prevent phantom drags
- InlineContainerBox.Add: tighten Debug.Assert to fire unconditionally when BlockIndex==0
- FootnoteRenderer.TryAppendToLastInlineBox: use continue for StackBox/ListItemBox failed recursion instead of return false

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…neBox

Using continue when a nested StackBox/ListItemBox recursion fails would skip
past that container to an earlier sibling, placing the ↩ backlink visually
before the non-container block that terminated the search. The correct
behaviour is return false so the caller's fallback appends ↩ after all
existing content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OnImageLoadCompleted and OnThemeRevisionChanged both dispatch back to the
UI thread via TryEnqueue. If the lambda is already in the dispatcher queue
when OnUnloaded runs, it fires after the control is torn down — creating a
zombie rebuild cycle that leaks CTS, re-subscribes scroll handlers, and
commits snapshots that are never Disposed.

Fix: set _isUnloaded = true at the very start of OnUnloaded (before any
unsubscription). Both dispatcher lambdas check the flag and return early
if the control has unloaded. Both the flag write and the lambda bodies
run on the UI thread, so no locking is needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… out-of-order blocks

Custom renderers and footnote groups may produce blocks with non-monotone
Bounds.Top values. Using break in Paint would silently omit all in-viewport
blocks that follow an out-of-order block — they would be invisible yet still
hit-testable. Match the existing HitTest behaviour which already uses continue
and documents this exact scenario.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The codebase uses BlockIndex == 0 as the sentinel for 'not yet assigned'
(see custom.BlockIndex == 0 and itemBox.BlockIndex == 0 conditional
assignments in LayoutBuilder). NextBlockIndex() previously post-incremented
from 0, so the very first block legitimately got BlockIndex = 0 — which
made the Round-23 tightened Debug.Assert fire on app startup.

Switch to pre-increment so the first block is index 1 and 0 unambiguously
means unassigned. Source-map and footnote-registry consumers don't care
about the absolute starting value; they only need uniqueness.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…st handler

Round-4 (5e50e19) wired PointerExited, PointerCanceled, and PointerCaptureLost to the same handler that cleared _selectionAnchor and _leftPointerCaptured. With pointer capture active, the pointer can briefly leave canvas bounds during a normal drag (especially on the embeds page where hosted WinUI controls sit on a sibling overlay), and that fires PointerExited even though the captured drag is still ongoing. Clearing the anchor there killed drag-select the moment the user moved the mouse.

Split into two handlers: OnPointerCanceledOrCaptureLost performs the destructive cleanup (real interruption), while OnPointerExited only resets cursor/hover state. End-of-drag cleanup is handled by OnPointerReleased as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two SVG rendering bugs:

1. Data URI SVG not rendering (data:image/svg+xml;utf8,<svg...>)
   - Uri.TryCreate rejects unescaped '<' / '>' per RFC 3986 → _loadFailed=true
   - Fix: detect data: URIs before calling Uri.TryCreate and dispatch to
     a new LoadSvgDataUriAsync() that parses the raw string directly,
     reusing the same base64 / UTF-8 decode + CanvasSvgDocument.LoadAsync
     path already used in LoadSvgAsync.

2. Remote SVG (hero-glow with stdDeviation Gaussian blur) bleeds past the
   allocated image bounds and overlaps the caption text.
   - DrawSvg does not clip Win2D filter effects to viewportSize.
   - Fix: wrap DrawSvg in ds.CreateLayer(1.0f, clipRect) so blur/shadow
     effects are clipped to the image's allocated (x, y, w, h) rect.

162/162 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 1 of correct SVG implementation plan.

- ImageBox.Measure now stores intrinsic-derived display width and
  height (downscale only when wider than column). Block bounds stay
  column-width for hit-test parity with paragraph blocks, but the
  drawn rect uses the tight intrinsic size so small images no longer
  stretch across the column.
- ImageBox.Paint reuses cached _imageWidth/_imageHeight for both
  bitmap and SVG branches.
- Sample app: replaced raw <svg> data URI (which CommonMark rejects
  due to literal spaces) with a base64-encoded blue circle and a
  percent-encoded red square so the Markdig pipeline delivers the
  full URL intact.
- Add ImageDataUriParsingTests pinning Markdig data-URI behaviour
  (base64, percent-encoded, charset, no media-type, and the raw
  inner-< rejection sentinel).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Win2D's CanvasSvgDocument doesn't support SVG filters, masks, clipPath,
foreignObject, animations, CSS via style, or several other spec features
that real-world SVGs (mermaid output, status badges, hero graphics) hit.

Adds a two-tier backend:
- Tier A (existing): CanvasSvgDocument for simple shapes/paths/gradients
- Tier B (new): SkiaSharp + Svg.Skia rasterize to BGRA bitmap, blit via
  CanvasBitmap.CreateFromBytes

SvgFeatureScanner does a conservative string scan over raw bytes and
classifies the document into a tier before parsing. A runtime safety net
in paint catches Win2D failures, promotes the URL to the Skia tier in a
static set, and rasterizes inline on the next frame.

All Skia interop is isolated to SvgSkiaRasterizer.cs so the rest of the
project never sees SkiaSharp/Svg.Skia types. AOT-clean: project still
builds with IsAotCompatible=true and WarningsAsErrors for trim/AOT.

Includes 21 new unit tests (188/188 passing) covering the scanner across
17 incompatible feature sentinels and the rasterizer across size/aspect/
intrinsic-size/error paths.

Sample app adds a base64 SVG with feGaussianBlur to exercise Tier B.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 3 of the SVG correctness work plus the new pixel-compare test rig that uses headless Edge/Chrome as the industry-standard ground truth.

- DPI awareness: MarkdownLayoutContext.RasterizationScale (init) is captured from XamlRoot and threaded through ImageBox.TryRasterize; capped at 4x to keep MaxRasterDimension headroom.
- currentColor theming: SvgThemeInjector pre-processes SVG bytes to inject the active foreground color on the root <svg> element so octicon-style assets re-tint with the live theme.
- UIA accessibility: SvgTitleExtractor pulls <title>/<desc>; ImageBox surfaces them via SvgTitle/SvgDesc; MarkdownAutomationPeer falls back to title when alt is empty and appends desc.
- 14 new unit tests for the two helpers (Tests project: 188 -> 202 passing).
- New MarkdownRenderer.PixelTests project: HeadlessBrowserRasterizer wraps msedge.exe / chrome.exe with --headless=new, --virtual-time-budget, and per-invocation user-data-dir; PixelComparer normalizes BGRA-premul to RGBA and reports per-channel deltas; SvgSkiaVsBrowserTests asserts our Skia rasterizer agrees with the browser within tolerance for solid, circle, linear-gradient, and blurred-diamond fixtures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CommonMark stops a link destination at the first space, so `![x](data:image/svg+xml;utf8,<svg xmlns=...>)` would otherwise truncate to `data:image/svg+xml;utf8,<svg`. ForgivingDataUriFixer pre-walks the markdown, balances parens around each ](data: opening, and percent-encodes the four characters CommonMark can't tolerate (space, <, >, double-quote) so the destination survives Markdig's parser intact. ImageBox.LoadSvgDataUriAsync already uses Uri.UnescapeDataString to reverse the encoding before rasterization.

8 new unit tests covering: no-op on plain markdown / base64 / angle-bracket-wrapped, encoding spaces and angles, round-trip through Uri.UnescapeDataString, multiple images per document, malformed input bails safely, null/empty input. 210/210 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Multi-agent review round 1 fixes:

- ImageBox: cache raw pre-injection SVG bytes so theme switches re-tint without refetch; cache title/desc so accessibility metadata survives layout rebuilds; Skia-tier SVGs now write to cache too (was a rebuild loop).

- SvgThemeInjector: skip XML comments / declarations / DOCTYPEs before <svg>; quote-aware HasColorAttribute avoids false-positives on color= inside style values.

- SvgTitleExtractor: ignore <title>/<desc> inside <defs>/<symbol>/<mask>/<clipPath>/<pattern> sub-resources; only the root document title surfaces to UIA.

- HeadlessBrowserRasterizer: try/finally wrap to kill the launcher's process tree on timeout and clean up the temp profile dir on failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lf-closing defs

Multi-agent review round 2 fixes:

- SvgThemeInjector.HasColorAttribute: terminate on '/' or '>' so a self-closing root <svg/> with no color attribute no longer hangs the loader thread.

- SvgTitleExtractor.ExtractRootScope: detect self-closing <defs/>/<symbol/>/etc. before entering the subtree-skip loop; without this the scanner consumed the rest of the document looking for a non-existent close tag and dropped the root <title>/<desc>.

- Regression tests for both bugs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Vendors thorvg v1.0.4 (337 KB x64 DLL) under MarkdownRenderer/native/win-x64/
and replaces the ~10.8 MB Svg.Skia + libSkiaSharp + Win2D-SVG pipeline with
a single ThorVG-based rasterizer.

* New ThorVgNative.cs: source-generated P/Invoke wrapper for
  tvg_engine/tvg_swcanvas/tvg_picture APIs. AOT-compatible (LibraryImport).
* New ThorVgRasterizer.cs: per-call canvas+picture lifecycle, BGRA
  premultiplied output that maps 1:1 to CanvasBitmap
  (B8G8R8A8UIntNormalized) on little-endian Windows.
* ImageBox.cs collapsed to one render branch (just _bitmap). The cache now
  stores rasterized BGRA bytes keyed by (url, themeColorArgb,
  devicePixelScale); on cache hit with matching color+scale the constructor
  creates the CanvasBitmap synchronously -- no placeholder flash on layout
  rebuild. Theme/DPI mismatch falls back to async re-rasterize from cached
  raw bytes.
* Deleted SvgFeatureScanner, SvgSkiaRasterizer, their tests, and
  SvgSkiaVsBrowserTests; removed SkiaSharp + Svg.Skia package refs from all
  three csproj.

All 197 unit tests pass. Expanded pixel test suite and multi-agent review
follow in subsequent commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds 4 P/Invoke smoke tests verifying thorvg.dll loads at runtime,
BGRA byte layout matches CanvasBitmap.B8G8R8A8UIntNormalized
expectations, null/empty/malformed inputs return null cleanly,
and gradient+feGaussianBlur SVGs (previously unsupported under
Win2D's CanvasSvgDocument) rasterize without crashing.

Drops the '$(Platform)'=='x64'' condition from the thorvg.dll
Content items in all three csprojs - it was preventing deployment
to bin output when the active platform wasn't explicitly x64.

Cherry-picks ThorVgNative.cs and ThorVgRasterizer.cs into the
Tests project so smoke tests can exercise the renderer without
adding a project reference to the WinUI core lib.

201/201 tests passing (197 pre-existing + 4 new).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds SvgComplianceTests with auto-discovered fixtures across 20 categories (basic shapes, paths, strokes, fills, gradients, patterns, filters, masks/clips, use/symbol, transforms, viewBox, text, real-world icons, complex/Flying-Pig caliber, CSS-in-SVG, currentColor, data URIs, edge cases, stress). Ground truth comes from headless Edge with --screenshot; comparison via existing BitmapComparer at <=12.0 mean channel delta / <=20% differing-pixel fraction.

Fixes the long-standing 'blue strip' Chromium capture bug by removing --user-data-dir (a fresh profile silently consumes the screenshot canvas even under --headless=new). Serializes browser invocations with a process-wide lock so xUnit parallelism doesn't corrupt screenshots. Replaces first-non-zero polling with stable-size polling (5 consecutive equal reads) to avoid reading partially-written PNGs before <defs>/url(#...) resolve.

KnownThorVgGaps allowlist soft-passes 3 fixtures hitting documented ThorVG 1.0.4 gaps (<pattern>, feColorMatrix saturate) - rasterize succeeds + buffer shape correct but pixel compare is skipped. Generator script's PowerShell precedence bug at the 20-stress fixtures fixed (string concat now wrapped).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…caches

Addresses round-1 multi-agent review findings:

- ImageBox: add hard cap (256 bitmap / 128 SVG) on the static URL caches with arbitrary-victim eviction. Prevents unbounded growth on long-lived processes viewing many distinct image URLs. SVG entries pin rasterized BGRA buffers (potentially several MB at high DPI) so the tighter cap there is intentional.
- ThorVgRasterizer: compute output buffer size with checked long arithmetic and reject any raster whose BGRA payload would exceed 64 MB. Removes a latent int32-overflow path where adversarial intrinsic dimensions could produce a smaller-than-expected pinned buffer that ThorVG then writes past.
- csproj: restore Condition=x64 on the thorvg.dll Content item. x86/ARM64 builds were silently shipping the x64 DLL; EnsureEngine catches the resulting DllNotFoundException so the SVG path falls back to the alt-text placeholder rather than crashing.
- SvgComplianceTests: convert browser-failed silent return into Assert.Fail. A working browser that produces no PNG is a real harness regression, not a transient skip; previously the suite could degrade to rasterize-only checks without notice.

All 201 unit tests + 81 pixel-compliance fixtures still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Round-2 multi-agent review fixes:

- BLOCKER: TrimCache no longer disposes evicted CanvasBitmap instances. Cached bitmaps are aliased into _bitmap on every cache-hit ImageBox; disposing on eviction would yank the GPU resource out from under any live box still painting that URL (would manifest after the 257th unique image in a long session as random ObjectDisposedException/COMException on draw). The dictionary slot is dropped; GC + finalizer reclaim the underlying handle once no live box references it.
- MEDIUM: csproj Content condition for thorvg.dll widened to also accept empty / AnyCPU Platform values. A plain dotnet build (no -p:Platform) sets the global to AnyCPU, which the project's <Platform> default cannot override; the previous Condition='x64' silently dropped the DLL from default builds.
- LOW: bound _failedUrls at 512 entries with the same arbitrary-victim eviction so a long stream of unique bad URLs can't grow the latch indefinitely.

All 201 unit tests + 81 pixel-compliance fixtures still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Round-3 multi-agent review fixes:

- HIGH: Replaced HttpClient.GetByteArrayAsync with GetAsync(ResponseHeadersRead)+capped stream read for remote SVGs. Enforces a 4 MB hard cap both via Content-Length (before any data transfer) and via the actual stream read (before any allocation into rawBytes). The same 4 MB cap is applied to data-URI SVGs after base64/URL decode. A malicious or accidental multi-MB SVG no longer causes unbounded memory growth in the loading pipeline.
- MEDIUM: Replaced silent return with xunit.skippablefact Skip.If for the no-browser code path in SvgComplianceTests. Tests now appear as Skipped (not Passed) on CI machines without Edge/Chrome, making it visible when the pixel-comparison half did not run. The [Theory] -> [SkippableTheory] attribute change is the xUnit v2.x mechanism for dynamic skip.

All 201 unit tests + 81 pixel-compliance fixtures still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Round-4 multi-agent review fix:

HIGH: The Round-3 4 MB cap placed in LoadSvgAsync was dead code. Data URIs are short-circuited by StartLoad() to LoadSvgDataUriAsync before LoadSvgAsync is ever called, so the guard in LoadSvgAsync never fired for data: URIs. A large base64-encoded SVG data URI would be fully decoded into a managed byte[] before any limit was checked. Fix: add the MaxSvgBytes post-decode guard inside LoadSvgDataUriAsync immediately after rawBytes is assigned. Remove the dead data: branch from LoadSvgAsync (now handles only http/https/file; added a comment documenting the invariant) to prevent future confusion.

All 201 unit tests + 81 pixel-compliance fixtures still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…origin snap

Bug 1 - last character not visually selected:

InlineContainerBox.HitTest ignored DirectWrite's trailingSide flag. When the pointer was on the right half of a glyph, the returned CharacterIndex was the index of that glyph, not one past it, so dragging past the trailing edge of the last character produced a caret offset one short of the run length and the final char was never highlighted. Now honor the out-bool trailingSide param (++charIndex when set) in both HitTest and RunAt.

Bug 2 - heading vibration during selection drag (defense-in-depth):

GetSnappedOrigin previously snapped only to whole DIPs. At a non-1x rasterization scale (1.25x / 1.5x / 2x), an integer-DIP origin still lands on a fractional device-pixel column, so when the canvas dirty-rect shape changes between frames during a drag DirectWrite can pixel-snap to a slightly different column - visible as vertical/horizontal text jitter, most amplified on large heading glyphs. Now snap to device pixels via RasterizationScale.

Diagnostics:

- ShakeLogger.Enabled flipped on by default in the sample so any reproduction immediately captures text_shaking2.log next to the repo root.

- SelectionController.SetAnchor / ExtendTo log every range update.

- MarkdownRendererControl.UpdateSelectionOverlay logs every pool growth so we can see if Children.Add storms during steady-state drag.

All 201 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three improvements to eliminate selection shake at fractional DPI:

1. Physical-pixel snap for selection rects: replace Math.Floor/Ceiling
   in DIP space with Floor/Ceiling applied in physical-pixel space then
   converted back. At 125%/150% DPI an integer-DIP edge lands at a
   fractional physical pixel; the prior DIP snap left rect edges straddling
   two rows, causing XAML's AA rasterizer to vary the alpha by ±1px as
   GetCharacterRegions returned slightly different floats on consecutive
   frames — the visible heading shake at fractional DPI.

2. UseLayoutRounding=true on pooled Rectangles: belt-and-braces guard so
   XAML itself snaps the final on-screen position even if a residual
   fractional offset slips through Canvas.SetLeft/SetTop.

3. PreWarmSelectionPool(8): pre-populate 8 invisible Rectangles into
   _overlay.Children after every CommitSnapshot clear. Previously the pool
   started empty and grew via Children.Add on the very first drag, which
   triggers a XAML measure pass on each Add — up to 5 in rapid succession
   on an initial heading→paragraph selection. That measure cascade was the
   last remaining cause of the one-time shake on the first post-load drag.
   With the pool pre-warmed, steady-state drag never touches Children.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause of text-selection shake: PointerExited fires while the pointer is
captured during a drag (when the cursor moves outside canvas bounds). The handler
was calling InvalidateLinkHoverRegion() unconditionally, which issued a partial
_canvas.Invalidate(). That partial repaint triggered InlineContainerBox.Paint()
→ ApplyHoverColor() → CanvasTextLayout.SetColor() → DirectWrite invalidated its
cached glyph-run metrics → character region coordinates shifted by sub-pixel
amounts on the next draw → visible text shake throughout the drag gesture.

Fix: guard the hover-cleanup / canvas-invalidation path in OnPointerExited with
'if (_selectionAnchor is not null) { SetCursorShape(null); return; }'. The canvas
was already fully invalidated at drag-start (OnPointerPressed), so no intermediate
repaint is needed during the gesture. OnPointerCanceledOrCaptureLost already
clears _selectionAnchor *before* delegating to OnPointerExited, so true
capture-loss / cancel still runs the full cleanup path.

Secondary: increase PreWarmSelectionPool count from 8 to max(32, blocks×3) so
full-document drag-selects no longer grow the overlay Children list mid-gesture.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Click-to-dismiss (capture-loss) routed through OnPointerExited with
_selectionAnchor already nulled, bypassing the drag guard. The hover-
clear path then called InvalidateLinkHoverRegion even when the cleared
run was a plain TextRun. Partial CanvasVirtualControl invalidates expose
DirectWrite sub-pixel glyph-position variance at tile boundaries, which
is the residual "text shake" the user still saw on deselect.

Only invalidate when the cleared hover was an actual LinkRun (the only
case with a painted color change). TextRun hover transitions need no
repaint and so no longer trigger a tile invalidation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…repaints

Hovering over body text or links no longer mutates the CanvasTextLayout or invalidates canvas tiles. Previously, every link hover transition called ApplyHoverColor -> CanvasTextLayout.SetColor, which invalidated DirectWrite's cached glyph-run metrics. Combined with partial-region canvas invalidates issued for the hovered/previous box bounds, this caused glyphs near the hovered region to re-rasterize at sub-pixel-different positions on each pointer move - the long-standing 'text shake' bug.

Approach: remove ApplyHoverColor entirely; keep HoveredRun strictly as bookkeeping for click routing. Link hover affordance is now communicated only by the Hand cursor shape (matches Win11 Settings, Word, Notepad). A future enhancement may render link hover decoration via a XAML overlay rectangle (no canvas invalidation) if a stronger visual affordance is wanted.

Adds new automation probe 'hover-does-not-shake' that drives a hover sweep across the renderer and asserts zero inline-paint and zero canvas region events fire during pure pointer motion. All 14 automation probes and 201 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Selection is rendered entirely on the XAML overlay, but OnPointerPressed still invalidated the DirectWrite canvas when starting or clearing selection. On the Embeds sample this produced the reported sel-anchor -> full region repaint -> inline-paint sequence, which could visibly shake text during drag selection.

Remove those remaining selection-start canvas invalidates and add an embeds-selection-does-not-shake automation probe that proves the Embeds page starts a real selection drag while logging zero canvas region or inline-paint events during the tested drag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…TODO backlog

Adds a full documentation set under docs/markdown-renderer covering:
- Overview and philosophy
- Quick start guide
- Architecture (visual layers, rebuild pipeline, layout model)
- Tech stack and decisions (Markdig, Win2D, DirectWrite, ThorVG, WinUI)
- Rendering pipeline and syntax support
- Theming and customization API
- Selection and clipboard model
- Accessibility and UIA peer model
- Native integration and hosted controls
- Performance and memory characteristics
- Extensibility API (custom renderers, embed factories)
- Images, SVG, and asset loading
- Testing and diagnostics
- Packaging and distribution
- Current gaps and roadmap

Also adds MarkdownRenderer/TODO.md with 29 maturity backlog items
(🔴 release blockers, 🟠 before-1.0, 🟡 v1.1 candidates) and a
docs/README.md root index.

Fixes selection automation in Program.cs to only probe points that
produce a real sel-anchor diagnostic, replacing the prior approach
that started drags outside the renderer bounds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@zhuowcui zhuowcui merged commit 14a8616 into main May 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants