prism branch T-1976/bugfix-html-image-tag-quote-scan pull request #420 commits 5 files 7 touched lines +1301 / -82 round-2 blockers 2 closed new blockers 0

Pre-push review (round 3): T-1976 quote-aware HTML tag extent

PR #420, redesigned after round 2 rejected it on two blocking findings. Both blockers are genuinely closed — verified by reproduction, not by reading. The residue is documentation: three places claim a test witnesses something it provably cannot, and the changelog now contradicts itself inside one release.

At a glance

  • Blocker 1 (quote model) — closed, verified. The HTML5 before-attribute-value model is implemented faithfully; I traced the recurrence by hand and fuzzed it at every offset against my own three-state oracle (60k inputs, zero divergence). All four regressed tags are back to mediated, and 20,000 well-formed generated tags lose mediation on none.
  • Blocker 2 (quadratic) — closed, verified. The one-slot whitespaceRunStart cache is gone, replaced by WhitespaceRunIndex. With the old cache reinstated on the shipped tree I measure G14 at 16.00x (ceiling 8x) while G12/G13 sit at 2.45x/2.68x — confirming both that G14 catches it and that the earlier shapes provably cannot.
  • The model change could NOT have substituted for the cache fix. Confirmed directly: round 2's own adversarial input (<img"×n + spaces + >" + spaces + >) measures 3.97x with the old cache still in place, because under the HTML5 model that bare " opens nothing. The model change silently removes the old symptom and leaves the premise broken — which is exactly why a new shape was required.
  • Major — the oracle does not witness the collapse. The report, the type's doc comment and the suite comment all claim the differential oracle keeps three states live "so the collapse is witnessed rather than assumed". It does not: in freshTagScan, inUnquotedValue feeds only sawTrailingSlash, which feeds only the tuple's second element, which freshFirstUnquotedCloseAngle discards and no test ever asserts. The collapse is true — but by the hand proof in the doc comment, not by any test in this PR.
  • Major — the changelog contradicts itself. CHANGELOG.md:95 (T-1951) still reads "One thing deliberately left standing is a > written inside a quoted attribute value … which still ends the tag early"; CHANGELOG.md:146 announces that fixed. Both sit in the same [Unreleased] / Fixed block, so one release ships both statements.
  • Every round-1 and round-2 non-blocking finding I could check has been addressed. The two quote models in HTMLImageSourceRewriter now agree (<img alt=a" src="x>y"> mediates as src="M:x&gt;y", against nothing on main); tagRanges gained G15/G16; parse gained an end-to-end golden; the unused Equatable conformances, the closeAngle name collision, startsAtOrBefore, the reversed() copy, the report's closesUpperBound error, the Impact overstatement and the removed-coverage disclosure are all fixed.
  • Builds and tests are clean. make build-macos, make build-ios, make lint (0 violations / 583 files), make verify-test-isolation, verify-make-guards and verify-workflow-triggers all pass. 14 targeted suites: 149 executed, 149 passed, confirmed through the result bundle with Tools/check-test-results.sh.
  • One deliberate, disclosed behaviour change. Over 40,000 pathological fragments (unbalanced quotes, no real tag structure) 85 lose mediation against main — every one because an attribute value opens and never closes, so the candidate is unresolvable and skipped. The changelog and tagRanges' doc both name this, with the sanitizer allowlist and the CSP as the backstop. It does not touch well-formed markup.

Verdict

Ready to push — after two documentation fixes

Both round-2 blockers are closed, and I confirmed each by reproduction rather than by reading. Compiling the branch and origin/main standalone with swiftc -O, all four tags the any-quote model regressed (<img alt=don't src=cat.png>, <img src=cat.png alt=5" wide>, <img alt="a" title=b" src=cat.png>, <source srcset=a.png alt=it's>) are mediated on the branch exactly as on main; over 20,000 generated well-formed tags, zero lost mediation against main and 2,194 gained it. Reinstating the deleted one-slot whitespace cache on top of the shipped code makes G14 measure 16.00x against the guard's 8x ceiling while G12 (2.45x) and G13 (2.68x) stay green — so G14 really does pin the defect the earlier shapes were blind to, and the author's 15.97x reproduces.

The load-bearing simplification holds. I re-derived the U ≡ N induction by hand over the doc comment's own transition table (all five cases agree, base cases agree), then checked the production index against a three-state forward oracle I wrote myself at every offset of 60,000 fragments: zero divergence. WhitespaceRunIndex likewise matches a naive backward walk at every offset. The restored fuzz gate is sound and is not hiding anything: 98.7% and 93.2% of rounds reach the retired-regex oracle, and 57% / 83% of all rounds reach it carrying quote characters — the exact region round 2 said had been thrown away.

What is left is prose. Two major findings, neither a code defect: the claim that the test oracle witnesses the U→N collapse is false (the oracle's close result is causally independent of its inUnquotedValue flag, and the flag it feeds is never asserted), and the surviving T-1951 changelog entry still says the T-1976 defect is "deliberately left standing" fifty lines above the entry announcing it fixed. Both are one-paragraph edits. Nothing here needs code to move.

Review findings

18 raised · 0 fixed · 18 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 15

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

HTML lets a > sit inside a quoted attribute: <img alt="2 > 1" src=cat.png> is one tag, not two. Prism had two places that decided where a tag ended by looking for the first >, full stop. Both cut the tag short, never reached src, and the image silently failed to appear.

A new file, HTMLQuoteAwareTagScan.swift, works out for every position in the text "where is the first > from here that isn't inside a quoted value?" — all of them at once, in a single backwards pass. Both places now ask it.

Why this is the third attempt

The first attempt remembered answers between questions, which is wrong because the answer depends on where you started looking. The second attempt fixed that but decided that any quote starts a quoted section. That broke ordinary tags: a stray apostrophe in alt=don't convinced it the rest of the tag was quoted, and that image stopped working — the very problem the fix existed to solve. It also slowed parsing to a crawl on a specially built document, because a small speed-up nearby assumed tag endings only ever move forwards, and they no longer do.

This version follows what a browser actually does: a quote only starts a value when it comes straight after an =. The nearby speed-up has been replaced with a proper index too.

Does it work?

Yes. I compiled this branch and the current main branch side by side and ran the same inputs through both. All four tags that broke last round work again. Twenty thousand generated, well-formed image tags: none lost. And the slow case is fast again — I put the old speed-up back by hand to check the new timing guard would have caught it, and it fails it by twice the margin.

What still needs a touch-up

Two pieces of writing, not code. The report claims a test proves something it cannot actually see, and the release notes now say in one place that this bug is "deliberately left standing" and in another that it is fixed. Both are short edits.

Architecture

HTMLQuoteAwareTagScan holds three value types. CloseAngleIndex materialises the function "first unquoted > at or after p" for every p, built in one right-to-left pass and stored only at the offsets where the answer changes; a query is a binary search. WhitespaceRunIndex does the same for "where does the whitespace run immediately before here begin", stored as maximal runs. Lookup owns both and builds each on first use, so a raw-HTML block with no void-element candidate pays for neither.

Two call sites consume it: HTMLImageSourceRewriter.tagRanges (pre-sanitizer prism-doc:// mediation) and HTMLImageParser.normaliseSelfClosingTags (XML normalisation, which runs unconditionally on every raw-HTML block at parse time).

The quote model, and why it is the third one tried

A quote opens a value only in the HTML5 tokenizer's before attribute value state — immediately after an =, whitespace allowed — and the value ends at the very next matching quote with no escape processing. This is the model FootnotePreprocessor.scanTagRemainder already implements, and whose doc comment records the any-quote model being tried and abandoned in the T-1877 review. Round 2 shipped the any-quote model anyway, and it cost four ordinary tags their mediation. The two scans now agree.

Patterns worth taking away

  • Index the whole answer function instead of caching per query. When an answer is a function of where the scan started, no answer is evidence about a later start. Computing them all in one pass and storing only the change points makes that O(n) time and near-nothing space on ordinary input.
  • Retiring an invariant invalidates every cache that rested on it. Round 2 deleted closeCursor because quote-awareness broke monotonicity, and left the sibling whitespace cache eight lines below resting on exactly the same premise — with its comment still asserting it.
  • A growth guard proves linearity only for the path its shape drives. G12 and G13 stayed green through the whole regression because on both of them the whitespace lookup walks zero characters. G14 exists because those two provably cannot see it, and it was validated by reinstating the defect.
  • Gate a differential fuzz on the disagreeing region, not on the alphabet. Round 2 deleted the quote characters from the retired-regex fuzz alphabets, which threw away the large region where the two implementations should still agree. This round generates quotes again and skips a round only when the quote-aware close differs from a literal first-> search at some offset.

Trade-offs

A start with no reachable unquoted > — an attribute value that opens and never closes — is skipped rather than ending the loop. Skipped means un-mediated, not unsanitised: the sanitizer allowlist still drops a relative src, and a bare http(s) one has the CSP's img-src prism-doc: data: behind it. That residue is named at tagRanges and in the changelog.

Lookups are O(log n) rather than amortised O(1), deliberately: a carried-forward cursor is the shape this ticket has twice been rejected for.

The recurrence, re-derived

Three tokenizer states can reach a >: N (normal), E (before attribute value), U (unquoted value). Writing the answer for a scan that arrives at p in each state:

  • N(len) = E(len) = U(len) = nil
  • at >: all three answer p
  • at a quote, in E only: E(p) = N(c+1) for the next occurrence c of that same character (nil when there is none)
  • at =: all three move to E(p+1)
  • at whitespace: E stays E, U returns to N, N stays N
  • otherwise: E moves to U(p+1); N and U stay

The collapse is sound. Compare U and N case by case: at > both answer p; at = both go to E(p+1); at whitespace both go to N(p+1); at a quote and at anything else N(p) = N(p+1) and U(p) = U(p+1) = N(p+1) by the induction hypothesis. Base cases agree. So U(p) = N(p) everywhere, and the pass carries two running answers. I re-derived this by hand and then checked production against a three-state forward oracle I wrote independently, at every offset of 60,000 generated fragments: zero divergence. WhitespaceRunIndex matches a naive backward walk at every offset of the same corpus.

The implementation is correct in the fiddly places. The per-quote memo is written from normal before normal is updated, so it holds N(c+1) for the nearest matching quote strictly right of the probe. The entry table stores p where N(p) != N(p+1), and firstUnquotedCloseAngle lower-bounds on maxStart >= position, which telescopes correctly because N is constant between change points. position == length falls off the end of the table and returns nil.

Where the collapse is not witnessed

Three places claim the differential oracle keeps all three states live "so the collapse is witnessed rather than assumed". Follow the data flow in freshTagScan: inUnquotedValue is read at exactly one site — sawTrailingSlash = unit == slash && !inUnquotedValue — and sawTrailingSlash is read only in the returned tuple's second element, which freshFirstUnquotedCloseAngle discards and which no test in the suite ever asserts. The oracle's close is therefore provably independent of inUnquotedValue; it has made the same collapse. The property is true, but by the proof above, not by the fuzz.

The deliberate divergence from strict HTML5

At = the model moves to E from every state, including from inside an unquoted value, where the spec makes = an ordinary value character. So <img alt=a="b>c"> is reported as closing at the final > where a browser closes at the one after b. The same simplification is in scanTagRemainder (line 728) and in the test oracle, so it is repository-consistent and no fuzz here can see it. I constructed the shape where this could plausibly swallow a following genuine tag — <img alt=a="x><img src=cat.png>"y> — and it does not: attributes(in:) still finds and mediates cat.png on both the branch and main, because its unquoted-value class terminates at the quote. The deviation is real and worth naming in the doc; it is not a defect.

The growth guards, checked against the defect

G14's shape is <img/="×n, then 4n spaces, >", 4n spaces, >. Each unit's =" puts its quote in value position, so candidate k's value closes at candidate k+1's quote and resumes at k+2: two parity classes resolving to two different closes, each sitting behind its own 4n-long whitespace run. Every candidate reaches whitespaceRunStart through the empty-alternative branch. A one-slot cache keyed on end therefore misses on every call and re-walks 4n characters: Θ(n²). Measured on the shipped sources with that cache reinstated: 16.00x per 4x of input (87.29 ms → 1396.25 ms), against the guard's 8x ceiling — the author's reported 15.97x reproduces. G12 (2.45x) and G13 (2.68x) stay green under the same mutation, because on both the unit before the close is a quote and the run walk is zero-length.

And the fourth claim holds: round 2's own adversary (<img"×n + spaces + >" + spaces + >) measures 3.97x with the old cache still in place, because under the HTML5 model that bare " opens nothing and all candidates share one close. The model change dissolves the old symptom while leaving the premise broken, so it could not have substituted for the cache fix. The shipped tree measures 4.03x / 5.07x / 3.88x / 5.03x on the four shapes.

The fuzz gate

quoteModelAgreesWithLiteralScan compares production's index against a literal first-> table at every offset and skips the round on any disagreement. That is sound: the only thing T-1976 changed in normaliseSelfClosingTags is which offset firstClose returns, so where the two agree everywhere the retired regex is still an exact oracle. Self-gating by a broken index is covered separately, by indexAnswersEveryStartOffsetLikeAFreshScan. I measured the pass-through: 24,686 / 25,000 (98.7%) and 23,291 / 25,000 (93.2%), with 56.6% and 82.5% of all rounds reaching the oracle carrying a quote character. So the compared > rounds / 10 assertion has ~9x headroom and is what its comment says it is — a filter, not an off switch — and round 2's "zero exactness coverage at character granularity" is fully repaired.

Behaviour delta against origin/main

Over 40,000 pathological fragments: 5,677 gain mediation, 85 lose it, 10,605 normalise differently. Every loss traces to an attribute value that opens and never closes, making the candidate unresolvable — the documented, deliberate residual. Over 20,000 generated well-formed tags (balanced quotes, realistic attributes including alt=don't, alt=5" wide, title=b", alt=a"): 2,194 gain, zero lose.

Important changes — detailed

HTMLQuoteAwareTagScan: the HTML5 three-state recurrence, collapsed to two

prism/Services/HTMLQuoteAwareTagScan.swift

Why it matters. This is the whole of blocker 1's fix, and it is correct. A quote now opens a value only in the before-attribute-value state, matching FootnotePreprocessor.scanTagRemainder, so alt=don't and alt=5" wide stay one ordinary tag. I re-derived the U-collapses-into-N induction by hand over all five transition cases, then checked the production index against a three-state forward oracle written independently, at every offset of 60,000 fragments: zero divergence.

What to look at. HTMLQuoteAwareTagScan.swift:23-175

Takeaway. When a per-query answer depends on where the query started, do not cache it across queries — materialise the whole answer function once. Storing only the change points makes that O(n) time and near-zero space on ordinary input, and each query a binary search. Under the HTML5 model the table is sparser still: N can change only at a `>` or an `=`, so prose contributes nothing at all.
Rationale. Round 1 cached a forward scan and was unsound; three provably-sound windowed caches each stayed quadratic; dropping caching entirely measured 4.3 s at 56 KB. Round 2 indexed correctly but with the any-quote model, which FootnotePreprocessor's doc comment had already recorded as tried and rejected in the T-1877 review. Round 3 keeps the index and adopts the recorded model.

WhitespaceRunIndex replaces the one-slot cache that reintroduced the quadratic

prism/Services/HTMLQuoteAwareTagScan.swift

Why it matters. Blocker 2's fix. The old cache's stated invariant — "end is non-decreasing" — was a consequence of quote-blindness, and quote-awareness retired it: N is not monotone (in `a="b>c">`, N(0)=7 while N(2)=4), so consecutive candidates resolve to two different closes and the single slot misses every call. The replacement is an index over maximal runs, binary-searched. I verified runStart against a naive backward walk at every offset of 60,000 fragments: zero divergence.

What to look at. HTMLQuoteAwareTagScan.swift:177-242; HTMLImageParser.swift:213-247

Takeaway. Retiring an invariant invalidates every cache that rested on it, not only the one you were looking at. `closeCursor` was deleted for exactly this reason and the sibling cache eight lines below shared the premise — with its comment still asserting it.
Rationale. Stated in the report's "Review round 2" section and in the type's own doc comment, with the measured table. A dictionary memo keyed on the close offset was considered and rejected for consistency with the close-angle index in the same file.

G14: the growth guard that pins the defect G12 and G13 could not see

prismTests/RawHTMLImageScanGrowthTests.swift

Why it matters. The author's claim is that G14 catches the reintroduced quadratic while the pre-existing G12/G13 do not, and that they validated it by reinstating the cache. I reproduced this independently: with the one-slot cache put back on top of the shipped code, G14 measures 16.00x per 4x of input (87.29 ms -> 1396.25 ms) against the guard's 8x ceiling, while G12 measures 2.45x and G13 2.68x — both comfortably green. The reason is structural, not luck: on G12/G13 the unit before the close is a quote, so the run walk is zero-length.

What to look at. RawHTMLImageScanGrowthTests.swift:181-256

Takeaway. A growth guard proves linearity only for the code path its shape actually drives. Validate a new guard against the defect it exists for, by reinstating that defect — a ratio that is green on the buggy code is not a guard.
Rationale. Documented in the test's own comment and in the report's Regression Test section, including the reinstatement measurement.

tagRanges and attributes(in:) now read "quoted" the same way

prism/Services/WebRendering/HTMLImageSourceRewriter.swift

Why it matters. Round 2's major finding: the extent scan used the any-quote model while attributes(in:) ten lines down used the after-`=` model, so `<img alt=a" src="x>y">` produced a range ending at the `>` after `x` and the attribute scan then refused `src="x>` as unterminated. Verified fixed against origin/main compiled standalone: the branch now mediates it as `src="M:x&gt;y"` where main mediates nothing.

What to look at. HTMLImageSourceRewriter.swift:77-130 vs :208-244

Takeaway. Two scans in one pipeline that both reason about quoting must share a model, or a range one produces is a range the other cannot parse. The cheapest way to hold that is to make the extent scan's model an explicit statement of the attribute scan's, and say so at both.
Rationale. Stated in tagRanges' doc comment, naming the exact input and the exact failure it produced.

The restored fuzz gate: compare on the agreeing region rather than mutilate the alphabet

prismTests/RawHTMLImageScanGrowthTests.swift

Why it matters. Round 2 deleted the quote characters from both retired-regex fuzz alphabets, which removed the region where the scan and the regex should still agree — leaving zero T-1951 exactness coverage at character granularity. Quotes are generated again, and a round is skipped only when the quote-aware close differs from a literal first-`>` search at some offset. I measured the pass-through rather than trusting it: 98.7% and 93.2% of rounds reach the oracle, and 56.6% / 82.5% of ALL rounds reach it carrying a quote character.

What to look at. RawHTMLImageScanGrowthTests.swift:296-370

Takeaway. When an oracle carries the defect you are fixing, gate on the disagreement, not on the characters that can produce it — and assert the gate is a filter. `#expect(compared > rounds / 10)` is the same idea as the project's zero-executed-tests result-bundle check, applied inside a test.
Rationale. Named in the test's own doc comment and in the report's "Known Residual and removed coverage" section.

The oracle tracks a third state it never asserts on

prismTests/RawHTMLImageScanGrowthTests.swift

Why it matters. MAJOR, documentation. Three places claim the differential oracle keeps all three tokenizer states live "so the collapse is witnessed rather than assumed". In freshTagScan, `inUnquotedValue` is read at exactly one site (`sawTrailingSlash = unit == slash && !inUnquotedValue`), and `sawTrailingSlash` is read only in the returned tuple's second element — which freshFirstUnquotedCloseAngle discards at line 451 and no test asserts. The close result is provably independent of the flag, so the oracle has made the same collapse the production code did. The collapse is still TRUE — the induction in the doc comment proves it and I checked it — but no test in this PR could have caught it being wrong.

What to look at. RawHTMLImageScanGrowthTests.swift:405-451; HTMLQuoteAwareTagScan.swift:71-79; report.md:129-134

Takeaway. "The oracle keeps the state separate" is not the same claim as "the oracle's answer depends on the state". If a differential test is offered as evidence for a derivation, trace the data flow from the state to the assertion — a variable that is tracked but never reaches a `#expect` is decoration.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Adopt the HTML5 before-attribute-value model rather than any-quote.

A quote opens a value only immediately after an = (whitespace allowed), and the value ends at the next matching quote with no escape processing. This is the model FootnotePreprocessor.scanTagRemainder already implements, and whose comment records the any-quote model being tried and abandoned in the T-1877 review for exactly the failure it caused here. Verified: all four regressed tags return to mediated, and it additionally resolves <img alt=a" src="x>y">, which both origin/main and round 2 mangled.

Carry two running answers, not three — U collapses into N.

Their transitions agree in every case and their base cases agree, so U(p) = N(p) by induction from the right. I re-derived this independently and it holds. U is still part of the model — it is what makes alt=5" wide behave — it simply cannot move the close angle, only what a / in an unquoted value means, which is scanTagRemainder's question rather than this one. The accompanying claim that the test oracle witnesses the collapse does not hold; see the findings.

Replace the whitespace-run cache with an index rather than restate its invariant.

The invariant it needed is not recoverable, because it was a consequence of quote-blindness. Fixing the comment would have left the quadratic standing. A dictionary memo keyed on the close offset was considered — sound and linear, since runs for distinct closes are disjoint — and rejected for consistency with the change-point index already in the same file.

Hold the laziness in a `Lookup` value type instead of repeating an `if x == nil` dance.

Answers round 1's minor finding that both call sites had grown the same three-line optional dance, and that guard let closeIndex = closes?… conflated "no index yet" with "no close found". The closes: test seam survives on both call sites, which is what leaves WhitespaceRunIndex without an injection point of its own.

Skip an unresolvable candidate rather than ending the loop.

Unchanged from round 2 and still right. Under a quote-blind search "no > left at all" really did hold for every later start; it does not once quoting is honoured. The goldens were re-pointed, because under the HTML5 model round 1's two reproductions no longer have an unresolvable candidate — <img a="<img alt=X> and <img alt="><img src=cat.png> now carry that weight, and the old pair is kept asserting unchanged output.

Accept `=` re-arming value position even inside an unquoted value.

Not stated anywhere. The spec makes = an ordinary character in the attribute-value-unquoted state, so <img alt=a="b>c"> really ends at the > after b; this model ends it at the final >. The same simplification is in scanTagRemainder and in the test oracle, so it is repository-consistent — and I could not construct an input where it costs a mediation. It should be named where "follows the HTML5 tokenizer" is claimed.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorprismTests/RawHTMLImageScanGrowthTests.swift:405-451, HTMLQuoteAwareTagScan.swift:71-79, report.md:129-134 — the oracle does not witness the U-collapseThree places state that the differential oracle keeps all three tokenizer states live so that the U-into-N collapse is 'witnessed rather than assumed' / 'witnessed rather than shared with the thing under test'. It is not. In `freshTagScan`, `inUnquotedValue` is read at exactly one site (`sawTrailingSlash = unit == slash && !inUnquotedValue`); `sawTrailingSlash` is read only in the returned tuple's second element; `freshFirstUnquotedCloseAngle` discards that element at line 451 and no test in the file asserts on it. The oracle's `close` is therefore provably independent of the flag — it has made exactly the same collapse. The collapse IS correct (I re-derived the induction over all five transition cases and checked production against a three-state oracle I wrote myself at every offset of 60,000 fragments, zero divergence), so this is an over-claim in prose, not a defect. It matters because over-claiming what a test can see is the failure mode that got rounds 1 and 2 rejected.Either make U live — assert `freshTagScan(...).isSelfClosing` against the ` />` insertion `normaliseSelfClosingTags` actually makes, which is the property U exists for — or downgrade all three sentences to what is true: the derivation is proved in the doc comment, and the oracle transcribes the same table over three variables.
majorCHANGELOG.md:95 vs CHANGELOG.md:146 — the release notes contradict themselvesThe surviving T-1951 entry still reads 'One thing deliberately left standing is a `>` written inside a quoted attribute value, as in `<img src="a>b">`, which still ends the tag early and is tracked separately (T-1976).' The new entry fifty lines below announces exactly that fixed. Both sit inside the same `## [Unreleased]` / `### Fixed` block (lines 26-147), so one release ships both statements. Separately confirmed: the T-1951 entry deleted by fc3a18a6 IS a genuine superset in substance — the survivor carries every clause including the 0.6 s/8 KB, 35 s/70 KB and 2.8 s/62 KB figures, the URL/DoS sentence and the million-fragment equivalence sentence, plus the comment-stripping step, the long-s divergence and T-2147. The only thing lost is the deleted entry's post-fix precision ('under a millisecond and a millisecond and a half respectively'), which became 'a few milliseconds at most'.Amend the sentence at line 95 to past tense and point forward — e.g. '…which still ended the tag early at the time; that is fixed under T-1976, below' — or delete it, since line 146 covers it. One-line edit.
minorreport.md:295-296 — 'the first growth guards of any kind on HTMLImageSourceRewriter.rewrite/tagRanges'G7 (`SrcsetCandidateSplitTests.rewriteOfLongSrcsetScalesLinearly`, RawHTMLImageScanGrowthTests.swift:869-876) pre-exists this branch and drives `HTMLImageSourceRewriter.rewrite` through the public entry point. The claim is true of `tagRanges` and of the many-tag-starts shape; it is not true of `rewrite`. The new suite's own in-file comment gets this right ('the suites above cover normaliseSelfClosingTags, comment stripping and srcset splitting, and none of them drives the rewriter over many tag starts') — the report generalised it and broke it.Say 'the first growth guards over many tag STARTS' or 'the first growth guards on tagRanges'.
minorHTMLQuoteAwareTagScan.swift:45-48 — the example does not demonstrate the claim it is offered for'no verdict computed from one start offset is evidence about a later one. In `x="ab"y>` a scan from 0 finds the `>` at 7, while a scan from 3 — inside that quoted value — reaches the `"` that closed it in the normal state, where it opens nothing, and runs on.' Both starts answer 7. The example shows the STATE differing while the ANSWER agrees, so it is not evidence for the claim. This looks like a mechanical carry-over: under round 2's any-quote model the sibling example `x"ab"y>` did diverge.Use a string where the answers actually differ, e.g. `a="b>c">`: from 0 the answer is 7 (the value swallows the inner `>`), from 2 it is 4. That pair is also the non-monotonicity WhitespaceRunIndex exists for, so one example can carry both arguments.
minorHTMLQuoteAwareTagScan.swift:193-194 — 'O(1) for markup with ordinary spacing' is wrong`WhitespaceRunIndex` stores every maximal whitespace run, so prose and ordinary markup produce one entry per inter-word gap — O(n), not O(1). Contrast the accurate sibling claim at :84-88, which is true precisely because N changes only at `>` or `=`. The type is still correct and still linear; only the space claim is wrong, and it is the kind of claim a future reader will rely on when deciding whether to build it eagerly.Reword to 'one entry per whitespace run — proportional to the number of gaps in the input, which is what re-walking a run per candidate cost'.
minorHTMLQuoteAwareTagScan.swift:26-36, :65 — 'follows the HTML5 tokenizer' is overstated at `=`The model moves to the before-attribute-value state at `=` from EVERY state, including from inside an unquoted value, where the spec makes `=` an ordinary value character; and from before-attribute-name, where the spec starts an attribute name. So `<img alt=a="b>c">` is reported as closing at the final `>` where a browser closes at the one after `b`. The deviation is shared deliberately with `scanTagRemainder:728` and with the test oracle, so no fuzz in this PR can see it. I looked for a case where it costs a mediation and could not build one: on `<img alt=a="x><img src=cat.png>"y>` both the branch and origin/main still mediate cat.png, because `attributes(in:)`'s unquoted-value class terminates at the quote.Add the exception where the HTML5 claim is made: 'follows the HTML5 tokenizer, except that an `=` inside an unquoted value re-arms value position — a deliberate agreement with scanTagRemainder'. No code change needed.
minorHTMLImageParser.swift:201, HTMLImageSourceRewriter.swift:107, HTMLQuoteAwareTagScan.swift:262 — the test seam injects only one of the two indexes`Lookup` owns both indexes but the `closes:` seam substitutes only `CloseAngleIndex`, so `normalisationMatchesAnAlwaysFreshLookup` runs `normaliseSelfClosingTags` twice with the SAME `WhitespaceRunIndex` on both sides — a defect in `runStart` cancels out. That is precisely the shared-layer blindness the suite's own comment at :470-476 says `referenceTagRanges` was added to avoid. Mitigating: `runStart` is covered transitively by the retired-regex fuzzes, which now generate whitespace and quotes and pass 98.7%/93.2% of rounds through to the oracle; and I verified it against a naive backward walk at every offset of 60,000 fragments with zero divergence. So the code is right — the seam is just asymmetric.Inject the `Lookup` itself (`lookup: HTMLQuoteAwareTagScan.Lookup? = nil`) rather than `closes:`. One parameter instead of three signatures, and a test can then substitute both indexes.
minorprismTests/RawHTMLImageScanGrowthTests.swift:88-92 — G3's comment describes deleted code'Guards the whitespace-run CACHE specifically … even though the `>` lookup itself is a FORWARD-ONLY CURSOR.' Both halves describe code this branch removes: the cache became `WhitespaceRunIndex` and `closeCursor` is gone. Round 1 flagged the same class at G10 and G10 is now clean; G3 was missed. The file header at :1-15 has drifted the same way — it scopes the file to T-1951 and names three passes, where it now holds five suites, two of them T-1976 and one of them a rewriter growth suite.Reword G3's comment to talk about the run index, and widen the file header to name the T-1976 suites.
minorreport.md:336 — 'its two close caches and its one-slot whitespace cache are gone'The merge-base had ONE close cache (`closeCursor` plus its `closesExhausted` flag — two variables, one cache) and one whitespace cache. Line 101 of the same report states this correctly ('its `closeCursor`/`closesExhausted` close cache'), so the Affected Files row contradicts the prose four hundred lines above it. Round 1 raised the neighbouring `closesUpperBound` error and that one IS fixed, with the squash-merge caveat correctly noted at :188-189.Say 'its close cache and its one-slot whitespace cache are gone'.
minorreport.md:330-338 — Affected Files omits a file the diff touches`prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift` is modified by this branch (a comment in `HTMLImageSourceRewriterQuotingTests` updated to record that T-1976 has since fixed the first-`>` limitation it was working around). It is not in the table. The neighbouring claim that `RawHTMLImageRewriteTests` is 'existing, unmodified' is true of that suite specifically, so it is not contradicted — the file is simply missing.Add the row.
minorreport.md:342-350 — the removed-coverage note reads as 'nothing net changed'The disclosure is honest about the deleted golden and about the gate, and the gate's `compared > rounds / 10` assertion is real (I measured the pass-through at 98.7% and 93.2%, so it has ~9x headroom and is what its comment says it is). But 'That is restored: quotes are generated again' understates it: net against merge-base, two COMPOSITE tokens were dropped and never restored — `src="a"` from `fuzzRandomFragments` and `<img src="a>b">` from `fuzzTagShapedFragments`. Low impact (the second would be gated out anyway; the first is constructible character by character from the surviving alphabet), but the sentence claims more than happened.Add half a sentence naming the two composite tokens that did not come back.
nitHTMLImageSourceRewriter.swift:82, HTMLImageParser.swift:186 — cross-reference names the wrong typeBoth say the call site asks `HTMLQuoteAwareTagScan.CloseAngleIndex`. Both actually ask `Lookup`, which owns the laziness; `CloseAngleIndex` is never named at either call site any more.Name `Lookup`.
nitHTMLQuoteAwareTagScan.swift:133 — no-op assignment in the `=` branch`nextBeforeValue = beforeValue` restates line 127's initialisation. Defensible as documentation of the E-stays-E transition, since the four sibling branches all assign, but it reads as a bug until you check.Keep it and say so in a comment, or drop it.
nitHTMLImageParser.swift:235, :246 — the same lookup is issued twice for one `close`Both branches call `lookup.whitespaceRunStart(endingAt: close)` for the same `close`, and only the first applies the `max(afterName + 1, …)` clamp, which makes the asymmetry easy to misread as significant. Cheap now that it is a binary search, but it was two full run walks under the old cache.Hoist `let runStart = lookup.whitespaceRunStart(endingAt: close)` above the `if` and clamp at the use.
nitHTMLQuoteAwareTagScan.swift:108-112 — `init(entries:)` has no ordering precondition`firstUnquotedCloseAngle` binary-searches, so an unsorted table silently returns wrong answers. The requirement is stated as `/// Ascending by maxStart` on the property, which is easy to miss from a test that builds the table itself — and a test is the only caller.Add a `precondition`/`assert` on the initialiser, or restate the requirement on it.
nitreport.md:196-197 — imprecise parenthetical'Under the HTML5 model neither of those two inputs has an unresolvable candidate any more (no `=` precedes either quote)'. In `<img alt=x"><img src="cat.png">` there IS an `=` before the quote; what disqualifies it is that an unquoted value (`x`) has already started. I traced both inputs through the merge-base and the branch and the surrounding claim is correct — both produce identical output either way.'no `=` IMMEDIATELY precedes either quote'.
nitCHANGELOG.md:92-94 — three near-identical T-1811 entriesSame shape as the T-1951 pair this branch cleaned up: three progressive supersets of one entry. `git show 653e1d49:CHANGELOG.md` confirms all three are already on the merge base, so this is pre-existing and correctly left alone here.Out of scope. Worth its own cleanup ticket alongside whatever produced the T-1951 duplicate.
nitT-2320 — sibling quote-blind scans, correctly deferred`DetailsTokenizer.findOpenTagEnd` (DetailsTokenizer.swift:70), its `<summary>` twin (:139) and `MarkdownBlockParser.stripEmptyAnchors` (:652, `[^>]*` twice) are all still quote-blind and all live paths. The last commit on the branch points the report's residual at T-2320, and that ticket exists and names exactly these three.Nothing to do here. Note when picking T-2320 up that all three work over `String.Index` while `CloseAngleIndex` is UTF-16-offset-based, so adopting it is an adaptation rather than a substitution.

Tests

Source: local run at 2026-09-07T04:11:12.673558+10:00 · snapshot 79ba253ca7741407e3dd25fa4e6d95f28a02228a

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

xcodebuild emits an .xcresult bundle, not JUnit XML, so no structured results could be attached. 14 targeted suites were run on macOS (-enableCodeCoverage NO, -parallel-testing-worker-count 1, en (base)): 149 executed, 149 passed, 0 failed, 0 skipped — confirmed through the result bundle with Tools/check-test-results.sh. make lint (0 violations, 583 files), make build-macos, make build-ios, make verify-test-isolation, make verify-make-guards and make verify-workflow-triggers all pass. A full prismTests run was started and had not finished within the session's foreground budget; the targeted run is the verified evidence.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 653e1d492682b2cf5053ca106a1a86659673b061.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 4ac34285..db401c88 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -92,7 +92,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Signing into iCloud with a document open no longer costs you the notes you already had on it (T-1811). Opening a document while signed out leaves its notes unread — there is nowhere to read them from — and signing in afterwards made notes available again without ever going back for them, so the first note you added was saved as if it were the only one the document had ever had, replacing every note already stored for it. Signing in now reloads the open document's notes straight away, so they reappear without closing and reopening the file, and a note added in the moment before that finishes waits for it rather than racing it. Saving a note can no longer replace notes it has not read, on any path: if nothing has been loaded for a document, what is already stored is read first and the new note is added to it. - Signing into iCloud with a document open no longer costs you the notes you already had on it (T-1811). Opening a document while signed out leaves its notes unread — there is nowhere to read them from — and signing in afterwards made notes available again without ever going back for them, so the first note you added was saved as if it were the only one the document had ever had, replacing every note already stored for it. Signing in now reloads the open document's notes straight away, so they reappear without closing and reopening the file, and a note added in the moment before that finishes waits for it rather than racing it. Saving a note can no longer replace notes it has not read, on any path: if nothing has been loaded for a document, what is already stored is read first and the new note is added to it. A note is also always saved to the document you are actually reading — opening a different file in the moment after signing in leaves the first one alone, and the notes you add then belong to the file in front of you rather than to the one you left. - Signing into iCloud with a document open no longer costs you the notes you already had on it (T-1811). Opening a document while signed out leaves its notes unread — there is nowhere to read them from — and signing in afterwards made notes available again without ever going back for them, so the first note you added was saved as if it were the only one the document had ever had, replacing every note already stored for it. Signing in now reloads the open document's notes straight away, so they reappear without closing and reopening the file, and a note added in the moment before that finishes waits for it rather than racing it. Saving a note can no longer replace notes it has not read, on any path: if nothing has been loaded for a document, what is already stored is read first and the new note is added to it — and two notes added in the same moment now both survive, rather than the second saving over the first. A note is also always saved to the document you are actually reading — opening a different file in the moment after signing in leaves the first one alone, and the notes you add then belong to the file in front of you rather than to the one you left.-- Documents containing images written in raw HTML no longer stall while opening (T-1951). Two steps of that work cost time in proportion to the *square* of what they were given, so a document that would otherwise open instantly could hold the app for tens of seconds. Half-written image tags — a run of `<img ` with no closing bracket, which is what a document being written, generated, or truncated mid-tag looks like — were the worst of it: 0.6 seconds for 8 KB of them, 35 seconds for 70 KB, and that step runs on every piece of raw HTML in a document before anything is drawn. A single image carrying a long list of alternative sizes cost 2.8 seconds for a 62 KB list. Both now take under a millisecond and a millisecond and a half respectively, and both grow in step with the length of the document rather than with its square. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. Nothing about how such documents are displayed changes: each replacement was checked against the exact step it replaced over more than a million generated fragments, character for character. One thing deliberately left standing is a `>` written inside a quoted attribute value, as in `<img src="a>b">`, which still ends the tag early and is tracked separately (T-1976). With these two, every step of the raw-HTML image path that was known to slow down this way has now been fixed. - Documents containing images written in raw HTML no longer stall while opening (T-1951). Three steps of that work cost time in proportion to the *square* of what they were given, so a document that would otherwise open instantly could hold the app for tens of seconds. Half-written image tags — a run of `<img ` with no closing bracket, which is what a document being written, generated, or truncated mid-tag looks like — were the worst of it: 0.6 seconds for 8 KB of them, 35 seconds for 70 KB, and that step runs on every piece of raw HTML in a document before anything is drawn. A single image carrying a long list of alternative sizes cost 2.8 seconds for a 62 KB list. And the removal of HTML comments, which runs even earlier on the same raw HTML, slowed the same way on a run of comment openers with no close — 2.3 seconds for 32 KB of them — a case found while this fix was being reviewed rather than by the original report. All three now take a few milliseconds at most, and grow in step with the length of the document rather than with its square. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. Nothing about how such documents are displayed changes: each replacement was checked against the exact step it replaced over more than a million generated fragments, character for character — with one deliberate exception too archaic to meet in practice, an image tag spelled with the mediaeval long-s (`<ſource>`), which the old matching treated as `<source>` and the new, standards-following matching does not. One thing deliberately left standing is a `>` written inside a quoted attribute value, as in `<img src="a>b">`, which still ends the tag early and is tracked separately (T-1976). With these three, every step of the raw-HTML image path that was known to slow down this way has now been fixed; one same-shaped scan on the neighbouring path — the comment removal applied when raw HTML is displayed as-is rather than as an image — was found while fixing these and is fixed under T-2147, above. - Choosing where to go while a document reloads now takes you there (T-1975). If a file changed on disk — or a URL document was refreshed — while you had it open, and during the moment the app spends preparing the new version you picked a table-of-contents entry, tapped a note, followed a link to a heading, or stepped to a search match, the reloaded document appeared at your saved reading position instead. What you chose was handed to the copy still on screen, which was about to be replaced, so nothing was left to say where you had asked to go and restoring your place won — and on a large document, where preparing the new version takes longest, that window is at its widest. The document on screen is now treated as superseded from the moment a reload starts rather than from the moment the new version is ready, so anything you choose in between is held for the version that is coming and takes precedence over your saved place, exactly as it already did when you chose a moment later. This holds when a file changes twice in quick succession, so a second reload beginning before the first has finished preparing still takes you where you asked rather than back to your saved place. Reloads you did not navigate during still return you to where you were reading, and once the reloaded document has taken you where you asked, the next reload restores your place normally. Scrolling while a reload prepares still counts too, however you do it — dragging, a trackpad or wheel, **Page Up** and **Page Down**, or **Scroll to Top** and **Scroll to Bottom** — because the document stays in front of you and stays scrollable the whole time: the place you scroll to is the place you are returned to. - Changing the reading font or text size no longer moves you somewhere else in the document (T-1965). Both settings already applied without reloading, but they reflow the whole document and nothing put you back afterwards: raising **Larger Text** to an accessibility size makes every block roughly three times as tall, so the text you were reading slid off the bottom of the screen and left you looking at something you had already been through. The app then recorded that new spot as where you were reading, so closing and reopening the document returned you to it as well. Your place is now kept across the change — including how far into a paragraph you were, so the same words stay in front of you rather than merely the same paragraph starting at the top — and a place you were never reading can no longer be saved while the document settles. Jumping somewhere while the change is settling wins: a table-of-contents entry, a link, a note, or a search match all take you where you asked, and the re-anchoring steps aside. Collapsing the section you were reading during the change leaves you at its heading rather than at content that is no longer shown.@@ -144,6 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A note written while a document's notes are still loading is no longer thrown away when the load finishes (T-2089). Notes can be added the moment a document appears, but loading them from iCloud takes a moment longer — and a note added in that gap vanished from the screen as soon as the load landed, because the load put back the set of notes it had read before your note existed. Where that load also had to re-attach notes to moved text, it saved that older set back to iCloud too, so the note was gone for good rather than just off-screen until the next reopen. The load now recognises when the notes have moved on beneath it and keeps what is on screen — your note and everything already stored — instead of replacing it. A reload with nothing added still picks up whatever iCloud holds, as before. - A backslash-escaped footnote reference (`\[^1]`) no longer consumes a display number or keeps an otherwise-orphaned definition alive (T-1968). The footnote preprocessor's reference scan counted `\[^1]` as a real reference even though it renders as literal text — a footnote it kept alive that way pushed every genuinely-referenced footnote's display number one higher, and the renderer already deliberately declined to badge it, so the two disagreed on what counted as a reference. The scan is now escape-aware the same way the renderer is: a reference preceded by an odd-length run of backslashes is literal, an even-length run (including zero) is live. Search shared the same blind spot — a block's searchable text and its footnote-badge indication both scanned raw source for `[^id]` with no escape awareness, so an escaped occurrence sitting alongside a live reference to the same footnote could pull that footnote's content into search a second time — and now shares the same escape check. - An escaped backtick no longer hides the footnote reference that follows it (T-1968). The preprocessor's code-span skip treated every backtick as a code-span delimiter, so in a line like `` \`[^1]` `` — an escaped backtick, a live reference, then a stray literal backtick — it paired the two backticks up and read the reference as code, dropping the footnote from the document's numbering and orphaning its definition. The renderer, which parses the line properly, showed the badge, so the footnote appeared with no number behind it. A backslash-escaped backtick is now literal text and opens nothing, matching CommonMark and matching what the renderer already did. Only the opening backtick is treated this way: backslashes have no escaping power inside a code span, so a backtick that closes one still closes it.+- A raw-HTML `<img>`/`<source>` tag no longer loses its `src` when an earlier quoted attribute contains a `>` (T-1976). HTML permits `>` inside a quoted attribute value — `<img alt="2 > 1" src=cat.png>` is one tag, not two — but both places that find a start tag's end (the raw-HTML image rewriter and the standalone-image XML normaliser) stopped at the first `>` regardless of quoting, truncating the tag before `src` was ever reached; the relative source was then stripped by the sanitizer's allowlist and the image never rendered. Both now share one quote-aware lookup that only treats a `>` outside a single- or double-quoted value as the tag's end. It reads quotes the way a browser does: a quote opens a value only where a value is expected, immediately after an `=`, so an apostrophe in `alt=don't` or an inch mark in `alt=5" wide` stays ordinary text and those tags keep working exactly as they did. The lookup answers every candidate position in the block from a single pass, and the whitespace run before each candidate's closing bracket is answered the same way, so a document of half-written tags still parses in time proportional to its length rather than its square — the shape T-1951 fixed. A tag whose close is unreachable (an attribute value that opens and never closes) is left alone on its own, without changing how any other tag in the same block is read.  ### Security 
prism/Services/HTMLImageParser.swift Modified +48 / -57
diff --git a/prism/Services/HTMLImageParser.swift b/prism/Services/HTMLImageParser.swiftindex 6ffef7c4..fde816a9 100644--- a/prism/Services/HTMLImageParser.swift+++ b/prism/Services/HTMLImageParser.swift@@ -34,7 +34,6 @@ enum HTMLImageParser: Sendable {         .map { Array($0.utf16) }      nonisolated private static let openAngle = unichar(UInt8(ascii: "<"))-    nonisolated private static let closeAngle = unichar(UInt8(ascii: ">"))     nonisolated private static let slash = unichar(UInt8(ascii: "/"))      // MARK: - Public API@@ -157,32 +156,50 @@ enum HTMLImageParser: Sendable {     /// Since any document can be opened from a URL, that was denial-of-service shaped. The     /// same input now takes 0.45 ms and grows in step with the input rather than its square.     ///-    /// The scan reproduces that pattern *exactly*. What establishes that is differential-    /// fuzzing against the pattern itself — 1.2 million generated fragments while the fix was-    /// written, 50,000 pinned in `VoidElementNormalisationTests` — and not the reading of it-    /// below, which explains the scan rather than being evidence for it. In particular the-    /// scan is deliberately **not** quote-aware: a `>` inside a quoted attribute value still-    /// ends the tag here, exactly as `[^>]` did, so `<img src="a>b">` is still mangled. That-    /// is a separate defect (T-1976) with its own observable behaviour change, kept out of a-    /// change whose whole warrant is that nothing else moves. One deliberate exception to-    /// exactness is documented at `asciiLowercased`: tag-name case folding is ASCII-only,-    /// where ICU's `(?i)` also folded U+017F onto `s`.+    /// The scan reproduces that pattern *exactly* for the shape T-1951 fixed, EXCEPT where a+    /// `>` sits inside a quoted attribute value (T-1976): `[^>]` could not see quoting, so the+    /// retired regex — and this scan, until T-1976 — ended `<img src="a>b">` at the `>` inside+    /// the quotes, mangling it to `<img src="a />b">`. The close-angle lookup below is+    /// quote-aware via the shared `HTMLQuoteAwareTagScan`, which follows the HTML5 tokenizer+    /// (a quote opens a value only just after an `=`), so the tag now runs to its real closing+    /// `>` while `alt=don't` stays one ordinary tag. What+    /// establishes exactness for everything else is differential fuzzing against the retired+    /// pattern itself — 1.2 million generated fragments while the T-1951 fix was written,+    /// 50,000 pinned in `VoidElementNormalisationTests` — and not the reading of it below,+    /// which explains the scan rather than being evidence for it; the quote-aware divergence+    /// is instead pinned by its own golden (`quotedCloseBracketNoLongerMangles`), since a+    /// fuzz oracle that itself carries the bug being fixed cannot demonstrate the fix. One+    /// other deliberate exception to exactness is documented at `asciiLowercased`: tag-name+    /// case folding is ASCII-only, where ICU's `(?i)` also folded U+017F onto `s`.     ///     /// Reading the pattern, a match at a `<` needs the tag name, then either-    /// `\s[^>]*?` or nothing, then `(?<!/)\s*>`. Two consequences drive the scan:+    /// `\s[^>]*?` or nothing, then `(?<!/)\s*>`. Two consequences drive the scan, and T-1976+    /// changed how each of them is answered because `[^>]` itself did not honour quoting:     ///-    /// - The closing `>` is necessarily the FIRST `>` at or after the name, because neither-    ///   `[^>]` nor `\s` can cross one. So each start needs one lookup — "the first `>` from-    ///   here" — and those lookups arrive in increasing order, so a cursor that only moves-    ///   forward answers all of them in one pass. Once no `>` remains, no start can match.+    /// - The closing `>` is necessarily the FIRST unquoted `>` at or after the name, because+    ///   neither `[^>]` nor `\s` can cross a raw `>`, and a quoted one is not the tag's own+    ///   delimiter at all. So each start needs one lookup — "the first unquoted `>` from+    ///   here" — which `HTMLQuoteAwareTagScan.CloseAngleIndex` answers for every offset at+    ///   once, in a single pass built on first use. Its own doc comment explains why the+    ///   answer cannot instead be carried forward from one candidate to the next.     /// - Lazy `[^>]*?` stops at the first position where the rest matches, so the group ends     ///   at the start of the whitespace run leading up to that `>` — one character later when     ///   the character before that run is a `/`, which is how `<img / >` normalises to-    ///   `<img /  />` while an already-closed `<img />` matches nothing at all.+    ///   `<img /  />` while an already-closed `<img />` matches nothing at all. That run start+    ///   is `HTMLQuoteAwareTagScan.WhitespaceRunIndex`, for the same reason: many candidates+    ///   share one `>`, and the answer is no longer non-decreasing, so it cannot be cached+    ///   per question.     ///     /// Internal rather than private so the differential fuzz can compare it against the     /// retired regex directly, instead of through `parse`'s output.-    nonisolated static func normaliseSelfClosingTags(_ html: String) -> String {+    ///+    /// - Parameter closes: the close-angle table to use. Defaults to the linear index built+    ///   from `html` on first use; supplied by tests so the same loop can be driven by a table+    ///   built one fresh forward scan at a time, and the two compared.+    nonisolated static func normaliseSelfClosingTags(+        _ html: String,+        closes suppliedCloses: HTMLQuoteAwareTagScan.CloseAngleIndex? = nil+    ) -> String {         let nsHTML = html as NSString         let length = nsHTML.length         guard length > 2 else { return html }@@ -190,47 +207,21 @@ enum HTMLImageParser: Sendable {         var output = ""         var copied = 0         var index = 0-        var closeCursor = -1-        var closesExhausted = false-        var cachedRunEnd = -1-        var cachedRunStart = -1--        /// The first `>` at or after `position`. Calls arrive with a non-decreasing-        /// `position`, so the underlying scan never revisits a unit and the whole sequence of-        /// lookups costs one pass over the input. The `max` below is defensive against that-        /// monotonicity ever breaking, not a live case: this line is only reached when-        /// `closeCursor < position`.-        func firstClose(atOrAfter position: Int) -> Int? {-            if closesExhausted { return nil }-            if closeCursor >= position { return closeCursor }-            var probe = max(position, closeCursor + 1)-            while probe < length, nsHTML.character(at: probe) != closeAngle { probe += 1 }-            guard probe < length else {-                closesExhausted = true-                return nil-            }-            closeCursor = probe-            return probe-        }--        /// Where the run of whitespace immediately before `end` begins (`end` itself when-        /// there is none). Cached for the last `end` asked about, because many tag starts can-        /// share one `>` — `<img<img<img…` followed by a long run — and re-walking that run-        /// per start would reintroduce the quadratic this scan exists to remove. One slot is-        /// enough: `end` is non-decreasing, and the runs for distinct `>` are disjoint.-        func whitespaceRunStart(endingAt end: Int) -> Int {-            if cachedRunEnd == end { return cachedRunStart }-            var start = end-            while start > 0, ICUWhitespace.contains(nsHTML.character(at: start - 1)) { start -= 1 }-            cachedRunEnd = end-            cachedRunStart = start-            return start-        }+        // Both lookups below answer every offset at once, each built on first use. Neither+        // may be a cache keyed on the last question asked: the close-angle answer is not+        // monotone once quoting is honoured, so consecutive candidates can resolve to two+        // different `>` and a one-slot cache then misses on every call. That is what the+        // whitespace-run cache that used to live here did, at 4x per doubling of the input+        // — see `HTMLQuoteAwareTagScan.WhitespaceRunIndex` (T-1976).+        var lookup = HTMLQuoteAwareTagScan.Lookup(scanning: nsHTML, closes: suppliedCloses)          while index < length {             guard nsHTML.character(at: index) == openAngle,                   let nameLength = matchedVoidElementLength(in: nsHTML, at: index + 1),-                  let close = firstClose(atOrAfter: index + 1 + nameLength)+                  // The first UNQUOTED `>` at or after the name (T-1976) —+                  // `<img alt="a > b" src=x>` closes at the real trailing `>`, not the one+                  // inside `alt`.+                  let close = lookup.firstUnquotedCloseAngle(atOrAfter: index + 1 + nameLength)             else {                 index += 1                 continue@@ -241,7 +232,7 @@ enum HTMLImageParser: Sendable {             // position from which `(?<!/)\s*>` matches.             var groupEnd: Int?             if afterName < length, ICUWhitespace.contains(nsHTML.character(at: afterName)) {-                let runStart = max(afterName + 1, whitespaceRunStart(endingAt: close))+                let runStart = max(afterName + 1, lookup.whitespaceRunStart(endingAt: close))                 if nsHTML.character(at: runStart - 1) != slash {                     groupEnd = runStart                 } else if runStart < close {@@ -252,7 +243,7 @@ enum HTMLImageParser: Sendable {             }             // The empty alternative — everything from the name to the `>` is whitespace. The             // lookbehind always passes here: the preceding character is the name's last.-            if groupEnd == nil, whitespaceRunStart(endingAt: close) <= afterName {+            if groupEnd == nil, lookup.whitespaceRunStart(endingAt: close) <= afterName {                 groupEnd = afterName             }             guard let groupEnd else {
prism/Services/HTMLQuoteAwareTagScan.swift Added +281 / -0
diff --git a/prism/Services/HTMLQuoteAwareTagScan.swift b/prism/Services/HTMLQuoteAwareTagScan.swiftnew file mode 100644index 00000000..ee6db194--- /dev/null+++ b/prism/Services/HTMLQuoteAwareTagScan.swift@@ -0,0 +1,281 @@+//+//  HTMLQuoteAwareTagScan.swift+//  prism+//+//  Shared quote-aware lookups for locating a start tag's terminating `>`, used by both+//  `HTMLImageSourceRewriter` (raw-HTML image mediation) and `HTMLImageParser` (standalone+//  image normalisation). HTML permits `>` inside a quoted attribute value — `<img alt="a >+//  b" src=cat.png>` is one tag, not two fragments — so a tag's real extent is not simply+//  "up to the first `>`". Both call sites used to find one that way (T-1976): the tag was cut+//  short inside the quote and `src` was never reached, after which the sanitizer's protocol+//  allowlist dropped the un-mediated relative reference and the image never rendered.+//++import Foundation++nonisolated enum HTMLQuoteAwareTagScan {++    private static let closeAngle = unichar(UInt8(ascii: ">"))+    private static let equalsSign = unichar(UInt8(ascii: "="))+    private static let doubleQuote = unichar(UInt8(ascii: "\""))+    private static let singleQuote = unichar(UInt8(ascii: "'"))++    /// Answers, for **every** start offset in one string, "the first `>` at or after here that+    /// is not inside a quoted attribute value".+    ///+    /// **Which quote model (T-1877 review, T-1976 review round 2).** A quote opens a value+    /// only in the HTML5 tokenizer's *before attribute value* state — immediately after an+    /// `=`, whitespace allowed — and the value then ends at the very next matching quote with+    /// no escape processing. Everywhere else a quote is an ordinary character. Treating+    /// **every** quote as a delimiter instead is a model this repository has already tried and+    /// rejected once: see `FootnotePreprocessor.scanTagRemainder`, whose doc comment records+    /// that any odd number of literal quotes inside a value — a careless straight quote in+    /// `title="a"b"`, an apostrophe in `alt=don't` — re-opens a value that then runs past the+    /// real `>`. Here that cost four ordinary tags their `src` mediation outright+    /// (`<img alt=don't src=cat.png>` among them), which is the very T-1976 symptom, so the+    /// two scans now agree on what "quoted" means. The one deliberate difference from+    /// `scanTagRemainder` is that a stray `<` does not end the scan: this lookup reports a+    /// tag's `>` and nothing else, and both call sites already treat a `<` inside a tag as+    /// ordinary text (`<img <img>` is one tag, as the retired regexes had it).+    ///+    /// **Why the answer has to be indexed rather than cached (T-1976, review round 1).** The+    /// obvious shape is a forward scan from the requested offset plus a cache, so that a+    /// document of `<img<img<img…` does not rescan to the end once per candidate. That cache+    /// is what makes it wrong: **the tokenizer state at an offset is left-context**, so no+    /// verdict computed from one start offset is evidence about a later one. In `x="ab"y>` a+    /// scan from 0 finds the `>` at 7, while a scan from 3 — inside that quoted value —+    /// reaches the `"` that closed it in the *normal* state, where it opens nothing, and runs+    /// on. Gating a single-function recurrence on a precomputed "this quote opens a value"+    /// predicate is the same trap wearing a different hat, for the same reason. Which cache+    /// shapes were tried, what each one broke, and what each measured is in+    /// `specs/bugfixes/html-image-tag-quote-scan/report.md`.+    ///+    /// So the answers are computed for all offsets at once, in a single **right-to-left**+    /// pass, from a recurrence over the tokenizer's states rather than over positions alone.+    /// Three states can reach a `>`: `N` (normal), `E` (before attribute value — an `=` seen+    /// with only whitespace since) and `U` (inside an unquoted value). Writing `N(p)`, `E(p)`,+    /// `U(p)` for the answer to a scan that arrives at `p` in that state:+    ///+    /// - `N(len) = E(len) = U(len) = nil` — nothing left to find.+    /// - at `>`: all three answer `p`. A `>` ends the tag from every state.+    /// - at a quote character, in `E` only: `E(p) = N(c + 1)`, where `c` is the next+    ///   occurrence of that **same** quote character — the value `[p, c]` is skipped whole and+    ///   the scan resumes outside it. `nil` when there is no such `c`: an unterminated value+    ///   runs to the end of the input. In `N` and `U` a quote is an ordinary character.+    /// - at `=`: all three move to `E(p + 1)` — this is what puts the *next* quote in the+    ///   state where it can open a value.+    /// - at whitespace: `E` stays `E` (whitespace between `=` and the value is allowed), `U`+    ///   returns to `N` (an unquoted value ends at whitespace), `N` stays `N`.+    /// - otherwise: `E` moves to `U(p + 1)`; `N` and `U` stay where they are.+    ///+    /// **`U` collapses into `N`.** Read the last four bullets for `U` and for `N` side by+    /// side: at `>` both answer `p`, at `=` both go to `E(p + 1)`, at whitespace both go to+    /// `N(p + 1)`, and otherwise one goes to `U(p + 1)` and the other to `N(p + 1)`. With+    /// `U(len) = N(len)`, induction from the right gives `U(p) = N(p)` everywhere. That is not+    /// a shortcut through the model — `U` is what makes `alt=5" wide` behave, and it is+    /// tracked; it simply cannot change *where the `>` is*, only what a `/` in an unquoted+    /// value means, which is `scanTagRemainder`'s question and not this one. So the pass+    /// carries two running answers, and the fuzz in `QuoteAwareTagExtentTests` compares it+    /// against a forward oracle that keeps all three separate.+    ///+    /// `N(c + 1)` is already known when `p` is reached (`c > p`), and it is remembered per+    /// quote character rather than looked up, so the pass is O(n) and visits each unit once.+    ///+    /// Only the offsets where `N(p) != N(p + 1)` are stored — `N` is constant between them, so+    /// a lookup is "the first stored offset at or after the requested one". Under this model+    /// `N` can only change at a `>` or at an `=`, so the table is empty for prose, holds a+    /// handful of entries per tag for ordinary markup, and reaches one entry per unit only for+    /// input that is mostly `>` or `=` characters, which costs O(n) to read regardless.+    struct CloseAngleIndex {++        /// One step of the answer function: for a start offset at or before `maxStart` (and+        /// after the previous entry's `maxStart`), the first unquoted `>` is `closeAngleOffset`.+        struct Entry {+            let maxStart: Int+            let closeAngleOffset: Int?+        }++        /// Ascending by `maxStart`.+        let entries: [Entry]++        /// Builds an index from an explicit table.+        ///+        /// Internal so a differential fuzz can drive the real call sites with a table built+        /// the slow, obvious way — one fresh forward scan per start offset — and compare. The+        /// review that rejected the first T-1976 fix landed precisely because no test could+        /// see the layer that answered these lookups; this is the seam that makes that layer+        /// testable from outside.+        init(entries: [Entry]) {+            self.entries = entries+        }++        /// Builds the index for `nsHTML` in one right-to-left pass.+        init(scanning nsHTML: NSString) {+            var descending: [Entry] = []+            /// `N(probe + 1)` and `E(probe + 1)`. `U` is `N` — see the type's doc comment.+            var normal: Int?+            var beforeValue: Int?+            /// `N(c + 1)` for the nearest `"` strictly right of `probe`, and likewise for `'`.+            /// Nil until one has been seen, which is also the answer for a value that never+            /// closes.+            var afterMatchingDouble: Int?+            var afterMatchingSingle: Int?+            var probe = nsHTML.length - 1+            while probe >= 0 {+                let unit = nsHTML.character(at: probe)+                var nextNormal = normal+                var nextBeforeValue = beforeValue+                if unit == HTMLQuoteAwareTagScan.closeAngle {+                    nextNormal = probe+                    nextBeforeValue = probe+                } else if unit == HTMLQuoteAwareTagScan.equalsSign {+                    nextNormal = beforeValue+                    nextBeforeValue = beforeValue+                } else if unit == HTMLQuoteAwareTagScan.doubleQuote {+                    // Read the memo for the nearest `"` to the right BEFORE overwriting it:+                    // from here leftwards, `probe` becomes that nearest `"`, and what a+                    // scan resumes with after it is `N(probe + 1)`.+                    nextBeforeValue = afterMatchingDouble+                    afterMatchingDouble = normal+                } else if unit == HTMLQuoteAwareTagScan.singleQuote {+                    nextBeforeValue = afterMatchingSingle+                    afterMatchingSingle = normal+                } else if !ICUWhitespace.contains(unit) {+                    // `E` moves into an unquoted value, which answers as `N` does.+                    nextBeforeValue = normal+                }+                if nextNormal != normal {+                    descending.append(Entry(maxStart: probe, closeAngleOffset: nextNormal))+                }+                normal = nextNormal+                beforeValue = nextBeforeValue+                probe -= 1+            }+            // Reversed in place rather than through `reversed()`, which would materialise a+            // second copy of a table that is O(n) in the worst case.+            descending.reverse()+            entries = descending+        }++        /// The first `>` at or after `position` that is not inside a quoted attribute value,+        /// or nil when none remains.+        func firstUnquotedCloseAngle(atOrAfter position: Int) -> Int? {+            var low = 0+            var high = entries.count+            while low < high {+                let mid = low + (high - low) / 2+                if entries[mid].maxStart < position {+                    low = mid + 1+                } else {+                    high = mid+                }+            }+            return low < entries.count ? entries[low].closeAngleOffset : nil+        }+    }++    /// Answers, for **every** offset in one string, "where does the run of whitespace+    /// immediately before here begin?" (the offset itself when there is none).+    ///+    /// **Why this is an index and not a cache (T-1976, review round 2).**+    /// `HTMLImageParser.normaliseSelfClosingTags` asks this about the `>` it has just located,+    /// and many tag starts can share one `>` — `<img<img<img…` followed by a long whitespace+    /// run — so re-walking that run per start is quadratic. It used to be answered by a+    /// one-slot cache justified by "the `>` asked about is non-decreasing". That was a+    /// consequence of the close search being quote-BLIND, and quote-awareness removed it:+    /// `N` is not monotone, so consecutive candidates can resolve to two different `>` and+    /// the single slot then misses on every call. Measured on an input built to alternate,+    /// that was 2.9 s at 44 KB and 40.7 s at 134 KB — 4x per doubling — on a pass that runs+    /// unconditionally on every raw-HTML block at parse time, which is the denial of service+    /// T-1951 was filed for. `closeCursor` was deleted for exactly this reason; this cache+    /// rested on the same premise and had to go the same way.+    ///+    /// Stored as the maximal whitespace runs, which is O(1) for markup with ordinary spacing+    /// and O(n) only for input that alternates whitespace with everything else.+    struct WhitespaceRunIndex {++        /// A maximal run of whitespace occupying `[start, end)`.+        struct Run {+            let start: Int+            let end: Int+        }++        /// Ascending, disjoint, and non-adjacent (adjacent runs would not be maximal).+        let runs: [Run]++        /// Builds the index for `nsHTML` in one left-to-right pass.+        init(scanning nsHTML: NSString) {+            var found: [Run] = []+            let length = nsHTML.length+            var probe = 0+            while probe < length {+                guard ICUWhitespace.contains(nsHTML.character(at: probe)) else {+                    probe += 1+                    continue+                }+                let start = probe+                while probe < length, ICUWhitespace.contains(nsHTML.character(at: probe)) { probe += 1 }+                found.append(Run(start: start, end: probe))+            }+            runs = found+        }++        /// Where the run of whitespace immediately before `end` begins — `end` itself when the+        /// unit before it is not whitespace, or when `end` is 0.+        func runStart(endingAt end: Int) -> Int {+            // The last run that begins before `end`. Runs are disjoint, so it is the only one+            // that can contain `end - 1`.+            var low = 0+            var high = runs.count+            while low < high {+                let mid = low + (high - low) / 2+                if runs[mid].start < end {+                    low = mid + 1+                } else {+                    high = mid+                }+            }+            guard low > 0 else { return end }+            let candidate = runs[low - 1]+            return candidate.end >= end ? candidate.start : end+        }+    }++    /// The two indexes above, each built on first use over the same string.+    ///+    /// Both call sites need "where does this tag end?", one of them also needs "where does the+    /// whitespace before that end begin?", and neither should pay to build an index it never+    /// queries — a raw-HTML block carrying no void-element candidate at all builds nothing.+    /// Holding that laziness here rather than repeating an `if x == nil { x = … }` dance at+    /// each site is also what keeps "no index yet" from being spelled the same way as "no+    /// close found".+    struct Lookup {++        private let nsHTML: NSString+        private var closes: CloseAngleIndex?+        private var whitespaceRuns: WhitespaceRunIndex?++        /// - Parameters:+        ///   - nsHTML: the string both indexes describe.+        ///   - closes: a pre-built close-angle table, supplied by tests so a call site's real+        ///     loop can be driven by one fresh forward scan per offset and the two compared.+        init(scanning nsHTML: NSString, closes: CloseAngleIndex? = nil) {+            self.nsHTML = nsHTML+            self.closes = closes+        }++        /// The first `>` at or after `position` that is not inside a quoted attribute value.+        mutating func firstUnquotedCloseAngle(atOrAfter position: Int) -> Int? {+            let index = closes ?? CloseAngleIndex(scanning: nsHTML)+            closes = index+            return index.firstUnquotedCloseAngle(atOrAfter: position)+        }++        /// Where the run of whitespace immediately before `end` begins.+        mutating func whitespaceRunStart(endingAt end: Int) -> Int {+            let index = whitespaceRuns ?? WhitespaceRunIndex(scanning: nsHTML)+            whitespaceRuns = index+            return index.runStart(endingAt: end)+        }+    }+}
prism/Services/WebRendering/HTMLImageSourceRewriter.swift Modified +39 / -8
diff --git a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift b/prism/Services/WebRendering/HTMLImageSourceRewriter.swiftindex 7da1e7db..c1e5ee7d 100644--- a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift+++ b/prism/Services/WebRendering/HTMLImageSourceRewriter.swift@@ -75,22 +75,53 @@ nonisolated enum HTMLImageSourceRewriter {     /// document order — the extents `<(?:img|source)\b[^>]*>` would have matched, found in     /// linear time.     ///-    /// A tag runs from its start to the first following `>`. Once no `>` remains, no later-    /// start can match either, so the scan stops rather than re-scanning to the end of the-    /// input once per start (the quadratic case the regex had).-    private static func tagRanges(in nsHTML: NSString) -> [NSRange] {+    /// A tag runs from its start to the first following `>` that is not inside a quoted+    /// attribute value (T-1976) — `<img alt="a > b" src=cat.png>` is one tag, not two. Each+    /// start asks `HTMLQuoteAwareTagScan.CloseAngleIndex` for that offset; the index answers+    /// every start offset in the fragment from one pass, built here on first use.+    ///+    /// That index follows the HTML5 tokenizer, in which a quote opens a value only just after+    /// an `=` — which is the SAME model `attributes(in:)` below reads the extracted tag with,+    /// deliberately. The two disagreeing is not academic: under an any-quote model+    /// `<img alt=a" src="x>y">` yielded a tag range ending at the `>` after `x`, and the+    /// attribute scan then read `src="x>` as an unterminated value and mediated nothing.+    ///+    /// A start with no unquoted `>` ahead of it is SKIPPED, not a reason to stop. Stopping is+    /// what the first T-1976 fix did, carrying over a `break` that had been sound while the+    /// search was quote-blind ("no `>` left at all" really does hold for every later start).+    /// It is not sound once quoting is honoured, because the tokenizer state at an offset is+    /// left-context: in `<img alt="><img src=cat.png>` the first tag's value opens at the `"`+    /// after `alt=` and never closes, so that tag has no reachable `>` at all — while the+    /// second tag, whose scan begins outside that value, has one. The `break` dropped it, so+    /// `cat.png` was never mediated through `prism-doc://` and never rendered: the very+    /// symptom T-1976 exists to fix, reached by a different trigger.+    ///+    /// A skipped start is un-mediated, not unsanitised: its `src` still reaches+    /// `HTMLSanitizer`, whose allowlist strips a relative one (the image simply does not+    /// render) and lets a bare `http(s)` one through with the CSP's+    /// `img-src prism-doc: data:` as the remaining backstop — the same defence-in-depth+    /// residue `attributes(in:)` names for the unquoted case.+    ///+    /// - Parameter closes: the close-angle table to use. Defaults to the linear index built+    ///   from `nsHTML` on first use; supplied by tests so the same loop can be driven by a+    ///   table built one fresh forward scan at a time, and the two compared.+    static func tagRanges(+        in nsHTML: NSString,+        closes suppliedCloses: HTMLQuoteAwareTagScan.CloseAngleIndex? = nil+    ) -> [NSRange] {         guard let tagStartRegex else { return [] }         let length = nsHTML.length         var ranges: [NSRange] = []         var cursor = 0+        var lookup = HTMLQuoteAwareTagScan.Lookup(scanning: nsHTML, closes: suppliedCloses)         let starts = tagStartRegex.matches(in: nsHTML as String, range: NSRange(location: 0, length: length))         for start in starts {             // Matches are non-overlapping: a start inside an already-consumed tag is skipped.             guard start.range.location >= cursor else { continue }-            let searchFrom = start.range.location + start.range.length-            let close = nsHTML.range(of: ">", range: NSRange(location: searchFrom, length: length - searchFrom))-            guard close.location != NSNotFound else { break }-            let end = close.location + close.length+            guard let closeIndex = lookup.firstUnquotedCloseAngle(+                atOrAfter: start.range.location + start.range.length+            ) else { continue }+            let end = closeIndex + 1             ranges.append(NSRange(location: start.range.location, length: end - start.range.location))             cursor = end         }
prismTests/RawHTMLImageScanGrowthTests.swift Modified +479 / -14
diff --git a/prismTests/RawHTMLImageScanGrowthTests.swift b/prismTests/RawHTMLImageScanGrowthTests.swiftindex 2ade1652..eeca8db0 100644--- a/prismTests/RawHTMLImageScanGrowthTests.swift+++ b/prismTests/RawHTMLImageScanGrowthTests.swift@@ -155,16 +155,103 @@ struct VoidElementNormalisationTests {         }     } -    @Test("A `>` inside a quoted attribute value still ends the tag (T-1976 preserved)")-    func quotedCloseBracketStillMangles() {-        // NOT a fix. `[^>]` could not see quoting either, so a quote-aware scan would be a-        // behaviour change smuggled into a performance change — and one that alters what-        // reaches the XML parser for every document with such a tag. The defect keeps its-        // own ticket; this test states that leaving it standing was deliberate, and fails-        // loudly if a later change to the scan closes it silently.-        let input = "<img src=\"a>b\">"-        #expect(HTMLImageParser.normaliseSelfClosingTags(input) == "<img src=\"a />b\">")-        #expect(Self.retiredNormalise(input) == HTMLImageParser.normaliseSelfClosingTags(input))+    @Test("A `>` inside a quoted attribute value no longer ends the tag early (T-1976)")+    func quotedCloseBracketNoLongerMangles() {+        // Was a defect: `[^>]` could not see quoting, so the retired regex — and this scan,+        // before T-1976 — ended the tag at the `>` inside `src`, mangling it to+        // `<img src="a />b">` and leaving `b">` as trailing text. The close-angle lookup is+        // now quote-aware (`HTMLQuoteAwareTagScan`), so the tag runs to its real closing `>` and+        // the attribute value survives intact. The retired regex is deliberately NOT+        // consulted here — it carries the very bug this golden proves fixed.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img src=\"a>b\">") == "<img src=\"a>b\" />")+        // Single-quoted values get the same treatment.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img src='a>b'>") == "<img src='a>b' />")+        // A `>` inside a quote, followed by more attributes, still finds the real close.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img alt=\"a>b\" src=\"c.png\">")+                == "<img alt=\"a>b\" src=\"c.png\" />")+        // An unterminated quote has no real closing `>` to find at all — left untouched,+        // exactly as a `>`-free input already was.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img src=\"a>b") == "<img src=\"a>b")+        // A `>` outside any quote still ends the tag exactly as before quoting was tracked.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img src=x>ignored")+                == "<img src=x />ignored")+    }++    /// `<img/="` repeated. Every candidate's `afterName` lands on the `/`, so the candidate+    /// fails and the scan advances one character rather than jumping past a close — which is+    /// what keeps all `count` of them in play. Each unit's `="` puts its quote in value+    /// position, so candidate k's value ends at candidate k+1's quote and resumes at candidate+    /// k+2: the candidates split into two parity classes whose answers are unrelated, which is+    /// the property every cache over a forward scan died on.+    private static func alternatingQuoteStarts(count: Int) -> String {+        String(repeating: "<img/=\"", count: count)+    }++    @Test("G10: many starts before a shared quoted `>` normalise linearly (T-1976)")+    func manyStartsBeforeSharedQuotedCloseScalesLinearly() {+        // The shape quote-awareness put at risk: many failed `<img` candidates all asking+        // "where's my close?" before reaching one shared, quote-guarded `>` far away. The+        // close-angle index must answer all of them from the single pass it already made,+        // rather than each candidate re-scanning to the end.+        GrowthRatioGuard.expectLinearGrowth(shape: "starts before one shared quoted `>`", baseCount: 2_000) { count in+            let html = String(repeating: "<img", count: count)+                + " alt=\"" + String(repeating: ">", count: count) + "\" src=\"x.png\">"+            _ = HTMLImageParser.normaliseSelfClosingTags(html)+        }+    }++    @Test("G11: an unterminated quote after many starts fails linearly (T-1976)")+    func unterminatedQuoteAfterManyStartsScalesLinearly() {+        // The failure-side twin of G10: the shared attribute value's quote never closes, so+        // every one of the leading candidates must resolve to "no unquoted close reachable"+        // from the shared index, not by each independently rescanning to the end.+        GrowthRatioGuard.expectLinearGrowth(shape: "starts before an unterminated quote", baseCount: 2_000) { count in+            let html = String(repeating: "<img", count: count) + " alt=\"" + String(repeating: "a", count: count)+            _ = HTMLImageParser.normaliseSelfClosingTags(html)+        }+    }++    @Test("G12: starts straddling alternating quoted values normalise linearly (T-1976)")+    func startsStraddlingQuoteRunsScaleLinearly() {+        // The shape that decided the SECOND T-1976 fix. Every candidate here sits at a+        // different point in the quote structure — candidate k begins inside the value that+        // candidate k-1 began outside — so no verdict computed for one is reusable for the+        // next, and every windowed cache tried over a forward scan degraded to one full+        // rescan per candidate: quadratic, which is the denial of service T-1951 removed. The+        // whole-string index has no such window, so this stays linear. No `>` anywhere, so+        // every candidate resolves to "none".+        GrowthRatioGuard.expectLinearGrowth(shape: "starts straddling quoted values", baseCount: 2_000) { count in+            _ = HTMLImageParser.normaliseSelfClosingTags(Self.alternatingQuoteStarts(count: count))+        }+    }++    @Test("G13: starts straddling quoted values before a real close normalise linearly (T-1976)")+    func startsStraddlingQuoteRunsBeforeCloseScaleLinearly() {+        // As G12, but with a real unquoted `>` at the far end. The two parity classes now get+        // two DIFFERENT answers — one reaches that `>`, the other still runs out of input — so+        // consecutive candidates alternate, which is what rules out any "last close found"+        // cursor. (No candidate matches, so the scan never jumps: all `count` of them ask.)+        GrowthRatioGuard.expectLinearGrowth(shape: "straddled starts before a real close", baseCount: 2_000) { count in+            _ = HTMLImageParser.normaliseSelfClosingTags(Self.alternatingQuoteStarts(count: count) + ">")+        }+    }++    @Test("G14: alternating closes behind long whitespace runs normalise linearly (T-1976)")+    func alternatingClosesBehindWhitespaceRunsScaleLinearly() {+        // The shape that reopened T-1976 in review round 2, and the one G12/G13 cannot see:+        // on both of those the whitespace-run lookup walks ZERO characters, because the unit+        // before the close is a quote. Here each of the two closes sits behind its own long+        // whitespace run, and consecutive candidates alternate between them, so a lookup+        // CACHED on the last close asked about misses on every call and re-walks the whole+        // run: 2.9 s at 44 KB, 11.8 s at 90 KB, 40.7 s at 134 KB — 4x per doubling — on a+        // pass that runs unconditionally on every raw-HTML block at parse time. The run start+        // is indexed for every offset instead (`HTMLQuoteAwareTagScan.WhitespaceRunIndex`).+        GrowthRatioGuard.expectLinearGrowth(shape: "alternating closes behind whitespace runs",+                                            baseCount: 1_000) { count in+            let spaces = String(repeating: " ", count: count * 4)+            let html = Self.alternatingQuoteStarts(count: count) + spaces + ">\"" + spaces + ">"+            _ = HTMLImageParser.normaliseSelfClosingTags(html)+        }     }      @Test("Tag-name matching is ASCII-case-insensitive only — a deliberate divergence")@@ -196,30 +283,72 @@ struct VoidElementNormalisationTests {         // after a `>` makes it part of a composed character sequence — which is how the         // first draft of this scan went wrong, by looking for `>` with `NSString.range(of:)`         // instead of scanning UTF-16 units.+        //+        // Quote characters ARE generated (T-1976 review round 2), at character granularity —+        // `<img ""x>`, `<img '  >`, a quote adjacent to the name — because that is the region+        // where the two must still agree and where dropping them left no exactness coverage+        // at all. What is skipped is not the character but the DISAGREEING region: see+        // `expectFuzzAgreement`.         let alphabet = ["<", ">", "/", "i", "m", "g", "s", "o", "u", "r", "c", "e", "b",-                        "I", "M", "G", "B", "R", "S", " ", " ", " ", "\t", "\n", "\"", "'",-                        "=", "a", "x", "\u{00A0}", "\u{200B}", "\u{2028}", "\u{0301}",-                        "<img", "<img ", "<source ", "<br", " />", "/>", "src=\"a\""]+                        "I", "M", "G", "B", "R", "S", " ", " ", " ", "\t", "\n",+                        "=", "a", "x", "\"", "'", "\u{00A0}", "\u{200B}", "\u{2028}", "\u{0301}",+                        "<img", "<img ", "<source ", "<br", " />", "/>"]         expectFuzzAgreement(seed: 0x5195_1A, alphabet: alphabet, maxUnits: 40, rounds: 25_000)     }      @Test("The scan reproduces the retired regex on generated tag-shaped fragments")     func fuzzTagShapedFragments() {+        // As above, with realistic attribute shapes as well as bare quotes: balanced quoted+        // values (`" src=..."`, `" srcset=..."`) alongside the loose `"`/`'` that can be left+        // open across a token boundary.         let alphabet = ["<img", "<source", "<br", "<IMG", "<Br", " ", "  ", "\t", "\n", ">",                         "/>", " />", "/", " src=\"a.png\"", " srcset='a 1x, b 2x'", " alt=x",                         "=", "\"", "'", "<p>", "</p>", "text", "<!--", "-->",-                        "<img src=\"a>b\">", "\u{00A0}", "\u{200B}"]+                        "\u{00A0}", "\u{200B}"]         expectFuzzAgreement(seed: 0x1976_1A, alphabet: alphabet, maxUnits: 60, rounds: 25_000)     } +    /// Whether the retired regex is still a valid oracle for `input`.+    ///+    /// The regex's `[^>]` cannot see quoting, so it carries the very defect T-1976 fixes;+    /// where the two models disagree, agreement would mean the fix regressed. But the+    /// disagreement is confined to exactly one thing — WHERE a tag's `>` is — and everything+    /// else about the scan (T-1951's linear-time shape and its byte-for-byte exactness) is+    /// untouched by T-1976. So the gate is the disagreement itself: when the quote-aware+    /// answer equals a plain "first literal `>`" search at every offset, the two must produce+    /// identical output, and the round is compared. Excluding the quote CHARACTERS instead —+    /// which is what the first version of this fix did — throws away the region where they+    /// should still agree, which is most of it.+    private static func quoteModelAgreesWithLiteralScan(_ input: String) -> Bool {+        let nsInput = input as NSString+        let index = HTMLQuoteAwareTagScan.CloseAngleIndex(scanning: nsInput)+        let closeAngle = unichar(UInt8(ascii: ">"))+        var literal: [Int?] = Array(repeating: nil, count: nsInput.length)+        var nextLiteral: Int?+        var probe = nsInput.length - 1+        while probe >= 0 {+            if nsInput.character(at: probe) == closeAngle { nextLiteral = probe }+            literal[probe] = nextLiteral+            probe -= 1+        }+        for start in 0..<nsInput.length+        where index.firstUnquotedCloseAngle(atOrAfter: start) != literal[start] {+            return false+        }+        return true+    }+     /// Fails on the first disagreement, reporting the input verbatim — a fuzz failure is only     /// useful if it hands back the case that broke it.     private func expectFuzzAgreement(seed: UInt64, alphabet: [String], maxUnits: Int, rounds: Int) {         var rng = SeededRandomNumberGenerator(seed: seed)+        var compared = 0         for _ in 0..<rounds {             let units = Int.random(in: 0...maxUnits, using: &rng)             var input = ""             for _ in 0..<units { input += alphabet.randomElement(using: &rng) ?? "a" }+            guard Self.quoteModelAgreesWithLiteralScan(input) else { continue }+            compared += 1             let expected = Self.retiredNormalise(input)             let actual = HTMLImageParser.normaliseSelfClosingTags(input)             guard expected == actual else {@@ -229,6 +358,342 @@ struct VoidElementNormalisationTests {                 return             }         }+        // A gate that silently swallowed the whole corpus would leave this test green having+        // compared nothing — the same failure mode the project's result-bundle check exists+        // for. The threshold is deliberately loose; it asserts that the gate is a filter, not+        // an off switch.+        #expect(compared > rounds / 10,+                Comment(rawValue: "only \(compared) of \(rounds) rounds reached the oracle —"+                    + " the quote-model gate is excluding almost everything"))+    }+}++// MARK: - Quote-aware tag extent (HTMLQuoteAwareTagScan + both call sites)++// Correctness-only, so deliberately NOT `.serialized`: nothing here measures time.+//+// This suite exists because of what two rejected rounds of this fix got wrong and why their+// tests could not see it. Round 1 kept a forward scan and cached its verdicts across candidate+// starts, which is unsound — the tokenizer state at an offset is left-context, so a verdict+// computed from one start is not evidence about a later one — and every test it shipped+// exercised the bare scan, never the layer that answered the call sites' lookups. Round 2+// fixed that but shipped the wrong quote model: any quote opening a value, which+// `FootnotePreprocessor.scanTagRemainder` had already tried and rejected in the T-1877 review,+// and which regressed four ordinary tags from mediated to not mediated. So the tests here are+// deliberately aimed at both layers:+//+//  - `indexAnswersEveryStartOffsetLikeAFreshScan` compares the production index against a+//    forward HTML5 oracle at EVERY offset, which is the whole contract of the lookup. The+//    oracle keeps the tokenizer's three states separate where production collapses two of+//    them, so the collapse is witnessed rather than assumed.+//  - the two call-site fuzzes drive the real `normaliseSelfClosingTags` and `tagRanges`+//    loops twice — once with the production index, once with a table built one fresh scan at+//    a time — so anything the lookup layer does differently from re-scanning shows up in the+//    call site's own output.+//  - `onlyAnEqualsSignPutsAQuoteInValuePosition` pins the model itself, on the four tags the+//    any-quote model regressed.+//  - the remaining goldens pin the reproductions from both review rounds, all of which are+//    the T-1976 symptom (an image that never renders) reached by a route other than a quoted+//    `>`.+@Suite("Quote-aware tag extent — index and call sites (T-1976)")+struct QuoteAwareTagExtentTests {++    /// The DEFINITION of the lookup, kept here as the differential oracle: a forward scan in+    /// the HTML5 tokenizer's states, transcribed from `FootnotePreprocessor.scanTagRemainder`+    /// — the repository's other implementation of the same model — over UTF-16 units instead+    /// of `Character`s, and without its `<` rule (this lookup reports a tag's `>` and nothing+    /// else; both call sites read `<img <img>` as one tag, as their retired regexes did).+    /// Obvious and O(n) per query, which is why production indexes the answers instead.+    ///+    /// It keeps all THREE states as the two independent booleans the tokenizer carries, and+    /// returns the self-closing verdict so that `inUnquotedValue` is genuinely live here. That+    /// matters: production collapses the unquoted-value state into the normal one, on the+    /// argument that it cannot move a `>`, and an oracle that had already made the same+    /// collapse could not witness the argument being wrong.+    private static func freshTagScan(+        in nsHTML: NSString,+        from start: Int+    ) -> (close: Int?, isSelfClosing: Bool) {+        let closeAngle = unichar(UInt8(ascii: ">"))+        let equalsSign = unichar(UInt8(ascii: "="))+        let slash = unichar(UInt8(ascii: "/"))+        let quotes: Set<unichar> = Set("\"'".utf16)+        var expectingValue = false+        var inUnquotedValue = false+        var sawTrailingSlash = false+        var probe = start+        while probe < nsHTML.length {+            let unit = nsHTML.character(at: probe)+            if expectingValue, quotes.contains(unit) {+                probe += 1+                while probe < nsHTML.length, nsHTML.character(at: probe) != unit { probe += 1 }+                guard probe < nsHTML.length else { return (nil, false) }+                sawTrailingSlash = false+                expectingValue = false+                inUnquotedValue = false+                probe += 1+                continue+            }+            if unit == closeAngle { return (probe, sawTrailingSlash) }+            if ICUWhitespace.contains(unit) {+                inUnquotedValue = false+            } else {+                if expectingValue { inUnquotedValue = true }+                sawTrailingSlash = unit == slash && !inUnquotedValue+                expectingValue = unit == equalsSign+            }+            probe += 1+        }+        return (nil, false)+    }++    private static func freshFirstUnquotedCloseAngle(in nsHTML: NSString, from start: Int) -> Int? {+        freshTagScan(in: nsHTML, from: start).close+    }++    /// The oracle's answers as a lookup table — one entry per start offset, each from its own+    /// fresh scan. Quadratic by construction, which is exactly the point: it is the+    /// "always re-scan" reference the production index is compared against, and the seam that+    /// lets both call sites be driven by it without duplicating their loops in a test.+    private static func alwaysFreshIndex(for html: String) -> HTMLQuoteAwareTagScan.CloseAngleIndex {+        let nsHTML = html as NSString+        return HTMLQuoteAwareTagScan.CloseAngleIndex(+            entries: (0..<nsHTML.length).map {+                .init(maxStart: $0, closeAngleOffset: freshFirstUnquotedCloseAngle(in: nsHTML, from: $0))+            }+        )+    }++    /// `HTMLImageSourceRewriter.tagRanges` written the obvious way: the same start pattern, a+    /// fresh forward scan per candidate, and a candidate with no reachable close simply+    /// skipped rather than ending the loop.+    ///+    /// Needed as a SEPARATE oracle from `alwaysFreshIndex`, because that one only swaps how+    /// the lookups are answered — both sides of such a comparison run the same loop, so a+    /// wrong branch in the loop itself (which is exactly what the `break` was) cancels out and+    /// is invisible. Verified by mutation: turning the production `continue` back into a+    /// `break` leaves the lookup-level comparison green.+    private static func referenceTagRanges(in nsHTML: NSString) -> [NSRange] {+        guard let regex = try? NSRegularExpression(pattern: #"<(?:img|source)\b"#,+                                                   options: [.caseInsensitive]) else { return [] }+        var ranges: [NSRange] = []+        var cursor = 0+        let whole = NSRange(location: 0, length: nsHTML.length)+        for start in regex.matches(in: nsHTML as String, range: whole) {+            guard start.range.location >= cursor else { continue }+            guard let close = freshFirstUnquotedCloseAngle(+                in: nsHTML, from: start.range.location + start.range.length+            ) else { continue }+            ranges.append(NSRange(location: start.range.location, length: close + 1 - start.range.location))+            cursor = close + 1+        }+        return ranges+    }++    /// Quote characters at high density next to the units that decide a tag's extent, so a+    /// generated fragment is far more likely than real markup to leave a quote open across a+    /// tag boundary — the shape both defects needed.+    private static let alphabet = ["<img", "<source", "<br", "<IMG", " ", "  ", "\t", "\n",+                                   ">", ">>", "/", "/>", " />", "=", "a", "x", "\"", "'",+                                   "\"\"", "''", "src=", "alt=", "<img src=\"a>b\">",+                                   "<img alt='a>b'>", "<p>", "text", "\u{00A0}", "\u{200B}"]++    /// Generates fragments until `check` reports a failure message, then records it and stops+    /// — a fuzz failure is only useful if it hands back the case that broke it.+    private func fuzz(seed: UInt64, rounds: Int, maxUnits: Int, check: (String) -> String?) {+        var rng = SeededRandomNumberGenerator(seed: seed)+        for _ in 0..<rounds {+            let units = Int.random(in: 0...maxUnits, using: &rng)+            var input = ""+            for _ in 0..<units { input += Self.alphabet.randomElement(using: &rng) ?? "a" }+            if let failure = check(input) {+                Issue.record(Comment(rawValue: failure))+                return+            }+        }+    }++    @Test("The index answers every start offset exactly as a fresh forward scan does")+    func indexAnswersEveryStartOffsetLikeAFreshScan() {+        // Every offset, not a sampled one: the defect class here is a verdict that is right+        // for the offset it was computed from and wrong for a later one, so sampling a single+        // start per input is the one thing that cannot see it.+        fuzz(seed: 0x1976_2A, rounds: 25_000, maxUnits: 40) { input in+            let nsInput = input as NSString+            let index = HTMLQuoteAwareTagScan.CloseAngleIndex(scanning: nsInput)+            for start in 0..<nsInput.length {+                let indexed = index.firstUnquotedCloseAngle(atOrAfter: start)+                let fresh = Self.freshFirstUnquotedCloseAngle(in: nsInput, from: start)+                if indexed != fresh {+                    return "index diverged from a fresh scan on \(input.debugDescription) at \(start):"+                        + " indexed \(String(describing: indexed)), fresh \(String(describing: fresh))"+                }+                // A quote-aware lookup can only ever SKIP a `>` because it sits inside a+                // quoted value; it can never end a tag EARLIER than an ordinary "first+                // literal `>`" search over the same string would. Checked alongside the+                // oracle rather than instead of it, so a matching pair of wrong answers+                // still fails.+                let blind = nsInput.range(of: ">", range: NSRange(location: start, length: nsInput.length - start))+                if let indexed, blind.location == NSNotFound || indexed < blind.location {+                    return "index found a close BEFORE the quote-blind one on"+                        + " \(input.debugDescription) at \(start): \(indexed) vs \(blind.location)"+                }+            }+            return nil+        }+    }++    @Test("Void-element normalisation matches an always-fresh lookup on random fragments")+    func normalisationMatchesAnAlwaysFreshLookup() {+        // Drives the REAL `normaliseSelfClosingTags` loop twice, differing only in how its+        // close-angle lookups are answered. Nothing in the PR this replaced could do that:+        // its fuzzers all compared the loop against the retired regex over a quote-free+        // alphabet, so the layer answering the lookups was invisible to every one of them.+        fuzz(seed: 0x1976_3A, rounds: 25_000, maxUnits: 20) { input in+            let indexed = HTMLImageParser.normaliseSelfClosingTags(input)+            let fresh = HTMLImageParser.normaliseSelfClosingTags(input, closes: Self.alwaysFreshIndex(for: input))+            return indexed == fresh ? nil+                : "normalisation diverged from an always-fresh lookup on \(input.debugDescription):"+                    + " indexed \(indexed.debugDescription), fresh \(fresh.debugDescription)"+        }+    }++    @Test("Raw-HTML tag ranges match an always-fresh scan on random fragments")+    func tagRangesMatchAnAlwaysFreshScan() {+        fuzz(seed: 0x1976_4A, rounds: 25_000, maxUnits: 20) { input in+            let nsInput = input as NSString+            let indexed = HTMLImageSourceRewriter.tagRanges(in: nsInput)+            // The whole loop against the obvious implementation — this is what sees a wrong+            // branch inside the loop.+            let reference = Self.referenceTagRanges(in: nsInput)+            if indexed != reference {+                return "tag ranges diverged from an always-fresh reference on \(input.debugDescription):"+                    + " production \(indexed), reference \(reference)"+            }+            // The same loop with only its lookups swapped — this is what localises a+            // divergence to the lookup layer rather than the loop.+            let freshLookups = HTMLImageSourceRewriter.tagRanges(in: nsInput, closes: Self.alwaysFreshIndex(for: input))+            return indexed == freshLookups ? nil+                : "tag ranges diverged from an always-fresh lookup on \(input.debugDescription):"+                    + " indexed \(indexed), fresh \(freshLookups)"+        }+    }++    @Test("A tag with no reachable close does not suppress the well-formed tags after it")+    func unresolvableTagDoesNotSuppressLaterTags() {+        // An earlier candidate whose close is unreachable — because an attribute value opens+        // and never closes, swallowing every `>` after it — must not change the answer for a+        // LATER, perfectly well-formed tag. The tokenizer state at an offset is left-context,+        // so the earlier candidate's verdict is never evidence about the later one.++        // The `<img a="` opens a value that never closes, so it has no reachable `>` at all.+        // The `<img alt=X>` after it does, and must still be normalised.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img a=\"<img alt=X>")+                == "<img a=\"<img alt=X />")++        // The rewriter's blast radius was wider: a single unresolvable candidate stopped the+        // whole loop, so every tag after it lost `prism-doc://` mediation and never rendered.+        #expect(HTMLImageSourceRewriter.rewrite("<img alt=\"><img src=cat.png>") { "mediated:\($0)" }+                == "<img alt=\"><img src=\"mediated:cat.png\">")++        // The two inputs the FIRST T-1976 fix was rejected on. Under the any-quote model both+        // had an unresolvable leading candidate; under the HTML5 model neither quote opens a+        // value at all (no `=` before it), so both tags resolve. Kept because the outputs are+        // what the user must see either way, and because they pin that the model change did+        // not quietly move them.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img\"<img alt=\"X\">")+                == "<img\"<img alt=\"X\" />")+        #expect(HTMLImageSourceRewriter.rewrite("<img alt=x\"><img src=\"cat.png\">") { "mediated:\($0)" }+                == "<img alt=x\"><img src=\"mediated:cat.png\">")+    }++    @Test("A quote opens a value only just after an `=`, as HTML5 has it (T-1976 review 2)")+    func onlyAnEqualsSignPutsAQuoteInValuePosition() {+        // The four tags a whole-string any-quote model regressed from mediated on `main` to+        // not mediated at all — the T-1976 symptom, caused by the T-1976 fix. Each carries a+        // quote that is NOT in value position (an apostrophe in prose, an inch mark, a+        // careless straight quote), which under that model re-opened a value that then ran+        // past the real `>`. `FootnotePreprocessor.scanTagRemainder` records the same model+        // being tried and rejected in the T-1877 review; the two scans now agree.+        let mediated: [(input: String, expected: String)] = [+            ("<img alt=don't src=cat.png>", "<img alt=don't src=\"mediated:cat.png\">"),+            ("<img src=cat.png alt=5\" wide>", "<img src=\"mediated:cat.png\" alt=5\" wide>"),+            ("<img alt=\"a\" title=b\" src=cat.png>", "<img alt=\"a\" title=b\" src=\"mediated:cat.png\">"),+            ("<source srcset=a.png alt=it's>", "<source srcset=\"mediated:a.png\" alt=it's>"),+            // Still unfixed under the any-quote model: `alt=a"` is an unquoted value, so the+            // `"` after `src=` is the one that opens, and the tag ends at the LAST `>`. The+            // extent scan and `attributes(in:)` now read this the same way, which is what+            // lets `src` be found at all.+            ("<img alt=a\" src=\"x>y\">", "<img alt=a\" src=\"mediated:x&gt;y\">")+        ]+        for (input, expected) in mediated {+            #expect(HTMLImageSourceRewriter.rewrite(input) { "mediated:\($0)" } == expected,+                    Comment(rawValue: "\(input.debugDescription) rewrote to "+                        + HTMLImageSourceRewriter.rewrite(input) { "mediated:\($0)" }.debugDescription))+        }++        // The normaliser sees the same extents. (These tags carry unquoted attribute values,+        // which no XML parse accepts, so `parse` still declines them — the mediation above is+        // where their images survive. What matters here is that the tag is not cut short.)+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img alt=don't src=cat.png>")+                == "<img alt=don't src=cat.png />")+        #expect(HTMLImageParser.normaliseSelfClosingTags("<img alt=a\" src=\"x>y\">")+                == "<img alt=a\" src=\"x>y\" />")+    }++    @Test("The public parse entry point recovers a src behind a quoted `>` (T-1976)")+    func parseRecoversSourceBehindQuotedCloseAngle() {+        // The normaliser goldens assert the intermediate string. This asserts the actual+        // user-visible fix at the public entry point, which additionally depends on the+        // normalised tag surviving the XML round-trip.+        let parsed = HTMLImageParser.parse("<img alt=\"2 > 1\" src=\"cat.png\">")+        #expect(parsed?.source == "cat.png")+        #expect(parsed?.alt == "2 > 1")+    }++    @Test("Many tag starts sharing one distant quoted close still normalise correctly")+    func manyStartsBeforeSharedQuotedCloseNormaliseCorrectly() {+        // The growth guards (G10/G11) only pin timing. This pins the VALUE for the exact+        // shape they exercise, at a size small enough to work out by hand: three failed+        // `<img` starts, sharing one real close that sits past a quoted attribute value+        // itself containing `>`. All three must get the SAME answer, and the winning+        // candidate (the last one — the only one whose `afterName` lands on the whitespace+        // the group needs) must still resolve to the real trailing `>`, not the first `>`+        // inside `alt`.+        let input = "<img<img<img alt=\">>>\" src=\"x.png\">"+        let expected = "<img<img<img alt=\">>>\" src=\"x.png\" />"+        #expect(HTMLImageParser.normaliseSelfClosingTags(input) == expected)+    }+}++// MARK: - Raw-HTML tag extent (HTMLImageSourceRewriter.tagRanges)++// `tagRanges` is the OTHER call site of the close-angle index, and the one whose loop+// termination branch T-1976 changed (`break` to `continue`). It had no growth guard of any+// kind: the suites above cover `normaliseSelfClosingTags`, comment stripping and srcset+// splitting, and none of them drives the rewriter over many tag starts. Both guards go+// through the public `rewrite`, which is where a document reaches it.+@Suite("Raw-HTML tag extent — growth (T-1976)", .serialized)+struct RawHTMLTagExtentGrowthTests {++    @Test("G15: many tag starts with no closing bracket rewrite linearly")+    func unclosedTagStartsScaleLinearly() {+        // The `break` this change removed was, among other things, the thing that kept this+        // shape cheap: every candidate now asks the index rather than the loop ending at the+        // first unresolvable one, so "asks the index" has to be O(log n), not O(n).+        GrowthRatioGuard.expectLinearGrowth(shape: "rewrite of unclosed <img starts", baseCount: 2_000) { count in+            _ = HTMLImageSourceRewriter.rewrite(String(repeating: "<img", count: count)) { $0 }+        }+    }++    @Test("G16: tag starts straddling alternating quoted values rewrite linearly")+    func startsStraddlingQuotedValuesScaleLinearly() {+        // The rewriter's twin of G13: consecutive candidates resolve to two unrelated answers,+        // so nothing carried forward from one is usable for the next.+        GrowthRatioGuard.expectLinearGrowth(shape: "rewrite of straddled starts", baseCount: 2_000) { count in+            let html = String(repeating: "<img/=\"", count: count) + ">"+            _ = HTMLImageSourceRewriter.rewrite(html) { $0 }+        }     } } 
prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift Modified +4 / -2
diff --git a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swiftindex cb0a015a..a9a9e9a4 100644--- a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift@@ -420,8 +420,10 @@ struct HTMLImageSourceRewriterQuotingTests {         // The ticket's example shape: an unencoded data: URI can legitimately contain raw         // `"` characters. The `rewrite` closure's data: passthrough hands this back         // unchanged, so the rewriter — not the closure — must make it safe. (No `<`/`>` in-        // the payload: the rewriter finds a tag's extent by scanning to the first `>`, which-        // is a separate, pre-existing limitation this ticket is not about.)+        // the payload: when this was written the rewriter ended a tag at the first `>`+        // regardless of quoting, a separate limitation T-1942 was not about. T-1976 has since+        // fixed it — a `>` inside this quoted value would now be part of the tag — but the+        // payload is left as it was, so this test keeps asserting only what it is for.)         let value = "data:application/json,{\"a\":1}"         let raw = "<img src='\(value)'>"         let rewritten = HTMLImageSourceRewriter.rewrite(raw) { $0 }
specs/bugfixes/html-image-tag-quote-scan/report.md Added +449 / -0
diff --git a/specs/bugfixes/html-image-tag-quote-scan/report.md b/specs/bugfixes/html-image-tag-quote-scan/report.mdnew file mode 100644index 00000000..9a06d133--- /dev/null+++ b/specs/bugfixes/html-image-tag-quote-scan/report.md@@ -0,0 +1,449 @@+# Bugfix Report: Raw HTML Image Tags Break on Greater-Than Signs Inside Quotes++**Date:** 2026-09-07+**Status:** Fixed++## Description of the Issue++Raw-HTML `<img>`/`<source>` tags whose quoted attribute values contain a `>` character were+mis-parsed. HTML permits `>` inside a quoted attribute value (`<img alt="2 > 1" src=cat.png>`+is one tag, not two), but both places in Prism that locate a start tag's end found the FIRST+`>` in the string regardless of quoting, cutting the tag short inside the quote.++**Reproduction steps.** The two call sites fail differently and need separate repros.++*The rewriter (`HTMLImageSourceRewriter`, raw-HTML image mediation):*+1. Open a markdown document containing+   `<div><img alt="2 > 1" src="cat.png"><span>x</span></div>`.+2. `tagRanges` ends the tag at the `>` inside `alt`, so the substring handed to `rewriteTag`+   stops before `src`.+3. Observe: the image never renders. `src` was never mediated through `prism-doc://`, so the+   relative `cat.png` reference was stripped by the sanitizer's protocol allowlist+   (`HTMLSanitizer.swift`).++*The normaliser (`HTMLImageParser`, standalone-image XML normalisation):*+1. Open a markdown document whose whole HTML block is `<img alt="2 > 1" src="cat.png">`.+2. `normaliseSelfClosingTags` inserts ` />` at the `>` inside `alt`, producing+   `<img alt="2 />1" src="cat.png">`.+3. Observe: no image block. The mangled string is not well-formed XML, `parseXML` fails, and+   the block falls back to being displayed as raw HTML.++Note what does NOT happen, contrary to an earlier draft of this report: the truncated+remainder does not leak into the surrounding content. On the rewriter path `rewriteTag`+re-emits the tag byte-for-byte when `attributes(in:)` finds nothing, so the string reaching+`HTMLSanitizer` is identical to the input and SwiftSoup re-parses it quote-aware; on the+parser path the mangled string simply fails the XML parse. The user-visible outcome — no+image — is the same either way, but through the allowlist and the XML fallback, not through+leaked text.++**Impact:** A raw-HTML image tag lost its image when a `>` sat inside a quoted attribute that+comes BEFORE `src`/`srcset`. On the rewriter path the ordering matters:+`<img src="a.png" alt="2 > 1">` truncates to `<img src="a.png" alt="2 >`, `src` is still found+and mediated, and the tail re-joins the output — so that shape was unaffected. On the+normaliser path any such tag failed, because the inserted ` />` breaks the XML parse wherever+it lands. Medium severity, no security impact (the tag is still fully sanitised afterwards) —+a rendering/data-loss defect rather than a policy bypass.++## Investigation Summary++- **Symptoms examined:** The ticket (T-1976) named the exact defect and both call sites, with+  a worked example, so investigation focused on confirming the mechanism rather than+  discovering it.+- **Code inspected:** `HTMLImageSourceRewriter.tagRanges(in:)` (raw-HTML image mediation) and+  `HTMLImageParser.normaliseSelfClosingTags` (standalone-image XML normalisation). Both ended+  a tag at the first `>` — `tagRanges` via `nsHTML.range(of: ">", ...)`,+  `normaliseSelfClosingTags` via a plain `!= closeAngle` scan — neither tracking whether that+  `>` sits inside a quoted value.+- **Hypotheses tested:** Confirmed the already-quote-aware `attributes(in:)` scan inside+  `HTMLImageSourceRewriter` is a red herring — it correctly parses quoted values *within* the+  substring it is given, but that substring is already truncated by `tagRanges` before+  `attributes(in:)` ever sees it. The fix has to live at the tag-boundary scan, not the+  attribute scan.+- **Prior art found (review round 2):** `FootnotePreprocessor.scanTagRemainder` already+  answers the same question — where does this start tag end, given quoting — and its doc+  comment records which quote model was chosen and why. That should have been found before+  the primitive was written; see "Review round 2" below.++## Discovered Root Cause++Both start-tag boundary scans treat `>` as an unconditional tag terminator, with no notion of+being inside a single- or double-quoted attribute value.++**Defect type:** Missing state tracking in a hand-written linear scan (the scans exist as+hand-written replacements for regexes retired in T-1951 for performance; the replacements+reproduced the regexes' `[^>]`-style quote-blindness along with their linear-time shape).++**Why it occurred:** `[^>]*` in the original regexes was never quote-aware either, so the+defect predates the T-1951 performance rewrite and was carried forward faithfully by the+scans that replaced it — each rewrite's goal was byte-for-byte equivalence with the retired+pattern, which pinned the bug in place as "expected" behaviour until this ticket.++**Contributing factors:** `HTMLImageParser.normaliseSelfClosingTags`'s own comment (T-1951)+explicitly named this as a known, deliberately deferred defect with its own ticket, which is+why it was tracked separately rather than fixed opportunistically.++## Resolution for the Issue++**Changes made:**+- `prism/Services/HTMLQuoteAwareTagScan.swift` (new) — three types:+  - `CloseAngleIndex`, which answers "the first `>` at or after here that is not inside a+    quoted attribute value" for EVERY start offset in a string, built in one right-to-left+    pass and stored only at the offsets where the answer changes.+  - `WhitespaceRunIndex`, which answers "where does the run of whitespace immediately before+    here begin?" for every offset, stored as the maximal whitespace runs.+  - `Lookup`, a small mutating value type that owns both and builds each on first use.+- `prism/Services/WebRendering/HTMLImageSourceRewriter.swift` — `tagRanges(in:)` asks the+  index instead of searching for a plain `>`, and SKIPS a start with no reachable close+  rather than ending the loop. It also went from `private static` to internal `static`, so a+  test can drive the real loop directly, and gained an internal `closes:` parameter.+- `prism/Services/HTMLImageParser.swift` — `normaliseSelfClosingTags` asks the same two+  indexes; its `closeCursor`/`closesExhausted` close cache and its one-slot+  `whitespaceRunStart` cache are both gone. It also gained an internal `closes:` parameter, so+  a fuzz can drive the real loop with a table built one fresh forward scan at a time.++**Approach rationale:** A shared lookup keeps both call sites' quote semantics identical and+gives the T-1951 performance concern one place to be proven correct.++*The quote model.* A quote opens a value only in the HTML5 tokenizer's *before attribute+value* state — immediately after an `=`, whitespace allowed — and the value then ends at the+very next matching quote, with no escape processing. Everywhere else a quote is an ordinary+character. This is the model `FootnotePreprocessor.scanTagRemainder` already implements, and+adopting it is what makes the repository hold one answer rather than two. See "Review round 2"+for what shipping the other model cost.++*Why the answers are indexed rather than cached.* The tokenizer state at an offset is+**left-context**: in `x="ab"y>` a scan from 0 finds the `>` at 7, while a scan from 3 — inside+that quoted value — meets the `"` that closed it in the normal state, where it opens nothing,+and runs on. No verdict computed from one start offset is evidence about a later one, which is+exactly what the first version of this fix assumed (see "Review round 1"). Indexing sidesteps+the question: each offset gets its own answer, from a recurrence over the tokenizer's states+rather than over positions alone. Writing `N` for normal, `E` for before-attribute-value and+`U` for inside an unquoted value:++- `N(len) = E(len) = U(len) = nil`+- at `>`: all three answer `p`+- at a quote, in `E` only: `E(p) = N(c + 1)` for the next occurrence `c` of that same quote+  character (`nil` when there is none — an unterminated value runs to end of input)+- at `=`: all three move to `E(p + 1)`+- at whitespace: `E` stays `E`, `U` returns to `N`, `N` stays `N`+- otherwise: `E` moves to `U(p + 1)`; `N` and `U` stay where they are++`U` is provably identical to `N` — their transitions agree in every case and the base cases+agree — so the pass carries two running answers rather than three. That is a derived+simplification, not a shortcut through the model: the differential oracle in the tests keeps+all three states separate, so the derivation is witnessed rather than shared with the code+under test.++The whole thing computes in one right-to-left pass, since `N(c + 1)` is already known when `p`+is reached. `N` is constant between the offsets where it changes, so the table holds only+those — and under this model `N` can change only at a `>` or an `=`, which makes it sparser+still than the any-quote version it replaced: nothing for prose, a handful per tag for+ordinary markup.++**Alternatives considered:**+- **Treat every quote as a value delimiter** (the second version of this fix) — rejected: it+  is the model `FootnotePreprocessor.scanTagRemainder`'s comment records being tried and+  abandoned in the T-1877 review, and it regressed four ordinary tags. See "Review round 2".+- **Keep the single-function recurrence and gate it on a precomputed `opensValue[p]`+  predicate** — rejected: whether a given quote opens or closes a value is itself+  start-relative, so the predicate cannot be global. (The local review prototyped this and+  fuzzed it against an HTML5 forward oracle; it diverges, for the same reason round 1 did.)+- **A forward scan plus a cache of its verdicts** (the first version of this fix) — rejected:+  unsound, see "Review round 1" below.+- **A forward scan plus a cache narrowed to a provably safe window** — three narrowings were+  built and fuzzed (validity only over the scan's quote-free prefix; only over its quote-free+  suffix; only over offsets the scan was outside a quote at, recorded as a flip list). All+  three are sound — 800,000 generated fragments, zero divergence — but each stays quadratic on+  some shape: `<img "a<img "a…` puts every candidate at a different point in the quote+  structure, so no window covers the next one and every candidate rescans to the end. Measured+  16x per 4x of input, i.e. the denial of service T-1951 removed.+- **Dropping the caching outright and accepting O(n·k)** — the review's own suggested fallback,+  and rejected on measurement rather than principle: `<img<img<img…` at 56 KB took 4.3 s+  against 0.001 s indexed, growing 16x per 4x of input. Since any document can be opened from+  a URL, that is the same denial of service T-1951 was filed for.+- **Keeping the one-slot `whitespaceRunStart` cache and re-stating its invariant** — rejected:+  the invariant it needs (the `>` asked about is non-decreasing) is not recoverable, because it+  was a consequence of quote-blindness. Fixing the comment would have left the quadratic.+- **A dictionary memo keyed on the close offset** for the whitespace-run question — sound and+  linear (the runs for distinct `>` are disjoint, so each whitespace unit is walked once), but+  a change-point index matches what the close-angle answer already does in the same file, is+  allocation-free per query, and needs no argument about hashing.+- **Full SwiftSoup DOM re-serialisation** — already rejected historically (see the file's+  header comment) for corrupting `<figure>`/`<picture>` children; not reconsidered here.++## Review round 1: why the first version of this fix was rejected++The first version kept a forward scan (`firstUnquotedCloseAngle(in:from:length:)`) and layered+caching on top of it at both call sites. Local review rejected it, having reproduced both+failures by extracting the production algorithms into standalone programs and fuzzing them —+not by inspection. Both are the T-1976 symptom (an image that never renders) reached through a+poisoned cache rather than through a quoted `>`:++- `HTMLImageParser.normaliseSelfClosingTags("<img\"<img alt=\"X\">")` returned its input+  unchanged. The unrelated `<img"` candidate's failed scan set that version's+  `closesUpperBound` to the offset of a stray quote three tags away, and the documented+  invariant that "requests at or before where that quote opened share the outcome" is simply+  false — a fresh scan from the second candidate finds a legitimate close the cache+  suppressed. ~10% divergence over 200,000 fuzzed inputs against an always-fresh reference.+  (`closesUpperBound` existed only in this branch's first commit, which a squash merge+  discards; the code on `main` had `closeCursor`/`closesExhausted`.)+- `HTMLImageSourceRewriter.rewrite("<img alt=x\"><img src=\"cat.png\">")` found ZERO tags.+  `tagRanges` ended its loop on any `.notFound`, a `break` that had been sound while the search+  was quote-blind ("no `>` left at all" does hold for every later start) and is not once+  quoting is honoured. Every tag after a poisoning one lost `prism-doc://` mediation, not just+  the adjacent one. ~2.5% under-detection over 50,000 inputs.++Under the HTML5 model neither of those two inputs has an unresolvable candidate any more (no+`=` precedes either quote), so both goldens now assert unchanged output rather than a rescued+tag. The `break`-versus-`continue` property they were written for is pinned instead by+`<img a="<img alt=X>` and `<img alt="><img src=cat.png>`, where a value genuinely opens and+never closes.++The review's structural point was that none of the first version's tests could see the caching+layer at all: its fuzzers compared the loop against the retired regex over deliberately+quote-free alphabets, and its one quote-bearing fuzz exercised the bare scan. The tests below+are aimed at that layer specifically, and each was mutation-checked.++## Review round 2: the quote model, and a second cache resting on the same premise++Two blocking findings, both reproduced by the reviewer against `origin/main` by compiling the+real production sources standalone rather than by inspection.++**The any-quote model regressed four ordinary tags.** `<img alt=don't src=cat.png>`,+`<img src=cat.png alt=5" wide>`, `<img alt="a" title=b" src=cat.png>` and+`<source srcset=a.png alt=it's>` all went from normalised-and-mediated on `origin/main` to+neither. In each, a quote that is not in value position re-opened a value that then ran past+the real `>`, so the tag had no reachable close and was skipped entirely; the relative `src`+was then stripped by the sanitizer — the T-1976 symptom, newly caused by the T-1976 fix.+`FootnotePreprocessor.scanTagRemainder`'s doc comment records this exact model being tried and+abandoned in the T-1877 review, for the same reason. Adopting the HTML5 model fixes all four,+additionally resolves `<img alt=a" src="x>y">` (which both `origin/main` and the previous+round mangled), and removes the second, related finding: `tagRanges` and `attributes(in:)` in+the same file no longer disagree about what "quoted" means, so a tag range and the attribute+scan run over it can no longer contradict each other.++**A quadratic returned through the one-slot `whitespaceRunStart` cache.** Its comment asserted+"`end` is non-decreasing". That was true only while the close search was quote-blind; a+quote-aware answer is not monotone, so consecutive candidates can resolve to two different `>`+and the single slot misses on every call, re-walking a whole whitespace run each time. This+pass runs unconditionally on every raw-HTML block at parse time — the T-1951+denial-of-service shape. `closeCursor` had been deleted for precisely this reason, and the+sibling cache eight lines below shared the premise and kept its comment asserting it. It is+now `WhitespaceRunIndex`.++Measured on the real production sources compiled standalone (`swiftc -O`), input+`"<img/=\""×n + " "×4n + ">\"" + " "×4n + ">"` — the shape that alternates two closes, each+behind its own long whitespace run:++| input size | one-slot cache | indexed (shipped) |+|------------|----------------|-------------------|+| 44 KB      | 0.80 s         | 0.0073 s          |+| 90 KB      | 3.30 s         | 0.0072 s          |+| 134 KB     | 7.33 s         | 0.0147 s          |++The reviewer's own adversary (`"<img\""×n + " "×n + ">\"" + " "×n + ">"`, measured at 2.92 /+11.82 / 40.69 s at the same sizes) is no longer adversarial under the HTML5 model — the `"`+after `<img` is not in value position, so it opens nothing — and measures 0.0048 / 0.0068 /+0.0109 s on the shipped tree. That is exactly why the model change could not be allowed to+stand in for the cache fix: it removes one adversary and leaves the premise broken. Both were+fixed.++## Regression Test++**Test file:** `prismTests/RawHTMLImageScanGrowthTests.swift`+**Test names:**+- `QuoteAwareTagExtentTests.indexAnswersEveryStartOffsetLikeAFreshScan` — differential fuzz of+  the production index against a forward HTML5 oracle at EVERY offset of each generated+  fragment, not a sampled one: a verdict that is right for the offset it was computed from and+  wrong for a later one is precisely what sampling one start per input cannot see. The oracle+  is transcribed from `FootnotePreprocessor.scanTagRemainder` and keeps the tokenizer's three+  states separate where production collapses `U` into `N`, so the collapse is witnessed rather+  than shared with the thing under test.+- `QuoteAwareTagExtentTests.onlyAnEqualsSignPutsAQuoteInValuePosition` — goldens for the four+  tags the any-quote model regressed, plus `<img alt=a" src="x>y">`, driven through the public+  `HTMLImageSourceRewriter.rewrite`, plus the normaliser's view of two of them.+- `QuoteAwareTagExtentTests.parseRecoversSourceBehindQuotedCloseAngle` — the user-visible fix+  at the public `HTMLImageParser.parse` entry point, which additionally depends on the+  normalised tag surviving the XML round-trip.+- `QuoteAwareTagExtentTests.normalisationMatchesAnAlwaysFreshLookup` and+  `.tagRangesMatchAnAlwaysFreshScan` — differential fuzzes that drive the REAL call-site loops+  against always-fresh references. `tagRanges` is compared two ways, because the two see+  different things: against a reference loop written the obvious way (which is what sees a+  wrong branch inside the loop, such as the `break`), and against the same production loop+  with only its lookups swapped (which localises a divergence to the lookup layer). Swapping+  lookups alone cannot see the `break`, since both sides share it — confirmed by mutation.+- `QuoteAwareTagExtentTests.unresolvableTagDoesNotSuppressLaterTags` — goldens for both review+  rounds' reproductions, one per call site.+- `QuoteAwareTagExtentTests.manyStartsBeforeSharedQuotedCloseNormaliseCorrectly` —+  hand-verified value correctness for "many failed candidates share one distant quoted close".+- `VoidElementNormalisationTests.quotedCloseBracketNoLongerMangles` — goldens for `"`/`'`+  quoting, multi-attribute tags, an outside-quote `>` (unchanged behaviour), and an+  unterminated quote (fails safe, left untouched).+- `VoidElementNormalisationTests` G10–G14, **all five added by this change**+  (`manyStartsBeforeSharedQuotedCloseScalesLinearly`,+  `unterminatedQuoteAfterManyStartsScalesLinearly`, `startsStraddlingQuoteRunsScaleLinearly`,+  `startsStraddlingQuoteRunsBeforeCloseScaleLinearly`,+  `alternatingClosesBehindWhitespaceRunsScaleLinearly`) — growth-ratio guards. G12/G13 are the+  shapes that ruled out every windowed close cache: each candidate sits at a different point in+  the quote structure, so no verdict is reusable for the next. **G14 is the one that pins+  review round 2's quadratic**, and it exists because G12/G13 provably cannot: on both of those+  the whitespace-run lookup walks ZERO characters (the unit before the close is a quote), so+  neither drives the cache that regressed. Measured against the shipped code with the one-slot+  cache reinstated: G12 1.42x, G13 4.02x (both green, both blind), G14 15.97x — over the 8x+  ceiling. With the index: 1.83x / 4.10x / 3.66x.+- `RawHTMLTagExtentGrowthTests` G15/G16 (new suite) — the first growth guards of any kind on+  `HTMLImageSourceRewriter.rewrite`/`tagRanges`, which is the call site whose loop TERMINATION+  branch this change altered (`break` to `continue`) and which had none.+- `RawHTMLImageRewriteTests` (existing, unmodified) continues to cover+  `HTMLImageSourceRewriter` end to end.++**Mutation check:** six mutations of round 2's code, each run against+`QuoteAwareTagExtentTests` alone, all caught — `continue` back to `break` in `tagRanges`+(2 failures); a quote-blind index (4); an off-by-one in the recurrence's carry (3); `<` to+`<=` in the lookup's binary search (2); dropping the quote offsets from the table (4); an+unterminated quote resolving to the following close (3). The round-3 model change is checked+the same way by construction: the any-quote model IS one of those mutations, and+`onlyAnEqualsSignPutsAQuoteInValuePosition` fails on it.++**What it verifies:** A `>` inside a single- or double-quoted attribute value no longer ends a+raw-HTML or standalone `<img>`/`<source>`/`<br>` tag early, in both call sites; a quote that is+NOT in value position does not end one late either; a candidate with no reachable close does+not change the answer for any other candidate; and none of that reintroduces quadratic+behaviour on adversarial "many tag starts, shared, unresolved, or alternating quoted close"+input — including the whitespace-run shape, which is the one the earlier guards missed.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData \+  -resultBundlePath ./DerivedData/t1976.xcresult -testPlan prism \+  -only-test-configuration "en (base)" \+  -parallel-testing-worker-count 1 -enableCodeCoverage NO \+  -only-testing:prismTests/QuoteAwareTagExtentTests \+  -only-testing:prismTests/VoidElementNormalisationTests \+  -only-testing:prismTests/RawHTMLTagExtentGrowthTests \+  -only-testing:prismTests/HTMLImageParserTests \+  -only-testing:prismTests/HTMLImageSourceRewriterQuotingTests+./Tools/check-test-results.sh ./DerivedData/t1976.xcresult+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/HTMLQuoteAwareTagScan.swift` | New shared HTML5-model close-angle index, whitespace-run index, and the lazy `Lookup` over both |+| `prism/Services/WebRendering/HTMLImageSourceRewriter.swift` | `tagRanges` uses the shared lookup; a candidate with no reachable close is skipped, not fatal; `private static` → internal `static`, plus an internal `closes:` test seam |+| `prism/Services/HTMLImageParser.swift` | `normaliseSelfClosingTags` uses the shared lookup for both the close angle and the whitespace run; its two close caches and its one-slot whitespace cache are gone; internal `closes:` test seam |+| `prismTests/RawHTMLImageScanGrowthTests.swift` | Updated/added goldens, fuzz, and growth-ratio tests, including the new `RawHTMLTagExtentGrowthTests` suite |+| `CHANGELOG.md` | `[Unreleased] / Fixed` entry |++## Known Residual and removed coverage++- **Removed coverage.** The golden `quotedCloseBracketStillMangles` was deleted — it asserted+  the defect. The retired-regex differential fuzzes (`fuzzRandomFragments`,+  `fuzzTagShapedFragments`) briefly lost the quote characters from their alphabets, which+  removed the region where the scan and the retired regex should still agree. That is+  restored: quotes are generated again, and the oracle comparison is gated on the *disagreeing+  region* instead — a round is compared only when the quote-aware close equals a plain "first+  literal `>`" search at every offset, which is the only thing T-1976 changed. The gate+  asserts it is a filter, not an off switch: the test fails if fewer than a tenth of rounds+  reach the oracle.+- **`attributes(in:)` is still a conservative subset of the tokenizer** inside an *unquoted*+  value: `"`, `'`, `<` and a backtick terminate a value there where a browser would keep them.+  That is pre-existing and deliberately documented at `isUnquotedValueUnit`. What T-1976+  changed is that the two scans now agree on the thing a tag's extent depends on — what opens+  a quoted value.+- **Three sibling scans remain quote-blind:** `DetailsTokenizer.findOpenTagEnd` and its+  `<summary>` twin, and `MarkdownBlockParser.stripEmptyAnchors` (`[^>]*` twice). All are live+  paths with the same defect class; out of scope here, and `HTMLQuoteAwareTagScan` is the+  natural home for them now that its model is settled. Filed as T-2320.+- **Index memory.** `CloseAngleIndex.Entry` is two words, and entry density reaches 1.0 on+  input that is mostly `>` or `=`; `WhitespaceRunIndex.Run` likewise on input alternating+  whitespace with non-whitespace. Both are bounded by the 1 MB raw-HTML block ceiling and both+  are transient, but they are not free. `Int32` offsets would halve them; not taken, because+  the conversion would trap rather than degrade on input longer than 2^31 units and this is a+  path that handles arbitrary document text. The `descending.reversed()` copy the review+  flagged is gone (reversed in place).+- **Lookup cost.** `firstUnquotedCloseAngle` is O(log n) per call although both call sites+  usually query with non-decreasing `position`; a stored cursor over `entries` would be+  amortised O(1). Not taken: a carried-forward cursor is exactly the shape this ticket has+  twice been rejected for, and the binary search is not what was slow.++## Verification++**Automated:**+- [x] Regression tests pass (`QuoteAwareTagExtentTests`, `VoidElementNormalisationTests`,+      `RawHTMLTagExtentGrowthTests`)+- [x] Related suites pass: `HTMLImageParserTests`, `HTMLImageSourceRewriterQuotingTests`,+      `RawHTMLImageRewriteTests`, `BlockHTMLEmitterImageRewriteTests`,+      `RawHTMLDeliberateChangeTests`, `MediaContainerMarkupTests`, `WebSecurityRegressionTests`,+      `MarkdownBlockParserTests`, `ImageParserCommentStrippingTests`, `SrcsetCandidateSplitTests`+- [x] `make build-macos` passes+- [x] `make build-ios` passes+- [x] `make lint` passes+- [ ] Full `make test-quick` / `make test-locales` — not run in this session; the targeted+      suites above were run instead, with explicit result-bundle verification via+      `Tools/check-test-results.sh`. Note that no CI workflow on this repository runs any+      test, so a green PR proves nothing here — the local gate is the only gate.++**Manual verification:** Hand-traced the recurrence against `<img "a">` and against+`<img<img<img alt=">>>" src="x.png">` (three failed candidates sharing one distant,+quote-guarded close). Both are pinned by tests. The quadratic and its fix were measured on the+real production sources compiled standalone with `swiftc -O` (the table under "Review round+2"), and the new G14 guard was validated by reinstating the one-slot cache and confirming it+fails at 15.97x while G12/G13 stay green — i.e. the guard was checked against the defect it+exists for, not only against the fix.++## Prevention++**Recommendations to avoid similar bugs:**+- **Grep for the problem before writing the primitive.** `FootnotePreprocessor` already solved+  "where does this start tag end, given quoting" and had written down which model it rejected+  and why. Two review rounds and a shipped regression were the cost of not finding it. When+  introducing a shared low-level type, search for the question it answers, not just for the+  name it would have.+- When replacing a regex with a hand-written scan for performance (T-1951-style), treat+  "byte-for-byte equivalence with the retired pattern" as a starting point, not a permanent+  constraint — file a ticket for any pre-existing defect the pattern carried, as T-1951 did+  here, rather than letting exactness become an argument against ever fixing it.+- A tag-boundary scan that has to reason about `>` should default to quote-awareness from the+  start; "first `>`" is very rarely what HTML actually means by a tag's end. And "quoted" means+  what the tokenizer means, not "between two quote characters".+- **Removing an invariant invalidates every cache that rested on it, not only the one you were+  looking at.** `closeCursor` was deleted precisely because quote-awareness broke monotonicity;+  the sibling cache eight lines below shared the premise, kept its comment asserting it, and+  went quadratic. When a premise is retired, grep the function for everything that cited it.+- **A growth guard proves linearity only for the code path its shape actually drives.** G12 and+  G13 were green throughout the regression, because on both of them the cache that had gone+  quadratic walks zero characters. Before trusting a wall-clock ratio to cover a cache, check+  that the shape makes that cache do work — and validate a new guard against the defect, by+  reinstating it.+- A cache whose validity depends on WHERE a scan began is only as sound as the window it is+  restricted to, and that window is easy to state wrongly — the first version of this fix, and+  three later narrowings of it, each had a plausible-sounding invariant that a differential+  fuzz refuted in seconds. When a lookup has to answer many start offsets over one string,+  computing all the answers is both simpler to prove and faster than deciding, per query,+  whether an earlier answer still applies.+- A differential fuzz that swaps one layer of an algorithm cannot see a defect in the layer+  above it: both sides share that layer, so the defect cancels. Compare against a reference+  implementation of the whole loop as well, and mutation-check that it fails. The same applies+  to an oracle that shares a *simplification* with the code under test — the HTML5 oracle here+  keeps the state production collapses, on purpose.+- When two call sites solve the same low-level problem (here: "where does this start tag+  end"), share the primitive rather than let each accrue independent, possibly-diverging+  quote logic — and check that the primitive agrees with the OTHER scans in the same file, not+  just with itself.+- **Excluding a character class from a fuzz alphabet to dodge a known divergence throws away+  the region where the two still agree.** Gate the comparison on the divergence instead, and+  assert that the gate still lets most of the corpus through.++## Related++- Transit T-1976+- T-1951 (origin of the quote-blind scans this fixes)+- T-1877 (where the any-quote model was first tried and rejected, in `FootnotePreprocessor`)+- T-1942 (PR #408) — attribute-value escaping on the rewrite path; composes cleanly with this+  fix since it operates strictly downstream of tag-boundary detection+- T-1977 (encoded entities in image URLs) — NOT addressed here, out of scope+- T-2074 (mixed raw-HTML images lacking zoom/failure placeholders) — NOT addressed here, out+  of scope

Things to double-check

How I reproduced both blockers being closed.

Exported the branch and merge-base copies of the five relevant sources into a scratch directory outside the worktree and compiled each standalone (swiftc -O) with a small ImageDimension shim, then ran the same corpora through both binaries. The worktree was never modified — git status is clean. The scratch tree is at /tmp/t1976v/; rebuild with swiftc -O -o bin *.swift main.swift in /tmp/t1976v/branch and /tmp/t1976v/main.

The G14 validation, if you want to redo it.

/tmp/t1976v/oldcache/ is the branch's sources with the merge-base's one-slot whitespaceRunStart cache patched back in over the shipped WhitespaceRunIndex (the close-angle index left alone). Ratios per 4x of input, my machine: oldcache G12 2.45x, G13 2.68x, G14 16.00x, round-2 adversary 3.97x. Shipped G12 4.03x, G13 5.07x, G14 3.88x, round-2 adversary 5.03x. Ceiling is 8x. The report quotes 1.42x / 4.02x / 15.97x and 1.83x / 4.10x / 3.66x — same verdicts, different machine noise.

The one claim I could not verify.

The report's "six mutations, all caught" and the historical figures from rounds 1 and 2 (800,000 fragments across three windowed caches, ~10% and ~2.5% divergence, 4.3 s at 56 KB) describe work done outside the tree. I did not attempt to reproduce them and take no position. Everything else in the report that makes a checkable claim, I checked.

Merge state and CI.

The branch is 5 commits ahead of origin/main with a clean tree. Remember that localisation-tests.yml is workflow_dispatch-only, so no push and no pull request runs any test on this repository — the local gate is the only gate, and a green PR #420 says nothing. make test-locales is still owed before merge.

The deliberate behaviour change, quantified.

Over 40,000 pathological fragments: 5,677 gain mediation against main, 85 lose it, 10,605 normalise differently. Every loss is an attribute value that opens and never closes, making the candidate unresolvable and skipped — disclosed in the changelog and at tagRanges, with the sanitizer allowlist and img-src prism-doc: data: behind it. Over 20,000 well-formed generated tags: 2,194 gain, zero lose. If you want a sharper bound before merge, that second corpus is the one to grow.