Skip to content

release: v1.14.4 - #53

Merged
b451c merged 110 commits into
mainfrom
release/1.14.4
Nov 5, 2026
Merged

b451c merged 110 commits into
mainfrom
release/1.14.4

Conversation

@b451c

@b451c b451c commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Release 1.14.4 (build 31). Replaces the 1.14.3 spacing change, which left documents cramped, and brings the renderer closer to GitHub.

  • One vertical rhythm (VerticalRhythm.swift, BlockLayout.Spacing): the space between two blocks follows from the pair of kinds (heading above and below, paragraph, list, quote, table, code, math, diagram, alert), the same with or without blank lines in the source. The measurer keeps exact row heights; print and PDF use the same rules and never end a page on a heading.
  • Parser: one text row per CommonMark block, lazy quote continuation, definitions that cannot interrupt a paragraph, 1) lists, multi-line setext headings, indented fences that drop their indentation, an alert needs a body, footnote and reference labels normalized as cmark does.
  • Inline: HTML5 character references, formatting inside link text, reference links and footnotes inside quotes, alerts, headings and table cells, escapes kept in math, a linked image alone in its paragraph shows the picture.
  • Reading Mode: the column is 720 pt times the zoom. Zoom and Reading Mode round trips keep the reading position (0 pt drift).
  • Search centres the match inside a tall table, code block or quote. Links to other.md#section scroll to that section. An unreadable file says so instead of reporting it missing. Settings remembers its tab by name.
  • Mac App Store build: the rating prompt counts a reading minute only while the reader is active.
  • Found in the final GUI pass and fixed: badges one per line (one paragraph on GitHub) stay one row of linked placeholders instead of a column of pictures; the heading, code and diagram hover buttons no longer stay visible on rows that scrolled past a pointer resting below the window.

967 tests pass locally; Debug, Release and Release with -DAPPSTORE build. End-to-end checks on a separate bundle id, in both the sandboxed App Store build and the unsandboxed GitHub build: rendering and copy against gh api markdown, search through tall tables, code blocks and a 2000-line quote, Source Edit save in place, PDF export, cross-document links, unreadable and deleted files, Settings tab memory, zoom and Reading Mode round trips, the rating prompt timing.

b451c and others added 7 commits November 4, 2026 15:46
Reverts 185c99d. Trimming the blank lines at the ends of a text run made
every heading sit 8 pt from its text and from the section above, closer
than paragraphs sit to each other (28 pt): documents looked cramped. The
blank lines in the source supplied that air for years, so they do again
until headings get margins of their own. The parser is byte-identical
to 1.14.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spacing around headings and lists is back to 1.14.2: the 1.14.3 trim of
blank lines around blocks is reverted. The formula fallback and the
App Store rating prompt from 1.14.3 stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text run (everything between two non-text blocks) used to become rows
of up to 30 raw lines, blank lines included. Every blank line rendered
as two attribute-less "\n" paragraphs (Helvetica 12, 14 pt each, never
scaled with zoom), and every row ended in a line break that laid out an
empty line under it. So the space between two paragraphs depended on
the blank lines in the source and on where the 30-line cut fell.

Now a run is cut into rows the way CommonMark reads it:
- a blank line ends a block (a paragraph, an item of a loose list);
- a list item right after paragraph text starts a block (the list);
- a thematic break is a block of its own.
Blank lines never reach a row, and a text row never ends in "\n".

A block longer than textChunkLineLimit (30 joined lines) is still cut,
and every row after the cut is marked MarkdownBlock.joinsPrevious, as
are the second and later nesting groups of one `>` run.

Soft breaks are joined and the ordered-list hangs computed over the
WHOLE run before it is cut (MarkdownRenderer.joinSoftBreaksIndexed,
render(joined:hangs:)), so a loose ordered list split into one row per
item keeps one text edge.

ParserTests: three paragraphs are now three rows, and a definition list
row has five lines, not six (no trailing line break).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The space between two blocks came from four unrelated sources: an 8 pt
intercell spacing, blank-line paragraphs set in Helvetica 12, the empty
line under every text row, and the heading row's copy-button floor. It
depended on the source's blank lines, ignored zoom in part, and the
same pair of blocks could be 20 or 48 pt apart.

BlockLayout.Spacing (VerticalRhythm.swift) replaces all of it. In units
of the body size at the current zoom, u = 14 x fontScale:
- 1.75u between any two non-heading blocks, edge to edge (line box or
  box edge), boxes included;
- 2.25u under every heading, optical: last baseline to the next
  block's cap top or box edge, whatever block follows;
- 2.0u for a heading directly under its parent heading;
- above headings, optical: H1 4.75u, H2 4.25u, H3 3.75u, H4-H6 3.5u;
- front matter to the title: 1.75u edge to edge; first row: 0;
- 0 above a row that only continues the previous one (joinsPrevious).

The measurer reads every row's edges from the layout it already runs:
textInk returns the first cap top and the last baseline from the same
TextKit pass as exactHeight, and a heading row is measured with its
title inset and button floor (the old estimate left those to the
report). MeasuredBlocks.rhythm keeps the inputs so measureMathRows can
update the rows it finishes and derive the column again; a height
report changes only the block's height (replacingHeight).

The list: each row is the space above its block plus the block
(BlockHeightTable.rowHeight); intercell spacing is 0 and the content
inset is exactly the style's vertical padding. RowGeometry puts every
position on the BLOCK: `.top` jumps (ToC, links, Source Edit landing)
land on its top, `.center` (search) centres it, the scroll anchor is
relative to it (stable across zoom, theme and width), height-report
compensation tests the block's top, the selection tint covers the
block only, an atomic row splits at its block's middle, and a point in
the gap belongs to the nearer block. Document.blockSpacing is gone;
the image caption's 8 pt is ImageBlock.captionSpacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rows are blocks now, but a long block is still cut every 30 lines and
the nested groups of one `>` run are separate rows. A copy joined every
row with a blank line, so a selection across such a cut gained a blank
line the document does not have.

SelectionRows.parts marks a piece whose row continues the row of the
piece before it (MarkdownBlock.joinsPrevious), and
DocumentCopyBuilder.build(parts:) joins it with a single line break.
build(_ pieces:) keeps the old behaviour for callers without rows.

Source Edit's selection hint: selectionHintMaxRows 3 -> 8. A row is a
single block now, so a few short paragraphs are already several rows;
the 1000-character cap is what bounds the hint's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDF put a flat 8 pt between every two blocks and relied on the
blank-line paragraphs inside text blocks for the rest, so a printed
document had none of the screen's rhythm. The dead whole-document
MarkdownPrintableView (never instantiated) went the same way.

PrintRhythm.row reads each printed block's edges the way
MarkdownPrintableBlockView draws it (text and headings through SwiftUI
Text, measured without paragraph styles; boxes from their frame edge; a
display formula with its padding) and BlockLayout.Spacing.column turns
them into the same gaps as on screen, at 1.0 zoom. PrintRhythm.paginate
places the segments: a block's space above is dropped at the top of a
page, and the later slices of a block taller than a page get none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VerticalRhythmTests checks every gap as the reader sees it (the space
above a row plus the air inside the two rows) against the approved
targets, restated in the test rather than read from the code:
- blank-line neutrality (`## H` with and without a blank line, a list
  after a paragraph with and without one) and no blank or trailing
  lines in text rows; rows are CommonMark blocks with the right
  source lines;
- the same optical gap under a heading for every following block
  kind, and above one for every preceding kind; per-level targets,
  heading under its parent, front matter to the title;
- edge-to-edge margins between non-heading blocks, first row 0, and
  every gap at fontScale 1.0, 1.25 and 1.5;
- a 70-item list cut into three rows with no space at the cuts and a
  copy across them without blank lines; nested quote groups joining;
- a loose ordered list split into ten rows keeping one head indent;
- measureMathRows deriving the same table and rhythm as a full
  measure, and a height report leaving the spacing alone;
- RowGeometry: content rect, `.top` and `.center` targets, the gap's
  upper half belonging to the previous row, an atomic block's middle;
- print and screen landing every gap on the same target, and
  pagination dropping the space at a page top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b451c
b451c marked this pull request as draft November 4, 2026 17:18
@b451c b451c changed the title release: v1.14.4 (build 30) release: v1.14.4 Nov 4, 2026
b451c and others added 21 commits November 4, 2026 18:56
Three ways the content top drifted after a layout change, all in the
list's anchor bookkeeping:

- Re-anchoring on a different block. The Reading Mode toggle moves the
  content top by the inset delta (24 pt), which can cross into the row
  above; the next install then anchored on THAT block, and the way back
  re-anchored on the original one. The round trip drifted by any
  difference between the two layouts in between (the space between the
  two blocks, a block that wraps differently, a placeholder). The list
  now remembers the anchor it last put the content top on (restore,
  toggle, .top jump) and keeps it while the content top is still there.

- Compensating the anchored block's own report. A restore puts the
  reader at an offset measured at the block's real height while the
  block sits at an estimate (an image placeholder, the diagram default).
  When it reported, the block straddled the content top and was
  compensated by the estimate's error, moving the reader a whole image.
  The anchored block's report now leaves the scroll offset alone.

- Double compensation at the end of the document. The content top was
  read after noteHeightOfRows, which had already clamped the clip origin
  when the table shrank; the shrink was applied twice.

The anchor math and the report rule live in RowGeometry; tests cover
the Reading Mode round trip (with a different gap between the two
blocks in the narrow layout), an anchor inside a placeholder across two
re-measures, and the report rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The debug log of the Reading Mode round trip shows the anchor as
`row 21 (image) + 579.5` on the way in and `row 29 + 0.0` on the way
out; a zoom round trip with the viewport top in the image comes back on
"Diagram". Every new table (zoom, width) has the image at its 116 pt
placeholder, so restoring the reader 579.5 pt into it put the content
top far past the block: the image was off screen, its view was never
built, it never reported its real height, and the next capture took
whatever block the placeholder had left under the content top.

- The anchor keeps the reader's offset and where the content top was
  actually put. Past the end of a `.reported` block whose height is
  still an estimate (no report since the table was installed), the
  content top goes to the block's last point, so the block is on screen
  and reports; the anchor is then placed again at the reader's offset.
  A capture while the content top is still there returns the reader's
  offset, so the round trip is exact even through the placeholder.
- A jump (search, ToC) anchors on its target. Blocks above the target
  that report while the scroll is in flight re-aim it, and after it
  landed they shift the content top with them, so a search hit stays in
  the middle while the images above it load (it ended at the bottom of
  the window, in 1.14.4 as well).
- The Mermaid snapshot path seeds the height cache like the web view
  path does, so a re-measure after a re-parse no longer measures the
  diagram at the 200 pt default.

The decisions are RowGeometry.anchorPlacement and reportAction; the
tests replay the coordinator's flow on a model of fixture.md (zoom
round trip and Reading Mode round trip with the reader inside the
image, the reader in a gap, a re-measure before the image reported, a
search jump while the blocks above report), each with the previous
rules as a control that must fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Orbit.md on Letter ended page 1 with "## Configuration" and started its
table on page 2 (1.14.4 did the same).

PrintRhythm.paginate now takes whole blocks and returns the printed
slices. A run of headings (an H2 and the H3 under it) that would end a
page moves to the next page with the block after it, when the run and
that block fit on one page; otherwise moving would only orphan it there,
and it stays. A block taller than a page is not moved: its first slice
fills the rest of the heading's page (the run moves first when not even
two lines fit below it), the rest is a page per slice as before. A run
that already starts its page never moves, so nothing loops.

The PDF export builds its pages from those slices; the screen path is
untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
YAML that `FrontMatter` rejects (a nested map) falls back to a `yaml`
code block at row 0, which got the box role: ~66 pt above the title
instead of the 24.5 pt front matter gets, on screen and in print.

The parser now marks the block it made from the front-matter fence
(`MarkdownBlock.isFrontMatter`, either form) and the screen and print
rhythm read that flag instead of guessing from the row: a user's own
```yaml fence on the first line stays an ordinary code block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line like `1984. Something` inside a paragraph became its own row with
a 24.5 pt gap above it. In CommonMark only a bullet item, or an ordered
item numbered 1 (and not an empty one), interrupts a paragraph; any
other item line is paragraph continuation. The parser now starts a row
only there. The line is still drawn as a list item, as in 1.14.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on fixture.md at 100 %: `## Quotes` to the quote's first line
read 35 pt, `## Loose ordered list` to its first line 30.5 pt. The quote
was measured to the top of its bar, which spans the text's line boxes,
3.5 pt above the cap top; the eye reads the text.

- A quote's edges are now a paragraph's, set the quote's padding inside
  the row: optical on its text (cap top, last baseline) around a
  heading, its line boxes between blocks. Its text sits where a
  paragraph's would.
- A diagram has no frame either: its first ink is Mermaid's 8 px
  diagram padding inside the picture (measured 8-9 pt), at the
  diagram's scale. That inset is now part of its edges, on screen and
  in a printed diagram picture.
- A display formula's picture is its ink (measured headlessly with
  MathImage: within 1.5 pt at 1.0 and 1.5), alerts, code, tables and
  images keep their box edges, and so does a printed quote, which has a
  background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prose document now has a row per paragraph, and one measure pass lays
out every text row on its own TextKit stack; the autoreleased parts of
those stacks stayed alive until the pass ended. 5000 short paragraphs:
peak footprint 37.7 MB (1.14.4: 20.5 MB), now 24.7 MB; 2000 headings
with a paragraph each: 26.7 MB in 1.14.4, now 16.5 MB. No time cost.

The copy builder also reuses its two separator strings instead of
making one per row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m reported heights

Reading Mode off lost the reader by 37.5 pt: between the toggle and the
install of the table measured for the new width, the clip origin moved
without the list moving it, and the install re-captured the anchor by
geometry on the block above. The toggle now pins the sticky anchor
(isSwitchingLayout) until the install, the width report finding nothing
to re-measure, or two seconds; reports during the switch re-place it.
DEBUG builds log every clip move setContentTop did not make, with the
stack that made it.

A width re-measure sent every image back to its placeholder and every
diagram to the default height (as 1.14.4 did). Heights reported by
placed images, SVGs and Mermaid diagrams are now remembered by content,
width, zoom and theme (ReportedHeights) and seed the next measure where
the key matches, so Reading Mode on/off and a zoom round trip start from
the real heights. BlockHeightCache moves next to the measurer.

Tests replay the reported sequence (819 table with real heights, 720
re-measure, 819 re-measure, anchor in a gap, a -37.5 clip move during the
switch) within 1 pt, with the old rules as a control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent on resize

The -24.5 / -37.5 pt on leaving Reading Mode: NSTextView keeps its visible
part visible while it resizes (_setFrameSize:forceScroll: ->
scrollRectToVisible:). A text view lying in the list's top content inset
is visible to it but not to scrollRectToVisible:, which measures against
the inset-adjusted area, so every width change with a text row under the
top inset scrolled the document until that row's text sat at the content
top (both GUI logs: the latex row's text, "row 27 + 38"). The document
list's text views now derive from DocumentCellTextView, which refuses a
scroll requested from inside its own frame change; an explicit request
(VoiceOver) still scrolls. The layout-switch pin stays as the second line.

A Mermaid diagram at a zoom it was never placed at started at the 200 pt
default and swung the table by ~120 pt on every zoom step. Its height is
now remembered per unit of zoom (from the web view and the snapshot path)
and seeds the measurer and the view's first frame, scaled.

Tests: the failing run replayed on the fixture's real 41-row tables
(search jump at -351.5, Reading Mode on, 720 table, off, a -24.5 clip
move, 819 table) lands on row 32 at -351.5; the 4f67351 rules reproduce
the log's "row 27 + 38" at 1452.0 as the control. AppKit's resize scroll
is pinned with a plain NSTextView as the control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…any width and zoom

Every re-measure (zoom, width, Reading Mode, theme) sent images and SVGs
back to the 100 pt loading view and Mermaid diagrams to the 200 pt
default; each then reported its real height on screen and swung the
table by hundreds of points (as 1.14.4 did).

The rows are now computed with the rule the views draw by:
- Images: the view records the original size (ImageLoader keeps it) per
  document, keyed by the link; ImageBlock.pictureSize gives the picture at
  any width and zoom (D4 cap, own width x zoom, HTML width), and the row
  adds the caption (TextKit lines x singleLineHeight, which ImageCaption
  is at least as tall as) with the padding SwiftUI applies to each element
  of the view's two-view body. The loading view, caption included, is
  measured as drawn too, and holds the picture's size once it is known.
- SVG: the markup is in the block, so the measurer decodes it once
  (SVGImageDecoder cache, shared with the view, which no longer shows the
  loading view on a re-created row).
- Mermaid: the template posts the diagram's natural size and the page's
  overhead (mermaidGeometry); BlockLayout.Mermaid.diagramHeight is the
  template's own resizeDiagram formula, used by the measurer and by the
  view's first frame. Process-wide by theme and source, like the snapshots.

This replaces the reported-height keys of f5bbb2d (which hashed whole
data: links) and the per-zoom diagram seed of 7ddc1ce.

Tests: the measurer gives exactly what the GUI logs reported (655 / 716 /
809 for the icon at 100 / 110 / 125 %, 655 in Reading Mode, 155 while
loading; 94 / 101 / 111.5 for the diagram); ImageCaption hosted in a
window is as tall as captionHeight across the zoom ladder, three widths
and a wrapping caption; zoom and Reading Mode round trips report 0 times
(the 116 / 224 control reports on every step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e web view has painted

A diagram row that scrolled back after its view was recycled, without a
snapshot at its exact zoom and width (a new width or zoom, or a view
recycled before its snapshot was taken), drew an empty box for the
0.5-1 s the web view takes to render. Every snapshot is now also kept as
the diagram's latest in its theme; a view without its own shows that one
over the web view, scaled to the height the diagram will have, until the
page has painted (the snapshot delay after its height message). The keys,
the frame-fits rule for taking a snapshot, the JSON-encoded source and
ScrollPassthroughWebView are unchanged. MermaidSnapshotStore moves next to
MermaidGeometryStore so the tests compile it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rences

UserDefaults(suiteName: "pl.falami.studio.QuickMD.<x>.<uuid>") plus
removePersistentDomain empties the domain, but cfprefsd writes the empty
plist into ~/Library/Preferences seconds later, so every test run left
files behind. TemporaryDefaults uses an absolute-path suite inside a
per-instance temporary directory (the approach ReviewPromptTests already
had) and deletes the directory; FontTests, RendererTests,
DocumentSelectionTests and ReviewPromptTests use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cells beyond the header's column count are dropped by design: the GFM
spec says the excess is ignored, and fewer cells are padded with empty
ones. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ning mark still splits

parseTableRow iterated Characters: "|" followed by a combining mark
(U+0301) is one grapheme, never equal to "|", so the cell did not end
there. GFM splits on code points. The row scan, the pipe count for table
detection / definition terms and the body-row check now walk unicode
scalars; escaped \| and pipes in code spans behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…source

MTMathListBuilder accepts formulas that draw nothing: only spacing or
style changes, empty groups, an accent or a line over nothing,
\left. \right., and this SwiftMath's typesetter declines \color,
\textcolor and \colorbox (no line at all). The typeset check only parsed,
so such a display formula took the math path and drew an empty row, and
an inline one an invisible attachment.

MathInk.typesetsToSomething (parses, the parsed list has something to
draw, the typesetter builds a line of some size) is now the engine's
typesets, so display math falls back to the ```latex block on screen and
in print alike (one substitution). The inline paths, screen and print,
check the image they already rendered (a size, and something to draw)
and fall back to the italic literal. A headless check of the vendored
SwiftMath (ink scan of MathImage) agreed with the verdict on 36 formulas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed whole

Since 1.14.3 a display formula the engine cannot typeset is a ```latex
code block, and copying it (a drag across it, Cmd-A) gave the bare
LaTeX, where 1.14.2 copied the formula as $$...$$. The substitution now
marks its block (MarkdownBlock.substitutesFormula); a selection covering
that row whole copies it as display math again. A selection inside its
text copies those characters, a user's own ```latex fence still copies as
code, and search counts in the string the row shows, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arest one

tocMaxHeadingLevel outside {2, 3, 4, 6} (only reachable through
defaults write) matched no Picker tag, so the sidebar's context menu and
Settings both showed no selection. ToCDepth.normalized maps it to the
nearest choice (a tie goes to the deeper one) for the Pickers, the filter
and the empty-state hint; the stored value is only written when the user
picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 60 s display run kept counting while the Mac sat idle, and the input
check ran only when it completed. A reader who came back after a break
could get the rating prompt seconds after a single touch (E2E, App Store
build: 150 s idle, one mouse move, prompt 33.6 s later). The 1.14.3
notes promise it never asks right after the Mac sat idle.

Every tick now needs input within the last 60 s as well as the window on
screen; an idle tick ends the run like a hidden window does. A complete
run is a minute of display in which the reader kept using the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…here a click there lands

A double-click in the space between two blocks only collapsed the
selection: in the upper half there is no text view to ask, and in the
lower half TextKit was asked about a point above the text. It now acts
where a single click in that gap lands (hit): the end of the row above
(upper half) or the start of the row below (lower half), and selects the
word there (the paragraph, or a table's cell, with a triple-click) the
way TextKit selects next to an insertion point - stepping back over a
table's terminating line break. A neighbouring image, diagram or formula
is selected whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per line

List items were always joined with ", ", so authors: ["Doe, Jane", Smith]
read as three names. FrontMatter.joinedItems keeps ", " when no item has
a comma (the common case looks unchanged) and otherwise separates the
items with U+2028 LINE SEPARATOR: a line break inside the value cell's
one paragraph, so the table layout invariant (one paragraph per cell),
the measurer and print all see the same string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ured heading titles

Cmd-A Cmd-C converted every heading title again on the main thread
(selectableText built a renderer and an NSAttributedString per heading),
and every search rendered them again. The measurer already converts each
title, the same way, to measure it; it now keeps it in
MeasuredBlocks.converted, where copy (for the installed content version)
and search find it, as they do for paragraphs.

Release harness, median of 9, headings-2000 (4001 rows):
- Cmd-A Cmd-C 73.9 -> 14.4 ms (pieces 59.6 -> 0.5 ms)
- search 51.7 -> 2.8 ms
- measure 249.4 -> 248.9 ms (unchanged)
prose-5000 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b451c and others added 25 commits November 4, 2026 22:52
GitHub keeps a Setext title's line breaks in the heading's text and the
id drops them: `Alpha` / `Beta` / `===` is `#alphabeta` (a hard break
too). The title was joined with spaces, which the slug turned into `-`
(`alpha-beta`), so `[go](#alphabeta)` went to another heading or
nowhere.

The title now keeps its line breaks (MarkdownBlockParser.setextTitle,
which also drops a hard break's backslash). Anchors read them
(MarkdownRenderer.headingAnchorText); the heading, search and the
Contents sidebar show each as a space (shownHeadingTitle,
ToCEntry.entries). Copy Section is unchanged (source lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table's body took every line with a `|`. GitHub closes a table for any
block start: a fence, a quote, an ATX heading, a list item or an HTML
block. Here `$$` opens a formula too. A fence or `$$` line with a pipe
became a row while the definition pre-pass read it as a block, and the
block's closing line then opened one that ran to the end of the
document.

MarkdownBlockParser.interruptsTable ends the body, and a quote's table
the same way (QuoteTip): an item after the table goes on lazily, as on
GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fence indented N columns drops up to N columns from each of its lines.
A tab reaching past them was kept whole, so the line was drawn too deep:
`\tTab` under a fence indented 2 showed a tab where CommonMark and GitHub
show "  Tab". The columns the tab has left are now spaces; a later tab
stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`> [x]: url` is a definition on GitHub: it defines `x` and leaves the
quote empty. It was shown as the quote's text. The definition pre-pass
now takes a link definition on a quote line that starts a paragraph of
its quote (DefinitionPlacement.linkDefinitionText,
QuoteTip.admitsDefinition). It stays text after the quote's text (a
definition cannot interrupt a paragraph), in the quote's fence or table,
and after an alert's marker, as on GitHub.

One GitHub definition stays text: a deeper quote opened right under the
quote's text (`> a`, then `> > [x]: url`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two blank `>` lines drew two gaps in a quote or an alert, at any
nesting level. GitHub gives one paragraph break, as our top-level rhythm
does, where spacing comes from structure, not from blank lines. A
definition the pre-pass takes out between two blank lines left the same
double gap.

The parser now makes each run of blank lines in a quote group one
(MarkdownBlockParser.collapsingBlankRuns); blank lines inside the
quote's fence are code and stay. The view, measurer, search and print
all read that content. Blank lines at a quote's start or end still add
nothing.

Copy pasted the quote's blank line as a line holding one space (the body
draws a blank line as a space to keep its height). A line of only spaces
and tabs is now copied as an empty line, so paragraphs copy with one
blank line between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in the id

A line break inside a code span is a space (CommonMark), so on GitHub
`code` / `span` inside one code span, under a Setext underline, is
`#code-span`. Outside a code span the break is dropped (`#codespan` for
two spans). Since the anchor text keeps a multi-line title's breaks
(H1), the span kept its break too and the slug dropped it. The inline
code renderer now turns a line break inside a span into a space. Only
the anchor text still has one there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deeper `>` opens a nested quote whose first line starts a new
paragraph, so on GitHub `> a` then `>> [x]: url` is a definition: an
empty nested quote, with `[x]` a link. The definition pre-pass read
every quote level as one quote, so the line continued "a" and stayed
the nested quote's text.

DefinitionPlacement now groups quote lines as the block loop does: a
deeper line opens a new group with a fresh QuoteTip, and a shallower
line continues the group lazily or starts a new one. Lazy text stays
text (`>> a` then `> [x]: url`), and so does a nested quote that a lazy
line kept open. A deeper `>` inside the quote's fence is code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved keeping both sides:
- MarkdownBlockParser: DefinitionPlacement's definition pass keeps the
  entity decoding of destinations (quote definitions included); table
  rows scan on unicode scalars and stop at interruptsTable; quote bodies
  collapse blank runs, then resolve references (ReferenceLinks).
- MarkdownRenderer.renderHeader: shownHeadingTitle, then the formula
  source with its escapes (R14).
- ToCEntry: entries(of:) and shownTitle(renderer:) both kept.
- SectionExtractorTests: both sides' tests kept.

Semantic fix: headingAnchorText also keeps a formula's LaTeX escapes, so
a heading's id text is renderHeader's text with the line breaks kept.
Tests cover that and quote references across lazy lines, blank runs and
quote definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link definition the pre-pass takes out of a quote opens a paragraph of
that quote (CommonMark), so on GitHub a lazy line right after it stays
in the quote: `> [y]: url` then `lazy` is a quote "lazy", and under
`> a` / `>> [z]: url` the line `code` is the inner quote's. The pre-pass
dropped the line, so the block loop never saw that paragraph and the
lazy line landed outside the quote or in the outer one.

The line now stays in `lines` as a marker without text
(definitionLines). The block loop's quote group takes it as an open
paragraph of definitions only (QuoteTip.takeDefinition) and draws
nothing for it: a group with only markers is no block, and a group
starts at its first line of text. The pre-pass follows the same state
(DefinitionPlacement.linkDefinition), so more definitions may follow:
marked, lazy or with fewer markers. After them `===` is text, as on
GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`# [Link text][r]` showed its source and its id was `link-textr`
(GitHub: a link, `link-text`); collapsed and shortcut references and
footnote references in headings drew their source too. Headings are
stored raw and drawn by definition-less renderers (view, measurer,
anchors, search, Contents, print), so their references are now inlined
at parse time, as quotes, alerts and cells are
(ReferenceLinks.inlinedHeading). The title is scanned as one run with
its line breaks kept, so a Setext `[Link⏎text][r]` is one link with
GitHub's id `linktext`.

A footnote reference counts as its ASCII number in the heading's id
(FootnoteNumberAttribute): GitHub's slug reads `Note<sup>1</sup> here`
as `note1-here`, and the superscript digit drawn here is no word
character for the slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The definition pre-pass tried the footnote pattern only on the whole
line, and on a quote line the `>` comes first. What follows the
markers was tried only against the link pattern, so `> [^m]: two`
became a reference `^m` to `two` and `[^m]` a link. On GitHub it is
footnote 1, "two", in an empty quote.

The footnote pattern now runs first on a quote line's text too
(DefinitionPlacement.footnoteDefinitionText). A footnote definition
there may interrupt a paragraph, as at the top level, but not inside
the quote's fence. Its line stays as a marker without text, as a link
definition's does (definitionTaken), so a lazy line after it stays in
the quote. A footnote definition starts a block (startsNoBlock), so at
fewer markers it ends a nested quote rather than continuing it lazily,
as on GitHub. `[^]` is no footnote label and remains a link, as on
GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A label across a Setext break (`[link⏎text]`), with extra spaces or a
tab (`[a  b]`, `[A⇥B]`) or in another case form (`[ẞ]` for `[SS]:`,
`[straße]` for `[STRASSE]:`) stayed literal; GitHub links them all.
ReferenceLabel.normalized is now the one key every lookup uses (the
definition pre-pass, the renderer, InlineSyntaxScanner, ReferenceLinks):
whitespace runs (cmark's set: a no-break space is not one) become one
space, the ends are trimmed, and the label is Unicode case-folded.

The first definition of a label now wins, as on GitHub; the last one
did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On GitHub `> [!NOTE]` alone is a plain quote "[!NOTE]", and so is a
marker followed only by blank `>` lines or by definitions the pre-pass
takes out. We drew an alert with an empty box. A body is any block after
the marker: text, a lazy line, a list, a heading, or a nested quote right
after it, even an empty one.

The quote branch now makes a level-1 group an alert only with a body or
a nested quote following; otherwise it is a quote. ParserTests and
BlockHeightMeasurerTests pinned the header-only alert. Their fixtures
now give the alert an empty nested quote, and the marker alone is
asserted to be a quote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The footnote definition pattern took any label and wanted a space after
the colon. cmark-gfm's label is one or more characters other than `]`,
spaces, tabs and line breaks. A no-break space, `^`, `[`, `\` and other
punctuation are allowed. The text may follow the colon directly, or be
empty. So on GitHub `[^ m]: url` and `[^a b]: url` are LINK definitions,
which we read as footnotes, and `[^m]:note` is a footnote, which we
read as neither.

One pattern serves every level (top level, quotes, startsNoBlock), and
the footnote pattern is still tried before the link pattern
(constraint 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved keeping both sides:
- Link definition pre-pass: normalised label, first definition wins and
  the destination's entities decoded (backlog-b), and the quote's marker
  line kept when the definition was taken out of a quote (H9).
- Quote branch: an alert needs a body (H10); its body still has its
  references inlined (ReferenceLinks).

Test: quote-level footnote and link definitions number and resolve in
the quote, an alert and a paragraph alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`[^Note]` and `[^NOTE]` stayed literal with only `[^note]:` defined, and
`[^ẞ]` did not find `[^ss]:`; GitHub links them all to the footnote.
Footnote ids are now stored as ReferenceLabel.normalized and looked up
the same way by the renderer and ReferenceLinks (quotes, alerts, cells,
headings). The label grammar stays cmark-gfm's (H11). The first
definition of an id wins and a later one is not listed, as on GitHub;
both were listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`[ ]: https://empty` was taken out as a definition of the empty label;
CommonMark and GitHub keep the line as paragraph text, and `[ ]` /
`[x][ ]` stay literal. The pre-pass now rejects a label that
ReferenceLabel.normalized makes empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`[a\]b]: https://esc` was not a definition (the pattern stopped at the
first `]`), and `[y][a\]b]` read the label `a\`. GitHub links both, the
label matched as written. The definition pattern now takes escaped
characters and no unescaped bracket (`[a[b]:` stays text, as on
GitHub), and the full form's label ends at the first unescaped `]`
with no unescaped `[` inside (ReferenceLabel.closingBracket) in the
renderer, ReferenceLinks and the linked-image line, the rule
InlineSyntaxScanner.matchLabel already used for images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GitHub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…graph

Badges one per line ("[![Platform](...)](...)" on four lines, this
project's README among them) are ONE paragraph on GitHub, the same as a
badge row on one line. Each line became its own image block, so the
badges stacked into a column of pictures with captions. The linked-image
rule now applies only when no paragraph is open before the line (the
buffer is empty or ends in a blank line or a rule) and the paragraph ends
with it: the last line, or a blank line or a block that may interrupt a
paragraph follows (fence, $$, ATX heading, quote, rule, a bullet or an
item numbered 1 with text, HTML kinds 1-6, a table). A setext underline
makes the line a heading. A logo or single badge standing alone is still
the picture; a badge paragraph keeps its linked placeholders until badge
rows (1.15). Every case checked with gh api markdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ument

With the pointer resting just below the window, every few headings the
document scrolled past that point kept its copy icon (a search jump, a
keyboard or scroller scroll): SwiftUI reports a hover for a row that
lies outside the clip view, and no mouse move ever ends it. 1.14.3 did
not show it; the 1.14.4 rows and refreshes do.

The heading copy, code copy and diagram zoom buttons now use
DocumentHover: a continuous hover that counts only while the pointer is
over the clip view of this list's window with no other window above it
(SelectionController.pointerIsOverVisibleDocument), checked on every
move, so a real hover after such a report still shows the button.
Checked end to end: twelve scroll positions and the search flow with
the pointer below the window show no icon; hovering a heading, a code
block and a diagram shows its button and leaving hides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One vertical rhythm for every kind of block, replacing the cramped
spacing of 1.14.3, and Markdown read as on GitHub. CHANGELOG entry
rewritten for the shipped content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b451c
b451c marked this pull request as ready for review November 5, 2026 04:12
b451c and others added 2 commits November 5, 2026 05:28
The same eight views as for 1.14.3, captured again from 1.14.4 so the
README shows its spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@b451c
b451c merged commit 3870506 into main Nov 5, 2026
3 checks passed
@b451c
b451c deleted the release/1.14.4 branch November 5, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant