Five commits (PR #341) teaching InlineHTMLRenderer.Walker to claim the footnote occurrences it consumes outside text position — image alt/src/title, link destination/title, raw inline HTML — so a later live [^id] with the same identifier can no longer bind to them. Reviewed as the merge-base diff git diff origin/main...HEAD, because main has advanced past the branch point with the sibling T-1941 / PR #342.
FootnoteReferenceScanner marks every resolvable [^id] before anything knows where its characters will land. A token that lands in an image alt, a link destination or raw inline HTML is restored to literal text and emits no run — so the cursor never steps over it and its occurrence stays in the list. The next same-id text-position marker then binds to that stale occurrence, the prose in between is searched with an upper bound lying before it, and the run comes out zero-length: Add Note is declined and badgeSourceStarts reports the attribute's offset to SearchStateFeeder.claimOccurrences(in:) locates the consumed text in the source at or after the cursor and claims exactly the occurrences inside that span, then steps the cursor to its end. Nothing is keyed on the identifier — an identifier match cannot tell one spelling of [^1] from another.Text child, already stepped over by the descent, so a forward search can only find a spurious later span the covering constraint is structurally unable to reject).!occurrences.isEmpty plus a reserved-PUA-unit fast path, so a document without footnotes — and an ordinary image inside one that has them — moves no cursor and takes no new allocation.Needs fixes
The implementation is correct within its documented limits and unusually well evidenced: four review rounds converged on positional locate-then-claim, a covering constraint, and an autolink-body exclusion, each pinned by a differential regression test. Lint is clean, macOS builds clean, and 218 targeted tests pass across the emitter, source-map, search-bridge and live-WebPage suites.
Three things stand between this and a push:
origin/main (CHANGELOG.md, DocumentSourceMapTests.swift, WebSelectionNoteTests.swift) from the sibling T-1941 / PR #342. All mechanical, all must be resolved before merge. Flagged, not resolved, per the review's read-only scope.![*a*[^1]](x.png) choose [^1] after still yields a zero-length prose run and badgeSourceStarts == [5] instead of [25] — the exact T-1992 symptom. It is a residual (main behaves identically), not a regression, but the user-facing entry claims the image case now works without qualification, and the limitation has no ticket. The sibling PR set the precedent by filing T-2032 for its own residual and referencing it from the CHANGELOG.No production-code changes were made by this review.
ecebe14 Fix T-1992: Claim footnote occurrences consumed in non-text positions 5855b3b review: claim footnote occurrences by source range 1cf739b review: bound the positional claim so it cannot overshoot 95587db review: exclude an autolink's body from the destination claim 3c1efcd review: require the autolink SHAPE as well as the spelling working-tree No changes applied by this review Prism lets you select text in a document and attach a note to it. To do that it keeps a map from what you see on screen back to the exact characters in the original markdown file. This change fixes a case where that map went wrong.
Footnotes in markdown are written [^1]. Prism turns them into a little numbered badge you can tap. But [^1] is just four characters — an author can also type those same characters somewhere they are not a footnote, such as inside an image's description text or inside a web address:
![[^1]](cat.png) choose [^1] afterHere the first [^1] is the image's caption text and must stay as plain characters; the second one is a real footnote and should become a badge. Prism got the badge right — but it recorded the badge as living at the first [^1]'s position. Everything downstream then measured from the wrong place.
Two things broke for anyone whose paragraph contained both spellings:
Cursor. As Prism walks the document it keeps a finger on the place in the original text it has accounted for so far. Everything it renders is looked up forward from that finger.
Claiming. The problem was that the image's caption never moved the finger — it produced nothing visible, so nothing advanced. The fix teaches those cases to say "I used up these characters" and move the finger past them. That is all "claiming" means.
Claiming by position, not by name. The first attempt claimed "the next [^1]", by name. That cannot work: every [^1] in a paragraph looks the same. The version that shipped finds where the caption's characters actually sit and claims only what is physically inside them.
InlineHTMLRenderer.Walker is a MarkupWalker over swift-markdown's inline AST that emits HTML while maintaining a forward UTF-16 cursor into the block's source. Every leaf that contributes visible text is located in the source from that cursor and recorded as a DocumentSourceMap.Run. Footnote badges are decided by a pre-pass (FootnoteReferenceScanner) that rewrites each resolvable [^id]'s identifier characters into a Private Use Area window — length-preserving and parse-shape-preserving, so the marked source parses to the same tree and runs come out in block coordinates.
The invariant the walker relies on is that the cursor and the occurrence list advance together. Text position keeps them in step implicitly: appendVisible locates the text and moves the cursor, and liveOccurrence(atOrAfter:) floors the occurrence index to the cursor. Non-text landing places broke the invariant, because they render no run — nothing to locate, nothing to advance.
The change adds one primitive and two callers' worth of discipline:
advanceOccurrenceFloor(to:) — extracted from liveOccurrence so the read path and the new claim path share one definition of "behind us".claimOccurrences(in:) — locate the consumed text at or after the cursor, claim exactly the occurrences inside the located span, set cursor = end. Refuses when it cannot locate.consumingVisibleText(_:) — the restore-and-claim pairing, for the non-text places that do render something (alt, src, raw HTML). Titles claim directly, since the emitter writes no title attribute.Callers pass their node's parts in source order: visitImage does alt → src → title; visitLink claims destination → title after descendInto, because the link's own text precedes the destination in [text](dest "title") and its markers are stepped over during the descent.
Positional over nominal. An identifier-keyed claim was tried first (commit ecebe14) and abandoned in 5855b3b: it is sound only while the attribute's own span sits ahead of the cursor, and both link/image titles and autolinks violate that.
Fail closed. cmark does not always hand an attribute back verbatim — Image.plainText flattens InlineContainer children, destinations are entity-decoded. When the locate fails, nothing is claimed. Losing a claim costs precision; claiming one still owed would cost a real badge its offset, so declining is the safe direction.
Bounded, not unbounded, forward search. Because the search runs forward, a normalised attribute can match a later span. The covering constraint (1cf739b) refuses any span that does not contain the next unclaimed occurrence, which turns a fail-open overshoot back into a fail-closed refusal.
One shape the constraint cannot see. An autolink's occurrence is consumed by its own descent and floored away before the constraint runs, leaving the guard anchored on a later occurrence that the spurious span covers exactly. consumedAsAutolinkBody filters it out up front, conjoining the AST shape (Link.isAutolink's definition, spelled out locally because swift-markdown declares it internal at Link.swift:89) with the source spelling (the angle brackets behind the cursor). Each half alone admits a counter-example, pinned by I9 and I17 respectively.
The claim's soundness argument is entirely positional and worth stating precisely, because three of the five commits are the argument failing and being repaired.
claimOccurrences(in: text):
text carries a reserved PUA unit. Documented (correctly) as a fast path only: a span covering an occurrence necessarily carries its marker, so the constraint below subsumes it.locate(units, from: cursor) → start; end = start + units.count. locate searches forward and units is non-empty, so end > cursor unconditionally and the cursor step is provably monotone. (An earlier cursor = max(cursor, end) was correctly reduced to cursor = end with the invariant stated.)advanceOccurrenceFloor(to: cursor), then require occurrences[i].sourceStart >= start and occurrences[i].end <= end — the covering constraint. An occurrence still owed lying before the span proves the span is not the attribute's own.end; set cursor = end.The constraint's premise is "the marker this attribute carries is the next unclaimed occurrence". That holds for every attribute whose span is ahead of the cursor and fails for exactly one shape — the autolink, whose destination and link text are the same source characters. The fix is a caller-side filter rather than a deeper guard, which the doc comment justifies correctly: the evidence the constraint would need has already been floored away by the descent.
Deliberately narrow. render's signature is untouched and no emitter call site moves, which is what keeps this branch textually disjoint from T-1941's renderInline(_:in:context:) rework in BlockHTMLEmitter — verified: git merge-tree reports conflicts in three files, none of them production code. Semantically the two are orthogonal: T-1941 rebases runs by a sub-span offset while deliberately not rebasing badgeSourceStarts, and this branch operates entirely inside the string handed to render.
Every new path is doubly gated (!occurrences.isEmpty, then the reserved-unit scan over text.utf16 before materialising an array), so a footnote-free document is bit-identical and allocation-identical to before.
InlineCode.plainText returns the code with its backticks, so ![`a`[^1]](x.png) matches the source verbatim and takes the accept path. I11b pins the boundary where swift-markdown actually draws it — a correction made in round 3 after I11 had been mislabelled as fail-closed.[](url) descends into nothing, inheriting the previous node's cursor; a spelling-only test then matches the preceding autolink's body and refuses a claim genuinely owed. I17 is the red-first repro for the shape conjunct.<a@b.c>) have destination mailto:a@b.c ≠ their text, so they fail the shape test and fall through to an ordinary claim that simply fails to locate. Correct by accident, but correct.![*a*[^1]](x.png) choose [^1] after → spans [<zero-length@0>, " after"], badgeSourceStarts == [5] (want [25]). Same on main, so it is a residual and not a regression — but it is the one production-reachable normalisation shape (paragraph.format() round-trips it verbatim), and the branch's own doc calls it "bounded precision loss" when it is in fact the full symptom.CMARK_OPT_SOURCEPOS is on by default in swift-markdown's CommonMarkConverter, so Markup.range is populated and would give exact node extents instead of a locate heuristic. Not free — cmark columns are byte-based and inline sourcepos is historically unreliable — but the decision to use text search instead is currently unrecorded anywhere.InlineHTMLRenderer.swift
Why it matters. This is the whole fix. It is also the only place in the walker that can move the cursor without emitting anything, so an error here silently corrupts every downstream run in the block — which is exactly the failure class T-1992 belongs to (the fourth occurrence per T-1941's own report).
What to look at. InlineHTMLRenderer.swift:388-410 (claimOccurrences), :312-319 (advanceOccurrenceFloor), :336-340 (consumingVisibleText)
InlineHTMLRenderer.swift
Why it matters. It is a caller-side special case that weakens an otherwise uniform rule, so it needs to be exactly as wide as the shape it excludes — no wider. Rounds 3 and 4 each found it one conjunct too wide, and each counter-example (I9, I17) is a real markdown spelling.
What to look at. InlineHTMLRenderer.swift:693-706, called from visitLink at :649-651
InlineHTMLRenderer.swift
Why it matters. Ordering is load-bearing, not cosmetic. The search runs forward from the cursor, so a part claimed out of order simply fails to locate. visitLink's placement *after* descendInto is the non-obvious one — the link's text precedes its destination in source even though the destination is read first in code.
What to look at. InlineHTMLRenderer.swift:708-725 (image: alt → src → title), :639-652 (link: destination → title, post-descent), :475 (inline HTML)
FootnoteBadgeSubstitutionTests.swift
Why it matters. The suite is the reason this design is trustworthy across four rewrites. Every test says explicitly whether it is red against main (reproduces T-1992) or green against main (pins out a failure mode this branch's own claim introduced) — so a future reader can tell a regression guard from a bug repro without re-deriving it.
What to look at. FootnoteBadgeSubstitutionTests.swift:483-816 (FootnoteNonTextOccurrenceTests), DocumentSourceMapTests.swift:303-459, WebSelectionNoteTests.swift:323-386
CLAUDE.md
Why it matters. CLAUDE.md is loaded into every session in this repo. This bullet went from 847 to 3,893 characters — one unbroken paragraph now longer than several whole sections of the file — and most of the added text is mechanism detail (why the autolink filter needs both conjuncts, which normalisation cases fail closed) rather than orientation.
What to look at. CLAUDE.md:66
An identifier match cannot tell one spelling of [^1] from another, so "the next [^1] at or after the cursor" is sound only while the attribute's own span is known to sit ahead of the cursor. Link.title/Image.title and autolinks both violate that precondition. Shipped identifier-keyed in ecebe14, replaced in 5855b3b.
The forward search can match a later span when cmark hands an attribute back normalised, and stepping the cursor there skips prose the walk has not rendered yet. Every real attribute span satisfies the constraint by construction, because the marker it carries is that occurrence. This also demotes the reserved-unit check to a pure fast path and makes the cursor = max(cursor, end) guard provably dead.
The autolink's own occurrence has already been consumed by the descent and floored away by the time claimOccurrences runs, so the constraint is left comparing against a later occurrence that the spurious span covers exactly. The evidence needed to reject it no longer exists at that point.
Considered and rejected in 95587db: making the marker window encode the occurrence rather than the identifier would need one 128-slot window per occurrence, i.e. nearly the whole BMP Private Use Area. The scanner bails to literal rendering for any source already using the reserved window, so that would disable footnote badges in any block containing a Nerd Font / Powerline glyph. A genuine alternative with a concrete, quantified rejection reason.
Verified during this review: isAutolink is declared at Link.swift:89 with no public, so it is module-internal and invisible to Prism. The local copy matches the upstream definition exactly (destination != nil is implicit here, since destination is already unwrapped to "").
Losing a claim leaves one occurrence shadowing until the next text located past it — bounded precision loss. Claiming one that is still owed would cost a real badge its source offset. The asymmetry decides the direction. Caveat (see finding B): for the normalised-alt shape this is not bounded precision loss, it is the full T-1992 symptom.
Unrecorded anywhere — inferred by omission during this review. CommonMarkConverter sets CMARK_OPT_SOURCEPOS unless .disableSourcePosOpts is passed, and exposes range(_:), so exact node ranges are available and would replace the locate heuristic outright. The likely reasons are real (cmark columns are byte-based, so a UTF-8→UTF-16 conversion is needed; inline sourcepos is historically unreliable), but none is written down.
Checked against precedent rather than assumed: the immediate predecessor PR #329 (T-1716/T-1945) and the sibling PR #342 (T-1941) both carried their bugfix reports in the commit message body and neither added a specs/ file for the mechanism. This branch matches that precedent. Not a finding — recorded so the next reviewer does not re-raise it.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| blocker | merge / origin/main | git merge-tree against the current origin/main reports three add/add content conflicts, all introduced by the sibling T-1941 / PR #342 which merged after this branch's merge-base (2c8f765): CHANGELOG.md (both add a bullet at the head of [Unreleased] > Fixed), prismTests/WebRendering/DocumentSourceMapTests.swift (both append to DocumentSourceMapInvariantTests) and prismTests/WebRendering/WebSelectionNoteTests.swift (both append tests). No production file conflicts — InlineHTMLRenderer.swift and FootnoteReferenceScanner.swift are untouched by #342, and T-1941's renderInline(_:in:context:) rework is semantically orthogonal (it rebases runs by a sub-span offset and deliberately does not rebase badgeSourceStarts, while this branch works entirely inside the string handed to render). | Flagged, not resolved — outside a report-only review's scope, and the CHANGELOG ordering is an authoring choice. All three are mechanical appends. Re-run the targeted suites after the rebase; both sides add tests to the same two suites, so a bad resolution silently drops tests rather than failing to compile. |
| major | CHANGELOG.md / untracked residual | The user-facing entry states the image-alt, link-destination, image-tooltip and raw-HTML cases 'now work', unqualified. Measured with a throwaway probe (since reverted), two shapes still reproduce the full T-1992 symptom: `![*a*[^1]](x.png) choose [^1] after` yields run spans [<zero-length@0>, " after"] and badgeSourceStarts == [5] where the live token is at 25; `[l](x&/[^1]) choose [^1] after` yields ["l", <zero-length@2>, " after"] and [11] where the live token is at 24. Both are residuals, not regressions — main behaves identically, because it claims nothing at all — and both arise from the documented fail-closed locate miss. But the branch's own doc comment calls this 'bounded precision loss', and it is not: the prose run is zero-length (Add Note declined) and badgeSourceStarts reports the attribute's offset to SearchStateFeeder (wrong badge marked) — verbatim the two symptoms the CHANGELOG says are fixed. The first shape is the one production-reachable normalisation case: the branch's own I10 comment notes paragraph.format() round-trips `![*a*[^1]](x.png)` verbatim, and inline markup inside an image alt is ordinary authoring. No follow-up ticket exists. | NOT fixed — needs the author. Two things: (1) qualify the CHANGELOG bullet so it does not claim a class it fixes only in part — an alt whose caption contains emphasis, strong, strikethrough or a link, and an entity-encoded destination, are still affected; (2) file the follow-up ticket. The sibling PR #342 set the exact precedent, filing T-2032 for its own .unmapped residual and referencing it from the CHANGELOG entry, the decision log and the agent note. Tests I10/I11/I12 already build the fixtures; they assert badgeCount == 0 because no live token follows, so extending one of them with a trailing live reference would pin the residual as a known-limitation test. |
| minor | InlineHTMLRenderer.visitLink — efficiency | consumedAsAutolinkBody is called unconditionally for every Link node, including in documents with no footnotes at all. Commit 1cf739b explicitly took the opposite decision for the sibling path ('claimOccurrences guards on occurrences.isEmpty and scans text.utf16 lazily before materialising the array, so every link in every document no longer pays two array allocations for its destination and title when the block has no footnotes') — and 3c1efcd then reintroduced an ungated per-link cost in front of it. The cost is small (childCount check, then a String comparison against Text.string; Array(destination.utf16) is only reached for autolink-shaped links) but it is per-link, per-emit, and contradicts the stated intent of the commit two before it. | NOT fixed (production code; report-only). Wrapping the whole tail of visitLink in `if !occurrences.isEmpty { … }` restores 1cf739b's intent and makes the no-footnote path exactly as it was before this branch. One-line change, no behaviour difference — both claimOccurrences calls already no-op in that state. |
| minor | InlineHTMLRenderer.claimOccurrences — efficiency / simplification | The locate call inside claimOccurrences passes no upper bound, unlike appendVisible which threads a searchLimit for exactly this reason. On a locate miss it scans from the cursor to the end of the block for every attribute — the normalised-alt path in a block with several images is the worst case. The covering constraint already implies the answer must satisfy start <= occurrences[i].sourceStart, so locate could be bounded to that range: the failed search becomes O(gap) instead of O(rest of block), and the `sourceStart >= start` half of the constraint becomes structurally redundant rather than a post-hoc check. Blocks are paragraph-sized so nothing is at risk today; this is simplification with a speed-up attached, not a defect. | NOT fixed — production code, and the arithmetic deserves the author's own review round rather than a reviewer's edit. Note the two are not quite interchangeable: today a later span is located and then rejected; bounded, it is never found. Same observable outcome (claim nothing), but the tests I10/I11/I12 would then be exercising a different code path, so they should be re-run rather than assumed. |
| minor | CLAUDE.md — documentation | Item 5 of the footnote section grew from 847 characters (125 words) to 3,893 characters (573 words) as one unbroken paragraph. CLAUDE.md is loaded into every session in this repo, and most of the addition is proof-obligation detail — why consumedAsAutolinkBody needs both conjuncts, which swift-markdown plainText cases normalise, which claim paths are production-reachable — rather than the orientation the rest of the file provides. The project's own convention points elsewhere: CLAUDE.md line 71 already directs implementation history and gotchas to docs/agent-notes/webview-rendering-status.md. | NOT applied — the split is an authoring judgement, not an editorial typo, and there is no clean mechanical edit. Suggested shape: keep two or three sentences in CLAUDE.md (non-text landing places must claim; the claim is positional and fails closed; see the agent note for why), and move the autolink conjunct argument, the normalisation table and the production-reachability caveats into docs/agent-notes/webview-rendering-status.md beside the other footnote gotchas. |
| nit | Verification hygiene | Independent verification, recorded so it is not repeated: make lint 0 violations / 510 files; make build-macos clean; FootnoteNonTextOccurrenceTests + FootnoteBadgeSubstitutionTests + FootnoteBadgeOrderingTests + FootnoteBadgeNestedContextTests + DocumentSourceMapInvariantTests + WebStructuredSourceMapInvariantTests = 88/88; WebSelectionNoteTests + WebStructuredSelectionTests + WebParityFixtureTests + WebSearchParityTests + NoteStateFeederTests = 50/50; WebSearchBridgeTests + OffMainEmitTests + WebNotesBehaviourTests + MarkdownBlockTextContentTests + SamplesComplianceTests = 80/80. All counts read from the result bundle via Tools/check-test-results.sh, not from console text or exit codes. One caveat: a -only-testing:prismTests/SearchStateFeederTests selector in the second batch matched no such suite (SearchStateFeeder has no dedicated test file; its behaviour is covered by WebSearchBridgeTests, which ran in the third batch). The zero-test guard did not fire because the other five suites ran. | No action. Noted because a selector that silently matches nothing is the documented failure mode Tools/check-test-results.sh exists to catch, and it only catches it when the whole run is empty. |
Click to expand.
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 183ed7b..53a795c 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -56,7 +56,8 @@ nonisolated struct InlineHTMLRenderer { /// /// The walk then decides *where* a candidate may render as a badge: text position, and /// not nested inside an element that cannot contain an anchor (a `Link` — the badge is- /// itself an `<a>`). Everywhere else the marker is restored to the literal `[^id]`.+ /// itself an `<a>`). Everywhere else the marker is restored to the literal `[^id]`, and+ /// its occurrence is claimed by source range so it cannot shadow a later badge (T-1992). static func render( source: String, footnotes: FootnoteData,@@ -197,6 +198,9 @@ nonisolated struct InlineHTMLRenderer { /// The text as the author wrote it: markers restored to their literal `[^id]` form. /// A no-op (and allocation-free) when the block has no marked references.+ ///+ /// For text whose markers the cursor does NOT step over — attributes, destinations,+ /// raw HTML — use `consumingVisibleText` instead, which also claims the occurrences. private func visibleText(_ text: String) -> String { occurrences.isEmpty ? text : FootnoteReferenceMarker.restoringLiterals(text) }@@ -259,9 +263,12 @@ nonisolated struct InlineHTMLRenderer { /// /// A marker that arrives in any other position is restored to the literal `[^id]` by /// `visibleText` instead. Two ways that happens: the marker never reaches here at all- /// (a code span, a link destination, an image's `src`/`alt`, raw inline HTML — each- /// read as an attribute or as its own node), or it reaches here inside a link, where- /// `linkDepth` sends the whole text node down the unsplit path below.+ /// (a code span, a link's destination or title, an image's `src`/`alt`/title, raw+ /// inline HTML — each read as an attribute or as its own node), or it reaches here+ /// inside a link, where `linkDepth` sends the whole text node down the unsplit path+ /// below. An image's or link's title is the one landing place with no rendered form+ /// at all: the emitter writes no `title` attribute, so it is claimed but not+ /// restored. mutating func visitText(_ text: Text) { guard !occurrences.isEmpty, linkDepth == 0 else { // Inside a link, the whole node — markers included — is located against the@@ -290,19 +297,116 @@ nonisolated struct InlineHTMLRenderer { appendVisible(String(decoding: units[segmentStart...], as: UTF16.self)) } - /// The first live occurrence starting at or after `from`, when its identifier+ /// The first unclaimed occurrence starting at or after `from`, when its identifier /// matches. Used only to anchor the cursor and bound a forward search; a `nil` /// answer costs precision in the source map, never correctness of the badge. private mutating func liveOccurrence( atOrAfter from: Int, identifier: String ) -> FootnoteReferenceScanner.Occurrence? {+ advanceOccurrenceFloor(to: from)+ guard occurrenceIndex < occurrences.count,+ occurrences[occurrenceIndex].identifier == identifier else { return nil }+ return occurrences[occurrenceIndex]+ }++ /// Drops every occurrence that starts before `from` — the walk has moved past+ /// their source, so nothing it renders from here on can have come from them.+ private mutating func advanceOccurrenceFloor(to from: Int) { while occurrenceIndex < occurrences.count, occurrences[occurrenceIndex].sourceStart < from { occurrenceIndex += 1 }+ }++ // MARK: Occurrences consumed outside text position (T-1992)++ /// Restores the markers in text that landed in a NON-text position — an image's+ /// alt or `src`, a link destination, raw inline HTML — and claims the occurrences+ /// those markers came from. (Titles land there too, but render nothing, so they+ /// call `claimOccurrences` directly rather than going through here.)+ ///+ /// Restoring alone is not enough. The pre-pass marks an occurrence before anything+ /// knows where its characters will land, so an occurrence consumed by an attribute+ /// is still in the list, and these nodes render no run, so the cursor does not move+ /// past it either. A later text-position marker with the same identifier then binds+ /// to it — `liveOccurrence(atOrAfter:)` answers with the attribute's occurrence —+ /// and the prose before the real badge is searched with an upper bound that lies+ /// BEFORE it, so the run comes out zero-length and a selection over that prose is+ /// declined. Claiming the occurrence here is what keeps the two in step (T-1992).+ private mutating func consumingVisibleText(_ text: String) -> String {+ guard !occurrences.isEmpty else { return text }+ claimOccurrences(in: text)+ return FootnoteReferenceMarker.restoringLiterals(text)+ }++ /// Claims the occurrences that `text` — a stretch of source the walk consumed+ /// outside text position — physically covers, and steps the cursor past it.+ ///+ /// The claim is POSITIONAL, and that is the whole of what makes it sound: `text`+ /// is located in the source at or after the cursor, and exactly the occurrences+ /// lying inside that located span are claimed. Nothing is keyed on the identifier,+ /// because an identifier match cannot tell one spelling of `[^1]` from another —+ /// claiming "the next `[^1]` at or after the cursor" is only ever right while the+ /// attribute's own span is known to sit AHEAD of the cursor.+ ///+ /// That precondition is the caller's to honour, and it has exactly one exception:+ /// an autolink (`<http://x/[^1]>`) parses to a `Link` whose destination IS its+ /// `Text` child, so the descent steps the cursor over those characters before the+ /// destination is claimed. `visitLink` filters that case out up front with+ /// `consumedAsAutolinkBody` rather than letting it reach here, because nothing+ /// below can catch it: the autolink's own occurrence is behind the cursor and gets+ /// floored away, so the covering constraint ends up anchored on a LATER occurrence+ /// and accepts the later span that carries it. Otherwise callers pass a node's+ /// parts in SOURCE order (an image's alt before its `src` before its title; a+ /// link's destination only after its text has been walked), because the search+ /// runs forward from the cursor. Out of order, the later part simply fails to+ /// locate and claims nothing — a precision loss, not a mis-claim.+ ///+ /// Locating also fails, claiming nothing, for an attribute cmark hands back+ /// normalised rather than verbatim: an entity- or backslash-decoded destination,+ /// or an alt whose `InlineContainer` markup has been flattened (`Image.plainText`+ /// joins its children's plain text, so emphasis, strong, strikethrough and link+ /// markup all vanish — but NOT a code span, whose `InlineCode.plainText` keeps its+ /// backticks and so still matches the source verbatim). There the walk has no+ /// evidence of where the occurrence sits. Not claiming leaves one occurrence+ /// shadowing until the next text located past it: bounded precision loss. Claiming+ /// one that is still owed would cost a real badge its source offset, so declining+ /// is the safe direction.+ ///+ /// The forward search is what makes that normalised case dangerous, and the+ /// COVERING constraint below is what defuses it. A normalised attribute does not+ /// match at its own offset, but the search does not stop there — it can match a+ /// later span (`![*a*[^1]](x.png) prose `: the first alt's+ /// `a[^1]` matches inside the SECOND image's `src`), and stepping the cursor to+ /// the end of that span would skip prose the walk has not rendered yet. So a+ /// located span is only claimed when it CONTAINS the next unclaimed occurrence: an+ /// occurrence still owed that lies before the located span proves the span is not+ /// the attribute's own, and the claim is refused. Every real attribute span+ /// satisfies it, because the marker it carries IS that occurrence — which is+ /// precisely the premise the autolink breaks, and why it is excluded above rather+ /// than here.+ private mutating func claimOccurrences(in text: String) {+ guard !occurrences.isEmpty,+ text.utf16.contains(where: FootnoteReferenceMarker.isReserved) else { return }+ let units = Array(text.utf16)+ guard let start = locate(units, from: cursor) else { return }+ let end = start + units.count+ // Refuse a span that overshoots the next occurrence the walk still owes.+ // (This subsumes the reserved-unit check above — a span covering an occurrence+ // necessarily carries its marker — which stays only as a fast path.)+ advanceOccurrenceFloor(to: cursor) guard occurrenceIndex < occurrences.count,- occurrences[occurrenceIndex].identifier == identifier else { return nil }- return occurrences[occurrenceIndex]+ occurrences[occurrenceIndex].sourceStart >= start,+ occurrences[occurrenceIndex].sourceStart+ + occurrences[occurrenceIndex].length <= end else { return }+ while occurrenceIndex < occurrences.count,+ occurrences[occurrenceIndex].sourceStart+ + occurrences[occurrenceIndex].length <= end {+ occurrenceIndex += 1+ }+ // `locate` searches from the cursor and `units` is non-empty, so `end > cursor`+ // unconditionally — the step is always forward.+ cursor = end } /// Emits the badge as chrome: no run covers it, and the cursor steps over the@@ -320,9 +424,11 @@ nonisolated struct InlineHTMLRenderer { closeRun() // Record the badge's source position (emission order == DOM order). The // occurrence's own start is authoritative; when it is nil (defensive), the- // cursor sits at the marker's start because the preceding text was just- // located — an approximation that costs eligibility-matching precision for- // one occurrence, never badge correctness.+ // cursor stands in — the marker's own start whenever the preceding text+ // located verbatim, and otherwise the end of the last span the walk+ // accounted for, which need not be an offset any `[^id]` token occupies. An+ // approximation either way: it costs eligibility-matching precision for one+ // occurrence, never badge correctness. badgeSourceStarts.append(occurrence?.sourceStart ?? cursor) // Anchoring on the occurrence's own source range keeps the cursor exact even // when the preceding text did not locate verbatim (escaped or entity-encoded@@ -364,8 +470,9 @@ nonisolated struct InlineHTMLRenderer { // text resolve to no run and are declined (design: hidden comments and // chrome have no run; notes on comments are a non-goal). // Raw HTML is not text position: a marker that landed here is author text- // (e.g. `<span title="[^1]">`), so it is restored before anything else runs.- let rawHTML = visibleText(inlineHTML.rawHTML)+ // (e.g. `<span title="[^1]">`), so it is restored — and its occurrence claimed+ // (T-1992) — before anything else runs.+ let rawHTML = consumingVisibleText(inlineHTML.rawHTML) if let commentText = HTMLCommentParser.parseBlock(rawHTML) { html += "<span class=\"prism-comment-inline\" data-prism-comment-inline>" + HTMLEscaping.escapeText(commentText) + "</span>"@@ -515,10 +622,11 @@ nonisolated struct InlineHTMLRenderer { // Links route through native link handling on click; the href is escaped // into the attribute. Internal anchors/relative md/http(s) are dispatched by // the controller's NavigationDeciding, not here (Req 2.4).- // A link destination is not text position: `[t](http://x/[^1])` keeps the- // literal token in the href rather than turning it into a badge.- let href = link.destination.map { HTMLEscaping.escapeAttribute(visibleText($0)) } ?? ""- appendInlineMarkup("<a href=\"\(href)\">")+ // Neither a link's destination nor its title is text position:+ // `[t](http://x/[^1] "t [^2]")` keeps the literal token in the href, and the+ // title is not rendered at all, rather than either turning into a badge.+ let destination = link.destination ?? ""+ appendInlineMarkup("<a href=\"\(HTMLEscaping.escapeAttribute(visibleText(destination)))\">") // A link's TEXT is text position, but not a position a badge may occupy: the // badge is an anchor, and `<a>` inside `<a>` is invalid HTML. `[see [^1]](url)` // therefore renders the literal `[^1]` inside the link caption rather than a@@ -528,17 +636,88 @@ nonisolated struct InlineHTMLRenderer { descendInto(link) linkDepth -= 1 appendInlineMarkup("</a>")+ // Source order in `[text](dest "title")` is text, destination, title, and the+ // text's own markers are stepped over by the cursor as each text node is+ // located — so both claims belong here, after the descent, in this order.+ // `Link.title` IS exposed by the AST and is never emitted, which makes a+ // reference inside one a live occurrence nothing else would ever account for+ // (T-1992 review). Defensive rather than production-reachable today:+ // `MarkdownBlockParser.convertParagraph` rebuilds the block source with+ // `paragraph.format()`, and swift-markdown's `MarkupFormatter` prints an+ // image title but NOT a link one, so this only fires for a directly built+ // `.paragraph(markdown:)` — or a future source that skips `format()`.+ if !consumedAsAutolinkBody(link, destination) {+ claimOccurrences(in: destination)+ }+ claimOccurrences(in: link.title ?? "")+ }++ /// True when `destination` is THIS link's own body, already consumed by the descent+ /// above — the autolink, the one shape whose destination and link text are the very+ /// same source characters (`<http://x/[^1]>` parses to a `Link` whose destination+ /// IS its single `Text` child).+ ///+ /// Skipping the claim is not just an optimisation. Those characters lie BEHIND the+ /// cursor, so a forward search can only ever find some LATER span — and the+ /// covering constraint in `claimOccurrences` cannot reject it, because the+ /// autolink's own occurrence was consumed by the descent and floored away, leaving+ /// the guard to compare against an occurrence that lives inside the spurious span+ /// and fits it exactly. `see <http://x/[^1]> prose  tail` claimed+ /// the image's span and stepped the cursor over " prose ", collapsing a run that+ /// maps correctly without any claim at all (T-1992 review round 3).+ ///+ /// BOTH the node shape and the source spelling have to agree, because each alone+ /// admits a link whose destination is a second, unconsumed span:+ ///+ /// - Shape alone is `Link.isAutolink` (`destination == the single Text child's+ /// string`), which is equally true of `[http://x/[^1]](http://x/[^1])`, whose+ /// destination is a SECOND source span that must be claimed. Only the angle+ /// brackets tell those two apart.+ /// - Spelling alone reads the characters behind the cursor, which belong to this+ /// link only if its descent actually moved the cursor. A zero-child link+ /// (`[](http://x/[^1])`) descends into nothing, so it inherits wherever the+ /// PREVIOUS node left the cursor — after `<http://x/[^1]>` that is the preceding+ /// autolink's body, angle brackets and all, and the test matches the wrong link+ /// (T-1992 review round 4).+ ///+ /// The shape half is spelled out here rather than calling `Link.isAutolink`, which+ /// swift-markdown declares internal (`Link.swift:89`, no `public`) and so is not+ /// visible outside its module.+ ///+ /// `<…>` is the whole set of autolink spellings the walker can see: swift-markdown+ /// attaches only the `table`, `strikethrough` and `tasklist` GFM extensions+ /// (`CommonMarkConverter.swift:624-626`), never `autolink`, so a bare `http://…`+ /// stays plain text. An email autolink (`<a@b.c>`) has destination `mailto:a@b.c`,+ /// which is not its text, so it fails the shape test and falls through to the+ /// ordinary claim, which simply fails to locate.+ private func consumedAsAutolinkBody(_ link: Link, _ destination: String) -> Bool {+ guard link.childCount == 1, let text = link.child(at: 0) as? Text,+ text.string == destination else { return false }+ let units = Array(destination.utf16)+ let start = cursor - units.count+ guard !units.isEmpty, start > 0, cursor < sourceUTF16.count,+ sourceUTF16[start - 1] == 0x3C, // '<'+ sourceUTF16[cursor] == 0x3E // '>'+ else { return false }+ for offset in 0..<units.count where sourceUTF16[start + offset] != units[offset] {+ return false+ }+ return true } mutating func visitImage(_ image: Image) { // Inline images inside text flow render as the alt text run (block-level // images are handled by the emitter via the dedicated .image case). The // image markup itself is rendered as a void <img> with a rewritten src.- // Neither an image's src nor its alt is text position: a reference-shaped token- // in either is author text and stays literal (it could not be chrome — an- // attribute cannot hold a badge element).- let src = image.source.map { BlockHTMLEmitter.rewriteImageSrc(visibleText($0)) } ?? ""- let alt = visibleText(image.plainText)+ // None of an image's alt, `src` or title is text position: a reference-shaped+ // token in any of them is author text and stays literal (it could not be+ // chrome — an attribute cannot hold a badge element). All three claim their+ // occurrences, in the source order of `` (T-1992). The+ // title is claim-only: the AST exposes it (`Image.title`) but the emitter+ // renders no `title` attribute, so it has no text to restore.+ let alt = consumingVisibleText(image.plainText)+ let src = image.source.map { BlockHTMLEmitter.rewriteImageSrc(consumingVisibleText($0)) } ?? ""+ claimOccurrences(in: image.title ?? "") closeRun() html += "<img src=\"\(HTMLEscaping.escapeAttribute(src))\"" + " alt=\"\(HTMLEscaping.escapeAttribute(alt))\""
diff --git a/prism/Services/WebRendering/FootnoteReferenceScanner.swift b/prism/Services/WebRendering/FootnoteReferenceScanner.swiftindex 1366949..1d4c15f 100644--- a/prism/Services/WebRendering/FootnoteReferenceScanner.swift+++ b/prism/Services/WebRendering/FootnoteReferenceScanner.swift@@ -41,10 +41,11 @@ import Foundation /// A marker is a badge when it reaches text position AND that position is not nested /// inside an element forbidden from containing an anchor — the badge is itself an `<a>`, /// so a `Link` ancestor disqualifies it (`<a>` inside `<a>` is invalid HTML). Every other-/// landing place — a code span, a link destination, an image's alt text, raw inline HTML,-/// or text inside a link — is restored to the literal `[^id]` the author wrote. All of-/// these are decisions about *where the character data ended up*, which the walker knows-/// for free; none is reconstructed from the parsed text.+/// landing place — a code span, a link's destination or title, an image's alt text, `src`+/// or title, raw inline HTML, or text inside a link — is restored to the literal `[^id]`+/// the author wrote (titles excepted: nothing renders them). All of these are decisions+/// about *where the character data ended up*, which the walker knows for free; none is+/// reconstructed from the parsed text. nonisolated enum FootnoteReferenceMarker { /// Identifier characters are shifted by this amount into the Private Use Area.@@ -165,10 +166,10 @@ nonisolated enum FootnoteReferenceScanner { /// other spelling is left exactly as the author wrote it, so the parser handles it the /// way it always has: an escaped token keeps its backslashes for cmark to strip, an /// entity-encoded one is never even a candidate (the source holds `[`, not `[`),- /// and a token inside a code span, a link destination, an image's alt text or a link's- /// text is restored to its literal form at emit time because that is where the marker- /// landed. Marking makes an occurrence a *candidate*; the walk decides whether its- /// landing place may hold a badge.+ /// and a token inside a code span, a link's destination or title, an image's alt text,+ /// `src` or title, or a link's text is restored to its literal form at emit time+ /// because that is where the marker landed. Marking makes an occurrence a *candidate*;+ /// the walk decides whether its landing place may hold a badge. /// /// Cost is O(source): the outer index advances on every iteration, and the identifier /// scans launched from distinct `[^` positions cannot overlap (neither `[` nor `^` is
diff --git a/prismTests/WebRendering/FootnoteBadgeSubstitutionTests.swift b/prismTests/WebRendering/FootnoteBadgeSubstitutionTests.swiftindex 12e1bc5..e281695 100644--- a/prismTests/WebRendering/FootnoteBadgeSubstitutionTests.swift+++ b/prismTests/WebRendering/FootnoteBadgeSubstitutionTests.swift@@ -480,3 +480,337 @@ struct FootnoteBadgeNestedContextTests { #expect(FootnoteRenderProbe.text(html).contains("outer [^1]")) } }++// MARK: - I: a non-text occurrence followed by a live one of the same id (T-1992)+//+// D3/D4 already pin that a reference in an image alt or a link destination renders+// literally — but they follow it with an ESCAPED token, which is never a candidate, so+// nothing there could bind to the wrong occurrence. When the token that follows is a+// LIVE one with the same identifier, the walker asks for "the first occurrence at or+// after the cursor with this identifier" and gets the attribute's occurrence, because+// nothing claimed it. The badge still renders (the count looks right), but it is+// recorded at the attribute's source offset — which is what search occurrence identity+// and the source map are built on.++struct FootnoteNonTextOccurrenceTests {++ private func footnotes(_ identifiers: [String]) -> FootnoteData {+ FootnoteRenderProbe.footnotes(identifiers)+ }++ /// The badge source offsets the walk records for a one-paragraph source.+ private func badgeStarts(_ source: String, _ identifiers: [String] = ["1"]) -> [Int] {+ InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: footnotes(identifiers))+ }++ /// The source substrings the emitted runs cover, in the paragraph's own coordinates.+ ///+ /// Badge count and rendered text are both already correct when an occurrence is+ /// mis-claimed — the damage is entirely in the coordinates — so every case here+ /// pins the run spans as well, or it pins nothing (T-1992 review).+ private func runSpans(_ source: String, _ identifiers: [String] = ["1"]) -> [String] {+ let doc = BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: source)],+ footnotes: footnotes(identifiers), settings: RenderSettings()+ )+ let units = Array(source.utf16)+ return (doc.sourceMap.runs.values.first ?? []).compactMap { run in+ let end = run.sourceStart + run.length+ guard run.length > 0, run.sourceStart >= 0, end <= units.count else { return nil }+ return String(decoding: units[run.sourceStart..<end], as: UTF16.self)+ }+ }++ /// The offset of the live `[^id]` token — the one spelled with a space on both sides.+ private func liveTokenStart(_ source: String) -> Int {+ (source as NSString).range(of: " [^1] ").location + 1+ }++ @Test("I1: image alt then a live reference of the same id badges only the live one")+ func imageAltThenLiveReference() {+ let source = "![[^1]](x.png) choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(html.contains("alt=\"[^1]\""))+ #expect(FootnoteRenderProbe.text(html) == "choose {1} after")+ #expect(runSpans(source) == [" choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ @Test("I2: link destination then a live reference of the same id badges only the live one")+ func linkDestinationThenLiveReference() {+ let source = "[link](http://x/[^1]) choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(html.contains("href=\"http://x/[^1]\""))+ #expect(FootnoteRenderProbe.text(html) == "link choose {1} after")+ #expect(runSpans(source) == ["link", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ @Test("I3: the badge source start is the LIVE token's offset, not the image alt's")+ func badgeSourceStartSkipsImageAltOccurrence() {+ // `badgeSourceStarts` is how `SearchStateFeeder` decides which `[^id]`-shaped+ // occurrence owns a DOM badge (T-1853). Reporting the alt's offset makes the+ // feeder mark the wrong occurrence eligible and address the wrong badge.+ let source = "![[^1]](x.png) choose [^1] after"+ let starts = InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: footnotes(["1"]))+ #expect(starts == [(source as NSString).range(of: " [^1] ").location + 1])+ }++ @Test("I4: the badge source start is the LIVE token's offset, not the destination's")+ func badgeSourceStartSkipsLinkDestinationOccurrence() {+ let source = "[link](http://x/[^1]) choose [^1] after"+ let starts = InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: footnotes(["1"]))+ #expect(starts == [(source as NSString).range(of: " [^1] ").location + 1])+ }++ @Test("I5: the badge source start is the LIVE token's offset, not the inline HTML's")+ func badgeSourceStartSkipsInlineHTMLOccurrence() {+ let source = "pre <em title=\"[^1]\"></em> choose [^1] after"+ let starts = InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: footnotes(["1"]))+ #expect(starts == [(source as NSString).range(of: " [^1] ").location + 1])+ }++ // MARK: Titles — exposed by the AST, so they hold live occurrences too++ @Test("I6: the badge source start is the LIVE token's offset, not the link TITLE's")+ func badgeSourceStartSkipsLinkTitleOccurrence() {+ // `Link.title` is exposed by swift-markdown 0.7.3, so the token in the title is a+ // live occurrence the walker is handed. Nothing emits it, so unless it is claimed+ // the badge that follows binds to it and reports the title's offset instead.+ //+ // The walker in isolation, not a production shape: a parsed document reaches the+ // walker through `paragraph.format()`, which prints an image title but drops a+ // link one, so only this directly-built source carries it. Kept as a guard on the+ // walker's own contract (and against a future non-`format()` source).+ let source = "[link](http://x \"t [^1]\") choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(FootnoteRenderProbe.text(html) == "link choose {1} after")+ #expect(runSpans(source) == ["link", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ @Test("I7: the badge source start is the LIVE token's offset, not the image TITLE's")+ func badgeSourceStartSkipsImageTitleOccurrence() {+ let source = " choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(FootnoteRenderProbe.text(html) == "choose {1} after")+ #expect(runSpans(source) == [" choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ // MARK: Autolinks — the destination IS the link text, so it must not be claimed twice++ @Test("I8: an autolink's single occurrence is claimed once, not twice")+ func autolinkDestinationIsNotDoubleClaimed() {+ // `<http://x/[^1]>` parses to a Link whose destination is its own Text child. The+ // text descent already accounts for that occurrence, so an identifier-keyed+ // destination claim floors past it onto the LIVE occurrence and consumes a real+ // badge's slot — both prose runs collapse and the recorded offset matches no+ // token at all.+ //+ // With only ONE occurrence of the token in the block, the positional claim is also+ // safe by accident: the forward search finds nothing and refuses. I14-I16 add the+ // second occurrence that gives the search something to land on, which is what+ // `consumedAsAutolinkBody` exists for.+ //+ // NOT red against `main` (which claims nothing at all, so nothing can double-claim):+ // this is a guard against the identifier-keyed claim this branch first shipped and+ // then replaced. It pins that regression out, it does not reproduce T-1992.+ let source = "<http://x/[^1]> choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(FootnoteRenderProbe.text(html) == "http://x/[^1] choose {1} after")+ #expect(runSpans(source) == ["http://x/[^1]", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ @Test("I9: a link whose text equals its destination still claims the destination")+ func linkTextEqualToDestinationStillClaimsDestination() {+ // Same AST shape as I8 (`destination == the single Text child's string`, i.e.+ // `Link.isAutolink` is true for BOTH) but an ordinary inline link: the destination+ // is a SECOND source span holding a second occurrence, which must be claimed.+ // Distinguishing THESE two by node shape cannot work; only the SOURCE spelling+ // can, which is why `consumedAsAutolinkBody` reads the angle brackets around the+ // characters sitting immediately behind the cursor. (It requires the shape too,+ // for the separate reason I17 pins — the two tests bound the predicate from+ // opposite sides.)+ //+ // Also walker-in-isolation: `paragraph.format()` condenses `[url](url)` back into+ // the `<url>` autolink of I8, so the two cover one production spelling between+ // them. The discrimination they pin is still the walker's to get right.+ let source = "[http://x/[^1]](http://x/[^1]) choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(runSpans(source) == ["http://x/[^1]", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ // MARK: Normalised attributes — the locate must fail closed, never overshoot+ //+ // The claim locates an attribute's text in the block source. cmark does not always+ // hand the attribute back verbatim: `Image.plainText` joins its children's plain+ // text, so any inline markup inside the alt is normalised away, and an entity or+ // backslash escape in a destination is decoded. The attribute's own span then does+ // not match, and because the search runs forward it can match a LATER span instead —+ // stepping the cursor over prose that has not been rendered yet.+ //+ // None of these are red against `main` (which claims nothing, so the cursor never+ // moves on an attribute); they pin out the overshoot this branch's claim introduced.+ // The bound is positional: a located span is only claimed when it COVERS the next+ // unclaimed occurrence, so a span that skips one is rejected by construction.++ @Test("I10: an alt normalised by inline markup does not claim a later attribute's span")+ func normalisedImageAltDoesNotOvershoot() {+ // `Image.plainText` for `![*a*[^1]](x.png)` is `a[^1]` — the source spells+ // `*a*[^1]`, so the alt cannot locate at its own offset. The only forward match is+ // inside the SECOND image's src, and claiming it would put the cursor past+ // " prose ", collapsing a run that maps correctly today.+ //+ // The one normalisation case that IS production-reachable: `paragraph.format()`+ // round-trips `![*a*[^1]](x.png)` verbatim, so this spelling reaches the walker+ // from a parsed document, not only from a directly-built `.paragraph(markdown:)`.+ let source = "![*a*[^1]](x.png) prose  tail"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == [" prose ", " tail"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I11: an alt normalised by a link child does not claim a later attribute's span")+ func linkNormalisedImageAltDoesNotOvershoot() {+ // Same normalisation class as I10 through a different child node: `Link` is an+ // `InlineContainer`, so `Image.plainText` for `[^1]](x.png)` is `a[^1]`+ // and the alt cannot locate at its own offset. The only forward match is inside+ // the SECOND image's src, which the covering constraint refuses.+ let source = "[^1]](x.png) prose  tail"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == [" prose ", " tail"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I11b: an alt holding a code span locates verbatim and claims its OWN span")+ func codeSpanImageAltClaimsItsOwnSpan() {+ // NOT a normalisation case, despite the neighbouring shapes: `InlineCode.plainText`+ // returns the code WITH its backticks (swift-markdown `InlineCode.swift:51`), so+ // `Image.plainText` for ``![`a`[^1]](x.png)`` is ``` `a`[^1] ``` — byte-identical to+ // the source. Only `InlineContainer` children (emphasis, strong, strikethrough,+ // link) normalise. The alt therefore locates at its own offset and the claim is+ // ACCEPTED; the second image's src then claims its own span in turn. Pinned as the+ // accept path it is, so the boundary between "verbatim" and "normalised" stays+ // where swift-markdown actually draws it.+ let source = "![`a`[^1]](x.png) prose  tail"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == [" prose ", " tail"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I12: an entity-decoded destination does not claim a later attribute's span")+ func entityDecodedDestinationDoesNotOvershoot() {+ // Destinations are entity-decoded, so the source `x&/[^1]` reaches the walker+ // as `x&/[^1]` and cannot locate at its own offset either.+ //+ // Walker-in-isolation, like I6/I9: `paragraph.format()` prints the DECODED+ // destination (`[l](x&/[^1])` comes back as `[l](x&/[^1])`), and every inline+ // source that reaches the walker in production is rebuilt by `format()`/`plainText`+ // (`MarkdownBlockParser.convertParagraph` and friends), so an entity-encoded+ // destination never survives to here. I10 carries the production-reachable half of+ // this class; this case guards the walker's own contract.+ let source = "[l](x&/[^1]) prose  tail"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == ["l", " prose ", " tail"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I13: an attribute holding no reference leaves the live occurrence alone")+ func markerFreeAttributeClaimsNothing() {+ // The claim must be inert for an attribute with no footnote token in it, or every+ // ordinary image in a document with footnotes would move the cursor. Green before+ // the bound too (the reserved-unit fast path already rejected it); pinned because+ // nothing else covered it and it is the shape a refactor is most likely to break.+ let source = " choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(runSpans(source) == [" choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ // MARK: An autolink whose occurrence repeats later — the covering constraint's blind spot+ //+ // I8 pins that an autolink's single occurrence is not double-claimed, but it has only+ // ONE occurrence, so the destination's forward search finds nothing and the claim is+ // refused for free. Add a second occurrence of the same token later in the block and+ // the search DOES find something — and the covering constraint cannot object, because+ // the autolink's own occurrence was consumed by the text descent and floored away, so+ // the guard is left comparing against the later occurrence, which the spurious span+ // covers exactly. The claim is accepted and the cursor jumps over the prose in+ // between (T-1992 review round 3).+ //+ // Unlike I6/I9/I12 these are reachable block sources: `paragraph.format()` round-trips+ // all three spellings verbatim.++ @Test("I14: an autolink before an image src holding the same token keeps the prose run")+ func autolinkBeforeMatchingImageSourceDoesNotOvershoot() {+ let source = "see <http://x/[^1]> prose  tail"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == ["see ", "http://x/[^1]", " prose ", " tail"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I15: two autolinks carrying the same token keep every run between them")+ func repeatedAutolinkDoesNotOvershoot() {+ let source = "<http://x/[^1]> and <http://x/[^1]>"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 0)+ #expect(runSpans(source) == ["http://x/[^1]", " and ", "http://x/[^1]"])+ #expect(badgeStarts(source).isEmpty)+ }++ @Test("I16: an autolink before an ordinary link with the same token still claims the link's")+ func autolinkBeforeOrdinaryLinkStillClaimsThatLinksDestination() {+ // The refusal must be scoped to the autolink's OWN destination, not to every+ // destination carrying that identifier: the second link's destination is a+ // genuine, unconsumed source span and has to be claimed like any other.+ //+ // A LIVE token follows, because that is the only thing that can tell the two+ // apart. Run spans and a zero badge count come out identical whether or not the+ // second destination is claimed — an unclaimed occurrence only shows itself when+ // a badge later binds to it. With the live token there, the badge's recorded+ // offset IS the assertion: `liveTokenStart` when the destination was claimed, the+ // destination's own offset when it was not (T-1992 review round 4).+ let source = "<http://x/[^1]> and [see](http://x/[^1]) choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(runSpans(source) == ["http://x/[^1]", " and ", "see", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }++ @Test("I17: a zero-child link after an autolink still claims its own destination")+ func zeroChildLinkAfterAutolinkStillClaimsItsDestination() {+ // `consumedAsAutolinkBody` reads the source characters immediately BEHIND the+ // cursor, and a zero-child link (`[](url)`) descends into nothing — so the cursor+ // is still parked where the PRECEDING autolink's descent left it: on the `>`, with+ // that autolink's body behind it. The bracket test therefore matches the wrong+ // link's body and refuses a claim that is genuinely owed; the second destination's+ // occurrence survives and the live badge after it binds there instead.+ //+ // Conjoining the AST shape — a link with exactly one `Text` child equal to its+ // destination, which is `Link.isAutolink`'s definition (internal to+ // swift-markdown, so spelled out locally) — settles it: a zero-child link is not+ // autolink-shaped whatever sits behind the cursor, and the two shapes the source+ // test exists to separate (I8 vs I9) are identical under it, so it can only ever+ // narrow the refusal, never widen it.+ let source = "<http://x/[^1]>[](http://x/[^1]) choose [^1] after"+ let html = FootnoteRenderProbe.emit(source)+ #expect(FootnoteRenderProbe.badgeCount(html) == 1)+ #expect(runSpans(source) == ["http://x/[^1]", " choose ", " after"])+ #expect(badgeStarts(source) == [liveTokenStart(source)])+ }+}
diff --git a/prismTests/WebRendering/DocumentSourceMapTests.swift b/prismTests/WebRendering/DocumentSourceMapTests.swiftindex d252113..9c9428d 100644--- a/prismTests/WebRendering/DocumentSourceMapTests.swift+++ b/prismTests/WebRendering/DocumentSourceMapTests.swift@@ -300,6 +300,163 @@ struct DocumentSourceMapInvariantTests { #expect(wordRange?.selectedText == "after") } + // MARK: - Non-text occurrences must not claim a later badge's slot (T-1992)+ //+ // The scanner marks every resolvable `[^id]` token before the parse, so a token that+ // lands in an image's alt/src, a link destination, or raw inline HTML is a live+ // occurrence too — the walker only finds out where it landed once it is holding it.+ // Those landing places restore the marker to literal text and emit no badge, so their+ // occurrence has to be CLAIMED; otherwise the next text-position marker with the same+ // identifier binds to it, the prose before the real badge is searched with an upper+ // bound that lies before it, and the run comes out zero-length or anchored over the+ // image/link syntax. Each case below pins the prose run's exact source span.++ @Test("A reference in an image alt does not steal a later badge's occurrence")+ func imageAltReferenceDoesNotStealLaterOccurrence() {+ let source = "![[^1]](x.png) choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" choose ", " after"])+ #expect(runs.first?.sourceStart == (source as NSString).range(of: " choose ").location)+ }++ @Test("A reference in an image src does not steal a later badge's occurrence")+ func imageSourceReferenceDoesNotStealLaterOccurrence() {+ let source = " choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" choose ", " after"])+ }++ @Test("A reference in a link destination does not steal a later badge's occurrence")+ func linkDestinationReferenceDoesNotStealLaterOccurrence() {+ let source = "[link](http://x/[^1]) choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["link", " choose ", " after"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ }++ @Test("A reference in raw inline HTML does not steal a later badge's occurrence")+ func inlineHTMLReferenceDoesNotStealLaterOccurrence() {+ // The element is empty on purpose: any text node between the raw HTML and the+ // live reference would advance the cursor past the stale occurrence on its own,+ // which is why the defect hides behind the more obvious spellings.+ let source = "pre <em title=\"[^1]\"></em> choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["pre ", " choose ", " after"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ }++ @Test("An image alt whose identifier differs still leaves the live occurrence intact")+ func imageAltWithOtherIdentifierKeepsLiveOccurrence() {+ // Claiming in source order matters: the alt's `[^2]` precedes the src's `[^1]`,+ // so claiming the src first would test `[^1]` against the alt's occurrence,+ // claim nothing, and leave the src occurrence to shadow the real badge.+ let source = "![[^2]](x/[^1].png) choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1", "2"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" choose ", " after"])+ }++ @Test("A link whose TEXT and destination both hold references keeps the live one")+ func linkTextAndDestinationReferencesKeepLiveOccurrence() {+ // Link text is text position (so its marker is walked, located, and stepped over+ // by the cursor) while the destination is not — and the destination follows the+ // text in source order, so it must be claimed after the descent.+ let source = "[see [^1]](http://x/[^1]) choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["see [^1]", " choose ", " after"])+ }++ @Test("A reference in a link TITLE does not steal a later badge's occurrence")+ func linkTitleReferenceDoesNotStealLaterOccurrence() {+ // `Link.title` IS exposed by swift-markdown (0.7.3, `Link.swift`), so a token in+ // a title is a live occurrence the walker holds — it is simply never emitted, and+ // an unclaimed one shadows the real badge exactly as a destination's used to.+ // Defensive: a parsed document reaches the walker via `paragraph.format()`, which+ // prints an image title but drops a link one, so only a directly built source+ // like this one carries it.+ let source = "[link](http://x \"t [^1]\") choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["link", " choose ", " after"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ }++ @Test("A reference in an image TITLE does not steal a later badge's occurrence")+ func imageTitleReferenceDoesNotStealLaterOccurrence() {+ // `Image.title` is exposed too; the image renders no run at all, so the whole+ // burden of accounting for its occurrence falls on the claim.+ let source = " choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" choose ", " after"])+ #expect(runs.first?.sourceStart == (source as NSString).range(of: " choose ").location)+ }++ @Test("An autolink's destination is its own link text and is claimed only once")+ func autolinkDestinationIsNotDoubleClaimed() {+ // `<http://x/[^1]>` parses to a Link whose destination IS its Text child, so the+ // one source occurrence is reachable by two paths. The text descent already steps+ // the cursor over it; a destination claim keyed on identifier alone would then+ // floor PAST it onto the live occurrence at " [^1] " and consume a real badge's+ // slot, leaving both prose runs zero-length. Positional correspondence is what+ // rules that out: the destination's characters sit behind the cursor.+ let source = "<http://x/[^1]> choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["http://x/[^1]", " choose ", " after"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ }++ @Test("A link whose text equals its destination still claims the destination")+ func linkTextEqualToDestinationStillClaimsDestination() {+ // The autolink shape test "destination == the text child's string" is TRUE here+ // too, but this is an ordinary inline link: the destination is a SECOND source+ // span, holding a second occurrence that must be claimed. Only a positional+ // claim tells the two apart — the destination here is ahead of the cursor.+ // `paragraph.format()` condenses this spelling back into the autolink above, so+ // the pair pins the walker's discrimination rather than two production shapes.+ let source = "[http://x/[^1]](http://x/[^1]) choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == ["http://x/[^1]", " choose ", " after"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ }++ @Test("A normalised alt does not claim a later attribute's span")+ func normalisedImageAltDoesNotOvershootOntoLaterAttribute() {+ // `Image.plainText` joins its children's plain text, so the alt of+ // `![*a*[^1]](x.png)` is `a[^1]` while the source spells `*a*[^1]`. The attribute+ // cannot locate at its own offset, and the forward search's only match is inside+ // the SECOND image's src — claiming it would step the cursor past " prose " and+ // record that run zero-length. The claim is therefore bounded: a located span is+ // only claimed when it covers the next unclaimed occurrence, so a span that skips+ // one is rejected and nothing is claimed (the safe direction).+ //+ // Green on `main` (nothing claims there, so the cursor cannot overshoot); this+ // pins out a failure mode introduced by this branch's claim, not T-1992 itself.+ let source = "![*a*[^1]](x.png) prose  tail"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" prose ", " tail"])+ #expect(runs.first?.sourceStart == (source as NSString).range(of: " prose ").location)+ }++ @Test("An attribute holding no reference claims nothing")+ func markerFreeAttributeLeavesLiveOccurrenceIntact() {+ // The inert case: an ordinary image in a document that has footnotes must not move+ // the cursor or consume the live occurrence that follows it.+ let source = " choose [^1] after"+ let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))+ let spans = runs.compactMap { sourceSpan($0, in: source) }+ #expect(spans == [" choose ", " after"])+ #expect(runs.first?.sourceStart == (source as NSString).range(of: " choose ").location)+ }+ @Test("UTF-16 coordinate space: astral chars count as surrogate pairs") func utf16Coordinates() { // One astral scalar = 2 UTF-16 units; the run length must reflect that.
diff --git a/prismTests/WebRendering/WebSelectionNoteTests.swift b/prismTests/WebRendering/WebSelectionNoteTests.swiftindex 9ae65b7..e55c310 100644--- a/prismTests/WebRendering/WebSelectionNoteTests.swift+++ b/prismTests/WebRendering/WebSelectionNoteTests.swift@@ -320,6 +320,70 @@ struct WebSelectionNoteTests { #expect(coordinator.addNoteTextRange?.selectedText == "gamma") } + // MARK: - Selections after a NON-TEXT footnote token (T-1992)+ //+ // A `[^id]`-shaped token in an image's alt/src, a link destination, or raw inline+ // HTML is marked live by the pre-pass (nothing knows where it will land until the+ // walker holds it) and then restored to literal text. Unless its occurrence is+ // claimed, the next same-id text-position badge binds to it, and the prose in+ // between is searched with an upper bound that lies BEFORE it — so the prose run+ // comes out zero-length and Add Note over "choose" is declined.++ /// Resolves the whole run at `runIndex` and asserts it quotes `expected`.+ private func expectRunQuotes(+ _ harness: WebDocumentLiveHarness,+ block: MarkdownBlock,+ runIndex: Int,+ source: String,+ expected: String+ ) async throws {+ let resolved = try await resolveWholeRun(+ harness, domID: domID(block), runIndex: runIndex, startOffset: 1+ )+ let start = (resolved?["start"] as? NSNumber)?.intValue ?? -1+ let length = (resolved?["length"] as? NSNumber)?.intValue ?? -1+ #expect(start == (source as NSString).range(of: expected).location)+ #expect(length == (expected as NSString).length)++ let session = DocumentSession(clipboardContent: "x")+ session.parsedBlocks = [block]+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(+ session: session, coordinator: coordinator,+ notesManager: NotesManager.makeForTesting(store: MockNotesStore())+ )+ router.createSelectionNote(+ blockID: domID(block), range: .init(start: start, length: length)+ )+ #expect(coordinator.addNoteTextRange?.selectedText == expected)+ }++ @Test("A live selection before a badge whose id also appears in an image alt resolves")+ func liveSelectionAfterImageAltLookAlikeResolvesSelectedText() async throws {+ let source = "![[^1]](x.png) choose [^1] after"+ let para = MarkdownBlock.paragraph(markdown: source)+ let harness = try await WebDocumentLiveHarness.make(+ blocks: [para], footnotes: footnoteData(["1"]), featureScripts: Self.notesScripts+ )+ // Run 0 is " choose " (the image contributes no run); offset 1 selects "choose ".+ try await expectRunQuotes(+ harness, block: para, runIndex: 0, source: source, expected: "choose "+ )+ }++ @Test("A live selection before a badge whose id also appears in a link destination resolves")+ func liveSelectionAfterLinkDestinationLookAlikeResolvesSelectedText() async throws {+ let source = "[link](http://x/[^1]) choose [^1] after"+ let para = MarkdownBlock.paragraph(markdown: source)+ let harness = try await WebDocumentLiveHarness.make(+ blocks: [para], footnotes: footnoteData(["1"]), featureScripts: Self.notesScripts+ )+ // Run 0 is the link caption, run 1 is " choose ".+ try await expectRunQuotes(+ harness, block: para, runIndex: 1, source: source, expected: "choose "+ )+ }+ @Test("A cross-block selection is reported as declined (crossBlock), not a range") func liveCrossBlockDeclined() async throws { let first = MarkdownBlock.paragraph(markdown: "first block")
diff --git a/CLAUDE.md b/CLAUDE.mdindex a054e97..e4830ec 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -63,7 +63,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 2. `MarkdownBlockParser.parseWithFootnotes()` integrates the preprocessor into the parsing pipeline; `parseFragment()` renders footnote content in popovers without the full pipeline 3. `DocumentSession` stores `FootnoteData` alongside parsed blocks; both are bundled in `ParseResult` 4. Badge substitution happens inside `InlineHTMLRenderer.render`, NOT by splitting the source in the emitter (T-1716/T-1945). `FootnoteReferenceScanner.scan` walks the block's inline SOURCE once and marks every occurrence that is spelled verbatim (no backslash escape anywhere in the token) and resolvable, rewriting only its identifier characters into a reserved Private Use Area window. The marker is length-preserving (runs come out in block coordinates — no rebasing, `rebased` is gone) and parse-shape-preserving (`[`, `^`, `]` untouched, so cmark builds the same tree). The whole marked source is then parsed ONCE-5. `InlineHTMLRenderer.Walker` turns a marker into a badge only where a badge is legal: TEXT position **and** not nested inside an element that cannot contain an anchor. Text position alone is not sufficient — the badge is itself an `<a>`, so a `Link` ancestor disqualifies it (`<a>` inside `<a>` is invalid HTML), tracked by the walker's `linkDepth` counter. Every other landing place — a code span, a link destination, an image `src`/`alt`, raw inline HTML, or text inside a link — is restored to the literal `[^id]` the author wrote. Classification is never inferred from parsed text, which is the failure class the redesign removes. `BlockHTMLEmitter.footnoteBadge` builds the styled pill-badge anchor (`prism://footnote/{id}`, display number from `FootnoteData`) carrying `data-prism-chrome`: inert, unselectable, covered by no source-map run+5. `InlineHTMLRenderer.Walker` turns a marker into a badge only where a badge is legal: TEXT position **and** not nested inside an element that cannot contain an anchor. Text position alone is not sufficient — the badge is itself an `<a>`, so a `Link` ancestor disqualifies it (`<a>` inside `<a>` is invalid HTML), tracked by the walker's `linkDepth` counter. Every other landing place — a code span, a link's destination or title, an image `src`/`alt`/title, raw inline HTML, or text inside a link — is restored to the literal `[^id]` the author wrote (titles excepted: the emitter renders no `title` attribute, so there is nothing to restore). Classification is never inferred from parsed text, which is the failure class the redesign removes. Restoring is only half of it: a landing place that declines the badge must also CLAIM the occurrence and step the cursor past it (`consumingVisibleText`/`claimOccurrences`, used by `visitImage` alt-then-src-then-title, `visitInlineHTML`, and `visitLink` for destination-then-title *after* the descent). Text position claims implicitly, because `appendVisible` locates the text and moves the cursor; the non-text paths render no run, so they must claim explicitly or a later live reference with the same identifier binds to the attribute's occurrence — zero-length source-map run for the prose before the real badge, wrong offset in `badgeSourceStarts` (T-1992). The claim is POSITIONAL, never keyed on the identifier: `claimOccurrences` locates the attribute text in the source at or after the cursor and claims exactly the occurrences inside that span. It is sound only while the attribute's own span sits AHEAD of the cursor, and exactly one shape breaks that — an autolink (`<http://x/[^1]>`), whose destination IS its own `Text` child, so the descent has already stepped the cursor over those characters. `visitLink` filters it out before the claim (`consumedAsAutolinkBody`, keyed on the source's angle brackets: `Link.isAutolink` is derived from `destination == the single Text child's string` and so is equally true of `[url](url)`, whose destination is a second span that MUST be claimed; and `<…>` is the whole set, because swift-markdown attaches only the `table`/`strikethrough`/`tasklist` GFM extensions, never `autolink`). Failing to locate claims nothing, a bounded precision loss for an attribute cmark hands back normalised rather than verbatim (`Image.plainText` joins its children's plain text, so `InlineContainer` markup — emphasis, strong, strikethrough, link — is flattened out of an alt; a code span is NOT, because `InlineCode.plainText` returns the code *with* its backticks and so still matches the source; destinations are entity-decoded). Because the search runs FORWARD, a normalised attribute can also match a *later* span, so a located span is only claimed when it CONTAINS the next unclaimed occurrence — an occurrence still owed that lies before the span proves the span is not the attribute's own, and the claim is refused rather than stepping the cursor over prose that has not been rendered yet. That covering constraint is also why the autolink needs its own filter and cannot be caught here: its occurrence is behind the cursor and gets floored away, leaving the constraint anchored on a later occurrence that the spurious span covers exactly. Only the IMAGE title claim is production-reachable: `MarkdownBlockParser.convertParagraph` rebuilds the block source with `paragraph.format()`, and swift-markdown's `MarkupFormatter` prints an image title but drops a link title (and collapses `[url](url)` back to an autolink), so the link-title claim is defensive against a future non-`format()` source. `BlockHTMLEmitter.footnoteBadge` builds the styled pill-badge anchor (`prism://footnote/{id}`, display number from `FootnoteData`) carrying `data-prism-chrome`: inert, unselectable, covered by no source-map run 6. Badge taps produce `prism://footnote/{id}` links → `linkActivated` over the bridge → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` popover state (`PrismLinkRoute.footnotePrefix`) 7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform. The popover page is bridge-less, so everything the document surface receives over the bridge must instead be BAKED into the emitted HTML via `RenderSettings`. Baked today: the palette — theme key **and** Increase Contrast state, carried as one `WebPaletteFeed` so they can never be applied out of step (T-1542/T-1829), emitted as `data-prism-theme`/`data-prism-contrast` by `BlockHTMLEmitter.paletteAttributes`; and the typography custom properties (T-1978, emitted as a validated `<html style>` by `BlockHTMLEmitter.rootStyleAttribute`). Not baked: the mermaid theme config and the note/search/section state, none of which a footnote fragment can contain. `FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:)` is the single pure builder of that render input — it reuses `WebDocumentControllerFactory.typographyVariables` rather than resolving fonts again (font-settings Decision 18), and its equatable result is one half of the re-present key. Because a bake can only be applied by re-emitting, an OPEN popover stays current only by re-presenting: `FootnotePopoverWebPage.RenderKey` (footnote id + the resolved `FootnoteDefinition` + that `RenderSettings`) is the equatable trigger, and `FootnotePopoverView` drives it with one `.onChange(of: renderKey, initial: true)` — evaluated in `body`, which is what registers the `AppSettings` reads with Observation (reading them only inside `onAppear` registered nothing). `present` no-ops on an unchanged key (an identical re-emit would blank the live page mid-read) and `renderGeneration` guards overlapping loads so a superseded one cannot win; it also varies the document URL's `rev`. Keying on `footnoteId` alone was T-1979: the identifier is the one input that does not move when the theme, contrast, typography, comment visibility, or a same-id definition does 8. `FootnoteStripping` removes `[^id]` references from heading text used in ToC entries, anchor IDs, and window titles
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 45fa2fc..6b2770e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Adding a note to text in a paragraph that also mentions a footnote reference inside an image, a link address, an image tooltip, or raw HTML now works (T-1992). In a paragraph like `![[^1]](cat.png) choose [^1] after` — or the same with the reference written inside a link address such as `[link](http://example.com/[^1])`, inside an image's tooltip text such as ``, or inside raw HTML — the badge still appeared in the right place, but the app matched it to the reference-shaped text inside the image, address or tooltip rather than the real one. Selecting the words in between and choosing **Add Note** was then declined, or saved a note quoting the wrong text and pointing at the image or link syntax, which the note carried into relocation and inline-note export. Stepping search onto such a footnote could also mark the wrong badge. Text that merely looks like a reference in those positions is now accounted for, so the words either side of a badge map to what you actually selected. This is separate from the earlier fix for selecting after a badge (T-1876); footnotes inside list items and table cells are still tracked separately. - Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings updates in place, as of the fix below (T-1979). - An open footnote popover now keeps up with the document instead of freezing at the moment it opened (T-1979). Switching to dark mode, turning **Increase Contrast** on, changing the **Body Font**, moving the **Text Size** slider, changing the system text size, or toggling **Show HTML comments** all left a popover that was already on screen showing the settings it opened with — and if the file changed on disk (or a URL document was refreshed) while the popover was open, the "Footnote N" heading could update to the new document while the footnote text below it still showed the old definition. The popover now re-renders whenever anything it displays changes, including replaced footnote content under the same reference, and leaves itself alone when nothing did, so reading is not interrupted by needless redraws. Changes arriving in quick succession settle on the newest one rather than whichever finished last. - Search and reading-position restore no longer lose their place to content the document hides (T-1944). Three faults shared one cause: hidden content — the body of a collapsed section, or the carrier holding a document's YAML frontmatter — measures as a zero-size box sitting exactly at the top of the window, and several parts of the app read that as "visible". Stepping to a search match inside a collapsed section silently did nothing: the section expanded, but the app had already treated the invisible match as on screen and skipped the scroll; the match is now brought to its usual resting place a third of the way down the window as soon as the section opens. Reopening a document after collapsing the section you were reading left the page at the top instead of anywhere near your place; the restore now lands on the collapsed heading that hides the saved block — and a stale saved position pointing at the hidden frontmatter carrier now falls back to the top of the document instead of doing nothing. Hidden matches also no longer count as on-screen when deciding which highlights to draw, and expanding a section lights its highlights up immediately instead of waiting for the next scroll. The zero-size test now lives in one shared check used by every reader of section layout, so a future feature cannot quietly reintroduce the assumption. Matches inside nested collapsed disclosure blocks (`<details>`) are tracked separately (T-1930).
Both sides append tests to DocumentSourceMapInvariantTests and WebSelectionNoteTests. A resolution that drops one side's additions still compiles and still passes. After rebasing, confirm DocumentSourceMapInvariantTests carries both T-1941's listItemRunsUseJoinedCoordinates / tableCellRunsUseJoinedCoordinates / subspanOffsetsAgreeWithTextContent / the multi-byte pair and T-1992's twelve non-text-occurrence cases, and that the totals go up rather than sideways.
Beyond ordering the two bullets: finding B says this branch's bullet needs narrowing anyway. Doing both in one pass avoids editing the same paragraph twice.
My numbers came from a probe suite appended to FootnoteBadgeSubstitutionTests.swift, run, and reverted — the working tree is clean and git status shows nothing. If you want it reproduced, the two inputs are ![*a*[^1]](x.png) choose [^1] after and [l](x&/[^1]) choose [^1] after, both with footnotes(["1"]), asserting badgeSourceStarts == [liveTokenStart(source)]. Both fail on this branch and on main.
Per the operational constraints on this review, only make build-macos, make lint and targeted xcodebuild suites on the macOS destination were run. The project's own pre-push bar also wants make build-ios, make test and make test-ui. The change is platform-neutral pure-Swift string handling, so the risk is low, but the bar has not formally been met.
CommonMarkConverter enables CMARK_OPT_SOURCEPOS by default and exposes range(_:). T-1941's report already counts this defect family to four occurrences (T-1673 → T-1876 → T-1716 → T-1941, now T-1992). If it recurs, exact node extents are the structural answer to "where did this node's characters come from", and the locate heuristic goes away entirely. Worth one line in the decision log recording why it was not taken this time.