Repository navigation
release: v1.14.4 - #53
Merged
Merged
Conversation
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
marked this pull request as draft
November 4, 2026 17:18
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>
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 ("[](...)" 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
marked this pull request as ready for review
November 5, 2026 04:12
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 1.14.4 (build 31). Replaces the 1.14.3 spacing change, which left documents cramped, and brings the renderer closer to GitHub.
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.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.other.md#sectionscroll to that section. An unreadable file says so instead of reporting it missing. Settings remembers its tab by name.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.