diff --git a/Casks/quickmd.rb b/Casks/quickmd.rb index 24a8d01..a43b4b1 100644 --- a/Casks/quickmd.rb +++ b/Casks/quickmd.rb @@ -1,6 +1,6 @@ cask "quickmd" do - version "1.14.3" - sha256 "a9d00b75f01778d2e5309358f847f74076734bf51a639873ef197c6756114b78" + version "1.14.4" + sha256 "17677468c385bbe190c8770fe35843c41485bfe02ccbeca3210d8ee2ca4f0104" url "https://github.com/b451c/quickmd/releases/download/v#{version}/QuickMD-v#{version}.zip" name "QuickMD" diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 4132ddb..4541a99 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.14.4] - 2026-11-05 + +Comfortable spacing, done properly, and Markdown that reads like it does on GitHub. + +### Changed +- **Spacing:** one consistent rhythm between every kind of block. Headings get clear space above and stay close to their own text; paragraphs, lists, quotes, tables, code and diagrams are evenly spaced, with or without blank lines in the source. This replaces the cramped spacing of 1.14.3. PDF export follows the same rhythm and keeps a heading on the page with its text. +- **Reading Mode:** the column grows with zoom, so a line holds the same amount of text at any size (720 pt at 100%). The heading copy buttons are hidden there. + +### Fixed +- Read as on GitHub: + - character references such as `&`, `©` and ` ` show as characters, also in link addresses; + - formatting inside link text (`[**Docs**](...)`, ``[`code`](...)``) renders; + - a linked image that stands alone in its paragraph (a README logo or a single badge) shows the picture; + - reference links and footnotes work inside quotes, alerts and table cells; + - a quote wrapped over several lines without `>` on each line stays one quote; + - an alert marker with nothing under it (`> [!NOTE]` alone) is a plain quote; + - `1)` lists, a year like `1984.` inside a paragraph, a heading underlined over several lines, `\$5` amounts, and link definitions inside code; + - a fenced code block indented under a list step is no longer drawn shifted right. +- Formulas keep their LaTeX escapes (`$50\%$`), and a formula that cannot be drawn shows its source. +- Zoom and Reading Mode keep your reading position exactly; images and diagrams keep their size once shown, and a diagram no longer flashes empty when it scrolls back into view. +- Search centres the match inside a tall table, code block or quote. +- A link to a section of another document (`other.md#section`) scrolls to that section. +- A file that cannot be read says so, instead of reporting it as missing. +- Settings reopens on the tab you used last. +- Faster copy and search in documents with many headings; long quotes no longer slow down scrolling and search. + ## [1.14.3] - 2026-11-04 Two rendering fixes. diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 286ee44..d3c1d00 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -36,9 +36,23 @@ A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; }; A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000231 /* ReviewPromptTrigger.swift */; }; A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000232 /* ReviewPromptTests.swift */; }; + A1B2C3D401000300 /* TemporaryDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000300 /* TemporaryDefaults.swift */; }; + A1B2C3D40100030B /* SettingsTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000030B /* SettingsTabTests.swift */; }; A1B2C3D401000201 /* PrintableTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000200 /* PrintableTableTests.swift */; }; A1B2C3D4010001C0 /* SelectionRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; + A1B2C3D4010002A0 /* VerticalRhythm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000002A0 /* VerticalRhythm.swift */; }; + A1B2C3D4010002A1 /* VerticalRhythm.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000002A0 /* VerticalRhythm.swift */; }; + A1B2C3D401000301 /* ReferenceLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000301 /* ReferenceLinks.swift */; }; + A1B2C3D401000302 /* ReferenceLinks.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000301 /* ReferenceLinks.swift */; }; + A1B2C3D401000303 /* CrossDocumentAnchors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000303 /* CrossDocumentAnchors.swift */; }; + A1B2C3D401000304 /* CrossDocumentAnchors.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000303 /* CrossDocumentAnchors.swift */; }; + A1B2C3D401000305 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000305 /* SettingsTab.swift */; }; + A1B2C3D401000306 /* SettingsTab.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000305 /* SettingsTab.swift */; }; + A1B2C3D401000309 /* HTMLEntityTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000309 /* HTMLEntityTable.swift */; }; + A1B2C3D40100030A /* HTMLEntityTable.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000309 /* HTMLEntityTable.swift */; }; + A1B2C3D401000307 /* HTMLEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000307 /* HTMLEntities.swift */; }; + A1B2C3D401000308 /* HTMLEntities.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000307 /* HTMLEntities.swift */; }; A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C1 /* TableSelectionTests.swift */; }; A1B2C3D40100000E /* ImageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000012 /* ImageBlockView.swift */; }; A1B2C3D40100000F /* BlockquoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000013 /* BlockquoteView.swift */; }; @@ -119,6 +133,7 @@ A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000066 /* ChromeHoverState.swift */; }; A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000069 /* BlockHeightMeasurer.swift */; }; A1B2C3D40100006A /* BlockHeightMeasurerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000006A /* BlockHeightMeasurerTests.swift */; }; + A1B2C3D4010002A2 /* VerticalRhythmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000002A1 /* VerticalRhythmTests.swift */; }; A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000069 /* BlockHeightMeasurer.swift */; }; A1B2C3D40100006C /* VirtualBlockList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000006C /* VirtualBlockList.swift */; }; A1B2C3D40100006D /* SVGBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000006D /* SVGBlockView.swift */; }; @@ -173,6 +188,7 @@ A1B2C3D400000064 /* FontTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontTests.swift; sourceTree = ""; }; A1B2C3D400000069 /* BlockHeightMeasurer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockHeightMeasurer.swift; sourceTree = ""; }; A1B2C3D40000006A /* BlockHeightMeasurerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockHeightMeasurerTests.swift; sourceTree = ""; }; + A1B2C3D4000002A1 /* VerticalRhythmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalRhythmTests.swift; sourceTree = ""; }; A1B2C3D40000006C /* VirtualBlockList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualBlockList.swift; sourceTree = ""; }; A1B2C3D40000006D /* SVGBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SVGBlockView.swift; sourceTree = ""; }; A1B2C3D40000006E /* SVGImageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SVGImageDecoder.swift; sourceTree = ""; }; @@ -226,8 +242,16 @@ A1B2C3D400000230 /* ReviewPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPrompt.swift; sourceTree = ""; }; A1B2C3D400000231 /* ReviewPromptTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTrigger.swift; sourceTree = ""; }; A1B2C3D400000232 /* ReviewPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTests.swift; sourceTree = ""; }; + A1B2C3D400000300 /* TemporaryDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryDefaults.swift; sourceTree = ""; }; + A1B2C3D40000030B /* SettingsTabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabTests.swift; sourceTree = ""; }; A1B2C3D400000200 /* PrintableTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintableTableTests.swift; sourceTree = ""; }; A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = ""; }; + A1B2C3D4000002A0 /* VerticalRhythm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalRhythm.swift; sourceTree = ""; }; + A1B2C3D400000301 /* ReferenceLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceLinks.swift; sourceTree = ""; }; + A1B2C3D400000303 /* CrossDocumentAnchors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDocumentAnchors.swift; sourceTree = ""; }; + A1B2C3D400000305 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; + A1B2C3D400000309 /* HTMLEntityTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLEntityTable.swift; sourceTree = ""; }; + A1B2C3D400000307 /* HTMLEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLEntities.swift; sourceTree = ""; }; A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = ""; }; A1B2C3D400000012 /* ImageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlockView.swift; sourceTree = ""; }; A1B2C3D400000013 /* BlockquoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockquoteView.swift; sourceTree = ""; }; @@ -334,12 +358,15 @@ A1B2C3D400000064 /* FontTests.swift */, A1B2C3D400000067 /* ChromeHoverTests.swift */, A1B2C3D40000006A /* BlockHeightMeasurerTests.swift */, + A1B2C3D4000002A1 /* VerticalRhythmTests.swift */, A1B2C3D400000082 /* ImageLoadingTests.swift */, A1B2C3D4000000E1 /* HTMLImageTests.swift */, A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000001B2 /* TableTextTests.swift */, A1B2C3D400000221 /* FrontMatterTests.swift */, A1B2C3D400000232 /* ReviewPromptTests.swift */, + A1B2C3D400000300 /* TemporaryDefaults.swift */, + A1B2C3D40000030B /* SettingsTabTests.swift */, A1B2C3D400000200 /* PrintableTableTests.swift */, A1B2C3D4000001C1 /* TableSelectionTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, @@ -377,6 +404,12 @@ A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, A1B2C3D4000001C0 /* SelectionRows.swift */, + A1B2C3D4000002A0 /* VerticalRhythm.swift */, + A1B2C3D400000301 /* ReferenceLinks.swift */, + A1B2C3D400000303 /* CrossDocumentAnchors.swift */, + A1B2C3D400000305 /* SettingsTab.swift */, + A1B2C3D400000309 /* HTMLEntityTable.swift */, + A1B2C3D400000307 /* HTMLEntities.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, A1B2C3D400000120 /* DocumentFileFormat.swift */, A1B2C3D400000121 /* DocumentFileWriter.swift */, @@ -674,6 +707,12 @@ A1B2C3D401000082 /* ImageLoader.swift in Sources */, A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */, A1B2C3D4010001C0 /* SelectionRows.swift in Sources */, + A1B2C3D4010002A0 /* VerticalRhythm.swift in Sources */, + A1B2C3D401000301 /* ReferenceLinks.swift in Sources */, + A1B2C3D401000303 /* CrossDocumentAnchors.swift in Sources */, + A1B2C3D401000305 /* SettingsTab.swift in Sources */, + A1B2C3D401000309 /* HTMLEntityTable.swift in Sources */, + A1B2C3D401000307 /* HTMLEntities.swift in Sources */, A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */, A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */, A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */, @@ -715,6 +754,7 @@ A1B2C3D401000067 /* ChromeHoverTests.swift in Sources */, A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */, A1B2C3D40100006A /* BlockHeightMeasurerTests.swift in Sources */, + A1B2C3D4010002A2 /* VerticalRhythmTests.swift in Sources */, A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */, A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */, A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */, @@ -726,6 +766,12 @@ A1B2C3D4010000E2 /* HTMLImageTests.swift in Sources */, A1B2C3D4010000C1 /* DocumentSelection.swift in Sources (Tests) */, A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */, + A1B2C3D4010002A1 /* VerticalRhythm.swift in Sources (Tests) */, + A1B2C3D401000302 /* ReferenceLinks.swift in Sources (Tests) */, + A1B2C3D401000304 /* CrossDocumentAnchors.swift in Sources (Tests) */, + A1B2C3D401000306 /* SettingsTab.swift in Sources (Tests) */, + A1B2C3D40100030A /* HTMLEntityTable.swift in Sources (Tests) */, + A1B2C3D401000308 /* HTMLEntities.swift in Sources (Tests) */, A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */, A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */, A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */, @@ -751,6 +797,8 @@ A1B2C3D4010001B3 /* TableTextTests.swift in Sources */, A1B2C3D401000222 /* FrontMatterTests.swift in Sources */, A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */, + A1B2C3D401000300 /* TemporaryDefaults.swift in Sources */, + A1B2C3D40100030B /* SettingsTabTests.swift in Sources */, A1B2C3D401000201 /* PrintableTableTests.swift in Sources */, A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */, ); @@ -891,7 +939,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -916,7 +964,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.3; + MARKETING_VERSION = 1.14.4; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -935,7 +983,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -960,7 +1008,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.3; + MARKETING_VERSION = 1.14.4; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift index b1f9b5e..796ee21 100644 --- a/QuickMD/QuickMD/BlockHeightMeasurer.swift +++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift @@ -65,14 +65,14 @@ enum BlockLayout { // MARK: Document chrome (MarkdownView) - /// Padding and spacing `MarkdownView` puts around and between blocks. + /// Padding `MarkdownView` puts around the document and around blocks. /// The per-kind `…OuterVerticalPadding` values are the `.padding(.vertical:)` /// modifiers applied in `MarkdownView.blockView(for:)`; they are part of a - /// row's height because the virtualized list hosts that whole view. + /// row's height because the virtualized list hosts that whole view. The + /// space BETWEEN blocks is `BlockLayout.Spacing` (VerticalRhythm.swift). enum Document { static let contentHorizontalPadding: CGFloat = 32 static let contentVerticalPadding: CGFloat = 24 - static let blockSpacing: CGFloat = 8 // MARK: Reading mode (v1.9) // @@ -82,11 +82,44 @@ enum BlockLayout { // so `BlockHeightMeasurer` needs no reading-mode branch at all — it is // handed the clamped `contentWidth` like any other width. - /// Upper bound on the text column in reading mode; wider windows centre - /// the column instead of stretching the lines. 720 pt is ~90 characters - /// at the 16 pt default body size — past that the eye loses the line it - /// came from on the way back to the left margin. + /// Upper bound on the text column in reading mode at 100 %; wider + /// windows centre the column instead of stretching the lines. 720 pt is + /// ~90 characters at the 16 pt default body size — past that the eye + /// loses the line it came from on the way back to the left margin. + /// The cap at other zooms is `readingColumnCap(fontScale:)`. static let readingMaxContentWidth: CGFloat = 720 + + /// Reading mode's column cap at `fontScale`: `readingMaxContentWidth` + /// scaled with the body text (16 pt × the zoom; there is no other body + /// size setting — Settings' default zoom IS the starting zoom), in + /// whole points. The line keeps its ~90 characters at every zoom: + /// until 1.14.4 the cap stayed 720 pt, so 200 % left ~45 characters a + /// line and 50 % ~180. Still capped by the window (`columnWidth`). + static func readingColumnCap(fontScale: CGFloat) -> CGFloat { + (readingMaxContentWidth * max(fontScale, 0)).rounded() + } + + /// The width a block view gets — the width the heights are measured + /// at: `inner` (the clip's whole points − 2 × `contentHorizontalPadding`), + /// no wider than `cap` in reading mode. THE one definition, used by + /// `VirtualBlockList` (cells and published width), by `MarkdownView` + /// (the width a zoom in reading mode measures its parse at) and by + /// `SourceEditorController.columnGeometry`. + static func columnWidth(inner: CGFloat, cap: CGFloat?) -> CGFloat { + guard let cap else { return max(0, inner) } + return max(0, floor(min(inner, cap))) + } + + /// The width a new parse is measured at: the list's `contentWidth` — + /// except at a new zoom in reading mode (`readingFontScale` = the + /// parse's zoom), whose cap follows the zoom: the column the list will have once the + /// parse is installed (`inner` capped at that zoom), so a zoom in + /// reading mode measures the document once, not at the old column + /// and then again. 0 (no layout yet) stays 0. + static func parseWidth(contentWidth: CGFloat, inner: CGFloat, readingFontScale: CGFloat?) -> CGFloat { + guard contentWidth > 0, inner > 0, let readingFontScale else { return contentWidth } + return columnWidth(inner: inner, cap: readingColumnCap(fontScale: readingFontScale)) + } /// `contentVerticalPadding`'s reading-mode counterpart: the top and /// bottom breathing room, doubled, because in reading mode nothing else /// occupies the top of the window. @@ -234,6 +267,14 @@ enum BlockLayout { /// title at the same reduced width. static let copyButtonReservedWidth: CGFloat = copyButtonSpacing + copyButtonWidth + /// Reading Mode hides the copy button (no chrome while reading)… + static func showsCopyButton(isReadingMode: Bool) -> Bool { !isReadingMode } + + /// …but NOT its strip: the title is padded by the same width in both + /// modes, so its wrap width, this measurer's heights and the row + /// heights do not change when the mode toggles. + static func titleTrailingPadding(isReadingMode: Bool) -> CGFloat { copyButtonReservedWidth } + // MARK: Horizontal placement of the copy button /// The width the title wraps at inside a heading row `rowWidth` wide — @@ -404,12 +445,91 @@ enum BlockLayout { /// the bitmap is decoded, so this is what a measured row starts at. static let placeholderHeight: CGFloat = 100 static let maxDisplayWidth: CGFloat = 600 + /// Between an image and its italic alt-text caption. `ImageBlockView`'s + /// body is a two-view tuple, flattened into the row's `VStack`, so this + /// is that stack's spacing (the 8 pt the 1.8.0 document stack had). + static let captionSpacing: CGFloat = 8 /// Scale the fitted 100% width, then keep the result inside the column. static func displayWidth(fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat { let available = max(1, contentWidth) return min(available, min(maxDisplayWidth, available) * fontScale) } + + /// The size a picture — a bitmap image (`ImageBlockView`) or an SVG + /// (`SVGBlockView`) — is drawn at, from its own size (`NSImage.size`: + /// the ORIGINAL pixel size for a bitmap, see `ImageLoader`, the declared + /// size for an SVG): `displayWidth`, never wider than its own width × + /// zoom (D4, GitHub parity) or an HTML `` in its place, at + /// its aspect ratio. The height is rounded to whole points, so it is + /// pixel-exact at 1× and 2×. The views FRAME the picture with this and + /// the measurer sizes the row with it: once a picture's size is known, + /// its row is exact at every width and zoom. + static func pictureSize(imageSize: CGSize, htmlWidth: ImageWidth?, fontScale: CGFloat, + contentWidth: CGFloat) -> CGSize { + let cap = displayWidth(fontScale: fontScale, contentWidth: contentWidth) + let width: CGFloat + if let htmlWidth { + width = htmlWidth.displayWidth(cap: cap, fontScale: fontScale) + } else if imageSize.width > 0 { + width = min(cap, imageSize.width * fontScale) + } else { + width = cap + } + guard imageSize.width > 0, imageSize.height > 0 else { + return CGSize(width: width, height: placeholderHeight) + } + return CGSize(width: width, height: (width * imageSize.height / imageSize.width).rounded()) + } + + /// The alt-text caption under an image (`ImageCaption`): 12 pt × zoom, + /// system italic. + static let captionFontSize: CGFloat = 12 + + static func captionFont(fontScale: CGFloat) -> NSFont { + let size = captionFontSize * fontScale + let base = NSFont.systemFont(ofSize: size) + return NSFont(descriptor: base.fontDescriptor.withSymbolicTraits(.italic), size: size) ?? base + } + + /// The caption's height at `contentWidth`: its lines, broken by TextKit + /// as SwiftUI breaks them, × `singleLineHeight` — an upper bound of + /// SwiftUI's own line height, which no font metric reproduces. + /// `ImageCaption` is at least this tall, so the row is exactly this + /// whenever the two agree on the lines, and grows (and reports) if + /// they ever don't, rather than clipping. 0 without a caption. + static func captionHeight(alt: String, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat { + guard !alt.isEmpty else { return 0 } + let font = captionFont(fontScale: fontScale) + // A fresh stack per call: TextKit is not thread-safe and this runs + // off-main in the measurer. + let storage = NSTextStorage(string: alt, attributes: [.font: font]) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer(size: NSSize(width: BlockLayout.clampedWidth(contentWidth), + height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + layoutManager.ensureLayout(for: container) + var lines = 0 + layoutManager.enumerateLineFragments(forGlyphRange: layoutManager.glyphRange(for: container)) { _, _, _, _, _ in + lines += 1 + } + return CGFloat(max(1, lines)) * BlockLayout.singleLineHeight(for: font) + } + + /// An image row: the view's body is a two-view tuple (picture, then the + /// caption), so the row's vertical padding applies to EACH of them, and + /// the row's stack puts `captionSpacing` between them (measured in the + /// GUI: a 1024 px icon at 819 pt reports 8 + 600 + 8 + 8 + 8 + 15 + 8 = + /// 655). `picture` is `pictureSize(...).height`, or the 100 pt loading + /// view while the size is unknown. + static func rowHeight(picture: CGFloat, alt: String, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat { + let outer = BlockLayout.Document.imageOuterVerticalPadding + guard !alt.isEmpty else { return picture + 2 * outer } + return picture + 2 * outer + captionSpacing + + captionHeight(alt: alt, fontScale: fontScale, contentWidth: contentWidth) + 2 * outer + } } // MARK: Display math @@ -428,9 +548,56 @@ enum BlockLayout { enum Mermaid { static let verticalPadding: CGFloat = 8 + /// Space between the diagram's box and its first ink, at 1.0: the + /// `diagramPadding` Mermaid puts around a flowchart, class or state + /// graph inside its SVG (8 px), drawn at the diagram's scale — the + /// zoom, for a diagram narrower than the column. The diagram has no + /// frame, so this is where the eye sees it start (measured: 8–9 pt). + static let inkInset: CGFloat = 8 /// Height a diagram starts at when nothing has measured it yet. static let defaultHeight: CGFloat = 200 static let cornerRadius: CGFloat = 6 + /// The least height of a diagram that did NOT render (Mermaid's error + /// text, or nothing): a rendered one is as tall as it is. + static let failedRenderMinimumHeight: CGFloat = 50 + + /// What a rendered diagram's height follows from, as the inline web view + /// posts it (`mermaidGeometry` in mermaid-template.html): the SVG's + /// natural size (its viewBox) and what the page adds below it. + struct Geometry: Equatable { + var naturalWidth: CGFloat + var naturalHeight: CGFloat + var overhead: CGFloat + } + + /// The diagram's height in a web view `contentWidth` wide at + /// `fontScale` — the template's `resizeDiagram`: the natural size × + /// zoom, never wider than the view (`window.innerWidth`, whole CSS + /// px), plus the page's overhead. No floor: a flat diagram at a small + /// zoom is that flat (1.14.4 kept it at least 50 pt, blank below it). + static func diagramHeight(_ geometry: Geometry, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat { + let viewport = floor(contentWidth) + guard geometry.naturalWidth > 0, geometry.naturalHeight > 0, viewport > 0 else { return defaultHeight } + let fit = viewport / geometry.naturalWidth + let factor = min(fontScale * min(1, fit), fit) + return geometry.naturalHeight * factor + geometry.overhead + } + + /// The height a diagram's web view reported, as its row takes it. + /// `rendered`: the diagram rendered (its geometry was posted) — the + /// height is exact, as `diagramHeight` computes it; a report of + /// nothing is ignored (nil). Otherwise the page shows Mermaid's error + /// text (or nothing): at least `failedRenderMinimumHeight`. + static func acceptedHeight(reported: Double, rendered: Bool) -> CGFloat? { + guard reported.isFinite else { return nil } + guard rendered else { return max(CGFloat(reported), failedRenderMinimumHeight) } + return reported > 0 ? CGFloat(reported) : nil + } + + /// A diagram row: the diagram, the view's padding and the row's. + static func rowHeight(diagram: CGFloat) -> CGFloat { + diagram + 2 * verticalPadding + 2 * BlockLayout.Document.mermaidOuterVerticalPadding + } } // MARK: - Derived text widths @@ -530,7 +697,7 @@ struct MathRendering { guard case .mathBlock(let latex) = block.content, !typesets(latex) else { return block } return MarkdownBlock(id: block.id, content: .codeBlock(code: latex, language: "latex"), - sourceLine: block.sourceLine) + sourceLine: block.sourceLine, substitutesFormula: true) } } } @@ -570,15 +737,13 @@ enum BlockTextConverter { ?? NSAttributedString(string: String(attributed.characters)) } - let plain = String(attributed.characters) - let segments = InlineMathSegmenter.split(plain) let result = NSMutableAttributedString() var attrIndex = attributed.startIndex - for segment in segments { - switch segment { + for piece in InlineMathSegmenter.pieces(of: attributed) { + switch piece.segment { case .text(let str): - if let end = attributed.characters.index(attrIndex, offsetBy: str.count, + if let end = attributed.characters.index(attrIndex, offsetBy: piece.length, limitedBy: attributed.endIndex) { let slice = AttributedString(attributed[attrIndex.. Bool { + if frameSizeChanges > 0 { return false } + return super.scrollToVisible(rect) + } +} + +// MARK: - Image caption + +/// The italic alt-text line under an image (`ImageBlockView`). Here rather +/// than in the view's file so the unit tests host the very view the measurer +/// sizes (`BlockLayout.ImageBlock.captionHeight`): at least that tall, so the +/// row is exact whenever SwiftUI breaks the caption into the lines TextKit +/// counted. +struct ImageCaption: View { + let alt: String + let color: Color + let fontScale: CGFloat + let contentWidth: CGFloat + + var body: some View { + Text(alt) + .font(.system(size: BlockLayout.ImageBlock.captionFontSize * fontScale)) + .foregroundColor(color) + .italic() + .frame(minHeight: BlockLayout.ImageBlock.captionHeight(alt: alt, fontScale: fontScale, + contentWidth: contentWidth), + alignment: .topLeading) + } +} + +// MARK: - Block Height Cache + +/// Remembers the last measured height of each block, keyed by block id. +/// LazyVStack destroys and recreates views during scroll; without this a +/// recreated view starts at a placeholder height and "jumps" when its real +/// height arrives, making upward scrolling stutter. Since 1.8.0 text and code +/// blocks size synchronously (`SelfSizingTextView.size(fitting:)`) and no longer +/// need it — Mermaid diagrams (height arrives from the WebView) still do. +/// +/// Main-thread only BY CONVENTION (all reads/writes happen from SwiftUI view +/// inits / height callbacks dispatched to main). Deliberately NOT @MainActor: +/// view initializers are nonisolated on older SDKs where the SwiftUI View +/// protocol annotates only `body`, and an isolated init call there is a hard +/// compile error (caught by CI on the GitHub runner toolchain). +final class BlockHeightCache { + private var heights: [String: CGFloat] = [:] + /// The size of every image this document has shown (`NSImage.size`, the + /// original pixel size), by its link as written. An `NSString` key, like + /// `ImageLoader`'s caches: a multi-megabyte `data:` link hashes in + /// constant time. Per document, because a relative link names another file + /// in another folder. A re-parse keeps them: they are keyed by the link. + private var imageSizes: [NSString: CGSize] = [:] + + func height(for id: String) -> CGFloat? { heights[id] } + func set(_ height: CGFloat, for id: String) { heights[id] = height } + /// Forgets the block-id heights (a re-parse: ids are positional). + func removeAll() { heights.removeAll() } + + func setImageSize(_ size: CGSize, for url: String) { + guard size.width > 0, size.height > 0 else { return } + imageSizes[url as NSString] = size + } + func imageSize(for url: String) -> CGSize? { imageSizes[url as NSString] } + + /// A value copy for `BlockHeightMeasurer.measure(heightSeeds:)` — the + /// measurer runs off-main and must not read this reference type from there. + var snapshot: [String: CGFloat] { heights } + /// The same for `measure(imageSizes:)`. + var imageSizesSnapshot: [NSString: CGSize] { imageSizes } +} + +// MARK: - Mermaid geometry + +/// The geometry (`BlockLayout.Mermaid.Geometry`) of every diagram a web view +/// has rendered, by theme and source. With it a diagram that rendered once +/// never starts at the 200 pt default again: the measurer and the view's +/// first frame compute its height at any width and zoom. Process-wide, like +/// `MermaidSnapshotStore`, because a diagram re-created from a snapshot never +/// runs its web view — in this document or another. Locked: the measurer +/// reads it off-main. +enum MermaidGeometryStore { + private static let lock = NSLock() + private static var geometries: [NSString: BlockLayout.Mermaid.Geometry] = [:] + /// Entries are tiny; the cap only keeps a session-long store bounded. + static let limit = 1024 + + private static func key(source: String, isDark: Bool) -> NSString { "\(isDark)|\(source)" as NSString } + + static func set(_ geometry: BlockLayout.Mermaid.Geometry, source: String, isDark: Bool) { + guard geometry.naturalWidth > 0, geometry.naturalHeight > 0 else { return } + let key = key(source: source, isDark: isDark) + lock.lock() + defer { lock.unlock() } + if geometries.count >= limit, geometries[key] == nil { geometries.removeAll() } + geometries[key] = geometry + } + + static func geometry(source: String, isDark: Bool) -> BlockLayout.Mermaid.Geometry? { + let key = key(source: source, isDark: isDark) + lock.lock() + defer { lock.unlock() } + return geometries[key] + } + + /// The diagram's height at `fontScale` and `contentWidth`, if it ever + /// rendered in this theme. + static func diagramHeight(source: String, isDark: Bool, fontScale: CGFloat, + contentWidth: CGFloat) -> CGFloat? { + geometry(source: source, isDark: isDark).map { + BlockLayout.Mermaid.diagramHeight($0, fontScale: fontScale, contentWidth: contentWidth) + } + } +} + +// MARK: - Mermaid snapshots + +/// Shared store of rendered-diagram snapshots, keyed by (isDark, source). +/// Written by the inline MermaidBlockView after each successful render; read +/// back by MermaidBlockView on re-creation AND by the PDF exporter +/// (MermaidPDFRenderer) as a fast path before spinning up an offscreen +/// WebView. NSCache evicts under memory pressure. Here rather than in the +/// view's file so the unit tests compile it. +enum MermaidSnapshotStore { + private static let cache = NSCache() + + private static func key(source: String, isDark: Bool) -> NSString { + "\(isDark)|\(source)" as NSString + } + + static func image(source: String, isDark: Bool) -> NSImage? { + cache.object(forKey: key(source: source, isDark: isDark)) + } + + static func set(_ image: NSImage, source: String, isDark: Bool) { + cache.setObject(image, forKey: key(source: source, isDark: isDark)) + } + + /// The latest snapshot of `diagram` in this theme at ANY zoom and width: + /// what a view without a snapshot of its own shows while its web view + /// renders, instead of an empty box. + static func latest(diagram: String, isDark: Bool) -> NSImage? { + cache.object(forKey: key(source: "latest|" + diagram, isDark: isDark)) + } + + static func setLatest(_ image: NSImage, diagram: String, isDark: Bool) { + cache.setObject(image, forKey: key(source: "latest|" + diagram, isDark: isDark)) + } +} + // MARK: - Height table /// How a row's height was obtained, and therefore whether the placed view is @@ -690,15 +1033,42 @@ enum RowKind: Equatable { /// One height per block, in block order, for a specific content width. struct BlockHeightTable: Equatable { + /// The BLOCK's height: what its view lays out at, what a `.reported` row + /// reports and what the parity tests pin. The list row is taller by + /// `spacingAbove` (see `rowHeight`). let heights: [CGFloat] let kinds: [RowKind] /// The width the heights were measured at. A row height is only valid for /// this width — the list re-measures when it changes. let contentWidth: CGFloat + /// The space above each block (`BlockLayout.Spacing`; row 0: 0). Part of + /// the block's list row, above its view. + let spacingAbove: [CGFloat] var count: Int { heights.count } - static let empty = BlockHeightTable(heights: [], kinds: [], contentWidth: 0) + /// The space above row `row`'s block; 0 outside the table. + func spacing(_ row: Int) -> CGFloat { + row >= 0 && row < spacingAbove.count ? spacingAbove[row] : 0 + } + + /// The list row's height: the space above the block, then the block. + func rowHeight(_ row: Int) -> CGFloat { + row >= 0 && row < heights.count ? spacing(row) + heights[row] : 0 + } + + /// This table with row `row`'s block height replaced — a `.reported` row + /// measured itself. Only the block changes: its space above, and every + /// other row, stay as measured. + func replacingHeight(_ height: CGFloat, at row: Int) -> BlockHeightTable { + guard row >= 0, row < heights.count else { return self } + var heights = heights + heights[row] = height + return BlockHeightTable(heights: heights, kinds: kinds, contentWidth: contentWidth, + spacingAbove: spacingAbove) + } + + static let empty = BlockHeightTable(heights: [], kinds: [], contentWidth: 0, spacingAbove: []) } /// `measure`'s output: the height table plus the NSAttributedStrings that had to @@ -713,7 +1083,9 @@ struct BlockHeightTable: Equatable { struct MeasuredBlocks: @unchecked Sendable { let table: BlockHeightTable /// Keyed by `MarkdownBlock.id`, for the blocks whose text renders through - /// `TextBlockView` (paragraphs/footnotes, quote bodies, alert bodies). + /// `TextBlockView` (paragraphs/footnotes, quote bodies, alert bodies, and + /// heading titles — what `HeadingBlockView` converts, kept so a copy and a + /// search of a heading-heavy document never convert them again). let converted: [String: NSAttributedString] /// Keyed by `MarkdownBlock.id`, for every `.table` block: the string and /// cell layout the row was measured with, which `TableTextView` installs @@ -727,8 +1099,20 @@ struct MeasuredBlocks: @unchecked Sendable { /// main actor before using the table for layout. Empty whenever `measure` /// got a real engine. let mathPendingRows: [Int] + /// Every row's input to `BlockLayout.Spacing` — what `table.spacingAbove` + /// was derived from, kept so `measureMathRows` can update the rows it + /// finishes and derive the column again without measuring anything else. + let rhythm: [BlockLayout.Spacing.Row] + + /// This measurement with row `row`'s block height replaced (see + /// `BlockHeightTable.replacingHeight`). + func replacingHeight(_ height: CGFloat, at row: Int) -> MeasuredBlocks { + MeasuredBlocks(table: table.replacingHeight(height, at: row), converted: converted, + tables: tables, mathPendingRows: mathPendingRows, rhythm: rhythm) + } - static let empty = MeasuredBlocks(table: .empty, converted: [:], tables: [:], mathPendingRows: []) + static let empty = MeasuredBlocks(table: .empty, converted: [:], tables: [:], mathPendingRows: [], + rhythm: []) } // MARK: - Measurer @@ -749,6 +1133,53 @@ enum BlockHeightMeasurer { /// container height, `ensureLayout`, `ceil(usedRect.height)`. Anything else /// here breaks parity. static func exactHeight(text ns: NSAttributedString, width: CGFloat) -> CGFloat { + autoreleasepool { + let (layoutManager, container, storage) = laidOut(ns, width: width) + return withExtendedLifetime(storage) { ceil(layoutManager.usedRect(for: container).height) } + } + } + + /// `exactHeight`, plus where the text's ink starts and ends — the edges + /// `BlockLayout.Spacing` measures optical gaps from. ONE layout pass: the + /// baselines are read from the layout `exactHeight` computes anyway. + /// + /// Both run in their own autorelease pool: TextKit autoreleases parts of + /// every stack it lays out, and one measure pass lays out every text row — + /// thousands in a prose document, whose peak memory grew with the row + /// count (5000 paragraphs: 37.7 MB → 26.1 MB, no time cost). + static func textInk(text ns: NSAttributedString, width: CGFloat) -> BlockLayout.Spacing.TextInk { + autoreleasepool { textInkInPool(text: ns, width: width) } + } + + private static func textInkInPool(text ns: NSAttributedString, width: CGFloat) -> BlockLayout.Spacing.TextInk { + let (layoutManager, container, storage) = laidOut(ns, width: width) + return withExtendedLifetime(storage) { + let height = ceil(layoutManager.usedRect(for: container).height) + let glyphCount = layoutManager.numberOfGlyphs + guard glyphCount > 0 else { + return .init(height: height, firstBaseline: 0, firstCapHeight: 0, lastBaseline: height) + } + func baseline(ofGlyph glyph: Int) -> CGFloat { + layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY + + layoutManager.location(forGlyphAt: glyph).y + } + var firstLine = NSRange() + _ = layoutManager.lineFragmentRect(forGlyphAt: 0, effectiveRange: &firstLine) + let firstCharacters = layoutManager.characterRange(forGlyphRange: firstLine, actualGlyphRange: nil) + var capHeight: CGFloat = 0 + storage.enumerateAttribute(.font, in: firstCharacters, options: []) { value, _, _ in + if let font = value as? NSFont { capHeight = max(capHeight, font.capHeight) } + } + return .init(height: height, firstBaseline: baseline(ofGlyph: 0), firstCapHeight: capHeight, + lastBaseline: baseline(ofGlyph: glyphCount - 1)) + } + } + + /// The TextKit 1 stack both functions above lay `ns` out on. The storage + /// is returned too, and the caller must keep it alive while it reads the + /// layout: the layout manager does not retain it. + private static func laidOut(_ ns: NSAttributedString, width: CGFloat) + -> (NSLayoutManager, NSTextContainer, NSTextStorage) { let storage = NSTextStorage(attributedString: ns) let layoutManager = NSLayoutManager() let container = NSTextContainer(size: NSSize(width: max(0, width), @@ -759,7 +1190,7 @@ enum BlockHeightMeasurer { layoutManager.addTextContainer(container) storage.addLayoutManager(layoutManager) layoutManager.ensureLayout(for: container) - return ceil(layoutManager.usedRect(for: container).height) + return (layoutManager, container, storage) } /// Measures every block for one `(blocks, theme, fontScale, contentWidth)` @@ -770,6 +1201,20 @@ enum BlockHeightMeasurer { /// the list column width minus `2 * Document.contentHorizontalPadding`. /// - heightSeeds: last-known heights (`BlockHeightCache`) used to seed /// Mermaid rows so a re-created diagram doesn't start at the default. + /// - imageSizes: the sizes of the images this document has shown + /// (`BlockHeightCache.imageSizesSnapshot`): such an image's row is + /// computed (`BlockLayout.ImageBlock`) instead of starting at the + /// loading view. SVG sizes come from `SVGImageDecoder`, diagram + /// geometry from `MermaidGeometryStore`. + /// - reusing: strings a PREVIOUS measurement of the same parse built, + /// keyed by block id — the caller's last `MeasuredBlocks.converted`. + /// Quote and alert bodies found here are laid out again as they are: + /// a paragraph's string is stored in its block, a quote's is rendered + /// from its source (`renderQuotedBody`), and that render is the whole + /// cost of a long quote (a 5,000-line one: ~370 ms of ~530). A width + /// change does not change a character, so the render is done once per + /// parse. Pass it ONLY for the same content, theme and zoom (the + /// `measureMathRows` contract); a new parse passes nothing. /// - math: the math engine, or `nil` when the call is NOT on the main /// thread — see the thread rule in the file header. With `nil`, rows /// that need the engine come back at a placeholder height, listed in @@ -789,6 +1234,8 @@ enum BlockHeightMeasurer { fontScale: CGFloat, contentWidth: CGFloat, heightSeeds: [String: CGFloat] = [:], + imageSizes: [NSString: CGSize] = [:], + reusing: [String: NSAttributedString] = [:], math: MathRendering?) -> MeasuredBlocks { #if DEBUG let signpostID = heightSignpost.makeSignpostID() @@ -823,44 +1270,57 @@ enum BlockHeightMeasurer { var heights: [CGFloat] = [] var kinds: [RowKind] = [] + var rhythm: [BlockLayout.Spacing.Row] = [] var converted: [String: NSAttributedString] = [:] var tables: [String: TableText] = [:] var mathPendingRows: [Int] = [] heights.reserveCapacity(blocks.count) kinds.reserveCapacity(blocks.count) + rhythm.reserveCapacity(blocks.count) for block in blocks { + // Text and headings have their edges measured below; every other + // kind's are fixed by its view (`boxRhythm`) — a quote's from its + // text's ink. + var textEdges: BlockLayout.Spacing.Edges? + var quoteInk: BlockLayout.Spacing.TextInk? switch block.content { case .text(let attributed): let hasMath = BlockTextConverter.containsInlineMath(String(attributed.characters)) if hasMath, math == nil { // Building the string means rendering `$…$` segments through - // the math engine, which this thread may not touch. + // the math engine, which this thread may not touch. The + // edges are a placeholder too, until `measureMathRows`. mathPendingRows.append(heights.count) heights.append(pendingParagraphHeight) kinds.append(.exact) + textEdges = .box(top: 0, bottom: 0) } else { let ns = BlockTextConverter.makeNSAttributedString( from: attributed, hasInlineMath: hasMath, theme: theme, fontScale: fontScale, math: engine) converted[block.id] = ns - heights.append(exactHeight(text: ns, width: textWidth)) + let ink = textInk(text: ns, width: textWidth) + heights.append(ink.height) kinds.append(.exact) + textEdges = .text(ink) } case .blockquote(let content, let level): // The quote body is inline-rendered exactly as BlockquoteView // does it, and passed to TextBlockView WITHOUT inline math (the // view does not opt in), so we must not opt in either — which is - // also why a quote never needs the math engine. - let ns = BlockTextConverter.makeNSAttributedString( + // also why a quote never needs the math engine. A string the + // previous measurement built is only laid out again (`reusing`). + let ns = reusing[block.id] ?? BlockTextConverter.makeNSAttributedString( from: renderer.renderQuotedBody(content), hasInlineMath: false, theme: theme, fontScale: fontScale, math: engine) converted[block.id] = ns let width = BlockLayout.Quote.bodyWidth(contentWidth: contentWidth, level: level) - heights.append(exactHeight(text: ns, width: width) - + 2 * BlockLayout.Quote.verticalPadding) + let ink = textInk(text: ns, width: width) + quoteInk = ink + heights.append(ink.height + 2 * BlockLayout.Quote.verticalPadding) kinds.append(.exact) case .alert(_, let content): @@ -868,7 +1328,7 @@ enum BlockHeightMeasurer { + 2 * BlockLayout.Alert.verticalPadding + 2 * BlockLayout.Document.alertOuterVerticalPadding if !content.isEmpty { - let ns = BlockTextConverter.makeNSAttributedString( + let ns = reusing[block.id] ?? BlockTextConverter.makeNSAttributedString( from: renderer.renderQuotedBody(content), hasInlineMath: false, theme: theme, fontScale: fontScale, math: engine) converted[block.id] = ns @@ -886,9 +1346,12 @@ enum BlockHeightMeasurer { kinds.append(.exact) case .heading(let level, let title, _): - heights.append(headingEstimate(title: title, level: level, - renderer: renderer, contentWidth: contentWidth)) + let heading = headingRow(title: title, level: level, + renderer: renderer, contentWidth: contentWidth) + converted[block.id] = heading.string + heights.append(heading.height) kinds.append(.reported) + textEdges = heading.edges case .table(let headers, let rows, let alignments, let style): // The string `TableTextView` installs (it is handed this very @@ -911,29 +1374,46 @@ enum BlockHeightMeasurer { } kinds.append(.reported) - case .image: - // Only the image itself: when `alt` is non-empty the view's body - // is a two-view tuple (image + italic caption `Text`), so the - // placed view reports a taller row than this. It is `.reported` - // precisely because the real size — decoded bitmap, caption or - // no caption — only exists once the view is placed. - heights.append(BlockLayout.ImageBlock.placeholderHeight - + 2 * BlockLayout.Document.imageOuterVerticalPadding) + case .image(let url, let alt, let htmlWidth): + // An image this document has shown has a known size, and the + // row is what the view draws for it (`ImageBlock.rowHeight`) at + // any width and zoom. Until then: the loading view, 100 pt + // plus the caption — also exact, until the image arrives. Still + // `.reported`: a link whose file changed, or failed, reports. + let picture = imageSizes[url as NSString].map { + BlockLayout.ImageBlock.pictureSize(imageSize: $0, htmlWidth: htmlWidth, fontScale: fontScale, + contentWidth: contentWidth).height + } ?? BlockLayout.ImageBlock.placeholderHeight + heights.append(BlockLayout.ImageBlock.rowHeight(picture: picture, alt: alt, fontScale: fontScale, + contentWidth: contentWidth)) kinds.append(.reported) - case .svgImage: - // Same shape as `.image`: the decoded size only exists once the - // view is placed (decode is off-main), so the row is reported. - heights.append(BlockLayout.ImageBlock.placeholderHeight - + 2 * BlockLayout.Document.imageOuterVerticalPadding) + case .svgImage(let source): + // The markup is in the block, so its size is known here: decoded + // once (off-main safe) and cached by `SVGImageDecoder`, where the + // view finds it too. Markup that does not decode shows a notice + // and reports. + let picture = SVGImageDecoder.size(of: source).map { + BlockLayout.ImageBlock.pictureSize(imageSize: $0, htmlWidth: nil, fontScale: fontScale, + contentWidth: contentWidth).height + } ?? BlockLayout.ImageBlock.placeholderHeight + heights.append(picture + 2 * BlockLayout.Document.imageOuterVerticalPadding) kinds.append(.reported) - case .mermaidDiagram: - heights.append((heightSeeds[block.id] ?? BlockLayout.Mermaid.defaultHeight) - + 2 * BlockLayout.Mermaid.verticalPadding - + 2 * BlockLayout.Document.mermaidOuterVerticalPadding) + case .mermaidDiagram(let source): + // A diagram that rendered once (in either document, from either + // its web view or the snapshot that came of it) has its height + // computed for this width and zoom; else this block's last + // height (a width-only re-measure), else the default. + let diagram = MermaidGeometryStore.diagramHeight(source: source, isDark: theme.isDark, + fontScale: fontScale, contentWidth: contentWidth) + ?? heightSeeds[block.id] + ?? BlockLayout.Mermaid.defaultHeight + heights.append(BlockLayout.Mermaid.rowHeight(diagram: diagram)) kinds.append(.reported) } + rhythm.append(textEdges.map { rhythmRow(block, edges: $0) } + ?? boxRhythm(block, fontScale: fontScale, quoteInk: quoteInk)) } #if DEBUG @@ -943,10 +1423,60 @@ enum BlockHeightMeasurer { #endif return MeasuredBlocks( - table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: contentWidth), + table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: contentWidth, + spacingAbove: BlockLayout.Spacing.column(rhythm, fontScale: fontScale)), converted: converted, tables: tables, - mathPendingRows: mathPendingRows) + mathPendingRows: mathPendingRows, + rhythm: rhythm) + } + + // MARK: - Rhythm inputs + + /// A text or heading row's rhythm input, from its measured edges. + private static func rhythmRow(_ block: MarkdownBlock, edges: BlockLayout.Spacing.Edges) + -> BlockLayout.Spacing.Row { + let role: BlockLayout.Spacing.Role + if case .heading(let level, _, _) = block.content { + role = .heading(level: level) + } else { + role = .text + } + return BlockLayout.Spacing.Row(role: role, edges: edges, joinsPrevious: block.joinsPrevious) + } + + /// The rhythm input of a row whose edges its view fixes: the vertical + /// padding between the row's edge and the visible box (or the picture) — + /// where the eye sees the block start. Two blocks have no visible box: a + /// quote is read by its text (`quoteInk`, its edges are a paragraph's, + /// `Edges.text(_:padding:)`), a diagram by its first ink + /// (`Mermaid.inkInset`). A display formula's picture IS its ink (measured: + /// within 1.5 pt). Front matter (either form) is the parser's call. + private static func boxRhythm(_ block: MarkdownBlock, fontScale: CGFloat, + quoteInk: BlockLayout.Spacing.TextInk? = nil) -> BlockLayout.Spacing.Row { + typealias Document = BlockLayout.Document + let role: BlockLayout.Spacing.Role = block.isFrontMatter ? .frontMatter : .box + let pad: CGFloat + switch block.content { + case .blockquote: + pad = BlockLayout.Quote.verticalPadding + if let quoteInk { + return BlockLayout.Spacing.Row(role: role, edges: .text(quoteInk, padding: pad), + joinsPrevious: block.joinsPrevious) + } + case .alert: pad = Document.alertOuterVerticalPadding + case .codeBlock: pad = Document.codeOuterVerticalPadding + case .table: + pad = Document.tableOuterVerticalPadding + case .mathBlock: pad = Document.mathOuterVerticalPadding + BlockLayout.Math.verticalPadding + case .mermaidDiagram: + pad = Document.mermaidOuterVerticalPadding + BlockLayout.Mermaid.verticalPadding + + BlockLayout.Mermaid.inkInset * fontScale + case .image, .svgImage: pad = Document.imageOuterVerticalPadding + case .text, .heading: pad = 0 // measured, never here + } + return BlockLayout.Spacing.Row(role: role, edges: .box(top: pad, bottom: pad), + joinsPrevious: block.joinsPrevious) } /// One `.codeBlock` row: the code laid out at `codeWidth` plus `chrome` @@ -1007,7 +1537,7 @@ enum BlockHeightMeasurer { reusing: [String: NSAttributedString] = [:]) -> MeasuredBlocks { guard !pending.isEmpty else { return MeasuredBlocks(table: measured.table, converted: measured.converted, - tables: measured.tables, mathPendingRows: []) + tables: measured.tables, mathPendingRows: [], rhythm: measured.rhythm) } #if DEBUG @@ -1020,6 +1550,11 @@ enum BlockHeightMeasurer { var heights = measured.table.heights var kinds = measured.table.kinds var converted = measured.converted + // Row for row with `blocks` whenever `measured` came from them; the + // guard only keeps a mismatched call from trapping. + var rhythm = measured.rhythm.count == blocks.count + ? measured.rhythm + : blocks.map { boxRhythm($0, fontScale: fontScale) } let textWidth = BlockLayout.clampedWidth(contentWidth) for index in pending { @@ -1033,7 +1568,9 @@ enum BlockHeightMeasurer { // this string are still the right images and only the wrap // has to be recomputed. converted[block.id] = existing - heights[index] = exactHeight(text: existing, width: textWidth) + let ink = textInk(text: existing, width: textWidth) + heights[index] = ink.height + rhythm[index] = rhythmRow(block, edges: .text(ink)) continue } // Re-derive the flag rather than assuming it: the row is pending @@ -1044,7 +1581,9 @@ enum BlockHeightMeasurer { from: attributed, hasInlineMath: hasMath, theme: theme, fontScale: fontScale, math: math) converted[block.id] = ns - heights[index] = exactHeight(text: ns, width: textWidth) + let ink = textInk(text: ns, width: textWidth) + heights[index] = ink.height + rhythm[index] = rhythmRow(block, edges: .text(ink)) case .mathBlock(let latex): heights[index] = math.displayHeight(latex, theme, fontScale) @@ -1059,37 +1598,50 @@ enum BlockHeightMeasurer { codeWidth: BlockLayout.clampedWidth(contentWidth - 2 * BlockLayout.Code.horizontalPadding), chrome: BlockLayout.Code.verticalChrome(hasLanguage: !language.isEmpty, fontScale: fontScale)) kinds[index] = .exact + rhythm[index] = boxRhythm(block, fontScale: fontScale) default: continue } } + // The finished rows' edges may have changed (a formula on the first + // line, a formula that became a code row), and with them the space + // around them: derive the column again — arithmetic only. return MeasuredBlocks( table: BlockHeightTable(heights: heights, kinds: kinds, - contentWidth: measured.table.contentWidth), + contentWidth: measured.table.contentWidth, + spacingAbove: BlockLayout.Spacing.column(rhythm, fontScale: fontScale)), converted: converted, tables: measured.tables, - mathPendingRows: []) + mathPendingRows: [], + rhythm: rhythm) } - // MARK: - Estimates for the reported kinds - - /// `.heading` — TextKit's height for the title string at the title's width - /// (since v1.11 the title IS an NSTextView laid out with this string), - /// floored at the hover-to-copy button's own height. The view also - /// reproduces the old first-baseline alignment with the button - /// (`BlockLayout.Heading.titleGeometry`), which can add another point or - /// two for single-line H2–H6; the placed view corrects it (the row stays - /// `.reported`). - private static func headingEstimate(title: String, level: Int, - renderer: MarkdownRenderer, - contentWidth: CGFloat) -> CGFloat { + // MARK: - Headings + + /// `.heading` — the height `HeadingBlockView` lays out at and the edges of + /// its title. Since v1.11 the title IS an NSTextView laid out with this + /// string at the title's width, pushed down by the title inset that lines + /// its first baseline up with the hover-to-copy button's + /// (`BlockLayout.Heading.titleGeometry`), and the row is at least as tall + /// as the button's bottom edge. The row stays `.reported`: the placed view + /// still has the last word. + /// `string` is the title exactly as the view shows and the selection + /// copies it: `BlockTextConverter.makeNSAttributedString` of the same + /// `renderHeader` output, which without inline math (a heading shows + /// `$…$` literally) is this one conversion. + private static func headingRow(title: String, level: Int, renderer: MarkdownRenderer, + contentWidth: CGFloat) + -> (height: CGFloat, edges: BlockLayout.Spacing.Edges, string: NSAttributedString) { let attributed = renderer.renderHeader(title, level: level) let ns = (try? NSAttributedString(attributed, including: \.appKit)) - ?? NSAttributedString(string: title) - let textWidth = BlockLayout.Heading.titleWrapWidth(rowWidth: contentWidth) - return max(exactHeight(text: ns, width: textWidth), BlockLayout.Heading.copyButtonHeight) + ?? NSAttributedString(string: String(attributed.characters)) + let geometry = BlockLayout.Heading.titleGeometry( + titleFirstBaseline: BlockLayout.Heading.titleFirstBaseline(font: renderer.headerAppKitFont(level: level))) + let ink = textInk(text: ns, width: BlockLayout.Heading.titleWrapWidth(rowWidth: contentWidth)) + let height = max(geometry.titleTopInset + ink.height, geometry.minimumHeight) + return (height, .text(ink, top: geometry.titleTopInset, rowHeight: height), ns) } } diff --git a/QuickMD/QuickMD/CrossDocumentAnchors.swift b/QuickMD/QuickMD/CrossDocumentAnchors.swift new file mode 100644 index 0000000..4a6de92 --- /dev/null +++ b/QuickMD/QuickMD/CrossDocumentAnchors.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - `other.md#section` links + +/// Hands the fragment of a link to ANOTHER Markdown document to the window +/// that shows that document, so it scrolls to the heading the way an +/// in-document `#section` link does (before, the fragment was dropped and the +/// other document opened at its top). +/// +/// The link's view cannot reach the target: the document opens through +/// `NSWorkspace` (DocumentGroup creates the window, or brings an already open +/// one forward). So the fragment is parked here under the file's path: +/// - a NEW window takes it once its first parse is laid out (a scroll request +/// before the list has its height table finds no row and is dropped); +/// - an already open window hears `requested` and takes it at once. +/// A request is only honoured for `lifetime` seconds, so a link whose file +/// failed to open cannot move a window the user opens much later. +/// +/// Locked rather than `@MainActor`: the views call it from helpers, which are +/// nonisolated on the older SDK CI builds with (constraints.md). +final class CrossDocumentAnchors: @unchecked Sendable { + static let shared = CrossDocumentAnchors() + + /// Posted on the main thread by `request`; `object` is the target's `key`. + static let requested = Notification.Name("QuickMDCrossDocumentAnchorRequested") + + /// Long enough for a large document's open + parse + first layout. + static let lifetime: TimeInterval = 30 + + private let lock = NSLock() + private var pending: [String: (fragment: String, date: Date)] = [:] + + /// The identity of a document file: its path with `.`/`..` and symlinks + /// resolved, so a link through a symlinked folder (or `/tmp` vs + /// `/private/tmp`) reaches the window that opened the real path. The same + /// function on both sides (park and take). + static func key(for fileURL: URL) -> String { fileURL.standardizedFileURL.resolvingSymlinksInPath().path } + + /// The fragment of a link to another document, percent-decoded (`#Install + /// Guide` arrives as `Install%20Guide`); nil when the link has none. + static func fragment(of link: URL) -> String? { + link.fragment(percentEncoded: false) + } + + /// Parks `fragment` for `fileURL` (replacing an older one) and tells an + /// already open window. + func request(_ fragment: String, for fileURL: URL, now: Date = Date()) { + let key = Self.key(for: fileURL) + lock.lock() + pending[key] = (fragment, now) + lock.unlock() + NotificationCenter.default.post(name: Self.requested, object: key) + } + + /// The fragment parked for `fileURL`, removed — nil when there is none or + /// it is older than `lifetime`. + func take(for fileURL: URL?, now: Date = Date()) -> String? { + guard let fileURL else { return nil } + lock.lock() + let entry = pending.removeValue(forKey: Self.key(for: fileURL)) + lock.unlock() + guard let entry, now.timeIntervalSince(entry.date) <= Self.lifetime else { return nil } + return entry.fragment + } +} diff --git a/QuickMD/QuickMD/DocumentSearch.swift b/QuickMD/QuickMD/DocumentSearch.swift index f6feecd..8f7c4ed 100644 --- a/QuickMD/QuickMD/DocumentSearch.swift +++ b/QuickMD/QuickMD/DocumentSearch.swift @@ -59,11 +59,20 @@ func searchMatchRanges(in string: String, term: String) -> [NSRange] { /// (called from updateNSView); not @MainActor for older-SDK compatibility (see /// the BlockHeightCache note in TextBlockView.swift). Lives here, not with the /// views, so the tests (which do not compile the views) can run it. -func applyNSSearchHighlight(in textView: NSTextView, term: String, focusedOccurrence: Int?) { +/// +/// `focusedText`: the part of the FOCUSED match inside `ranges` is drawn in +/// `color` (a temporary foreground, removed on the next call). A front-matter +/// key is secondary-colour text, which on the orange focus read poorly; the +/// properties table passes its key cells and the theme's text colour, so a +/// focused key reads like a focused value. +func applyNSSearchHighlight(in textView: NSTextView, term: String, focusedOccurrence: Int?, + focusedText: (color: NSColor, ranges: [NSRange])? = nil) { guard let layoutManager = textView.layoutManager, let storage = textView.textStorage else { return } let fullRange = NSRange(location: 0, length: storage.length) layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: fullRange) + // Unconditionally: a reused view may have shown a properties table before. + layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: fullRange) let yellow = NSColor.systemYellow.withAlphaComponent(0.55) let orange = NSColor.systemOrange @@ -71,12 +80,38 @@ func applyNSSearchHighlight(in textView: NSTextView, term: String, focusedOccurr for (occurrenceIndex, found) in searchMatchRanges(in: storage.string, term: term).enumerated() { let safeRange = NSIntersectionRange(found, fullRange) if safeRange.length > 0 { - let color = (focusedOccurrence == occurrenceIndex) ? orange : yellow - layoutManager.addTemporaryAttribute(.backgroundColor, value: color, forCharacterRange: safeRange) + let focused = focusedOccurrence == occurrenceIndex + layoutManager.addTemporaryAttribute(.backgroundColor, value: focused ? orange : yellow, + forCharacterRange: safeRange) + if focused, let focusedText { + for range in focusedText.ranges { + let part = NSIntersectionRange(safeRange, range) + guard part.length > 0 else { continue } + layoutManager.addTemporaryAttribute(.foregroundColor, value: focusedText.color, + forCharacterRange: part) + } + } } } } +extension NSTextView { + /// Where the `occurrence`-th match of `term` is in this view's text (view + /// coordinates) — the matcher its highlight draws with, so the index is + /// the block's focused occurrence. Nil past the last match. + func searchMatchRect(term: String, occurrence: Int) -> NSRect? { + guard let layoutManager, let textContainer, let storage = textStorage else { return nil } + let ranges = searchMatchRanges(in: storage.string, term: term) + guard occurrence >= 0, occurrence < ranges.count else { return nil } + let range = NSIntersectionRange(ranges[occurrence], NSRange(location: 0, length: storage.length)) + guard range.length > 0 else { return nil } + let glyphs = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + layoutManager.ensureLayout(forGlyphRange: glyphs) + let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) + return rect.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) + } +} + // MARK: - Document Search /// Pure search-match computation over parsed blocks. Free of any UI state so @@ -120,6 +155,20 @@ enum DocumentSearch { trigger == .termChanged && matchCount > 0 } + /// What a ⌘G / ⇧⌘G step that moved the focus from `oldBlockId` to + /// `newBlockId` scrolls: the new block into view, or — the same block — + /// the new match within it (which moves only a block taller than the + /// window). Nil without a focus. + enum StepScroll: Equatable { + case toBlock + case withinBlock + } + + static func stepScroll(from oldBlockId: String?, to newBlockId: String?) -> StepScroll? { + guard let newBlockId else { return nil } + return newBlockId == oldBlockId ? .withinBlock : .toBlock + } + /// The focused block and the occurrence within it that the block's /// highlight paints orange: match `index` is the k-th entry of its block /// in `matchBlockIds` (one entry per occurrence, reading order). Nil when @@ -264,8 +313,9 @@ enum DocumentSearch { if let shown = converted[block.id] { return shown.string } return String(renderer.renderQuotedBody(content).characters) case .heading(let level, let title, _): - // Never measured into `converted` (a `.reported` row): rendered + // The measurer keeps the title it measured; without it, rendered // here exactly as `HeadingBlockView` renders it. + if let shown = converted[block.id] { return shown.string } return String(renderer.renderHeader(title, level: level).characters) case .table, .codeBlock, .image, .svgImage, .mathBlock, .mermaidDiagram: return nil diff --git a/QuickMD/QuickMD/DocumentSelection.swift b/QuickMD/QuickMD/DocumentSelection.swift index ae164d1..b7ae888 100644 --- a/QuickMD/QuickMD/DocumentSelection.swift +++ b/QuickMD/QuickMD/DocumentSelection.swift @@ -329,14 +329,33 @@ enum DocumentCopyBuilder { /// Rows are joined with one blank line, like Markdown paragraphs. static let rowSeparator = "\n\n" + /// A row that only continues the row before it (`MarkdownBlock + /// .joinsPrevious`) is joined with the line break the cut replaced. + static let continuationSeparator = "\n" + + /// One covered row's piece, and whether its row continues the row of the + /// piece before it (`SelectionRows.parts`). + struct Part { + let piece: SelectionPiece + let continuesPrevious: Bool + } + /// Pieces of separate blocks: every row joined with `rowSeparator`. static func build(_ pieces: [SelectionPiece]) -> DocumentCopyOutput { + build(parts: pieces.map { Part(piece: $0, continuesPrevious: false) }) + } + + static func build(parts: [Part]) -> DocumentCopyOutput { let result = NSMutableAttributedString() - for piece in pieces { - let part = attributed(for: piece) - guard part.length > 0 else { continue } - if result.length > 0 { result.append(NSAttributedString(string: rowSeparator)) } - result.append(part) + let rowBreak = NSAttributedString(string: rowSeparator) + let lineBreak = NSAttributedString(string: continuationSeparator) + for part in parts { + let text = attributed(for: part.piece) + guard text.length > 0 else { continue } + if result.length > 0 { + result.append(part.continuesPrevious ? lineBreak : rowBreak) + } + result.append(text) } let full = NSRange(location: 0, length: result.length) // Colours come from the theme the document is displayed in, not from @@ -352,6 +371,7 @@ enum DocumentCopyBuilder { case .text(let text): let cleaned = replacingAttachments(in: text) trimBlankLines(cleaned) + emptyWhitespaceLines(cleaned) collapseBlankLineRuns(cleaned) return cleaned case .code(let code): @@ -386,9 +406,13 @@ enum DocumentCopyBuilder { } /// One TSV field: a tab or line break inside a cell would split it, so - /// those become spaces. + /// those become spaces — except U+2028, which only a front-matter list + /// puts in a cell (one item per line because an item holds a comma, + /// `FrontMatter.joinedItems`): a space would run "Doe, Jane" and "Smith" + /// together again, so those items are joined with "; ". private static func tsvField(_ cell: String) -> String { - cell.components(separatedBy: CharacterSet(charactersIn: "\t\n\r\u{2028}\u{2029}")) + cell.replacingOccurrences(of: FrontMatter.itemLineSeparator, with: "; ") + .components(separatedBy: CharacterSet(charactersIn: "\t\n\r\u{2029}")) .joined(separator: " ") } @@ -468,6 +492,22 @@ enum DocumentCopyBuilder { } } + private static let whitespaceLine = try? NSRegularExpression(pattern: "^[ \t]+$", + options: .anchorsMatchLines) + + /// A line of nothing but spaces and tabs becomes an empty line: a quote + /// or alert body draws a blank line as one space so it keeps its height + /// (`renderQuotedBody`), and a paragraph break must paste as an empty + /// line, not as a space. Only lines between others are left by + /// `trimBlankLines`; a line of no-break spaces is text and stays. + private static func emptyWhitespaceLines(_ text: NSMutableAttributedString) { + guard let whitespaceLine else { return } + let matches = whitespaceLine.matches(in: text.string, range: NSRange(location: 0, length: text.length)) + for match in matches.reversed() { + text.replaceCharacters(in: match.range, with: "") + } + } + private static let blankLineRun = try? NSRegularExpression(pattern: "\n{3,}") /// Three or more consecutive line breaks become two: a renderer chunk diff --git a/QuickMD/QuickMD/FileWatchManager.swift b/QuickMD/QuickMD/FileWatchManager.swift index d59015d..ad26fbe 100644 --- a/QuickMD/QuickMD/FileWatchManager.swift +++ b/QuickMD/QuickMD/FileWatchManager.swift @@ -17,9 +17,11 @@ import Foundation /// (debounced) change. `onFileMissing` fires once per disappearance, never per /// poll. Polling happens only while the path is ABSENT (ENOENT / ENOTDIR): a /// path that exists but cannot be opened or seen (EACCES / EPERM — a sandbox -/// or permission denial) would fail the same way every second for the life of -/// the tab, so the watcher then stays missing without polling, as it always -/// did before polling existed. +/// or permission denial, `chmod 000`) would fail the same way every second for +/// the life of the tab, so the watcher then reports `onFileUnreadable` instead +/// and does not poll (⌘R re-arms it). Until 1.14.4 that case was reported as +/// missing, and the window said "File no longer exists" about a file that is +/// right there. /// /// All callbacks fire on the main queue (the DispatchSource and the debounce /// both target `.main`). Changes are debounced 250 ms so an editor that @@ -39,6 +41,11 @@ final class FileWatcher { /// follows. var onFileMissing: (() -> Void)? + /// Fired when the file is at its path but cannot be opened (EACCES / + /// EPERM) — at start, after an atomic replace, or when a missing file + /// comes back without read permission. Not polled. + var onFileUnreadable: (() -> Void)? + private var source: DispatchSourceFileSystemObject? private var url: URL? private var debounce: DispatchWorkItem? @@ -94,10 +101,15 @@ final class FileWatcher { error == ENOENT || error == ENOTDIR } - /// Reports the disappearance ONCE; the poll that follows is silent. + /// Reports the disappearance ONCE; the poll that follows is silent. A file + /// that is there but off-limits is reported as unreadable, not missing. private func fileWentMissing(_ error: Int32) { + guard Self.isAbsence(error) else { + onFileUnreadable?() + return + } onFileMissing?() - if Self.isAbsence(error) { startMissingPoll() } + startMissingPoll() } private func startMissingPoll() { @@ -122,8 +134,11 @@ final class FileWatcher { guard let url else { return stopMissingPoll() } if access(url.path, F_OK) != 0 { // Still absent: keep waiting. Not visible for another reason - // (a parent folder became unreadable): give up, stay missing. - if !Self.isAbsence(errno) { stopMissingPoll() } + // (a parent folder became unreadable): give up, say so. + if !Self.isAbsence(errno) { + stopMissingPoll() + onFileUnreadable?() + } return } let error = arm() @@ -132,8 +147,9 @@ final class FileWatcher { scheduleChange() } else if !Self.isAbsence(error) { // Back, but cannot be opened (EACCES / EPERM): retrying every - // second would never succeed — stay missing. + // second would never succeed — stop, and say it is unreadable. stopMissingPoll() + onFileUnreadable?() } // Absent again between `access` and `open`: already reported, keep polling. } @@ -156,6 +172,21 @@ final class FileWatcher { } } + // MARK: - What the window says + + /// The banner a document window shows for a file the watcher lost. + enum Problem: Equatable { + case missing, unreadable + + func bannerText(path: String?) -> String { + let location = path ?? "this location" + switch self { + case .missing: return "File no longer exists at \(location)" + case .unreadable: return "File can\u{2019}t be read at \(location)" + } + } + } + private func scheduleChange() { debounce?.cancel() let work = DispatchWorkItem { [weak self] in diff --git a/QuickMD/QuickMD/FrontMatter.swift b/QuickMD/QuickMD/FrontMatter.swift index 347faeb..06e4338 100644 --- a/QuickMD/QuickMD/FrontMatter.swift +++ b/QuickMD/QuickMD/FrontMatter.swift @@ -26,8 +26,9 @@ enum FrontMatter { /// (or before a trailing `:`), spaces allowed, never starting with a YAML /// indicator. /// - `key:` followed by `- item` lines (indented or at column 0, one - /// indentation per list) → the items joined with ", "; `key:` alone → "". - /// - `key: [a, b, "c, d"]` → "a, b, c, d"; `[]` → "". + /// indentation per list) → the items joined (`joinedItems`); `key:` + /// alone → "". + /// - `key: [a, b]` → "a, b"; `[a, "c, d"]` → one item per line; `[]` → "". /// - `"…"` unescapes only `\"` and `\\`; `'…'` turns `''` into `'` (a `#` /// inside quotes is text); plain values are trimmed. /// - A comment anywhere in a value (` #`, or a value starting with `#`) → @@ -42,7 +43,7 @@ enum FrontMatter { func closeOpenKey() { guard let open = openKey else { return } - properties[open.index].value = open.items.joined(separator: ", ") + properties[open.index].value = joinedItems(open.items) openKey = nil } @@ -88,6 +89,19 @@ enum FrontMatter { // MARK: - Pieces + /// Separates list items when one of them holds a comma: U+2028 LINE + /// SEPARATOR, a line break INSIDE the value's paragraph — a table cell is + /// exactly one paragraph (`TableTextBuilder`, TS-D4), which a "\n" would + /// split (and `renderedCell` turns "\n" into a space). + static let itemLineSeparator = "\u{2028}" + + /// A list's items as one value: ", " between them — unless an item itself + /// contains a comma, then `"a, b"` and `c` would read as three items, so + /// each item gets its own line (`itemLineSeparator`). + static func joinedItems(_ items: [String]) -> String { + items.joined(separator: items.contains { $0.contains(",") } ? itemLineSeparator : ", ") + } + /// Characters that make a line or value something other than a plain /// scalar / plain key (YAML indicators). A key starting with one is out of /// the subset. @@ -227,7 +241,7 @@ enum FrontMatter { skipSpaces() guard index < text.endIndex else { return nil } if text[index] == "]" { - return isNothingAfter(text[text.index(after: index)...]) ? items.joined(separator: ", ") : nil + return isNothingAfter(text[text.index(after: index)...]) ? joinedItems(items) : nil } guard text[index] == "," else { return nil } index = text.index(after: index) diff --git a/QuickMD/QuickMD/HTMLEntities.swift b/QuickMD/QuickMD/HTMLEntities.swift new file mode 100644 index 0000000..b3085d4 --- /dev/null +++ b/QuickMD/QuickMD/HTMLEntities.swift @@ -0,0 +1,96 @@ +import Foundation + +// MARK: - HTML character references in Markdown text + +/// Decodes HTML character references the way CommonMark (and so GitHub) does +/// in inline text: `AT&T` → "AT&T", `©` → "©", `→` → "→". +/// +/// - Named: `&name;` for every HTML5 entity (`HTMLEntityTable`, generated); +/// the semicolon is required (`&` stays as written), unknown names stay. +/// - Decimal `&#N;` (1–7 digits) and hex `&#xH;` / `&#XH;` (1–6 digits); a +/// code point of 0, a surrogate or one above U+10FFFF becomes U+FFFD. +/// +/// The renderer applies it to text AFTER inline parsing (`*` is a literal +/// `*`, never emphasis) and never to code spans or code blocks. Pure and +/// thread-safe: the parse task, the measurer and the main thread all call it. +enum HTMLEntities { + + /// Name (without `&` and `;`) → replacement, built once from the table. + private static let named: [String: String] = { + var table: [String: String] = [:] + table.reserveCapacity(2_200) + for line in HTMLEntityTable.source.split(separator: "\n") { + guard let colon = line.firstIndex(of: ":") else { continue } + var value = String.UnicodeScalarView() + for hex in line[line.index(after: colon)...].split(separator: ",") { + if let code = UInt32(hex, radix: 16), let scalar = Unicode.Scalar(code) { value.append(scalar) } + } + table[String(line[.. String { + guard text.utf8.contains(UInt8(ascii: "&")) else { return text } + let scalars = text.unicodeScalars + var out = String.UnicodeScalarView() + var index = scalars.startIndex + while index < scalars.endIndex { + if scalars[index] == "&", let (replacement, next) = reference(in: scalars, at: index) { + out.append(contentsOf: replacement.unicodeScalars) + index = next + } else { + out.append(scalars[index]) + index = scalars.index(after: index) + } + } + return String(out) + } + + /// The reference at `start` (an `&`): its replacement and the index past + /// its `;`, or nil when the text there is not a valid reference. + private static func reference(in scalars: String.UnicodeScalarView, + at start: String.Index) -> (String, String.Index)? { + var index = scalars.index(after: start) + guard index < scalars.endIndex else { return nil } + + if scalars[index] == "#" { + index = scalars.index(after: index) + guard index < scalars.endIndex else { return nil } + let hex = scalars[index] == "x" || scalars[index] == "X" + if hex { index = scalars.index(after: index) } + var digits = "" + while index < scalars.endIndex, digits.unicodeScalars.count <= (hex ? 6 : 7) { + let scalar = scalars[index] + let isDigit = hex ? scalar.properties.isASCIIHexDigit : ("0"..."9").contains(scalar) + guard isDigit else { break } + digits.unicodeScalars.append(scalar) + index = scalars.index(after: index) + } + guard !digits.isEmpty, digits.unicodeScalars.count <= (hex ? 6 : 7), + index < scalars.endIndex, scalars[index] == ";", + let value = UInt32(digits, radix: hex ? 16 : 10) else { return nil } + let scalar = value == 0 ? nil : Unicode.Scalar(value) // nil for surrogates and > U+10FFFF + return (String(scalar ?? "\u{FFFD}"), scalars.index(after: index)) + } + + var name = "" + while index < scalars.endIndex, name.unicodeScalars.count <= maxNameLength { + let scalar = scalars[index] + if scalar == ";" { + guard let value = named[name] else { return nil } + return (value, scalars.index(after: index)) + } + guard scalar.isASCII, scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) else { return nil } + name.unicodeScalars.append(scalar) + index = scalars.index(after: index) + } + return nil + } +} diff --git a/QuickMD/QuickMD/HTMLEntityTable.swift b/QuickMD/QuickMD/HTMLEntityTable.swift new file mode 100644 index 0000000..c5a480c --- /dev/null +++ b/QuickMD/QuickMD/HTMLEntityTable.swift @@ -0,0 +1,2144 @@ +// +// HTMLEntityTable.swift +// QuickMD +// +// GENERATED — do not edit by hand. Every HTML5 named character reference +// that ends in ";" (the only form CommonMark recognises), one per line as +// "name:HEX[,HEX]" (the code points it stands for). The lines between the +// quotes are exactly the output of (Python 3.12.10, stdlib only): +// +// python3 -I -c 'import html.entities as h; [print(k[:-1] + ":" + ",".join("%X" % ord(c) for c in v)) for k, v in sorted(h.html5.items()) if k.endswith(";")]' +// +// A single string parsed once on first use (`HTMLEntities`) compiles in a +// fraction of the time a 2,000-entry dictionary literal takes. +// + +enum HTMLEntityTable { + static let source = """ +AElig:C6 +AMP:26 +Aacute:C1 +Abreve:102 +Acirc:C2 +Acy:410 +Afr:1D504 +Agrave:C0 +Alpha:391 +Amacr:100 +And:2A53 +Aogon:104 +Aopf:1D538 +ApplyFunction:2061 +Aring:C5 +Ascr:1D49C +Assign:2254 +Atilde:C3 +Auml:C4 +Backslash:2216 +Barv:2AE7 +Barwed:2306 +Bcy:411 +Because:2235 +Bernoullis:212C +Beta:392 +Bfr:1D505 +Bopf:1D539 +Breve:2D8 +Bscr:212C +Bumpeq:224E +CHcy:427 +COPY:A9 +Cacute:106 +Cap:22D2 +CapitalDifferentialD:2145 +Cayleys:212D +Ccaron:10C +Ccedil:C7 +Ccirc:108 +Cconint:2230 +Cdot:10A +Cedilla:B8 +CenterDot:B7 +Cfr:212D +Chi:3A7 +CircleDot:2299 +CircleMinus:2296 +CirclePlus:2295 +CircleTimes:2297 +ClockwiseContourIntegral:2232 +CloseCurlyDoubleQuote:201D +CloseCurlyQuote:2019 +Colon:2237 +Colone:2A74 +Congruent:2261 +Conint:222F +ContourIntegral:222E +Copf:2102 +Coproduct:2210 +CounterClockwiseContourIntegral:2233 +Cross:2A2F +Cscr:1D49E +Cup:22D3 +CupCap:224D +DD:2145 +DDotrahd:2911 +DJcy:402 +DScy:405 +DZcy:40F +Dagger:2021 +Darr:21A1 +Dashv:2AE4 +Dcaron:10E +Dcy:414 +Del:2207 +Delta:394 +Dfr:1D507 +DiacriticalAcute:B4 +DiacriticalDot:2D9 +DiacriticalDoubleAcute:2DD +DiacriticalGrave:60 +DiacriticalTilde:2DC +Diamond:22C4 +DifferentialD:2146 +Dopf:1D53B +Dot:A8 +DotDot:20DC +DotEqual:2250 +DoubleContourIntegral:222F +DoubleDot:A8 +DoubleDownArrow:21D3 +DoubleLeftArrow:21D0 +DoubleLeftRightArrow:21D4 +DoubleLeftTee:2AE4 +DoubleLongLeftArrow:27F8 +DoubleLongLeftRightArrow:27FA +DoubleLongRightArrow:27F9 +DoubleRightArrow:21D2 +DoubleRightTee:22A8 +DoubleUpArrow:21D1 +DoubleUpDownArrow:21D5 +DoubleVerticalBar:2225 +DownArrow:2193 +DownArrowBar:2913 +DownArrowUpArrow:21F5 +DownBreve:311 +DownLeftRightVector:2950 +DownLeftTeeVector:295E +DownLeftVector:21BD +DownLeftVectorBar:2956 +DownRightTeeVector:295F +DownRightVector:21C1 +DownRightVectorBar:2957 +DownTee:22A4 +DownTeeArrow:21A7 +Downarrow:21D3 +Dscr:1D49F +Dstrok:110 +ENG:14A +ETH:D0 +Eacute:C9 +Ecaron:11A +Ecirc:CA +Ecy:42D +Edot:116 +Efr:1D508 +Egrave:C8 +Element:2208 +Emacr:112 +EmptySmallSquare:25FB +EmptyVerySmallSquare:25AB +Eogon:118 +Eopf:1D53C +Epsilon:395 +Equal:2A75 +EqualTilde:2242 +Equilibrium:21CC +Escr:2130 +Esim:2A73 +Eta:397 +Euml:CB +Exists:2203 +ExponentialE:2147 +Fcy:424 +Ffr:1D509 +FilledSmallSquare:25FC +FilledVerySmallSquare:25AA +Fopf:1D53D +ForAll:2200 +Fouriertrf:2131 +Fscr:2131 +GJcy:403 +GT:3E +Gamma:393 +Gammad:3DC +Gbreve:11E +Gcedil:122 +Gcirc:11C +Gcy:413 +Gdot:120 +Gfr:1D50A +Gg:22D9 +Gopf:1D53E +GreaterEqual:2265 +GreaterEqualLess:22DB +GreaterFullEqual:2267 +GreaterGreater:2AA2 +GreaterLess:2277 +GreaterSlantEqual:2A7E +GreaterTilde:2273 +Gscr:1D4A2 +Gt:226B +HARDcy:42A +Hacek:2C7 +Hat:5E +Hcirc:124 +Hfr:210C +HilbertSpace:210B +Hopf:210D +HorizontalLine:2500 +Hscr:210B +Hstrok:126 +HumpDownHump:224E +HumpEqual:224F +IEcy:415 +IJlig:132 +IOcy:401 +Iacute:CD +Icirc:CE +Icy:418 +Idot:130 +Ifr:2111 +Igrave:CC +Im:2111 +Imacr:12A +ImaginaryI:2148 +Implies:21D2 +Int:222C +Integral:222B +Intersection:22C2 +InvisibleComma:2063 +InvisibleTimes:2062 +Iogon:12E +Iopf:1D540 +Iota:399 +Iscr:2110 +Itilde:128 +Iukcy:406 +Iuml:CF +Jcirc:134 +Jcy:419 +Jfr:1D50D +Jopf:1D541 +Jscr:1D4A5 +Jsercy:408 +Jukcy:404 +KHcy:425 +KJcy:40C +Kappa:39A +Kcedil:136 +Kcy:41A +Kfr:1D50E +Kopf:1D542 +Kscr:1D4A6 +LJcy:409 +LT:3C +Lacute:139 +Lambda:39B +Lang:27EA +Laplacetrf:2112 +Larr:219E +Lcaron:13D +Lcedil:13B +Lcy:41B +LeftAngleBracket:27E8 +LeftArrow:2190 +LeftArrowBar:21E4 +LeftArrowRightArrow:21C6 +LeftCeiling:2308 +LeftDoubleBracket:27E6 +LeftDownTeeVector:2961 +LeftDownVector:21C3 +LeftDownVectorBar:2959 +LeftFloor:230A +LeftRightArrow:2194 +LeftRightVector:294E +LeftTee:22A3 +LeftTeeArrow:21A4 +LeftTeeVector:295A +LeftTriangle:22B2 +LeftTriangleBar:29CF +LeftTriangleEqual:22B4 +LeftUpDownVector:2951 +LeftUpTeeVector:2960 +LeftUpVector:21BF +LeftUpVectorBar:2958 +LeftVector:21BC +LeftVectorBar:2952 +Leftarrow:21D0 +Leftrightarrow:21D4 +LessEqualGreater:22DA +LessFullEqual:2266 +LessGreater:2276 +LessLess:2AA1 +LessSlantEqual:2A7D +LessTilde:2272 +Lfr:1D50F +Ll:22D8 +Lleftarrow:21DA +Lmidot:13F +LongLeftArrow:27F5 +LongLeftRightArrow:27F7 +LongRightArrow:27F6 +Longleftarrow:27F8 +Longleftrightarrow:27FA +Longrightarrow:27F9 +Lopf:1D543 +LowerLeftArrow:2199 +LowerRightArrow:2198 +Lscr:2112 +Lsh:21B0 +Lstrok:141 +Lt:226A +Map:2905 +Mcy:41C +MediumSpace:205F +Mellintrf:2133 +Mfr:1D510 +MinusPlus:2213 +Mopf:1D544 +Mscr:2133 +Mu:39C +NJcy:40A +Nacute:143 +Ncaron:147 +Ncedil:145 +Ncy:41D +NegativeMediumSpace:200B +NegativeThickSpace:200B +NegativeThinSpace:200B +NegativeVeryThinSpace:200B +NestedGreaterGreater:226B +NestedLessLess:226A +NewLine:A +Nfr:1D511 +NoBreak:2060 +NonBreakingSpace:A0 +Nopf:2115 +Not:2AEC +NotCongruent:2262 +NotCupCap:226D +NotDoubleVerticalBar:2226 +NotElement:2209 +NotEqual:2260 +NotEqualTilde:2242,338 +NotExists:2204 +NotGreater:226F +NotGreaterEqual:2271 +NotGreaterFullEqual:2267,338 +NotGreaterGreater:226B,338 +NotGreaterLess:2279 +NotGreaterSlantEqual:2A7E,338 +NotGreaterTilde:2275 +NotHumpDownHump:224E,338 +NotHumpEqual:224F,338 +NotLeftTriangle:22EA +NotLeftTriangleBar:29CF,338 +NotLeftTriangleEqual:22EC +NotLess:226E +NotLessEqual:2270 +NotLessGreater:2278 +NotLessLess:226A,338 +NotLessSlantEqual:2A7D,338 +NotLessTilde:2274 +NotNestedGreaterGreater:2AA2,338 +NotNestedLessLess:2AA1,338 +NotPrecedes:2280 +NotPrecedesEqual:2AAF,338 +NotPrecedesSlantEqual:22E0 +NotReverseElement:220C +NotRightTriangle:22EB +NotRightTriangleBar:29D0,338 +NotRightTriangleEqual:22ED +NotSquareSubset:228F,338 +NotSquareSubsetEqual:22E2 +NotSquareSuperset:2290,338 +NotSquareSupersetEqual:22E3 +NotSubset:2282,20D2 +NotSubsetEqual:2288 +NotSucceeds:2281 +NotSucceedsEqual:2AB0,338 +NotSucceedsSlantEqual:22E1 +NotSucceedsTilde:227F,338 +NotSuperset:2283,20D2 +NotSupersetEqual:2289 +NotTilde:2241 +NotTildeEqual:2244 +NotTildeFullEqual:2247 +NotTildeTilde:2249 +NotVerticalBar:2224 +Nscr:1D4A9 +Ntilde:D1 +Nu:39D +OElig:152 +Oacute:D3 +Ocirc:D4 +Ocy:41E +Odblac:150 +Ofr:1D512 +Ograve:D2 +Omacr:14C +Omega:3A9 +Omicron:39F +Oopf:1D546 +OpenCurlyDoubleQuote:201C +OpenCurlyQuote:2018 +Or:2A54 +Oscr:1D4AA +Oslash:D8 +Otilde:D5 +Otimes:2A37 +Ouml:D6 +OverBar:203E +OverBrace:23DE +OverBracket:23B4 +OverParenthesis:23DC +PartialD:2202 +Pcy:41F +Pfr:1D513 +Phi:3A6 +Pi:3A0 +PlusMinus:B1 +Poincareplane:210C +Popf:2119 +Pr:2ABB +Precedes:227A +PrecedesEqual:2AAF +PrecedesSlantEqual:227C +PrecedesTilde:227E +Prime:2033 +Product:220F +Proportion:2237 +Proportional:221D +Pscr:1D4AB +Psi:3A8 +QUOT:22 +Qfr:1D514 +Qopf:211A +Qscr:1D4AC +RBarr:2910 +REG:AE +Racute:154 +Rang:27EB +Rarr:21A0 +Rarrtl:2916 +Rcaron:158 +Rcedil:156 +Rcy:420 +Re:211C +ReverseElement:220B +ReverseEquilibrium:21CB +ReverseUpEquilibrium:296F +Rfr:211C +Rho:3A1 +RightAngleBracket:27E9 +RightArrow:2192 +RightArrowBar:21E5 +RightArrowLeftArrow:21C4 +RightCeiling:2309 +RightDoubleBracket:27E7 +RightDownTeeVector:295D +RightDownVector:21C2 +RightDownVectorBar:2955 +RightFloor:230B +RightTee:22A2 +RightTeeArrow:21A6 +RightTeeVector:295B +RightTriangle:22B3 +RightTriangleBar:29D0 +RightTriangleEqual:22B5 +RightUpDownVector:294F +RightUpTeeVector:295C +RightUpVector:21BE +RightUpVectorBar:2954 +RightVector:21C0 +RightVectorBar:2953 +Rightarrow:21D2 +Ropf:211D +RoundImplies:2970 +Rrightarrow:21DB +Rscr:211B +Rsh:21B1 +RuleDelayed:29F4 +SHCHcy:429 +SHcy:428 +SOFTcy:42C +Sacute:15A +Sc:2ABC +Scaron:160 +Scedil:15E +Scirc:15C +Scy:421 +Sfr:1D516 +ShortDownArrow:2193 +ShortLeftArrow:2190 +ShortRightArrow:2192 +ShortUpArrow:2191 +Sigma:3A3 +SmallCircle:2218 +Sopf:1D54A +Sqrt:221A +Square:25A1 +SquareIntersection:2293 +SquareSubset:228F +SquareSubsetEqual:2291 +SquareSuperset:2290 +SquareSupersetEqual:2292 +SquareUnion:2294 +Sscr:1D4AE +Star:22C6 +Sub:22D0 +Subset:22D0 +SubsetEqual:2286 +Succeeds:227B +SucceedsEqual:2AB0 +SucceedsSlantEqual:227D +SucceedsTilde:227F +SuchThat:220B +Sum:2211 +Sup:22D1 +Superset:2283 +SupersetEqual:2287 +Supset:22D1 +THORN:DE +TRADE:2122 +TSHcy:40B +TScy:426 +Tab:9 +Tau:3A4 +Tcaron:164 +Tcedil:162 +Tcy:422 +Tfr:1D517 +Therefore:2234 +Theta:398 +ThickSpace:205F,200A +ThinSpace:2009 +Tilde:223C +TildeEqual:2243 +TildeFullEqual:2245 +TildeTilde:2248 +Topf:1D54B +TripleDot:20DB +Tscr:1D4AF +Tstrok:166 +Uacute:DA +Uarr:219F +Uarrocir:2949 +Ubrcy:40E +Ubreve:16C +Ucirc:DB +Ucy:423 +Udblac:170 +Ufr:1D518 +Ugrave:D9 +Umacr:16A +UnderBar:5F +UnderBrace:23DF +UnderBracket:23B5 +UnderParenthesis:23DD +Union:22C3 +UnionPlus:228E +Uogon:172 +Uopf:1D54C +UpArrow:2191 +UpArrowBar:2912 +UpArrowDownArrow:21C5 +UpDownArrow:2195 +UpEquilibrium:296E +UpTee:22A5 +UpTeeArrow:21A5 +Uparrow:21D1 +Updownarrow:21D5 +UpperLeftArrow:2196 +UpperRightArrow:2197 +Upsi:3D2 +Upsilon:3A5 +Uring:16E +Uscr:1D4B0 +Utilde:168 +Uuml:DC +VDash:22AB +Vbar:2AEB +Vcy:412 +Vdash:22A9 +Vdashl:2AE6 +Vee:22C1 +Verbar:2016 +Vert:2016 +VerticalBar:2223 +VerticalLine:7C +VerticalSeparator:2758 +VerticalTilde:2240 +VeryThinSpace:200A +Vfr:1D519 +Vopf:1D54D +Vscr:1D4B1 +Vvdash:22AA +Wcirc:174 +Wedge:22C0 +Wfr:1D51A +Wopf:1D54E +Wscr:1D4B2 +Xfr:1D51B +Xi:39E +Xopf:1D54F +Xscr:1D4B3 +YAcy:42F +YIcy:407 +YUcy:42E +Yacute:DD +Ycirc:176 +Ycy:42B +Yfr:1D51C +Yopf:1D550 +Yscr:1D4B4 +Yuml:178 +ZHcy:416 +Zacute:179 +Zcaron:17D +Zcy:417 +Zdot:17B +ZeroWidthSpace:200B +Zeta:396 +Zfr:2128 +Zopf:2124 +Zscr:1D4B5 +aacute:E1 +abreve:103 +ac:223E +acE:223E,333 +acd:223F +acirc:E2 +acute:B4 +acy:430 +aelig:E6 +af:2061 +afr:1D51E +agrave:E0 +alefsym:2135 +aleph:2135 +alpha:3B1 +amacr:101 +amalg:2A3F +amp:26 +and:2227 +andand:2A55 +andd:2A5C +andslope:2A58 +andv:2A5A +ang:2220 +ange:29A4 +angle:2220 +angmsd:2221 +angmsdaa:29A8 +angmsdab:29A9 +angmsdac:29AA +angmsdad:29AB +angmsdae:29AC +angmsdaf:29AD +angmsdag:29AE +angmsdah:29AF +angrt:221F +angrtvb:22BE +angrtvbd:299D +angsph:2222 +angst:C5 +angzarr:237C +aogon:105 +aopf:1D552 +ap:2248 +apE:2A70 +apacir:2A6F +ape:224A +apid:224B +apos:27 +approx:2248 +approxeq:224A +aring:E5 +ascr:1D4B6 +ast:2A +asymp:2248 +asympeq:224D +atilde:E3 +auml:E4 +awconint:2233 +awint:2A11 +bNot:2AED +backcong:224C +backepsilon:3F6 +backprime:2035 +backsim:223D +backsimeq:22CD +barvee:22BD +barwed:2305 +barwedge:2305 +bbrk:23B5 +bbrktbrk:23B6 +bcong:224C +bcy:431 +bdquo:201E +becaus:2235 +because:2235 +bemptyv:29B0 +bepsi:3F6 +bernou:212C +beta:3B2 +beth:2136 +between:226C +bfr:1D51F +bigcap:22C2 +bigcirc:25EF +bigcup:22C3 +bigodot:2A00 +bigoplus:2A01 +bigotimes:2A02 +bigsqcup:2A06 +bigstar:2605 +bigtriangledown:25BD +bigtriangleup:25B3 +biguplus:2A04 +bigvee:22C1 +bigwedge:22C0 +bkarow:290D +blacklozenge:29EB +blacksquare:25AA +blacktriangle:25B4 +blacktriangledown:25BE +blacktriangleleft:25C2 +blacktriangleright:25B8 +blank:2423 +blk12:2592 +blk14:2591 +blk34:2593 +block:2588 +bne:3D,20E5 +bnequiv:2261,20E5 +bnot:2310 +bopf:1D553 +bot:22A5 +bottom:22A5 +bowtie:22C8 +boxDL:2557 +boxDR:2554 +boxDl:2556 +boxDr:2553 +boxH:2550 +boxHD:2566 +boxHU:2569 +boxHd:2564 +boxHu:2567 +boxUL:255D +boxUR:255A +boxUl:255C +boxUr:2559 +boxV:2551 +boxVH:256C +boxVL:2563 +boxVR:2560 +boxVh:256B +boxVl:2562 +boxVr:255F +boxbox:29C9 +boxdL:2555 +boxdR:2552 +boxdl:2510 +boxdr:250C +boxh:2500 +boxhD:2565 +boxhU:2568 +boxhd:252C +boxhu:2534 +boxminus:229F +boxplus:229E +boxtimes:22A0 +boxuL:255B +boxuR:2558 +boxul:2518 +boxur:2514 +boxv:2502 +boxvH:256A +boxvL:2561 +boxvR:255E +boxvh:253C +boxvl:2524 +boxvr:251C +bprime:2035 +breve:2D8 +brvbar:A6 +bscr:1D4B7 +bsemi:204F +bsim:223D +bsime:22CD +bsol:5C +bsolb:29C5 +bsolhsub:27C8 +bull:2022 +bullet:2022 +bump:224E +bumpE:2AAE +bumpe:224F +bumpeq:224F +cacute:107 +cap:2229 +capand:2A44 +capbrcup:2A49 +capcap:2A4B +capcup:2A47 +capdot:2A40 +caps:2229,FE00 +caret:2041 +caron:2C7 +ccaps:2A4D +ccaron:10D +ccedil:E7 +ccirc:109 +ccups:2A4C +ccupssm:2A50 +cdot:10B +cedil:B8 +cemptyv:29B2 +cent:A2 +centerdot:B7 +cfr:1D520 +chcy:447 +check:2713 +checkmark:2713 +chi:3C7 +cir:25CB +cirE:29C3 +circ:2C6 +circeq:2257 +circlearrowleft:21BA +circlearrowright:21BB +circledR:AE +circledS:24C8 +circledast:229B +circledcirc:229A +circleddash:229D +cire:2257 +cirfnint:2A10 +cirmid:2AEF +cirscir:29C2 +clubs:2663 +clubsuit:2663 +colon:3A +colone:2254 +coloneq:2254 +comma:2C +commat:40 +comp:2201 +compfn:2218 +complement:2201 +complexes:2102 +cong:2245 +congdot:2A6D +conint:222E +copf:1D554 +coprod:2210 +copy:A9 +copysr:2117 +crarr:21B5 +cross:2717 +cscr:1D4B8 +csub:2ACF +csube:2AD1 +csup:2AD0 +csupe:2AD2 +ctdot:22EF +cudarrl:2938 +cudarrr:2935 +cuepr:22DE +cuesc:22DF +cularr:21B6 +cularrp:293D +cup:222A +cupbrcap:2A48 +cupcap:2A46 +cupcup:2A4A +cupdot:228D +cupor:2A45 +cups:222A,FE00 +curarr:21B7 +curarrm:293C +curlyeqprec:22DE +curlyeqsucc:22DF +curlyvee:22CE +curlywedge:22CF +curren:A4 +curvearrowleft:21B6 +curvearrowright:21B7 +cuvee:22CE +cuwed:22CF +cwconint:2232 +cwint:2231 +cylcty:232D +dArr:21D3 +dHar:2965 +dagger:2020 +daleth:2138 +darr:2193 +dash:2010 +dashv:22A3 +dbkarow:290F +dblac:2DD +dcaron:10F +dcy:434 +dd:2146 +ddagger:2021 +ddarr:21CA +ddotseq:2A77 +deg:B0 +delta:3B4 +demptyv:29B1 +dfisht:297F +dfr:1D521 +dharl:21C3 +dharr:21C2 +diam:22C4 +diamond:22C4 +diamondsuit:2666 +diams:2666 +die:A8 +digamma:3DD +disin:22F2 +div:F7 +divide:F7 +divideontimes:22C7 +divonx:22C7 +djcy:452 +dlcorn:231E +dlcrop:230D +dollar:24 +dopf:1D555 +dot:2D9 +doteq:2250 +doteqdot:2251 +dotminus:2238 +dotplus:2214 +dotsquare:22A1 +doublebarwedge:2306 +downarrow:2193 +downdownarrows:21CA +downharpoonleft:21C3 +downharpoonright:21C2 +drbkarow:2910 +drcorn:231F +drcrop:230C +dscr:1D4B9 +dscy:455 +dsol:29F6 +dstrok:111 +dtdot:22F1 +dtri:25BF +dtrif:25BE +duarr:21F5 +duhar:296F +dwangle:29A6 +dzcy:45F +dzigrarr:27FF +eDDot:2A77 +eDot:2251 +eacute:E9 +easter:2A6E +ecaron:11B +ecir:2256 +ecirc:EA +ecolon:2255 +ecy:44D +edot:117 +ee:2147 +efDot:2252 +efr:1D522 +eg:2A9A +egrave:E8 +egs:2A96 +egsdot:2A98 +el:2A99 +elinters:23E7 +ell:2113 +els:2A95 +elsdot:2A97 +emacr:113 +empty:2205 +emptyset:2205 +emptyv:2205 +emsp13:2004 +emsp14:2005 +emsp:2003 +eng:14B +ensp:2002 +eogon:119 +eopf:1D556 +epar:22D5 +eparsl:29E3 +eplus:2A71 +epsi:3B5 +epsilon:3B5 +epsiv:3F5 +eqcirc:2256 +eqcolon:2255 +eqsim:2242 +eqslantgtr:2A96 +eqslantless:2A95 +equals:3D +equest:225F +equiv:2261 +equivDD:2A78 +eqvparsl:29E5 +erDot:2253 +erarr:2971 +escr:212F +esdot:2250 +esim:2242 +eta:3B7 +eth:F0 +euml:EB +euro:20AC +excl:21 +exist:2203 +expectation:2130 +exponentiale:2147 +fallingdotseq:2252 +fcy:444 +female:2640 +ffilig:FB03 +fflig:FB00 +ffllig:FB04 +ffr:1D523 +filig:FB01 +fjlig:66,6A +flat:266D +fllig:FB02 +fltns:25B1 +fnof:192 +fopf:1D557 +forall:2200 +fork:22D4 +forkv:2AD9 +fpartint:2A0D +frac12:BD +frac13:2153 +frac14:BC +frac15:2155 +frac16:2159 +frac18:215B +frac23:2154 +frac25:2156 +frac34:BE +frac35:2157 +frac38:215C +frac45:2158 +frac56:215A +frac58:215D +frac78:215E +frasl:2044 +frown:2322 +fscr:1D4BB +gE:2267 +gEl:2A8C +gacute:1F5 +gamma:3B3 +gammad:3DD +gap:2A86 +gbreve:11F +gcirc:11D +gcy:433 +gdot:121 +ge:2265 +gel:22DB +geq:2265 +geqq:2267 +geqslant:2A7E +ges:2A7E +gescc:2AA9 +gesdot:2A80 +gesdoto:2A82 +gesdotol:2A84 +gesl:22DB,FE00 +gesles:2A94 +gfr:1D524 +gg:226B +ggg:22D9 +gimel:2137 +gjcy:453 +gl:2277 +glE:2A92 +gla:2AA5 +glj:2AA4 +gnE:2269 +gnap:2A8A +gnapprox:2A8A +gne:2A88 +gneq:2A88 +gneqq:2269 +gnsim:22E7 +gopf:1D558 +grave:60 +gscr:210A +gsim:2273 +gsime:2A8E +gsiml:2A90 +gt:3E +gtcc:2AA7 +gtcir:2A7A +gtdot:22D7 +gtlPar:2995 +gtquest:2A7C +gtrapprox:2A86 +gtrarr:2978 +gtrdot:22D7 +gtreqless:22DB +gtreqqless:2A8C +gtrless:2277 +gtrsim:2273 +gvertneqq:2269,FE00 +gvnE:2269,FE00 +hArr:21D4 +hairsp:200A +half:BD +hamilt:210B +hardcy:44A +harr:2194 +harrcir:2948 +harrw:21AD +hbar:210F +hcirc:125 +hearts:2665 +heartsuit:2665 +hellip:2026 +hercon:22B9 +hfr:1D525 +hksearow:2925 +hkswarow:2926 +hoarr:21FF +homtht:223B +hookleftarrow:21A9 +hookrightarrow:21AA +hopf:1D559 +horbar:2015 +hscr:1D4BD +hslash:210F +hstrok:127 +hybull:2043 +hyphen:2010 +iacute:ED +ic:2063 +icirc:EE +icy:438 +iecy:435 +iexcl:A1 +iff:21D4 +ifr:1D526 +igrave:EC +ii:2148 +iiiint:2A0C +iiint:222D +iinfin:29DC +iiota:2129 +ijlig:133 +imacr:12B +image:2111 +imagline:2110 +imagpart:2111 +imath:131 +imof:22B7 +imped:1B5 +in:2208 +incare:2105 +infin:221E +infintie:29DD +inodot:131 +int:222B +intcal:22BA +integers:2124 +intercal:22BA +intlarhk:2A17 +intprod:2A3C +iocy:451 +iogon:12F +iopf:1D55A +iota:3B9 +iprod:2A3C +iquest:BF +iscr:1D4BE +isin:2208 +isinE:22F9 +isindot:22F5 +isins:22F4 +isinsv:22F3 +isinv:2208 +it:2062 +itilde:129 +iukcy:456 +iuml:EF +jcirc:135 +jcy:439 +jfr:1D527 +jmath:237 +jopf:1D55B +jscr:1D4BF +jsercy:458 +jukcy:454 +kappa:3BA +kappav:3F0 +kcedil:137 +kcy:43A +kfr:1D528 +kgreen:138 +khcy:445 +kjcy:45C +kopf:1D55C +kscr:1D4C0 +lAarr:21DA +lArr:21D0 +lAtail:291B +lBarr:290E +lE:2266 +lEg:2A8B +lHar:2962 +lacute:13A +laemptyv:29B4 +lagran:2112 +lambda:3BB +lang:27E8 +langd:2991 +langle:27E8 +lap:2A85 +laquo:AB +larr:2190 +larrb:21E4 +larrbfs:291F +larrfs:291D +larrhk:21A9 +larrlp:21AB +larrpl:2939 +larrsim:2973 +larrtl:21A2 +lat:2AAB +latail:2919 +late:2AAD +lates:2AAD,FE00 +lbarr:290C +lbbrk:2772 +lbrace:7B +lbrack:5B +lbrke:298B +lbrksld:298F +lbrkslu:298D +lcaron:13E +lcedil:13C +lceil:2308 +lcub:7B +lcy:43B +ldca:2936 +ldquo:201C +ldquor:201E +ldrdhar:2967 +ldrushar:294B +ldsh:21B2 +le:2264 +leftarrow:2190 +leftarrowtail:21A2 +leftharpoondown:21BD +leftharpoonup:21BC +leftleftarrows:21C7 +leftrightarrow:2194 +leftrightarrows:21C6 +leftrightharpoons:21CB +leftrightsquigarrow:21AD +leftthreetimes:22CB +leg:22DA +leq:2264 +leqq:2266 +leqslant:2A7D +les:2A7D +lescc:2AA8 +lesdot:2A7F +lesdoto:2A81 +lesdotor:2A83 +lesg:22DA,FE00 +lesges:2A93 +lessapprox:2A85 +lessdot:22D6 +lesseqgtr:22DA +lesseqqgtr:2A8B +lessgtr:2276 +lesssim:2272 +lfisht:297C +lfloor:230A +lfr:1D529 +lg:2276 +lgE:2A91 +lhard:21BD +lharu:21BC +lharul:296A +lhblk:2584 +ljcy:459 +ll:226A +llarr:21C7 +llcorner:231E +llhard:296B +lltri:25FA +lmidot:140 +lmoust:23B0 +lmoustache:23B0 +lnE:2268 +lnap:2A89 +lnapprox:2A89 +lne:2A87 +lneq:2A87 +lneqq:2268 +lnsim:22E6 +loang:27EC +loarr:21FD +lobrk:27E6 +longleftarrow:27F5 +longleftrightarrow:27F7 +longmapsto:27FC +longrightarrow:27F6 +looparrowleft:21AB +looparrowright:21AC +lopar:2985 +lopf:1D55D +loplus:2A2D +lotimes:2A34 +lowast:2217 +lowbar:5F +loz:25CA +lozenge:25CA +lozf:29EB +lpar:28 +lparlt:2993 +lrarr:21C6 +lrcorner:231F +lrhar:21CB +lrhard:296D +lrm:200E +lrtri:22BF +lsaquo:2039 +lscr:1D4C1 +lsh:21B0 +lsim:2272 +lsime:2A8D +lsimg:2A8F +lsqb:5B +lsquo:2018 +lsquor:201A +lstrok:142 +lt:3C +ltcc:2AA6 +ltcir:2A79 +ltdot:22D6 +lthree:22CB +ltimes:22C9 +ltlarr:2976 +ltquest:2A7B +ltrPar:2996 +ltri:25C3 +ltrie:22B4 +ltrif:25C2 +lurdshar:294A +luruhar:2966 +lvertneqq:2268,FE00 +lvnE:2268,FE00 +mDDot:223A +macr:AF +male:2642 +malt:2720 +maltese:2720 +map:21A6 +mapsto:21A6 +mapstodown:21A7 +mapstoleft:21A4 +mapstoup:21A5 +marker:25AE +mcomma:2A29 +mcy:43C +mdash:2014 +measuredangle:2221 +mfr:1D52A +mho:2127 +micro:B5 +mid:2223 +midast:2A +midcir:2AF0 +middot:B7 +minus:2212 +minusb:229F +minusd:2238 +minusdu:2A2A +mlcp:2ADB +mldr:2026 +mnplus:2213 +models:22A7 +mopf:1D55E +mp:2213 +mscr:1D4C2 +mstpos:223E +mu:3BC +multimap:22B8 +mumap:22B8 +nGg:22D9,338 +nGt:226B,20D2 +nGtv:226B,338 +nLeftarrow:21CD +nLeftrightarrow:21CE +nLl:22D8,338 +nLt:226A,20D2 +nLtv:226A,338 +nRightarrow:21CF +nVDash:22AF +nVdash:22AE +nabla:2207 +nacute:144 +nang:2220,20D2 +nap:2249 +napE:2A70,338 +napid:224B,338 +napos:149 +napprox:2249 +natur:266E +natural:266E +naturals:2115 +nbsp:A0 +nbump:224E,338 +nbumpe:224F,338 +ncap:2A43 +ncaron:148 +ncedil:146 +ncong:2247 +ncongdot:2A6D,338 +ncup:2A42 +ncy:43D +ndash:2013 +ne:2260 +neArr:21D7 +nearhk:2924 +nearr:2197 +nearrow:2197 +nedot:2250,338 +nequiv:2262 +nesear:2928 +nesim:2242,338 +nexist:2204 +nexists:2204 +nfr:1D52B +ngE:2267,338 +nge:2271 +ngeq:2271 +ngeqq:2267,338 +ngeqslant:2A7E,338 +nges:2A7E,338 +ngsim:2275 +ngt:226F +ngtr:226F +nhArr:21CE +nharr:21AE +nhpar:2AF2 +ni:220B +nis:22FC +nisd:22FA +niv:220B +njcy:45A +nlArr:21CD +nlE:2266,338 +nlarr:219A +nldr:2025 +nle:2270 +nleftarrow:219A +nleftrightarrow:21AE +nleq:2270 +nleqq:2266,338 +nleqslant:2A7D,338 +nles:2A7D,338 +nless:226E +nlsim:2274 +nlt:226E +nltri:22EA +nltrie:22EC +nmid:2224 +nopf:1D55F +not:AC +notin:2209 +notinE:22F9,338 +notindot:22F5,338 +notinva:2209 +notinvb:22F7 +notinvc:22F6 +notni:220C +notniva:220C +notnivb:22FE +notnivc:22FD +npar:2226 +nparallel:2226 +nparsl:2AFD,20E5 +npart:2202,338 +npolint:2A14 +npr:2280 +nprcue:22E0 +npre:2AAF,338 +nprec:2280 +npreceq:2AAF,338 +nrArr:21CF +nrarr:219B +nrarrc:2933,338 +nrarrw:219D,338 +nrightarrow:219B +nrtri:22EB +nrtrie:22ED +nsc:2281 +nsccue:22E1 +nsce:2AB0,338 +nscr:1D4C3 +nshortmid:2224 +nshortparallel:2226 +nsim:2241 +nsime:2244 +nsimeq:2244 +nsmid:2224 +nspar:2226 +nsqsube:22E2 +nsqsupe:22E3 +nsub:2284 +nsubE:2AC5,338 +nsube:2288 +nsubset:2282,20D2 +nsubseteq:2288 +nsubseteqq:2AC5,338 +nsucc:2281 +nsucceq:2AB0,338 +nsup:2285 +nsupE:2AC6,338 +nsupe:2289 +nsupset:2283,20D2 +nsupseteq:2289 +nsupseteqq:2AC6,338 +ntgl:2279 +ntilde:F1 +ntlg:2278 +ntriangleleft:22EA +ntrianglelefteq:22EC +ntriangleright:22EB +ntrianglerighteq:22ED +nu:3BD +num:23 +numero:2116 +numsp:2007 +nvDash:22AD +nvHarr:2904 +nvap:224D,20D2 +nvdash:22AC +nvge:2265,20D2 +nvgt:3E,20D2 +nvinfin:29DE +nvlArr:2902 +nvle:2264,20D2 +nvlt:3C,20D2 +nvltrie:22B4,20D2 +nvrArr:2903 +nvrtrie:22B5,20D2 +nvsim:223C,20D2 +nwArr:21D6 +nwarhk:2923 +nwarr:2196 +nwarrow:2196 +nwnear:2927 +oS:24C8 +oacute:F3 +oast:229B +ocir:229A +ocirc:F4 +ocy:43E +odash:229D +odblac:151 +odiv:2A38 +odot:2299 +odsold:29BC +oelig:153 +ofcir:29BF +ofr:1D52C +ogon:2DB +ograve:F2 +ogt:29C1 +ohbar:29B5 +ohm:3A9 +oint:222E +olarr:21BA +olcir:29BE +olcross:29BB +oline:203E +olt:29C0 +omacr:14D +omega:3C9 +omicron:3BF +omid:29B6 +ominus:2296 +oopf:1D560 +opar:29B7 +operp:29B9 +oplus:2295 +or:2228 +orarr:21BB +ord:2A5D +order:2134 +orderof:2134 +ordf:AA +ordm:BA +origof:22B6 +oror:2A56 +orslope:2A57 +orv:2A5B +oscr:2134 +oslash:F8 +osol:2298 +otilde:F5 +otimes:2297 +otimesas:2A36 +ouml:F6 +ovbar:233D +par:2225 +para:B6 +parallel:2225 +parsim:2AF3 +parsl:2AFD +part:2202 +pcy:43F +percnt:25 +period:2E +permil:2030 +perp:22A5 +pertenk:2031 +pfr:1D52D +phi:3C6 +phiv:3D5 +phmmat:2133 +phone:260E +pi:3C0 +pitchfork:22D4 +piv:3D6 +planck:210F +planckh:210E +plankv:210F +plus:2B +plusacir:2A23 +plusb:229E +pluscir:2A22 +plusdo:2214 +plusdu:2A25 +pluse:2A72 +plusmn:B1 +plussim:2A26 +plustwo:2A27 +pm:B1 +pointint:2A15 +popf:1D561 +pound:A3 +pr:227A +prE:2AB3 +prap:2AB7 +prcue:227C +pre:2AAF +prec:227A +precapprox:2AB7 +preccurlyeq:227C +preceq:2AAF +precnapprox:2AB9 +precneqq:2AB5 +precnsim:22E8 +precsim:227E +prime:2032 +primes:2119 +prnE:2AB5 +prnap:2AB9 +prnsim:22E8 +prod:220F +profalar:232E +profline:2312 +profsurf:2313 +prop:221D +propto:221D +prsim:227E +prurel:22B0 +pscr:1D4C5 +psi:3C8 +puncsp:2008 +qfr:1D52E +qint:2A0C +qopf:1D562 +qprime:2057 +qscr:1D4C6 +quaternions:210D +quatint:2A16 +quest:3F +questeq:225F +quot:22 +rAarr:21DB +rArr:21D2 +rAtail:291C +rBarr:290F +rHar:2964 +race:223D,331 +racute:155 +radic:221A +raemptyv:29B3 +rang:27E9 +rangd:2992 +range:29A5 +rangle:27E9 +raquo:BB +rarr:2192 +rarrap:2975 +rarrb:21E5 +rarrbfs:2920 +rarrc:2933 +rarrfs:291E +rarrhk:21AA +rarrlp:21AC +rarrpl:2945 +rarrsim:2974 +rarrtl:21A3 +rarrw:219D +ratail:291A +ratio:2236 +rationals:211A +rbarr:290D +rbbrk:2773 +rbrace:7D +rbrack:5D +rbrke:298C +rbrksld:298E +rbrkslu:2990 +rcaron:159 +rcedil:157 +rceil:2309 +rcub:7D +rcy:440 +rdca:2937 +rdldhar:2969 +rdquo:201D +rdquor:201D +rdsh:21B3 +real:211C +realine:211B +realpart:211C +reals:211D +rect:25AD +reg:AE +rfisht:297D +rfloor:230B +rfr:1D52F +rhard:21C1 +rharu:21C0 +rharul:296C +rho:3C1 +rhov:3F1 +rightarrow:2192 +rightarrowtail:21A3 +rightharpoondown:21C1 +rightharpoonup:21C0 +rightleftarrows:21C4 +rightleftharpoons:21CC +rightrightarrows:21C9 +rightsquigarrow:219D +rightthreetimes:22CC +ring:2DA +risingdotseq:2253 +rlarr:21C4 +rlhar:21CC +rlm:200F +rmoust:23B1 +rmoustache:23B1 +rnmid:2AEE +roang:27ED +roarr:21FE +robrk:27E7 +ropar:2986 +ropf:1D563 +roplus:2A2E +rotimes:2A35 +rpar:29 +rpargt:2994 +rppolint:2A12 +rrarr:21C9 +rsaquo:203A +rscr:1D4C7 +rsh:21B1 +rsqb:5D +rsquo:2019 +rsquor:2019 +rthree:22CC +rtimes:22CA +rtri:25B9 +rtrie:22B5 +rtrif:25B8 +rtriltri:29CE +ruluhar:2968 +rx:211E +sacute:15B +sbquo:201A +sc:227B +scE:2AB4 +scap:2AB8 +scaron:161 +sccue:227D +sce:2AB0 +scedil:15F +scirc:15D +scnE:2AB6 +scnap:2ABA +scnsim:22E9 +scpolint:2A13 +scsim:227F +scy:441 +sdot:22C5 +sdotb:22A1 +sdote:2A66 +seArr:21D8 +searhk:2925 +searr:2198 +searrow:2198 +sect:A7 +semi:3B +seswar:2929 +setminus:2216 +setmn:2216 +sext:2736 +sfr:1D530 +sfrown:2322 +sharp:266F +shchcy:449 +shcy:448 +shortmid:2223 +shortparallel:2225 +shy:AD +sigma:3C3 +sigmaf:3C2 +sigmav:3C2 +sim:223C +simdot:2A6A +sime:2243 +simeq:2243 +simg:2A9E +simgE:2AA0 +siml:2A9D +simlE:2A9F +simne:2246 +simplus:2A24 +simrarr:2972 +slarr:2190 +smallsetminus:2216 +smashp:2A33 +smeparsl:29E4 +smid:2223 +smile:2323 +smt:2AAA +smte:2AAC +smtes:2AAC,FE00 +softcy:44C +sol:2F +solb:29C4 +solbar:233F +sopf:1D564 +spades:2660 +spadesuit:2660 +spar:2225 +sqcap:2293 +sqcaps:2293,FE00 +sqcup:2294 +sqcups:2294,FE00 +sqsub:228F +sqsube:2291 +sqsubset:228F +sqsubseteq:2291 +sqsup:2290 +sqsupe:2292 +sqsupset:2290 +sqsupseteq:2292 +squ:25A1 +square:25A1 +squarf:25AA +squf:25AA +srarr:2192 +sscr:1D4C8 +ssetmn:2216 +ssmile:2323 +sstarf:22C6 +star:2606 +starf:2605 +straightepsilon:3F5 +straightphi:3D5 +strns:AF +sub:2282 +subE:2AC5 +subdot:2ABD +sube:2286 +subedot:2AC3 +submult:2AC1 +subnE:2ACB +subne:228A +subplus:2ABF +subrarr:2979 +subset:2282 +subseteq:2286 +subseteqq:2AC5 +subsetneq:228A +subsetneqq:2ACB +subsim:2AC7 +subsub:2AD5 +subsup:2AD3 +succ:227B +succapprox:2AB8 +succcurlyeq:227D +succeq:2AB0 +succnapprox:2ABA +succneqq:2AB6 +succnsim:22E9 +succsim:227F +sum:2211 +sung:266A +sup1:B9 +sup2:B2 +sup3:B3 +sup:2283 +supE:2AC6 +supdot:2ABE +supdsub:2AD8 +supe:2287 +supedot:2AC4 +suphsol:27C9 +suphsub:2AD7 +suplarr:297B +supmult:2AC2 +supnE:2ACC +supne:228B +supplus:2AC0 +supset:2283 +supseteq:2287 +supseteqq:2AC6 +supsetneq:228B +supsetneqq:2ACC +supsim:2AC8 +supsub:2AD4 +supsup:2AD6 +swArr:21D9 +swarhk:2926 +swarr:2199 +swarrow:2199 +swnwar:292A +szlig:DF +target:2316 +tau:3C4 +tbrk:23B4 +tcaron:165 +tcedil:163 +tcy:442 +tdot:20DB +telrec:2315 +tfr:1D531 +there4:2234 +therefore:2234 +theta:3B8 +thetasym:3D1 +thetav:3D1 +thickapprox:2248 +thicksim:223C +thinsp:2009 +thkap:2248 +thksim:223C +thorn:FE +tilde:2DC +times:D7 +timesb:22A0 +timesbar:2A31 +timesd:2A30 +tint:222D +toea:2928 +top:22A4 +topbot:2336 +topcir:2AF1 +topf:1D565 +topfork:2ADA +tosa:2929 +tprime:2034 +trade:2122 +triangle:25B5 +triangledown:25BF +triangleleft:25C3 +trianglelefteq:22B4 +triangleq:225C +triangleright:25B9 +trianglerighteq:22B5 +tridot:25EC +trie:225C +triminus:2A3A +triplus:2A39 +trisb:29CD +tritime:2A3B +trpezium:23E2 +tscr:1D4C9 +tscy:446 +tshcy:45B +tstrok:167 +twixt:226C +twoheadleftarrow:219E +twoheadrightarrow:21A0 +uArr:21D1 +uHar:2963 +uacute:FA +uarr:2191 +ubrcy:45E +ubreve:16D +ucirc:FB +ucy:443 +udarr:21C5 +udblac:171 +udhar:296E +ufisht:297E +ufr:1D532 +ugrave:F9 +uharl:21BF +uharr:21BE +uhblk:2580 +ulcorn:231C +ulcorner:231C +ulcrop:230F +ultri:25F8 +umacr:16B +uml:A8 +uogon:173 +uopf:1D566 +uparrow:2191 +updownarrow:2195 +upharpoonleft:21BF +upharpoonright:21BE +uplus:228E +upsi:3C5 +upsih:3D2 +upsilon:3C5 +upuparrows:21C8 +urcorn:231D +urcorner:231D +urcrop:230E +uring:16F +urtri:25F9 +uscr:1D4CA +utdot:22F0 +utilde:169 +utri:25B5 +utrif:25B4 +uuarr:21C8 +uuml:FC +uwangle:29A7 +vArr:21D5 +vBar:2AE8 +vBarv:2AE9 +vDash:22A8 +vangrt:299C +varepsilon:3F5 +varkappa:3F0 +varnothing:2205 +varphi:3D5 +varpi:3D6 +varpropto:221D +varr:2195 +varrho:3F1 +varsigma:3C2 +varsubsetneq:228A,FE00 +varsubsetneqq:2ACB,FE00 +varsupsetneq:228B,FE00 +varsupsetneqq:2ACC,FE00 +vartheta:3D1 +vartriangleleft:22B2 +vartriangleright:22B3 +vcy:432 +vdash:22A2 +vee:2228 +veebar:22BB +veeeq:225A +vellip:22EE +verbar:7C +vert:7C +vfr:1D533 +vltri:22B2 +vnsub:2282,20D2 +vnsup:2283,20D2 +vopf:1D567 +vprop:221D +vrtri:22B3 +vscr:1D4CB +vsubnE:2ACB,FE00 +vsubne:228A,FE00 +vsupnE:2ACC,FE00 +vsupne:228B,FE00 +vzigzag:299A +wcirc:175 +wedbar:2A5F +wedge:2227 +wedgeq:2259 +weierp:2118 +wfr:1D534 +wopf:1D568 +wp:2118 +wr:2240 +wreath:2240 +wscr:1D4CC +xcap:22C2 +xcirc:25EF +xcup:22C3 +xdtri:25BD +xfr:1D535 +xhArr:27FA +xharr:27F7 +xi:3BE +xlArr:27F8 +xlarr:27F5 +xmap:27FC +xnis:22FB +xodot:2A00 +xopf:1D569 +xoplus:2A01 +xotime:2A02 +xrArr:27F9 +xrarr:27F6 +xscr:1D4CD +xsqcup:2A06 +xuplus:2A04 +xutri:25B3 +xvee:22C1 +xwedge:22C0 +yacute:FD +yacy:44F +ycirc:177 +ycy:44B +yen:A5 +yfr:1D536 +yicy:457 +yopf:1D56A +yscr:1D4CE +yucy:44E +yuml:FF +zacute:17A +zcaron:17E +zcy:437 +zdot:17C +zeetrf:2128 +zeta:3B6 +zfr:1D537 +zhcy:436 +zigrarr:21DD +zopf:1D56B +zscr:1D4CF +zwj:200D +zwnj:200C +""" +} diff --git a/QuickMD/QuickMD/InlineMathSegmenter.swift b/QuickMD/QuickMD/InlineMathSegmenter.swift index 63a3e49..d9801b0 100644 --- a/QuickMD/QuickMD/InlineMathSegmenter.swift +++ b/QuickMD/QuickMD/InlineMathSegmenter.swift @@ -1,30 +1,117 @@ import Foundation +/// Marks a character the author backslash-escaped (`\$`, `\%`, `\,`): the +/// renderer draws it without the backslash, but inline math is found in the +/// RENDERED text, so `InlineMathSegmenter` needs to know what was escaped: +/// - an escaped `$` never opens or closes a formula (`Price \$5 and \$10`); +/// - inside a formula every escaped character gets its backslash back, so +/// the LaTeX reaches SwiftMath intact (`$50\%$`, `$a\,b$`, `$\{x\}$`). +/// A dollar made by a character reference (`$`) is NOT tagged: GitHub +/// reads that one as a math delimiter. Not part of the AppKit scope, so the +/// NSAttributedString a view installs never carries it. +enum EscapedCharacterAttribute: AttributedStringKey { + typealias Value = Bool + static let name = "qmdEscapedCharacter" +} + /// Splits paragraph text into alternating plain-text and inline-math segments. /// Math is delimited by single `$` (never `$$` — that's display math). /// Pure Foundation — shared by TextBlockView (app) and the unit tests. enum InlineMathSegmenter { + /// The Character offsets, in `String(attributed.characters)`, of every + /// character the author escaped (`EscapedCharacterAttribute`). Empty for + /// almost every paragraph: only tagged runs are walked. + static func escapedCharacters(in attributed: AttributedString) -> Set { + var offsets: Set = [] + let characters = attributed.characters + for run in attributed.runs where run[EscapedCharacterAttribute.self] == true { + var offset = characters.distance(from: characters.startIndex, to: run.range.lowerBound) + for _ in characters[run.range] { + offsets.insert(offset) + offset += 1 + } + } + return offsets + } + enum Segment: Equatable { case text(String) case math(String) } - static func split(_ text: String) -> [Segment] { + /// A segment as the views draw it: text as is, a formula as the LaTeX to + /// typeset (escaped characters with their backslash back, `tex`), with + /// the number of Characters it spans in the rendered text (`$…$`). + struct Piece: Equatable { + let segment: Segment + let length: Int + } + + /// The paragraph's text and formulas, from the renderer's string: the one + /// split the screen, the measurer, search and print share. + static func pieces(of attributed: AttributedString) -> [Piece] { + let plain = String(attributed.characters) + let escaped = escapedCharacters(in: attributed) + var offset = 0 + return split(plain, escaped: escaped).map { segment in + switch segment { + case .text(let text): + offset += text.count + return Piece(segment: segment, length: text.count) + case .math(let source): + let piece = Piece(segment: .math(tex(source, escaped: escaped, contentStart: offset + 1)), + length: source.count + 2) + offset += piece.length + return piece + } + } + } + + /// A formula's LaTeX: `source` (as rendered) with a backslash put back + /// before every character that was escaped — by offset, never by + /// character, so a `$` or `%` the author did not escape stays as it is. + /// `contentStart` is the offset of `source`'s first character. + static func tex(_ source: String, escaped: Set, contentStart: Int) -> String { + guard !escaped.isEmpty else { return source } + var latex = "" + for (index, character) in source.enumerated() { + if escaped.contains(contentStart + index) { latex.append("\\") } + latex.append(character) + } + return latex + } + + /// `escaped`: Character offsets of escaped characters + /// (`escapedCharacters(in:)`). An escaped `$` is never a delimiter, so it + /// stays text or, inside a formula, part of its LaTeX. + static func split(_ text: String, escaped: Set = []) -> [Segment] { var segments: [Segment] = [] var remaining = text[...] var buffer = "" + let literal: Set = Set(escaped.compactMap { + text.index(text.startIndex, offsetBy: $0, limitedBy: text.endIndex) + }) + /// The next delimiter `$` at or after `from`. + func nextDollar(in slice: Substring) -> String.Index? { + var index = slice.startIndex + while let found = slice[index...].firstIndex(of: "$") { + if !literal.contains(found) { return found } + index = slice.index(after: found) + } + return nil + } while !remaining.isEmpty { // Look for opening $ - guard let dollarIndex = remaining.firstIndex(of: "$") else { + guard let dollarIndex = nextDollar(in: remaining) else { buffer.append(contentsOf: remaining) break } // Skip $$ (display math delimiter, not inline) let afterDollar = remaining.index(after: dollarIndex) - if afterDollar < remaining.endIndex && remaining[afterDollar] == "$" { + if afterDollar < remaining.endIndex && remaining[afterDollar] == "$" && !literal.contains(afterDollar) { buffer.append(contentsOf: remaining[remaining.startIndex...afterDollar]) remaining = remaining[remaining.index(after: afterDollar)...] continue @@ -33,7 +120,7 @@ enum InlineMathSegmenter { // Check for closing $ let searchStart = afterDollar guard searchStart < remaining.endIndex, - let closeIndex = remaining[searchStart...].firstIndex(of: "$") else { + let closeIndex = nextDollar(in: remaining[searchStart...]) else { buffer.append(contentsOf: remaining) break } diff --git a/QuickMD/QuickMD/InlineSyntaxScanner.swift b/QuickMD/QuickMD/InlineSyntaxScanner.swift index a9771e7..ad77c65 100644 --- a/QuickMD/QuickMD/InlineSyntaxScanner.swift +++ b/QuickMD/QuickMD/InlineSyntaxScanner.swift @@ -54,18 +54,34 @@ enum InlineSyntaxScanner { fileprivate func closingBracket(atAbsolute offset: Int) -> Int? { pairs.brackets[offset] } fileprivate func closingParen(atAbsolute offset: Int) -> Int? { pairs.parens[offset] } + /// Two linear passes. Parens first, everywhere (a destination is not + /// code). Then brackets, skipping code spans (code spans outrank + /// brackets) and the destination after a `](` — the renderer consumes + /// it as the link's, so a backtick in a URL (`[a](u`x) [b](v)`) must + /// not open a "span" that swallows a later link's `[`. private static func build(_ text: String) -> (brackets: [Int: Int], parens: [Int: Int]) { InlineSyntaxScanner.withBytes(text[...]) { bytes in - var brackets: [Int: Int] = [:], parens: [Int: Int] = [:] - var openBrackets: [Int] = [], openParens: [Int] = [] + var brackets: [Int: Int] = [:] + let parens = pairParens(bytes) + var openBrackets: [Int] = [] + var codeSpans = CodeSpanScanner() var i = 0 while i < bytes.count { if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue } + if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) { + i = end + continue + } switch bytes[i] { case UInt8(ascii: "["): openBrackets.append(i) - case UInt8(ascii: "]"): if let open = openBrackets.popLast() { brackets[open] = i } - case UInt8(ascii: "("): openParens.append(i) - case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = i } + case UInt8(ascii: "]"): + if let open = openBrackets.popLast() { + brackets[open] = i + if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "("), let close = parens[i + 1] { + i = close + 1 + continue + } + } default: break } i += 1 @@ -75,6 +91,24 @@ enum InlineSyntaxScanner { } } + /// Every `(` → its `)`, one stack pass, escapes respected — exactly the + /// closer a depth scan from that `(` finds (`scanParenDestination`). + fileprivate static func pairParens(_ bytes: Bytes) -> [Int: Int] { + var parens: [Int: Int] = [:] + var open: [Int] = [] + var i = 0 + while i < bytes.count { + if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue } + switch bytes[i] { + case UInt8(ascii: "("): open.append(i) + case UInt8(ascii: ")"): if let start = open.popLast() { parens[start] = i } + default: break + } + i += 1 + } + return parens + } + /// A pair table seen from a slice: offsets in the slice's byte buffer are /// `base` bytes into the table's text. fileprivate struct Lookup { @@ -171,6 +205,46 @@ enum InlineSyntaxScanner { } } + /// A line that is nothing but ONE linked image — `[![alt](img)](url)`, + /// `[![alt][logo]][site]` or any link form `MarkdownRenderer.tryParseLink` + /// accepts around one image, whitespace around it allowed — as the image + /// it shows. The link target is NOT returned: the parser makes the line an + /// ordinary image block (click = preview), as `` is; + /// links on images are a later feature. Text beside the link, a second + /// image or an outer link that does not resolve → nil (a paragraph, as + /// before). + static func linkedImage(in line: Substring, references: [String: String]) -> ImageSyntax? { + let scalars = line.unicodeScalars + guard let first = scalars.firstIndex(where: { !$0.properties.isWhitespace }), + let last = scalars.lastIndex(where: { !$0.properties.isWhitespace }) else { return nil } + // Cheap reject before any copy: most lines are not this. + guard Substring(scalars[first...last]).utf8.starts(with: "[![".utf8) else { return nil } + let text = String(Substring(scalars[first...last])) + let pairs = PairTable(text) + guard let close = closingBracket(in: text[...], pairs: pairs) else { return nil } + let linkText = text[text.index(after: text.startIndex).. Bool { @@ -198,6 +272,39 @@ enum InlineSyntaxScanner { } private static let backslash = UInt8(ascii: "\\") + private static let backtick = UInt8(ascii: "`") + + /// Where `MarkdownRenderer.tryParseInlineCode` would end a code span + /// starting at a backtick — so bracket matching skips what the renderer + /// will draw as code (CommonMark: code spans outrank brackets; + /// `[a `](` b](u)` is a link whose text holds the code "]("). + /// Its rules exactly: a run of 1 or 2 backticks closes at the FIRST later + /// occurrence of that many backticks (a 2-run whose closer is followed by + /// a third backtick opens nothing); a run of 3+ is literal and skipped + /// whole. No span → nil (the caller moves on one byte, as the renderer + /// moves on one character). A search that found no closer is remembered + /// per run length, so many unclosed backticks stay linear. + fileprivate struct CodeSpanScanner { + private var noCloserAfter: [Int: Int] = [:] // run length → offset searched from + + mutating func end(_ bytes: Bytes, at start: Int) -> Int? { + var run = 0 + while start + run < bytes.count, bytes[start + run] == backtick { run += 1 } + if run > 2 { return start + run } + if let from = noCloserAfter[run], from <= start + run { return nil } + var i = start + run + while i + run <= bytes.count { + if bytes[i] == backtick, run == 1 || bytes[i + 1] == backtick { + if run == 2, i + 2 < bytes.count, bytes[i + 2] == backtick { return nil } + return i + run + } + i += 1 + } + noCloserAfter[run] = start + run + return nil + } + } + private static let ampersand = UInt8(ascii: "&") /// CommonMark whitespace for these purposes: space, tab, LF, VT, FF, CR. private static func isWhitespace(_ byte: UInt8) -> Bool { @@ -233,13 +340,28 @@ enum InlineSyntaxScanner { if let lookup { return lookup.closingBracket(open).flatMap { $0 < bytes.count ? $0 : nil } } var depth = 0 var i = open + var codeSpans = CodeSpanScanner() + /// Built on the first inner `](` only: a depth scan per `](` that + /// never closes went to the end of the line each time (quadratic on + /// `![x` + `[a](` × N, review round 7). + var parens: [Int: Int]? while i < bytes.count { if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue } + if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) { i = end; continue } switch bytes[i] { case UInt8(ascii: "["): depth += 1 case UInt8(ascii: "]"): depth -= 1 if depth == 0 { return i } + // An inner link's destination is not scanned for code spans + // (see `PairTable.build`). + if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "(") { + if parens == nil { parens = pairParens(bytes) } + if let close = parens?[i + 1] { + i = close + 1 + continue + } + } default: break } i += 1 @@ -346,22 +468,35 @@ enum InlineSyntaxScanner { /// The bytes as a String with `\` → ``. Other /// backslashes stay literal (`C:\dir\x.png`). One copy when there is /// nothing to unescape — the common case, and the multi-MB `data:` case. + /// A destination's text: backslash escapes of ASCII punctuation removed + /// and character references decoded (`a?b=1&c=2` → `a?b=1&c=2`, as + /// CommonMark and GitHub read a link's href) — but not the `&` of an + /// escaped `\&`. One pass over the bytes when there is neither. private static func unescaped(_ bytes: Bytes, _ range: Range) -> String { let slice = Bytes(rebasing: bytes[range]) - guard slice.contains(backslash) else { return String(decoding: slice, as: UTF8.self) } - var out: [UInt8] = [] - out.reserveCapacity(slice.count) + guard slice.contains(where: { $0 == backslash || $0 == ampersand }) else { + return String(decoding: slice, as: UTF8.self) + } + var out = "" + var run: [UInt8] = [] + func flushRun() { + guard !run.isEmpty else { return } + out += HTMLEntities.decode(String(decoding: run, as: UTF8.self)) + run.removeAll(keepingCapacity: true) + } var i = 0 while i < slice.count { if isEscape(slice, at: i, before: slice.count) { - out.append(slice[i + 1]) + flushRun() + out += String(decoding: [slice[i + 1]], as: UTF8.self) i += 2 } else { - out.append(slice[i]) + run.append(slice[i]) i += 1 } } - return String(decoding: out, as: UTF8.self) + flushRun() + return out } /// An image starting at `start` (`![`), see `image(atStartOf:references:)`. @@ -385,13 +520,13 @@ enum InlineSyntaxScanner { if next < bytes.count, bytes[next] == UInt8(ascii: "["), let labelClose = matchLabel(bytes, open: next) { let labelRange = labelClose == next + 1 ? altRange : (next + 1)..` prefixes). The marker must /// be the only thing on the line — `[!NOTE] text` is a regular blockquote. /// Case-insensitive, matching GitHub's behavior. + /// Spaces and tabs around the marker only (`MarkdownRenderer.markerWhitespace`): + /// GitHub reads `[!NOTE]` + a no-break space as a plain quote. init?(markerLine: String) { - let trimmed = markerLine.trimmingCharacters(in: .whitespaces) + let trimmed = markerLine.trimmingCharacters(in: MarkdownRenderer.markerWhitespace) guard trimmed.hasPrefix("[!"), trimmed.hasSuffix("]") else { return nil } let name = trimmed.dropFirst(2).dropLast().lowercased() self.init(rawValue: name) @@ -110,6 +112,79 @@ struct MarkdownBlock: Identifiable, Sendable { /// `.heading(level:title:sourceLine:)`, which section copy (`SectionExtractor`) /// reads — that associated value stays the authority there. let sourceLine: Int + /// This row continues the block of the row before it: there is no block + /// boundary between them, only a cut the parser made. Two cases: + /// - a text run longer than `MarkdownBlockParser.textChunkLineLimit` lines, + /// cut to keep every row cheap to lay out; + /// - the second and later nesting groups of ONE `>` run (`> a` then + /// `>> b`), which render as separate quote rows. + /// + /// Such a row gets no space above it (`BlockLayout.Spacing`), and a copy + /// across the cut joins the two rows with a line break, not a blank line + /// (`DocumentCopyBuilder`). + let joinsPrevious: Bool + /// The document's YAML front matter — as the properties table, or as the + /// `yaml` code block it falls back to when `FrontMatter` cannot read it. + /// Set by the parser alone (only it knows where the fence was): a user's + /// own ```` ```yaml ```` fence on the first line is an ordinary code block. + /// The rhythm gives it its own role (`BlockLayout.Spacing.Role.frontMatter`). + let isFrontMatter: Bool + /// A display formula the math engine cannot typeset, shown as its source + /// in a ```` ```latex ```` code block + /// (`MathRendering.substitutingUntypesettableMath`). Copied whole, it + /// reads `$$…$$` again, as the formula it is (`SelectionRows`). + let substitutesFormula: Bool + + init(id: String, content: BlockContent, sourceLine: Int, joinsPrevious: Bool = false, + isFrontMatter: Bool = false, substitutesFormula: Bool = false) { + self.id = id + self.content = content + self.sourceLine = sourceLine + self.joinsPrevious = joinsPrevious + self.isFrontMatter = isFrontMatter + self.substitutesFormula = substitutesFormula + } + + /// A cheap content fingerprint of the block, to find the scroll anchor + /// again after the document changed shape (`VirtualBlockList`). Neither + /// identity the block already has can do that job: + /// + /// * `id` is POSITIONAL (`text-17`), so inserting one block above + /// renumbers every block below it. + /// * `sourceLine` moves with any edit above it. + /// + /// The characters are what the reader was actually looking at, so they + /// are the identity matched on. 80 characters distinguish paragraphs + /// without copying the document, and the kind tag in front keeps a + /// heading from matching a paragraph with the same words. A table is its + /// header AND first row: a header-less table (blank header cells) and the + /// front-matter properties table (no header) were only `b||`, so + /// any two of them with as many rows matched each other. + var anchorSignature: String { + func head(_ text: String) -> String { String(text.prefix(80)) } + switch content { + case .text(let attributed): + return "t|" + head(String(attributed.characters)) + case .table(let headers, let rows, _, _): + return "b|\(rows.count)|" + head((headers + (rows.first ?? [])).joined(separator: "\u{1F}")) + case .codeBlock(let code, let language): + return "c|\(language)|" + head(code) + case .image(let url, let alt, _): + return "i|\(head(url))|" + head(alt) + case .blockquote(let content, let level): + return "q|\(level)|" + head(content) + case .alert(let kind, let content): + return "a|\(kind.rawValue)|" + head(content) + case .heading(let level, let title, _): + return "h|\(level)|" + head(title) + case .mathBlock(let latex): + return "m|" + head(latex) + case .mermaidDiagram(let source): + return "d|" + head(source) + case .svgImage(let source): + return "s|" + head(source) + } + } enum BlockContent: Sendable { case text(AttributedString) @@ -138,23 +213,37 @@ struct MarkdownBlock: Identifiable, Sendable { // Every factory takes `sourceLine` WITHOUT a default: the parser is the only // caller and it always knows the mapped original line. A default here would // silently produce 0-anchored blocks the moment a new parser branch forgets it. - static func text(index: Int, _ attributed: AttributedString, sourceLine: Int) -> MarkdownBlock { - MarkdownBlock(id: "text-\(index)", content: .text(attributed), sourceLine: sourceLine) + /// A text row never ends in a line break: the renderer ends every line + /// with "\n", and a final one would lay out an extra, empty line under the + /// row — in the system's default font, so it would not even scale with + /// zoom. The space under a row is `BlockLayout.Spacing`'s job. + static func text(index: Int, _ attributed: AttributedString, sourceLine: Int, + joinsPrevious: Bool = false) -> MarkdownBlock { + var text = attributed + while let last = text.characters.last, last == "\n" { + text.removeSubrange(text.characters.index(before: text.endIndex).. MarkdownBlock { + style: TableStyle, sourceLine: Int, isFrontMatter: Bool = false) -> MarkdownBlock { MarkdownBlock(id: "table-\(index)", content: .table(headers: headers, rows: rows, alignments: alignments, style: style), - sourceLine: sourceLine) + sourceLine: sourceLine, isFrontMatter: isFrontMatter) } - static func codeBlock(index: Int, code: String, language: String, sourceLine: Int) -> MarkdownBlock { - MarkdownBlock(id: "code-\(index)", content: .codeBlock(code: code, language: language), sourceLine: sourceLine) + static func codeBlock(index: Int, code: String, language: String, sourceLine: Int, + isFrontMatter: Bool = false) -> MarkdownBlock { + MarkdownBlock(id: "code-\(index)", content: .codeBlock(code: code, language: language), + sourceLine: sourceLine, isFrontMatter: isFrontMatter) } static func image(index: Int, url: String, alt: String, width: ImageWidth?, sourceLine: Int) -> MarkdownBlock { MarkdownBlock(id: "image-\(index)", content: .image(url: url, alt: alt, width: width), sourceLine: sourceLine) } - static func blockquote(index: Int, content: String, level: Int, sourceLine: Int) -> MarkdownBlock { - MarkdownBlock(id: "blockquote-\(index)", content: .blockquote(content: content, level: level), sourceLine: sourceLine) + static func blockquote(index: Int, content: String, level: Int, sourceLine: Int, + joinsPrevious: Bool = false) -> MarkdownBlock { + MarkdownBlock(id: "blockquote-\(index)", content: .blockquote(content: content, level: level), + sourceLine: sourceLine, joinsPrevious: joinsPrevious) } static func alert(index: Int, kind: AlertKind, content: String, sourceLine: Int) -> MarkdownBlock { MarkdownBlock(id: "alert-\(index)", content: .alert(kind: kind, content: content), sourceLine: sourceLine) diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index 850d4c4..641780b 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -13,7 +13,7 @@ struct MarkdownBlockParser: Sendable { private let renderer: MarkdownRenderer // Static precompiled regex (avoid recompilation per parse call) - private static let headerRegex = try! NSRegularExpression(pattern: MarkdownTheme.headerPattern) + static let headerRegex = try! NSRegularExpression(pattern: MarkdownTheme.headerPattern) private static let refLinkDefRegex = try! NSRegularExpression(pattern: MarkdownTheme.referenceLinkDefinitionPattern) private static let footnoteDefRegex = try! NSRegularExpression(pattern: MarkdownTheme.footnoteDefinitionPattern) @@ -45,11 +45,11 @@ struct MarkdownBlockParser: Sendable { // this step the setext-H2 rule would promote the first frontmatter key // to a giant heading. var firstContentLine = 0 - if allLines.first?.trimmingCharacters(in: .whitespaces) == "---" { + if allLines.first.map(Self.markerLine) == "---" { var closingIndex: Int? var j = 1 while j < allLines.count { - let t = allLines[j].trimmingCharacters(in: .whitespaces) + let t = Self.markerLine(allLines[j]) if t == "---" || t == "..." { closingIndex = j; break } j += 1 } @@ -64,9 +64,10 @@ struct MarkdownBlockParser: Sendable { blocks.append(.table(index: blockIndex, headers: [], rows: properties.map { [$0.key, $0.value] }, alignments: [.leading, .leading], style: .properties, - sourceLine: 0)) + sourceLine: 0, isFrontMatter: true)) } else { - blocks.append(.codeBlock(index: blockIndex, code: yaml, language: "yaml", sourceLine: 0)) + blocks.append(.codeBlock(index: blockIndex, code: yaml, language: "yaml", sourceLine: 0, + isFrontMatter: true)) } blockIndex += 1 } @@ -83,26 +84,76 @@ struct MarkdownBlockParser: Sendable { // Headings carry the original line number so section copy can slice the // raw text without re-detecting headings itself. var lineMap: [Int] = [] + // Indices in `lines` of quote lines a link or footnote definition was + // taken out of: kept as markers without text, so the block loop knows + // the quote's paragraph they open (`DefinitionPlacement.definitionTaken`). + var definitionLines = Set() + // Fenced code and `$$` blocks, read as the block loop reads them: a + // definition inside one is that block's text (GitHub). + var verbatim = VerbatimRegions() + // A link definition cannot interrupt a paragraph (`DefinitionPlacement`). + var placement = DefinitionPlacement() for originalIndex in firstContentLine..` is decoded by `HTMLImageSyntax`). + blocks.append(.image(index: blockIndex, url: image.url, alt: HTMLEntities.decode(image.alt), + width: nil, sourceLine: sourceLine)) blockIndex += 1 } i += 1 continue } + // A PARAGRAPH that is only ONE linked image (`[![alt](img)](url)`, + // the README logo/badge form): the same `.image` block as + // `![alt](img)` — drawn on screen and in PDF, click = preview. The + // link target is dropped, as it is for `` (links + // on images: 1.15). Not a line of a longer paragraph: badges one + // per line are ONE paragraph on GitHub, a badge row like the one + // on a single line, and stay its linked placeholders (badge rows: + // 1.15) instead of a column of pictures. + if Self.noParagraphOpen(textBuffer), + let image = InlineSyntaxScanner.linkedImage(in: line[...], references: referenceDefinitions), + paragraphEnds(after: i, in: lines) { + flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows) + blocks.append(.image(index: blockIndex, url: image.url, alt: HTMLEntities.decode(image.alt), + width: nil, sourceLine: sourceLine)) + blockIndex += 1 + i += 1 + continue + } + // HTML image line (T-C): nothing but `` tags and layout // wrappers (`

`, `

`, ``, …), possibly an // `` is an @@ -255,7 +344,7 @@ struct MarkdownBlockParser: Sendable { textBuffer[last].line += " " } } else { - flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) + flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows) for tag in html.images { blocks.append(.image(index: blockIndex, url: tag.url, alt: tag.alt, width: tag.width, sourceLine: lineMap[i + tag.lineOffset])) @@ -266,29 +355,40 @@ struct MarkdownBlockParser: Sendable { continue } - // Setext headers (underlined headers) - let trimmed = line.trimmingCharacters(in: .whitespaces) - if i + 1 < lines.count && !trimmed.isEmpty && !trimmed.contains("|") { - let nextLine = lines[i + 1].trimmingCharacters(in: .whitespaces) - + // Setext headers (underlined headers): the WHOLE paragraph above + // the underline is the heading (CommonMark, GitHub), on the + // paragraph's first line. Its title keeps the lines' breaks + // (`setextTitle`): shown as spaces, dropped by the anchor. Not a + // list item's paragraph (`- a` over `===` is the item's text, over + // `---` the list and a rule) nor a quote line's (the quote branch + // takes those, lazy lines included). + // + // NOTE: no `!isTableSeparator(nextLine)` guard — a line of pure + // dashes ALWAYS matches the table-separator pattern (pipes are + // optional in it), which once made setext H2 unreachable. A + // pipe-bearing separator can't match setextH2Pattern anyway. + // (Caught by ParserTests.testSetextHeadings.) + let trimmed = trimmedLine + if i + 1 < lines.count && !trimmed.isEmpty && !trimmed.contains("|") && !trimmed.hasPrefix(">") { + let nextLine = Self.markerLine(lines[i + 1]) + let level: Int? if nextLine.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil { - flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) - // Setext: the TEXT line owns the heading, not the `===` underline. - blocks.append(.heading(index: blockIndex, level: 1, title: trimmed, sourceLine: sourceLine)) - blockIndex += 1 - i += 2 - continue + level = 1 + } else if nextLine.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil { + level = 2 + } else { + level = nil } - - // NOTE: no `!isTableSeparator(nextLine)` guard here — a line of - // pure dashes ALWAYS matches the table-separator pattern (pipes - // are optional in it), which made this branch unreachable and - // silently disabled setext H2 for years. A pipe-bearing table - // separator can't match setextH2Pattern anyway, so the guard - // added nothing. (Caught by ParserTests.testSetextHeadings.) - if nextLine.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil { - flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) - blocks.append(.heading(index: blockIndex, level: 2, title: trimmed, sourceLine: sourceLine)) + if let level, let start = Self.setextParagraphStart(in: textBuffer, endingWith: line) { + let title = Self.setextTitle(textBuffer[start...].map(\.line) + [line]) + let headingLine = start < textBuffer.count ? textBuffer[start].originalIndex : sourceLine + var preceding = Array(textBuffer[..= 2 && !trimmed.isEmpty { + if Self.pipeCount(line) >= 2 && !trimmed.isEmpty { if !isTableSeparator(trimmed) && i + 1 < lines.count && isTableSeparator(lines[i + 1]) { - flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) + flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows) var alignments = parseTableAlignments(lines[i + 1]) // The separator row is authoritative for column count: a header row @@ -309,7 +409,8 @@ struct MarkdownBlockParser: Sendable { var rows: [[String]] = [] i += 2 // Skip header and separator - while i < lines.count && lines[i].contains("|") && !isTableSeparator(lines[i]) { + while i < lines.count && lines[i].unicodeScalars.contains("|") && !isTableSeparator(lines[i]) + && !Self.interruptsTable(lines[i]) { rows.append(parseTableRow(lines[i])) i += 1 } @@ -318,6 +419,12 @@ struct MarkdownBlockParser: Sendable { headers = normalizeArray(headers, to: columnCount, default: "") alignments = normalizeArray(alignments, to: columnCount, default: .leading) rows = rows.map { normalizeArray($0, to: columnCount, default: "") } + // Cells are rendered later without the definitions (`ReferenceLinks`). + if !referenceDefinitions.isEmpty || !footnoteDefinitions.isEmpty { + let ids = footnoteDefinitions.map(\.id) + headers = headers.map { ReferenceLinks.inlined($0, definitions: referenceDefinitions, footnoteIds: ids) } + rows = rows.map { $0.map { ReferenceLinks.inlined($0, definitions: referenceDefinitions, footnoteIds: ids) } } + } // Header row line — `i` has already walked past the body rows. blocks.append(.table(index: blockIndex, headers: headers, rows: rows, alignments: alignments, @@ -329,10 +436,16 @@ struct MarkdownBlockParser: Sendable { // Blockquote detection - group consecutive > lines by nesting level if trimmed.hasPrefix(">") { - flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) + flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows) + // A later nesting group of this run continues the quote row + // before it (`MarkdownBlock.joinsPrevious`): `> a` then `>> b` + // is one quote on the page, drawn as two rows. + var previousGroupWasQuote = false while i < lines.count { - let qLine = lines[i].trimmingCharacters(in: .whitespaces) + // The predicate the branch was entered on (`markerLine`), + // so the first line is always taken. + let qLine = Self.markerLine(lines[i]) guard qLine.hasPrefix(">") else { break } // Count nesting level (number of leading > chars) @@ -350,14 +463,37 @@ struct MarkdownBlockParser: Sendable { // A single `>` run can yield SEVERAL blocks (one per nesting // level), so each group needs its OWN first line — the outer // `sourceLine` only describes the first group. - let groupSourceLine = lineMap[i] + // A definition marker (`definitionLines`) opens the + // quote's paragraph for a lazy line and draws nothing: + // the group starts at its first line of text. + var groupSourceLine: Int? let groupLevel = level - var groupLines = [String(scanner)] + var groupLines: [String] = [] + var tip = QuoteTip() + if definitionLines.contains(i) { + tip.takeDefinition() + } else { + groupLines.append(String(scanner)) + groupSourceLine = lineMap[i] + tip.take(groupLines[0]) + } i += 1 while i < lines.count { - let nextLine = lines[i].trimmingCharacters(in: .whitespaces) - guard nextLine.hasPrefix(">") else { break } + let nextLine = Self.markerLine(lines[i]) + guard nextLine.hasPrefix(">") else { + // No marker: a lazy continuation of the paragraph + // the group ends in (CommonMark), or the quote's end. + guard tip.endsInParagraph, + continuesQuoteLazily(lines[i], htmlImageLine: htmlImageLine(lines, at: i) != nil, + references: referenceDefinitions) + else { break } + groupLines.append(lines[i]) + groupSourceLine = groupSourceLine ?? lineMap[i] + tip.takeLazy() + i += 1 + continue + } var nextLevel = 0 var nextScanner = nextLine[...] @@ -369,23 +505,68 @@ struct MarkdownBlockParser: Sendable { } } - guard nextLevel == groupLevel else { break } - groupLines.append(String(nextScanner)) + if nextLevel != groupLevel { + // Fewer markers than the group: what follows them may + // continue the group's paragraph lazily (`> > a` then + // `> b` is one paragraph of the inner quote). + let content = String(nextScanner) + guard nextLevel < groupLevel, tip.endsInParagraph, + continuesQuoteLazily(content, + htmlImageLine: HTMLImageSyntax.scan(content[...]) != nil, + references: referenceDefinitions) + else { break } + if definitionLines.contains(i) { + tip.takeDefinition() + } else { + groupLines.append(content) + groupSourceLine = groupSourceLine ?? lineMap[i] + tip.takeLazy() + } + i += 1 + continue + } + if definitionLines.contains(i) { + tip.takeDefinition() + } else { + groupLines.append(String(nextScanner)) + groupSourceLine = groupSourceLine ?? lineMap[i] + tip.take(groupLines[groupLines.count - 1]) + } i += 1 } + // One paragraph break per run of blank lines (`collapsingBlankRuns`). + groupLines = Self.collapsingBlankRuns(groupLines) + // Quote and alert bodies are rendered later without the + // definitions, so references are resolved here, once per + // body (`ReferenceLinks`, soft-break groups as one line). let content = groupLines.joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) - if !content.isEmpty { + let footnoteIds = footnoteDefinitions.map(\.id) + if !content.isEmpty, let groupSourceLine { // GFM alert: level-1 quote whose first line is exactly // `[!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / - // `[!CAUTION]` (marker alone on the line, per GitHub spec). - if groupLevel == 1, let kind = AlertKind(markerLine: groupLines[0]) { - let body = groupLines.dropFirst().joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) + // `[!CAUTION]` (marker alone on the line, per GitHub spec) + // — and that has a body (GitHub): text after the marker, + // or a nested quote right after it, even an empty one. + // The marker alone (blank lines, definitions taken out) + // is a plain quote "[!NOTE]". + let alertKind = groupLevel == 1 ? AlertKind(markerLine: groupLines[0]) : nil + let rawBody = groupLines.dropFirst().joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + let nestedQuoteFollows = i < lines.count && Self.markerLine(lines[i]).hasPrefix(">") + if let kind = alertKind, !rawBody.isEmpty || nestedQuoteFollows { + let body = ReferenceLinks.inlined(rawBody, definitions: referenceDefinitions, + footnoteIds: footnoteIds, quoteBody: true) blocks.append(.alert(index: blockIndex, kind: kind, content: body, sourceLine: groupSourceLine)) + previousGroupWasQuote = false } else { - blocks.append(.blockquote(index: blockIndex, content: content, level: groupLevel, sourceLine: groupSourceLine)) + let body = ReferenceLinks.inlined(content, definitions: referenceDefinitions, + footnoteIds: footnoteIds, quoteBody: true) + blocks.append(.blockquote(index: blockIndex, content: body, level: groupLevel, + sourceLine: groupSourceLine, + joinsPrevious: previousGroupWasQuote)) + previousGroupWasQuote = true } blockIndex += 1 } @@ -407,7 +588,7 @@ struct MarkdownBlockParser: Sendable { // term line is NOT part of it (PHP Markdown Extra reads // "para line\n: def" as term + definition). var preceding = Array(textBuffer[.. Int { + line.unicodeScalars.reduce(0) { $1 == "|" ? $0 + 1 : $0 } + } + /// Check if line matches table separator pattern (e.g., |---|---|) private func isTableSeparator(_ line: String) -> Bool { - line.trimmingCharacters(in: .whitespaces) + Self.markerLine(line) .range(of: MarkdownTheme.tableSeparatorPattern, options: .regularExpression) != nil } @@ -499,26 +687,30 @@ struct MarkdownBlockParser: Sendable { /// never splits, even after `\\`: only that one backslash is dropped, so /// `\\|` leaves `\|` for the inline renderer (`|` in text, `\|` in code). /// Other escapes stay in the text for the inline renderer. + /// + /// The scan walks unicode SCALARS, as GFM works on code points: a `|` + /// followed by a combining mark (U+0301…) is one Swift `Character`, and a + /// Character scan neither split the cell there nor counted the pipe. private func parseTableRow(_ line: String) -> [String] { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) var cells: [String] = [] - var cell = "" - for ch in trimmed { - if ch != "|" { - cell.append(ch) + var cell = String.UnicodeScalarView() + for scalar in trimmed.unicodeScalars { + if scalar != "|" { + cell.append(scalar) } else if cell.last == "\\" { cell.removeLast() - cell.append("|") + cell.append(scalar) } else { - cells.append(cell) - cell = "" + cells.append(String(cell)) + cell = String.UnicodeScalarView() } } - cells.append(cell) + cells.append(String(cell)) // A leading pipe opens the row rather than ending an empty first cell - if trimmed.hasPrefix("|") { + if trimmed.unicodeScalars.first == "|" { cells.removeFirst() } @@ -594,11 +786,59 @@ struct MarkdownBlockParser: Sendable { // whitespace is kept: two trailing spaces are a hard break, and the // renderer's soft-break pass has to still see them. let text = String(scanner.drop(while: { $0 == " " || $0 == "\t" })) - return text.trimmingCharacters(in: .whitespaces).isEmpty ? nil : text + return MarkdownRenderer.isBlankLine(text) ? nil : text } + /// CommonMark's white space around block markers: spaces and tabs, not + /// every Unicode space separator (`isBlank`). + static let spacesAndTabs = MarkdownRenderer.markerWhitespace + + /// `line` without the spaces and tabs around it: what EVERY block marker + /// (fence, `$$`, quote, setext underline, table separator, front-matter + /// fence, link definition) is looked for in, at the branch that decides + /// AND in the loop that then walks the block. CommonMark indents with + /// spaces and tabs only — a no-break, em or ideographic space before a + /// marker leaves the line paragraph text, as on GitHub. Two predicates + /// for one decision once hung the parser (a quote marker after U+00A0). + static func markerLine(_ line: String) -> String { + line.trimmingCharacters(in: spacesAndTabs) + } + + /// CommonMark's blank line (`MarkdownRenderer.isBlankLine`): a line of + /// no-break spaces is text, not a separator. private static func isBlank(_ line: String) -> Bool { - line.trimmingCharacters(in: .whitespaces).isEmpty + MarkdownRenderer.isBlankLine(line) + } + + /// No paragraph goes on into the next line from the text buffered so + /// far: nothing is buffered, or it ends in a blank line or a rule (a + /// `---` under text never gets here: the setext branch takes it). + private static func noParagraphOpen(_ buffer: [(line: String, originalIndex: Int)]) -> Bool { + guard let last = buffer.last else { return true } + return isBlank(last.line) || MarkdownRenderer.isThematicBreak(last.line) + } + + /// The paragraph line `index` is in ends with it (CommonMark, GFM): it + /// is the last line, or the next line is blank or starts a block that + /// may interrupt a paragraph — a fence or `$$`, an ATX heading, a quote, + /// a rule, a bullet or an item numbered 1 with text + /// (`canInterruptParagraph`), an HTML block of kinds 1-6, a table (row + /// and separator). A setext underline makes the line a heading instead. + private func paragraphEnds(after index: Int, in lines: [String]) -> Bool { + guard index + 1 < lines.count else { return true } + let next = lines[index + 1] + if Self.isBlank(next) { return true } + let marker = Self.markerLine(next) + if marker.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil + || marker.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil { + return false + } + if Self.fenceOpening(marker) != nil || marker.hasPrefix("$$") || marker.hasPrefix(">") { return true } + if Self.headerRegex.firstMatch(in: next, range: NSRange(next.startIndex..., in: next)) != nil { return true } + if MarkdownRenderer.isThematicBreak(next) { return true } + if MarkdownRenderer.isListItem(next) { return MarkdownRenderer.canInterruptParagraph(next) } + if Self.startsHTMLBlock(marker, interruptingParagraph: true) { return true } + return Self.pipeCount(next) >= 2 && index + 2 < lines.count && isTableSeparator(lines[index + 2]) } /// Can this line be a definition-list TERM? Non-blank, not a definition @@ -612,8 +852,8 @@ struct MarkdownBlockParser: Sendable { /// definitions: a shortcut-reference image line (`![logo]`) is an image /// block, so it must not be a term either. private func isTermCandidate(_ line: String, references: [String: String]) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, Self.definitionText(of: line) == nil else { return false } + let trimmed = Self.markerLine(line) + guard !Self.isBlank(line), Self.definitionText(of: line) == nil else { return false } // Fences, quotes, display math if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { return false } @@ -621,11 +861,11 @@ struct MarkdownBlockParser: Sendable { // List items — bullets (task items included, they start `- [ ]`) and ordered if ["- ", "* ", "+ "].contains(where: { trimmed.hasPrefix($0) }) { return false } - if trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil { return false } + if trimmed.range(of: #"^(\d+)\.[ \t]"#, options: .regularExpression) != nil { return false } // Table rows (the table branch needs a separator to fire, but a pipe row // is still a table row, not a term) and separators - if line.filter({ $0 == "|" }).count >= 2 { return false } + if Self.pipeCount(line) >= 2 { return false } if isTableSeparator(trimmed) { return false } // Rules and setext underlines @@ -833,72 +1073,627 @@ struct MarkdownBlockParser: Sendable { // MARK: - Text Buffer - /// Cap on lines per text block. Large bullet/link lists (e.g. 11K-line bookmark dumps) - /// would otherwise coalesce into one ~10000-char Text(AttributedString) per heading - /// section — slow to lay out on window resize. We split at blank-line (paragraph) - /// boundaries so visual rendering is unaffected, but each individual block stays - /// small enough for SwiftUI Text to lay out quickly. - private static let textChunkLineLimit = 30 - - private func flushTextBuffer(_ buffer: inout [(line: String, originalIndex: Int)], to blocks: inout [MarkdownBlock], index: inout Int, using renderer: MarkdownRenderer) { + /// Cap on joined lines per text row. Large bullet/link lists (e.g. 11K-line + /// bookmark dumps) would otherwise become one ~10000-character row per + /// heading section — slow to lay out on window resize. A longer block is + /// cut into rows of this many lines; every row after the first is marked + /// `joinsPrevious`, so the cut adds no space and copies as a plain line break. + static let textChunkLineLimit = 30 + + /// Turns a run of text lines (everything between two non-text blocks) into + /// rows — ONE ROW PER BLOCK, as CommonMark reads the run: + /// - 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 themselves never reach a row: the space between two blocks + /// is `BlockLayout.Spacing`'s, the same whether the source has a blank line + /// there or not. + /// + /// Soft breaks are joined over the WHOLE run before it is cut. The rows are + /// rendered at the end of the parse (`renderPendingRows`), so a list split + /// into several rows (a loose list, one past the line cap, one whose items + /// hold a code block or a picture) keeps one text edge. + private func flushTextBuffer(_ buffer: inout [(line: String, originalIndex: Int)], to blocks: inout [MarkdownBlock], index: inout Int, pending: inout [PendingTextRow]) { guard !buffer.isEmpty else { return } - // A buffer of nothing but blank lines (the separator between two - // blockquotes, a heading and a code fence, …) used to flush an EMPTY - // text block that rendered as a ~42 pt gap. Drop it: block spacing is - // the stack's job, not the parser's. - guard buffer.contains(where: { !Self.isBlank($0.line) }) else { - buffer.removeAll() - return - } - - // Slice the buffer into chunks of ≤ textChunkLineLimit lines, preferring - // blank-line boundaries so we never split a paragraph. - var chunkStart = 0 - while chunkStart < buffer.count { - let hardEnd = min(chunkStart + Self.textChunkLineLimit, buffer.count) - var sliceEnd = hardEnd - - // If we're not at the end of the buffer, try to back up to the last blank - // line within this chunk so we don't split a paragraph mid-flow. - if hardEnd < buffer.count { - var probe = hardEnd - 1 - while probe > chunkStart { - if buffer[probe].line.trimmingCharacters(in: .whitespaces).isEmpty { - sliceEnd = probe + 1 // include the blank line in this chunk - break - } - probe -= 1 - } - // If no blank line found inside the window, fall through with hardEnd — - // the chunk is one long paragraph and we just have to take the hit. - } - - let chunk = buffer[chunkStart..= Self.textChunkLineLimit { + flushRow() + rowJoinsPrevious = true + } + append(position) + if startsList { inList = true } + } + flushRow() + } + + // MARK: - Setext headings + + /// A Setext heading's title: its lines, trimmed, joined with the line + /// breaks between them — GitHub's heading text keeps them, and its id + /// drops them (`Alpha⏎Beta` is `alphabeta`, where a space would be a + /// `-`); `MarkdownRenderer.shownHeadingTitle` shows each as a space. A + /// hard break's backslash is not text (an odd run of them ends the + /// line; trailing spaces are trimmed anyway). + static func setextTitle(_ lines: [String]) -> String { + let trimmed = lines.map(markerLine) + return trimmed.enumerated().map { index, text in + guard index < trimmed.count - 1 else { return text } + let backslashes = text.reversed().prefix(while: { $0 == "\\" }).count + return backslashes % 2 == 1 ? String(text.dropLast()) : text + }.joined(separator: "\n") + } + + /// Where the paragraph that `line` (the line over a Setext underline) + /// ends starts in the text buffer: after its last blank line or rule. + /// Nil when that paragraph is a list item's — an item line starts it or + /// interrupts it, by the rule rows are cut by (`flushTextBuffer`). + static func setextParagraphStart(in buffer: [(line: String, originalIndex: Int)], + endingWith line: String) -> Int? { + var start = buffer.count + while start > 0, !isBlank(buffer[start - 1].line), !MarkdownRenderer.isThematicBreak(buffer[start - 1].line) { + start -= 1 + } + let paragraph = buffer[start...].map(\.line) + [line] + for (offset, text) in paragraph.enumerated() where MarkdownRenderer.isListItem(text) { + if offset == 0 || MarkdownRenderer.canInterruptParagraph(text) { return nil } + } + return start + } + + // MARK: - Fences and `$$` blocks + + /// A quote's or alert's lines with each run of blank lines made ONE: a + /// blank line only separates paragraphs, and any run of them is one + /// paragraph break (GitHub, every nesting level; a definition the + /// pre-pass took out between two blank lines leaves none of its own). + /// Inside the quote's fence a blank line is code and stays. + static func collapsingBlankRuns(_ lines: [String]) -> [String] { + var result: [String] = [] + result.reserveCapacity(lines.count) + var fence: Fence? + for line in lines { + let marker = markerLine(line) + if let open = fence { + if closesFence(marker, open) { fence = nil } + } else if let opening = fenceOpening(marker) { + fence = opening + } else if isBlank(line), let last = result.last, isBlank(last) { + continue + } + result.append(line) + } + return result + } + + /// Columns of the spaces and tabs a line starts with (a tab runs to the + /// next multiple of 4, CommonMark). + static func indentColumns(_ line: String) -> Int { + var columns = 0 + for character in line { + if character == " " { + columns += 1 + } else if character == "\t" { + columns += 4 - columns % 4 + } else { + break + } + } + return columns + } + + /// `line` without up to `columns` columns of its leading spaces and tabs. + /// A tab that reaches past them leaves the columns it has left as spaces + /// (CommonMark: `\tTab` under a fence indented 2 is ` Tab`). + static func removingIndentation(_ line: String, upTo columns: Int) -> String { + guard columns > 0 else { return line } + var used = 0 + var index = line.startIndex + while index < line.endIndex, used < columns { + let character = line[index] + let width: Int + if character == " " { + width = 1 + } else if character == "\t" { + width = 4 - used % 4 + } else { + break + } + index = line.index(after: index) + if used + width > columns { + return String(repeating: " ", count: used + width - columns) + line[index...] + } + used += width + } + return String(line[index...]) + } + + /// A code fence's marker character and length. + typealias Fence = (marker: Character, length: Int) + + /// The fence `marker` (a `markerLine`) opens: three or more backticks or + /// tildes. THE opening test — the block loop, quote groups and the + /// definition pre-pass all ask it. + static func fenceOpening(_ marker: String) -> Fence? { + guard let first = marker.first, first == "`" || first == "~" else { return nil } + let length = marker.prefix(while: { $0 == first }).count + return length >= 3 ? (first, length) : nil + } + + /// Whether `marker` closes `fence`: the same character, at least as many, + /// and nothing after them but spaces and tabs. + static func closesFence(_ marker: String, _ fence: Fence) -> Bool { + marker.hasPrefix(String(repeating: fence.marker, count: fence.length)) + && MarkdownRenderer.isBlankLine(marker.drop(while: { $0 == fence.marker })) + } + + /// Whether `marker` opens a `$$` block that runs to a `$$` line — not a + /// one-line `$$…$$` formula. + static func opensMultilineMath(_ marker: String) -> Bool { + marker.hasPrefix("$$") + && !String(marker.dropFirst(2)).trimmingCharacters(in: .whitespaces).hasSuffix("$$") + } + + /// Whether `marker` closes a `$$` block. + static func closesMath(_ marker: String) -> Bool { marker == "$$" } + + /// The fenced code and `$$` blocks of a document, followed line by line + /// the way the block loop reads them (fence first, then `$$`; inside one, + /// only its closing line matters). The definition pre-pass leaves their + /// lines alone: a definition inside one is that block's text. + struct VerbatimRegions { + private var fence: Fence? + private var inMath = false + + /// Whether the line (its `markerLine`) opens, continues or closes + /// such a block. + mutating func contains(_ marker: String) -> Bool { + if let open = fence { + if MarkdownBlockParser.closesFence(marker, open) { fence = nil } + return true + } + if inMath { + if MarkdownBlockParser.closesMath(marker) { inMath = false } + return true + } + if let opening = MarkdownBlockParser.fenceOpening(marker) { + fence = opening + return true + } + if marker.hasPrefix("$$") { + inMath = MarkdownBlockParser.opensMultilineMath(marker) + return true + } + return false + } + } + + // MARK: - Quote laziness + + /// What a quote group's lines end in, as CommonMark reads them: an OPEN + /// paragraph (a paragraph's or a list item's text) is the one block a + /// lazy line may continue. A blank or empty quote line closes it, as do a + /// fenced code block (open or just closed), an ATX heading, a Setext + /// underline, a rule and a table. + struct QuoteTip { + private(set) var endsInParagraph = false + private var fence: Fence? + private var inTable = false + private var previousHadPipe = false + /// The open paragraph holds link definitions only, so far: another + /// may follow, and it draws nothing (`takeDefinition`). + private(set) var onlyDefinitions = false + + /// Whether a link definition may stand on the group's next line: it + /// would start a paragraph, or go on with one of definitions only — + /// not in the quote's fence or table (`DefinitionPlacement`). + var admitsDefinition: Bool { fence == nil && !inTable && (!endsInParagraph || onlyDefinitions) } + + /// A link definition the pre-pass took out: it opens (or goes on + /// with) a paragraph of definitions only, which a lazy line continues + /// in this quote (CommonMark, GitHub). + mutating func takeDefinition() { + endsInParagraph = true + onlyDefinitions = true + previousHadPipe = false + inTable = false + } + + /// Inside the quote's fence. + var inFence: Bool { fence != nil } + + /// A lazily continued line (no marker, or fewer markers than the + /// group): paragraph text whatever it looks like — CommonMark never + /// reads a lazy line as a table separator, a Setext underline or any + /// other structure, so the paragraph stays open for the next one. + mutating func takeLazy() { + endsInParagraph = true + onlyDefinitions = false + previousHadPipe = false + } + + /// A line of the group itself (marked at the group's level). + mutating func take(_ content: String) { + let marker = MarkdownBlockParser.markerLine(content) + // Under definitions only, `===` is text (CommonMark). + let afterDefinitions = onlyDefinitions + onlyDefinitions = false + if let open = fence { + // Inside the quote's fence: only its closing line matters. + if MarkdownBlockParser.closesFence(marker, open) { fence = nil } + endsInParagraph = false + return + } + defer { previousHadPipe = content.contains("|") } + if MarkdownRenderer.isBlankLine(content) { + endsInParagraph = false + inTable = false + return + } + if let opening = MarkdownBlockParser.fenceOpening(marker) { + fence = opening + endsInParagraph = false + inTable = false + return + } + // A row; a block start ends the table (`interruptsTable`). + if inTable, content.contains("|"), !MarkdownBlockParser.interruptsTable(content) { + endsInParagraph = false + return + } + inTable = false + if endsInParagraph, previousHadPipe, + marker.range(of: MarkdownTheme.tableSeparatorPattern, options: .regularExpression) != nil { + inTable = true + endsInParagraph = false + return + } + let nsRange = NSRange(content.startIndex..., in: content) + if MarkdownBlockParser.headerRegex.firstMatch(in: content, range: nsRange) != nil + || MarkdownRenderer.isThematicBreak(content) { + endsInParagraph = false + return + } + if endsInParagraph, !afterDefinitions, + marker.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil + || marker.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil { + endsInParagraph = false + return + } + endsInParagraph = true + } + } + + /// Whether `line` — a line without a quote marker, or what follows fewer + /// markers than the quote has — continues the paragraph a quote ends in + /// (CommonMark laziness): it is not blank and starts no block. Checked as + /// cmark does, against the DOCUMENT, not the paragraph: any list item ends + /// the quote (`2. b` too), and so does a lone HTML tag; `===`, an indented + /// line, a pipe row or `` text continue it (GitHub, gh api markdown). + /// Our own block starts end it as well: a picture line (Markdown or HTML) + /// and `$$` stay the picture and the formula they are everywhere else. + private func continuesQuoteLazily(_ line: String, htmlImageLine: Bool, + references: [String: String]) -> Bool { + guard !htmlImageLine, Self.startsNoBlock(line) else { return false } + if InlineSyntaxScanner.standaloneImages(in: line[...], references: references) != nil { return false } + return true + } + + /// Whether a block starts on `line`, which ends a table's body: GitHub + /// closes a table for any block start — a fence, a quote, an ATX + /// heading, a list item, an HTML block (gfm/h2_*.html) — and `$$` opens + /// a formula here. A fence or `$$` line taken as a row disagreed with the + /// definition pre-pass (`VerbatimRegions`), which reads it as a block. + static func interruptsTable(_ line: String) -> Bool { + !startsNoBlock(line) || markerLine(line).hasPrefix(">") + } + + /// CommonMark's part of `continuesQuoteLazily`: not blank, and no block + /// starts on the line (fence, `$$`, ATX heading, rule, list item, HTML + /// block) — checked against the document, as cmark does. + static func startsNoBlock(_ line: String) -> Bool { + guard !isBlank(line) else { return false } + let marker = markerLine(line) + if fenceOpening(marker) != nil || marker.hasPrefix("$$") { return false } + let nsRange = NSRange(line.startIndex..., in: line) + if headerRegex.firstMatch(in: line, range: nsRange) != nil { return false } + if MarkdownRenderer.isThematicBreak(line) || MarkdownRenderer.isListItem(line) { return false } + if ["-", "*", "+"].contains(marker) { return false } // an empty item + if startsHTMLBlock(marker) { return false } + // A footnote definition starts a block (GFM): `> > a` then + // `> [^m]: note` is the outer quote's footnote, not lazy text. + if footnoteDefRegex.firstMatch(in: marker, range: NSRange(marker.startIndex..., in: marker)) != nil { + return false + } + return true + } + + /// Whether the definition pre-pass may take a LINK definition out on the + /// next line: not where it would continue a paragraph. CommonMark: a + /// link definition cannot interrupt a paragraph; GitHub keeps such a + /// line as the paragraph's text — a list item's, a table's (its rows run + /// on) and a quote's lazy line included. (A footnote definition may + /// interrupt one, GitHub: the pre-pass takes those wherever they stand + /// outside fenced code.) + struct DefinitionPlacement { + private(set) var paragraphOpen = false + /// The quote group being read, grouped as the block loop groups it: + /// what its lines end in (`QuoteTip`) and its `>` count. A line with + /// more markers opens a nested quote, a new group; one with fewer + /// goes on lazily or starts a new group. + private var quote: QuoteTip? + private var quoteLevel = 0 + + /// A line of a fenced code or `$$` block (`VerbatimRegions`). + mutating func verbatim() { + paragraphOpen = false + quote = nil + } + + /// A definition the pre-pass took out: it ends what came before. + mutating func definition() { + paragraphOpen = false + quote = nil + } + + /// What a LINK definition on the next line (its `markerLine`) would + /// be read from, or nil where none may stand: the line itself where + /// no paragraph is open; what follows a quote line's markers where + /// the line starts a paragraph of its quote (GitHub: `> [x]: url` + /// defines `x` and leaves the quote empty) — a new quote, a deeper + /// one opened under the quote's text (`> a`, then `>> [x]: url`), or + /// a line of the group after no open paragraph. Not lazy text, nor + /// in the quote's fence or table. + func linkDefinitionText(_ marker: String) -> String? { + guard marker.hasPrefix(">") else { + // A lazy line of a quote paragraph of definitions only: another. + if let tip = quote, tip.endsInParagraph { + return tip.onlyDefinitions && MarkdownBlockParser.startsNoBlock(marker) ? marker : nil + } + return paragraphOpen ? nil : marker + } + let (level, content) = Self.quoteMarkers(of: marker) + let text = MarkdownBlockParser.markerLine(String(content)) + switch step(level: level, content: String(content)) { + case .same: + return quote?.admitsDefinition == true ? text : nil + case .lazy: + return quote?.onlyDefinitions == true ? text : nil + case .newGroup: + // A deeper `>` inside the quote's fence is that fence's code. + if level > quoteLevel, quote?.inFence == true { return nil } + return text + } + } + + /// What a FOOTNOTE definition on a quote line (its `markerLine`) + /// would be read from: what follows its markers. A footnote + /// definition may interrupt a paragraph (GitHub), so anywhere in the + /// quote but in its fence. Nil for any other line: the pre-pass + /// reads a line without markers whole. + func footnoteDefinitionText(_ marker: String) -> String? { + guard marker.hasPrefix(">") else { return nil } + let (level, content) = Self.quoteMarkers(of: marker) + if let tip = quote, tip.inFence, level >= quoteLevel { return nil } + return MarkdownBlockParser.markerLine(String(content)) + } + + /// A link or footnote definition taken out of `marker` (a + /// `markerLine`). Out of a quote line, it opens a paragraph of that + /// quote — of definitions only, which draws nothing and which a lazy + /// line continues in the quote (GitHub) — and the line stays for the + /// block loop as a marker: true. A lazy link definition after such a + /// paragraph goes on with it; any other definition ends what came + /// before. + mutating func definitionTaken(_ marker: String) -> Bool { + if marker.hasPrefix(">") { + let (level, content) = Self.quoteMarkers(of: marker) + var tip = quote ?? QuoteTip() + if step(level: level, content: String(content)) == .newGroup { + tip = QuoteTip() + quoteLevel = level + } + tip.takeDefinition() + quote = tip + paragraphOpen = true + return true + } + if let tip = quote, tip.endsInParagraph, tip.onlyDefinitions { return false } + definition() + return false + } + + /// How a quote line goes on from the group being read — the block + /// loop's grouping (lazy: fewer markers, continuing its paragraph). + private enum QuoteStep { case same, lazy, newGroup } + + private func step(level: Int, content: String) -> QuoteStep { + guard let tip = quote else { return .newGroup } + if level == quoteLevel { return .same } + if level < quoteLevel, tip.endsInParagraph, MarkdownBlockParser.startsNoBlock(content) { + return .lazy + } + return .newGroup + } + + /// A quote line's `>` count and what follows its markers (each `>` + /// with one optional space after it). + static func quoteMarkers(of marker: String) -> (level: Int, content: Substring) { + var content = marker[...] + var level = 0 + while content.hasPrefix(">") { + content = content.dropFirst() + level += 1 + if content.hasPrefix(" ") { content = content.dropFirst() } + } + return (level, content) + } + + /// Any other line, kept for the block loop. + mutating func kept(_ line: String) { + if MarkdownRenderer.isBlankLine(line) { + paragraphOpen = false + quote = nil + return + } + let marker = MarkdownBlockParser.markerLine(line) + if marker.hasPrefix(">") { + let (level, content) = Self.quoteMarkers(of: marker) + var tip = quote ?? QuoteTip() + switch step(level: level, content: String(content)) { + case .same: + tip.take(String(content)) + case .lazy: + tip.takeLazy() + case .newGroup: + tip = QuoteTip() + tip.take(String(content)) + quoteLevel = level + } + quote = tip + paragraphOpen = tip.endsInParagraph + return + } + if var tip = quote, tip.endsInParagraph, MarkdownBlockParser.startsNoBlock(line) { + tip.takeLazy() + quote = tip + paragraphOpen = true + return + } + quote = nil + let nsRange = NSRange(line.startIndex..., in: line) + if MarkdownBlockParser.headerRegex.firstMatch(in: line, range: nsRange) != nil + || MarkdownRenderer.isThematicBreak(line) + || (paragraphOpen && marker.range(of: MarkdownTheme.setextH1Pattern, + options: .regularExpression) != nil) { + paragraphOpen = false + return + } + paragraphOpen = true + } + } + + /// CommonMark's HTML block starts (kinds 1-7), as far as a lazy line needs + /// them: ``, `text` is inline HTML. + static func startsHTMLBlock(_ marker: String, interruptingParagraph: Bool = false) -> Bool { + guard marker.hasPrefix("<") else { return false } + if marker.hasPrefix("" || rest.hasPrefix("/>") + if ends, htmlBlockTags.contains(name) { return true } + // Kind 7 (a line that is one complete tag) cannot interrupt a paragraph. + if interruptingParagraph { return false } + return marker.range(of: #"^]*)?/?>[ \t]*$"#, + options: .regularExpression) != nil + } + + /// CommonMark HTML block kinds 1 and 6. + private static let htmlBlockTags: Set = [ + "pre", "script", "style", "textarea", + "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", + "col", "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption", + "figure", "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", + "header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem", "nav", + "noframes", "ol", "optgroup", "option", "p", "param", "search", "section", "summary", "table", + "tbody", "td", "tfoot", "th", "thead", "title", "tr", "track", "ul", + ] + + /// A text row waiting for `renderPendingRows`: its soft-break joined lines + /// and where its block sits in the block list. + struct PendingTextRow { + let position: Int + let index: Int + let lines: [String] + let sourceLine: Int + let joinsPrevious: Bool + } + + /// Stands in for a non-text block when the ordered-list hangs are worked + /// out: not blank, not a list item — so it continues a list exactly when + /// its first line is indented under the list's items (a step's code + /// block, a picture under an item), as a nested paragraph would. + private static let blockStandIn = "\u{FFFC}" + + /// Renders the text rows, every ordered item hanging under the widest + /// marker of ITS list (`MarkdownRenderer.widestOrderedPrefixes`), worked + /// out over the whole document: the rows' lines in order, each other + /// block standing in as one line with its indentation. Until 1.14.4 the + /// widest marker was each row's own, so a list cut into rows had two text + /// edges, and the steps of a list whose items hold code blocks each hung + /// under their own number ("1. " is narrower than "10. "). + private func renderPendingRows(_ pending: [PendingTextRow], in blocks: inout [MarkdownBlock], + allLines: [String], using renderer: MarkdownRenderer) { + guard !pending.isEmpty else { return } + var lines: [String] = [] + var starts: [Int] = [] + starts.reserveCapacity(pending.count) + var next = 0 + for (position, block) in blocks.enumerated() { + if next < pending.count, pending[next].position == position { + starts.append(lines.count) + lines.append(contentsOf: pending[next].lines) + next += 1 + } else { + let first = block.sourceLine >= 0 && block.sourceLine < allLines.count ? allLines[block.sourceLine] : "" + lines.append(String(first.prefix(while: { $0 == " " || $0 == "\t" })) + Self.blockStandIn) + } + } + let hangs = renderer.widestOrderedPrefixes(lines) + for (row, start) in zip(pending, starts) { + blocks[row.position] = .text(index: row.index, + renderer.render(joined: row.lines, + hangs: Array(hangs[start..<(start + row.lines.count)])), + sourceLine: row.sourceLine, joinsPrevious: row.joinsPrevious) } - buffer = [] } } diff --git a/QuickMD/QuickMD/MarkdownExport.swift b/QuickMD/QuickMD/MarkdownExport.swift index 67ef152..68f484b 100644 --- a/QuickMD/QuickMD/MarkdownExport.swift +++ b/QuickMD/QuickMD/MarkdownExport.swift @@ -170,79 +170,6 @@ extension FocusedValues { } } -// MARK: - Printable View (Light mode, white background) -// NOTE: ImageRenderer uses DEFAULT environment, not app's environment -// So we must explicitly set ALL colors, not rely on colorScheme - -struct MarkdownPrintableView: View { - let documentText: String - /// Base for relative image paths (nil for an untitled buffer). - let documentURL: URL? - - /// Parsed blocks with light theme - computed once on init - private let lightBlocks: [MarkdownBlock] - - init(documentText: String, documentURL: URL? = nil) { - self.documentText = documentText - self.documentURL = documentURL - self.lightBlocks = MarkdownBlockParser(colorScheme: .light).parse(documentText) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(lightBlocks) { block in - switch block.content { - case .text(let attributedString): - // Force black text color; inline $math$ as images (PrintableMath) - PrintableMath.text(from: attributedString) - .foregroundColor(.black) - - case .table(let headers, let rows, let alignments, let style): - PrintableTableView(headers: headers, rows: rows, alignments: alignments, style: style) - .padding(.vertical, 8) - - case .codeBlock(let code, let language): - PrintableCodeBlockView(code: code, language: language) - .padding(.vertical, 4) - - case .image(let url, let alt, let width): - PrintableImageView(url: url, alt: alt, width: width, documentURL: documentURL) - .padding(.vertical, 8) - - case .blockquote(let content, let level): - PrintableBlockquoteView(content: content, level: level) - .padding(.vertical, 4) - - case .alert(let kind, let content): - PrintableAlertView(kind: kind, content: content) - .padding(.vertical, 4) - - case .heading(let level, let title, _): - let renderer = MarkdownRenderer(colorScheme: .light) - Text(renderer.renderHeader(title, level: level)) - .foregroundColor(.black) - - case .mathBlock(let latex): - PrintableMathBlockView(latex: latex) - .padding(.vertical, 4) - - case .mermaidDiagram(let source): - // Graceful degradation: render as styled code block in PDF - PrintableCodeBlockView(code: source, language: "mermaid") - .padding(.vertical, 4) - - case .svgImage(let source): - PrintableSVGView(source: source) - .padding(.vertical, 4) - } - } - } - .padding(40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white) - } -} - // MARK: - Table Alignment Protocol /// Column alignment for the SwiftUI table (`PrintableTableView`). The on-screen @@ -341,8 +268,7 @@ struct PrintableTableView: View, TableAlignmentProvider { .padding(.horizontal, Self.horizontalPadding) .padding(.vertical, 4) Rectangle().fill(theme.borderColor) - Text(TableTextBuilder.printableCell(row.count > 1 ? row[1] : "", isHeader: false, - renderer: renderer)) + Text(TableTextBuilder.valueCell(row.count > 1 ? row[1] : "", renderer: renderer)) .foregroundColor(.black) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Self.horizontalPadding) @@ -534,6 +460,10 @@ enum PrintFolderAccess { } // MARK: - Single Block Printable View (for per-block PDF rendering) +// NOTE: ImageRenderer uses the DEFAULT environment, not the app's, so every +// printable view sets ALL its colours explicitly rather than relying on +// colorScheme. Blocks carry no outer padding: the space between them is +// `BlockLayout.Spacing`, applied by `PDFExportManager` (`PrintRhythm`). struct MarkdownPrintableBlockView: View { let block: MarkdownBlock @@ -637,7 +567,7 @@ struct PrintableBlockquoteView: View { // quote written one sentence per line prints as one paragraph. VStack(alignment: .leading, spacing: 4) { ForEach(Array(MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n")).enumerated()), id: \.offset) { _, line in - if line.trimmingCharacters(in: .whitespaces).isEmpty { + if MarkdownRenderer.isBlankLine(line) { Text(" ").font(renderer.theme.fonts.swiftUI(size: 12)) } else { Text(renderer.renderInline(line)) @@ -676,7 +606,7 @@ struct PrintableAlertView: View { // Soft breaks joined first — mirrors AlertBlockView on screen. ForEach(Array(MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n")).enumerated()), id: \.offset) { _, line in - if line.trimmingCharacters(in: .whitespaces).isEmpty { + if MarkdownRenderer.isBlankLine(line) { Text(" ").font(renderer.theme.fonts.swiftUI(size: 12)) } else { Text(renderer.renderInline(line)) @@ -810,24 +740,40 @@ class PDFExportManager { return nil } - // Paginate: distribute blocks/segments across pages - var pages: [[PlacedSegment]] = [[]] - var currentY: CGFloat = 0 - - for (block, size) in measuredBlocks { - let segments = sliceBlockIfOversized(block: block, size: size, maxHeight: contentHeight) - for segment in segments { - let blockHeight = segment.sliceHeight - - // If block doesn't fit on current page and page isn't empty, start new page - if currentY + blockHeight > contentHeight && !pages[pages.count - 1].isEmpty { - pages.append([]) - currentY = 0 - } - - pages[pages.count - 1].append(PlacedSegment(segment: segment, yOffset: currentY)) - currentY += blockHeight + 8 // 8pt spacing between blocks + // The screen's vertical rhythm (`BlockLayout.Spacing`), from the edges + // of the PRINTED views, at 1.0 zoom — print never follows the window's. + let printRenderer = MarkdownRenderer(colorScheme: .light) + let rhythm = measuredBlocks.map { measured in + let isPicture: Bool + if case .mermaidDiagram(let source) = measured.block.content { + isPicture = mermaidImages[source] != nil + } else { + isPicture = false } + return PrintRhythm.row(for: measured.block, renderer: printRenderer, width: contentWidth, + diagramIsPicture: isPicture) + } + let spacing = BlockLayout.Spacing.column(rhythm, fontScale: 1) + + // Paginate: a block taller than a page is sliced; only its first slice + // has the block's space above it, and a heading never ends a page + // without the start of the block after it (`PrintRhythm.paginate`). + let slices = PrintRhythm.paginate( + measuredBlocks.enumerated().map { index, measured in + let isHeading: Bool + if case .heading = measured.block.content { isHeading = true } else { isHeading = false } + return PrintRhythm.Block(height: measured.size.height, spacingAbove: spacing[index], + keepsWithNext: isHeading) + }, + pageHeight: contentHeight) + var pages: [[PlacedSegment]] = [[]] + for slice in slices { + while pages.count <= slice.page { pages.append([]) } + let measured = measuredBlocks[slice.block] + pages[slice.page].append(PlacedSegment( + segment: BlockSegment(block: measured.block, size: measured.size, + sliceOffset: slice.offset, sliceHeight: slice.height), + yOffset: slice.y)) } let pdfData = NSMutableData() @@ -882,26 +828,6 @@ class PDFExportManager { return pdfData as Data } - private static func sliceBlockIfOversized(block: MarkdownBlock, size: CGSize, maxHeight: CGFloat) -> [BlockSegment] { - guard size.height > maxHeight, size.height > 0 else { - return [BlockSegment(block: block, size: size, sliceOffset: 0, sliceHeight: size.height)] - } - - var segments: [BlockSegment] = [] - var offset: CGFloat = 0 - while offset < size.height { - let sliceHeight = min(maxHeight, size.height - offset) - segments.append(BlockSegment( - block: block, - size: size, - sliceOffset: offset, - sliceHeight: sliceHeight - )) - offset += sliceHeight - } - return segments - } - private static func showError(_ message: String) { let alert = NSAlert() alert.messageText = "Export Error" @@ -994,7 +920,8 @@ enum PrintableMath { var mathImage = MathImage(latex: latex, fontSize: 14, textColor: .black, labelMode: .text, textAlignment: .left) let (error, image, info) = mathImage.asImage() - guard error == nil, let image else { return nil } + // Nothing drawn: the italic literal, as on screen (`MathInk`). + guard error == nil, let image, MathInk.drawsSomething(latex, image: image) else { return nil } return (image, info?.descent ?? 0) } @@ -1008,16 +935,16 @@ enum PrintableMath { var result = Text("") var index = attributed.startIndex - for segment in InlineMathSegmenter.split(plain) { - switch segment { - case .text(let str): - if let end = attributed.characters.index(index, offsetBy: str.count, + for piece in InlineMathSegmenter.pieces(of: attributed) { + switch piece.segment { + case .text: + if let end = attributed.characters.index(index, offsetBy: piece.length, limitedBy: attributed.endIndex) { result = result + Text(AttributedString(attributed[index.. String { + entitiesLiteral ? text : HTMLEntities.decode(text) + } // Static precompiled regex for parsing (avoid recompilation per line) private static let taskListRegex = try! NSRegularExpression(pattern: MarkdownTheme.taskListPattern) @@ -167,19 +187,26 @@ struct MarkdownRenderer: Sendable { // MARK: - Main Render func render(_ markdown: String) -> AttributedString { - var result = AttributedString() - let lines = Self.joinSoftBreaks(markdown.components(separatedBy: "\n")) // Ordered items of one list line up on the WIDEST marker of that list: // the body font is not monospaced, so "1. " and "10. " have different // widths and each item's text would otherwise start at a different x. // The run is known before any rendering, so the widest prefix is too. - let hangs = widestOrderedPrefixes(lines) + return render(joined: lines, hangs: widestOrderedPrefixes(lines)) + } + + /// Renders lines that are ALREADY soft-break joined (`joinSoftBreaks`), + /// each ordered item hanging under `hangs[i]` (`widestOrderedPrefixes`). + /// + /// The parser's entry point: it joins and computes the hangs over a whole + /// text run, then renders the run one block at a time — so the items of a + /// loose list, one row each, still share their list's widest marker. + func render(joined lines: [String], hangs: [String?]) -> AttributedString { + var result = AttributedString() for (index, line) in lines.enumerated() { - result.append(renderLine(line, hangingPrefix: hangs[index])) + result.append(renderLine(line, hangingPrefix: index < hangs.count ? hangs[index] : nil)) result.append(AttributedString("\n")) } - return result } @@ -227,8 +254,8 @@ struct MarkdownRenderer: Sendable { /// `(prefix, level)` of an ordered list item, prefix exactly as /// `renderListItem` will emit it (indent spaces + normalised number). private static func orderedItem(_ line: String) -> (prefix: String, level: Int)? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil else { return nil } + let trimmed = markerLine(line) + guard trimmed.range(of: Self.orderedMarkerPattern, options: .regularExpression) != nil else { return nil } let level = listLevel(for: line.prefix(while: { $0 == " " || $0 == "\t" })) let number = Int(trimmed.prefix(while: { $0.isNumber })) ?? 1 return (indentSpaces(level) + "\(number). ", level) @@ -253,14 +280,24 @@ struct MarkdownRenderer: Sendable { /// spaces, or a trailing backslash — still ends the visual line, and /// headers, rules, and blanks always keep their own line. static func joinSoftBreaks(_ lines: [String]) -> [String] { - var joined: [String] = [] + joinSoftBreaksIndexed(lines).map(\.line) + } + + /// `joinSoftBreaks`, with the index in `lines` of each joined line's FIRST + /// input line — how the parser maps a row back to its source line. + static func joinSoftBreaksIndexed(_ lines: [String]) -> [(line: String, first: Int)] { + var joined: [(line: String, first: Int)] = [] var openParagraph = false // last joined line can absorb a continuation + // Inside a paragraph that is not a list item's, until a blank or + // structural line — across hard breaks too. + var inPlainParagraph = false - for line in lines { + for (index, line) in lines.enumerated() { let kind = classify(line) guard kind != .structural else { - joined.append(line) + joined.append((line, index)) openParagraph = false + inPlainParagraph = false continue } @@ -269,10 +306,21 @@ struct MarkdownRenderer: Sendable { while text.last == " " || text.last == "\t" { text.removeLast() } if hardBreak && text.hasSuffix("\\") { text.removeLast() } - if kind == .paragraph && openParagraph { - joined[joined.count - 1] += " " + text.trimmingCharacters(in: .whitespaces) + if kind == .listItem && inPlainParagraph && !canInterruptParagraph(line) { + // CommonMark: only a bullet or an ordered item numbered 1 may + // interrupt a paragraph. `1984. A year` after paragraph text + // is more of that text — drawn, copied and searched as such. + if openParagraph { + joined[joined.count - 1].line += " " + continuationText(text) + } else { + // After a hard break: its own visual line, still text. + joined.append((asParagraphText(text), index)) + } + } else if kind == .paragraph && openParagraph { + joined[joined.count - 1].line += " " + continuationText(text) } else { - joined.append(text) + joined.append((text, index)) + inPlainParagraph = kind == .paragraph } openParagraph = !hardBreak } @@ -291,23 +339,89 @@ struct MarkdownRenderer: Sendable { case paragraph } + /// A bullet, ordered or task list item — `renderLine` renders it as one. + static func isListItem(_ line: String) -> Bool { classify(line) == .listItem } + + /// CommonMark's blank line: nothing but spaces and tabs — the ONE blank + /// test of the parser and the renderers. Not "any white space": + /// `CharacterSet.whitespaces` is every Unicode space separator, so it + /// counted a line of no-break spaces (U+00A0) blank. Such a line is + /// paragraph text (how authors write an empty-looking paragraph): after + /// text it continues the paragraph, alone it is a paragraph of its own; + /// it never ends a block or separates two. + static func isBlankLine(_ line: S) -> Bool { + line.allSatisfy { $0 == " " || $0 == "\t" } + } + + /// CommonMark's white space around block and list markers: spaces and + /// tabs only. A no-break, em or ideographic space before a marker (or + /// after `#`, a list marker or a rule) leaves the line paragraph text, as + /// on GitHub; the patterns in `MarkdownTheme` say `[ \t]` for the same + /// reason. `MarkdownBlockParser.markerLine` trims with it too. + static let markerWhitespace = CharacterSet(charactersIn: " \t") + + /// `line` without the spaces and tabs around it — what every list, + /// rule and heading marker is looked for in. + private static func markerLine(_ line: String) -> String { + line.trimmingCharacters(in: markerWhitespace) + } + + /// A continuation line's text: CommonMark strips its leading spaces and + /// tabs, and nothing else (a no-break space is text). + private static func continuationText(_ text: String) -> String { + String(text.drop(while: { $0 == " " || $0 == "\t" })) + } + + /// An ordered item's marker (CommonMark): one to nine digits, then `.` or + /// `)`, then white space. Drawn as `n.` whichever delimiter it had, as + /// GitHub draws both (an HTML list). + static let orderedMarkerPattern = #"^(\d{1,9})[.)][ \t]"# + + /// `line` (an ordered item line that may not interrupt a paragraph) as + /// paragraph text: its delimiter backslash-escaped, so `renderLine` draws + /// `1984. A year` as the words it is and never as an item. + static func asParagraphText(_ line: String) -> String { + line.replacingOccurrences(of: #"^([ \t]*\d{1,9})([.)])"#, with: "$1\\\\$2", options: .regularExpression) + } + + /// Whether this item line may interrupt a paragraph (CommonMark): a + /// bullet or task item, or an ordered item numbered 1 (`1.` or `1)`) — + /// and not an empty one. Any other item line after paragraph text + /// (`1984. was a year`) continues the paragraph: `joinSoftBreaks` makes + /// it paragraph text, so it is no item and no block boundary. + static func canInterruptParagraph(_ line: String) -> Bool { + guard isListItem(line) else { return false } + let trimmed = markerLine(line) + if let marker = trimmed.range(of: #"^\d{1,9}[.)]"#, options: .regularExpression) { + guard Int(trimmed[marker].dropLast()) == 1 else { return false } + return !isBlankLine(trimmed[marker.upperBound...]) + } + return !isBlankLine(trimmed.dropFirst(2)) + } + + /// A thematic break (`---`, `***`, `___`) — `renderLine` draws it as a rule. + static func isThematicBreak(_ line: String) -> Bool { + markerLine(line) + .range(of: MarkdownTheme.horizontalRulePattern, options: .regularExpression) != nil + } + /// Must mirror the dispatch in `renderLine`, or a structural line could /// get glued into the paragraph before it. private static func classify(_ line: String) -> LineKind { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return .structural } + if isBlankLine(line) { return .structural } + let trimmed = markerLine(line) let nsRange = NSRange(line.startIndex..., in: line) if headerRegex.firstMatch(in: line, range: nsRange) != nil { return .structural } if trimmed.range(of: MarkdownTheme.horizontalRulePattern, options: .regularExpression) != nil { return .structural } if taskListRegex.firstMatch(in: line, range: nsRange) != nil { return .listItem } if ["- ", "* ", "+ "].contains(where: { trimmed.hasPrefix($0) }) { return .listItem } - if trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil { return .listItem } + if trimmed.range(of: orderedMarkerPattern, options: .regularExpression) != nil { return .listItem } return .paragraph } /// Two-plus trailing spaces, or an odd run of trailing backslashes (an /// even run is escaped literal backslashes, e.g. `foo\\`). - private static func endsWithHardBreak(_ line: String) -> Bool { + static func endsWithHardBreak(_ line: String) -> Bool { if line.hasSuffix(" ") { return true } return line.reversed().prefix(while: { $0 == "\\" }).count % 2 == 1 } @@ -315,7 +429,7 @@ struct MarkdownRenderer: Sendable { // MARK: - Line Rendering private func renderLine(_ line: String, hangingPrefix: String? = nil) -> AttributedString { - let trimmed = line.trimmingCharacters(in: .whitespaces) + let trimmed = Self.markerLine(line) // Header - extract hash count from regex group, not space position let nsRange = NSRange(line.startIndex..., in: line) @@ -345,7 +459,7 @@ struct MarkdownRenderer: Sendable { } // Ordered list - if let match = trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) { + if let match = trimmed.range(of: Self.orderedMarkerPattern, options: .regularExpression) { let level = Self.listLevel(for: line.prefix(while: { $0 == " " || $0 == "\t" })) let number = Int(trimmed.prefix(while: { $0.isNumber })) ?? 1 let content = String(trimmed[match.upperBound...]) @@ -354,7 +468,7 @@ struct MarkdownRenderer: Sendable { } // Empty line - if trimmed.isEmpty { + if Self.isBlankLine(line) { return AttributedString("\n") } @@ -364,13 +478,75 @@ struct MarkdownRenderer: Sendable { // MARK: - Block Renderers + /// A heading's title as shown: a Setext title keeps its paragraph's line + /// breaks (`MarkdownBlockParser.setextTitle`) and each reads as a space, + /// like a soft break in a paragraph — in the heading, the Contents + /// sidebar and search alike. + static func shownHeadingTitle(_ title: String) -> String { + title.replacingOccurrences(of: "\n", with: " ") + } + + /// The text GitHub takes a heading's id from: the rendered title with a + /// Setext title's line breaks KEPT — the slug drops them (`Alpha⏎Beta` + /// is `alphabeta`), where the space shown would be a `-`. + /// A footnote reference counts as its number in ASCII digits: GitHub's + /// slug reads the heading's text, `Note1` is `note1`, and the + /// superscript digit drawn here is not a word character for the slug. + func headingAnchorText(_ title: String) -> String { + let attributed = Self.formulaSourceWithEscapes(renderInlineFormatting(title)) + var text = "" + for run in attributed.runs { + if let number = run[FootnoteNumberAttribute.self] { + text += String(number) + } else { + text += String(attributed[run.range].characters) + } + } + return text + } + func renderHeader(_ text: String, level: Int) -> AttributedString { - var attr = renderInlineFormatting(text) + var attr = Self.formulaSourceWithEscapes(renderInlineFormatting(Self.shownHeadingTitle(text))) attr.setDualFont(size: headerFontSize(level: level), bold: true, fonts: theme.fonts) attr.setDualForeground(theme.textColor) return attr } + /// Headings typeset no inline math (the title view converts with + /// `hasInlineMath: false`), so a `$…$` shows its source — and that source + /// must keep its LaTeX escapes, which the Markdown escape pass removed: + /// `# H \% $a\,b$` shows "H % $a\,b$" (text outside the formula loses + /// its backslash as everywhere). The same split as math paragraphs + /// (`InlineMathSegmenter.pieces(of:)`); every heading consumer — view, + /// measurer, title cache, ids, Contents, search, copy, print — reads + /// `renderHeader`, so they all see this text. + private static func formulaSourceWithEscapes(_ attributed: AttributedString) -> AttributedString { + guard attributed.characters.contains("$") else { return attributed } + let pieces = InlineMathSegmenter.pieces(of: attributed) + guard pieces.contains(where: { if case .math = $0.segment { return true } else { return false } }) else { + return attributed + } + var result = AttributedString() + var index = attributed.startIndex + for piece in pieces { + guard let end = attributed.characters.index(index, offsetBy: piece.length, + limitedBy: attributed.endIndex) else { break } + switch piece.segment { + case .text: + result.append(attributed[index.. CGFloat { let sizes: [CGFloat] = [32, 26, 22, 18, 16, 14] @@ -400,7 +576,7 @@ struct MarkdownRenderer: Sendable { var result = AttributedString() let lines = Self.joinSoftBreaks(content.components(separatedBy: "\n")) for (index, line) in lines.enumerated() { - if line.trimmingCharacters(in: .whitespaces).isEmpty { + if Self.isBlankLine(line) { result.append(AttributedString(" ")) } else { result.append(renderInline(line)) @@ -648,6 +824,17 @@ struct MarkdownRenderer: Sendable { renderInlineFormatting(text) } + /// `renderInline` with backslash escapes off when `literalBackslashes` + /// (see `escapesDisabled`) and character references shown as written when + /// `literalEntities` (see `entitiesLiteral`). + func renderInline(_ text: String, literalBackslashes: Bool, literalEntities: Bool = false) -> AttributedString { + guard literalBackslashes || literalEntities else { return renderInlineFormatting(text) } + var literal = self + literal.escapesDisabled = literalBackslashes + literal.entitiesLiteral = literalEntities + return literal.renderInlineFormatting(text) + } + private func renderInlineFormatting(_ text: String) -> AttributedString { var result = AttributedString() // Native contiguous UTF-8 storage up front (a no-op unless the line is @@ -665,7 +852,7 @@ struct MarkdownRenderer: Sendable { // Helper to flush buffered plain text func flushPlainText() { guard !plainTextBuffer.isEmpty else { return } - var attr = AttributedString(plainTextBuffer) + var attr = AttributedString(decodedText(plainTextBuffer)) attr.setDualFont(size: scaled(14), fonts: theme.fonts) attr.setDualForeground(theme.textColor) result.append(attr) @@ -676,7 +863,7 @@ struct MarkdownRenderer: Sendable { // Try each inline format in order (escape first, then bold+italic to catch ***) var parsed: (AttributedString, Substring)? - if parsed == nil { parsed = tryParseEscape(&remaining) } + if parsed == nil, !escapesDisabled { parsed = tryParseEscape(&remaining) } if parsed == nil { parsed = tryParseInlineCode(&remaining) } if parsed == nil { parsed = tryParseFootnoteRef(&remaining) } if parsed == nil { parsed = tryParseBoldItalic(&remaining) } @@ -731,7 +918,10 @@ struct MarkdownRenderer: Sendable { } } - var code = String(afterOpening[.. 1 { @@ -753,7 +943,7 @@ struct MarkdownRenderer: Sendable { guard let endRange = afterMarker.range(of: marker) else { return nil } let text = String(afterMarker[.. (AttributedString, Substring) { - var attr: AttributedString - if text.contains("![") { - // Link-wrapped image (`[![b](img)](link)`): the text is rendered - // inline so the image becomes its "[Image: b]" placeholder, and the - // whole run carries the OUTER link (set below, over the image's - // own). Only for text holding an image, so every other link keeps - // its exact plain-text rendering. Rendered with links disabled: - // a link inside a link is literal text (bounds the recursion). - var linkText = self - linkText.linksDisabled = true - attr = linkText.renderInlineFormatting(text) - } else { - attr = AttributedString(text) - attr.setDualFont(size: scaled(14), fonts: theme.fonts) - } + // A link's text is inline Markdown, as on GitHub: `[**Docs**](u)` is + // bold, ``[`npm install`](u)`` code, `[~~old~~](u)` struck through, an + // escape loses its backslash (an escaped `$` is never math), an image + // becomes its "[Image: b]" placeholder (`[![b](img)](link)`). The + // whole run then carries the OUTER link (set below, over any inner + // one, an autolink's or an image's). Rendered with links disabled: a + // link inside a link is literal text (bounds the recursion). + // `ReferenceLinks` rewrites a link's text by the same rule. + var attr = renderLinkText(text) attr.setDualForeground(theme.linkColor) attr.setDualUnderline() - if let parsedUrl = URL(string: url) { - attr.link = parsedUrl - } else if let encoded = url.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let parsedUrl = URL(string: encoded) { + if let parsedUrl = Self.linkURL(for: url) { attr.link = parsedUrl } return (attr, remaining) } + /// A link's text before the link colour, underline and URL go on: + /// inline-parsed with links disabled — or, when `parse` is false, the one + /// plain run that parse produces for text with no syntax in it (the + /// renderer's font, the colour is replaced by the caller). `parse: nil` + /// picks the plain run exactly when `linkTextNeedsParsing` is false, which + /// keeps the common `[Install](…)` from a full inline pass (+25 % on a + /// link-heavy document, review R15). Tests compare both paths. + func renderLinkText(_ text: String, parse: Bool? = nil) -> AttributedString { + if parse ?? Self.linkTextNeedsParsing(text) { + var linkText = self + linkText.linksDisabled = true + return linkText.renderInlineFormatting(text) + } + var attr = AttributedString(decodedText(text)) + attr.setDualFont(size: scaled(14), fonts: theme.fonts) + attr.setDualForeground(theme.textColor) + return attr + } + + /// Whether `renderInlineFormatting` could do anything but one plain run: + /// a character some inline parser starts on — `\` escape, `` ` `` code, + /// `*` / `_` emphasis, `~` strikethrough, `!` image, `[` footnote or + /// link, `h` an autolink (`http://`, `https://`). Character references + /// (`&`) are decoded on the plain run too; `$` is only looked at later + /// (inline math, on the rendered text), as on the parsed path. + static func linkTextNeedsParsing(_ text: String) -> Bool { + text.utf8.contains { byte in + switch byte { + case UInt8(ascii: "\\"), UInt8(ascii: "`"), UInt8(ascii: "*"), UInt8(ascii: "_"), + UInt8(ascii: "~"), UInt8(ascii: "!"), UInt8(ascii: "["): + return true + default: + return false + } + } || text.contains("http://") || text.contains("https://") + } + + /// The URL of a link destination as written. + /// + /// `URL(string:)` first, exactly as before 1.14.4: on macOS 14+ it encodes + /// what needs encoding itself (spaces, non-ASCII, IDN hosts to punycode), + /// and every destination it accepts keeps that behaviour. Only when it + /// returns nil — macOS 13 rejects spaces, non-ASCII, `[` `]` in a path, a + /// second `#` — do we encode, the part before the FIRST `#` and the + /// fragment separately, so the `#` stays the fragment delimiter (the old + /// `.urlPathAllowed` fallback encoded it too: `README.md#Install Guide` + /// became a file named "README.md#Install Guide" and the jump failed). + /// `parse` is `URL(string:)`; tests pass the strict (macOS 13) parser. + static func linkURL(for destination: String, + parse: (String) -> URL? = { URL(string: $0) }) -> URL? { + if let url = parse(destination) { return url } + let scalars = destination.unicodeScalars[...] + guard let hash = scalars.firstIndex(of: "#") else { + return parse(percentEncoded(scalars)) + } + return parse(percentEncoded(scalars[.. String { + var out = "" + var index = scalars.startIndex + while index < scalars.endIndex { + let scalar = scalars[index] + let keep = scalar == "%" ? isEscape(scalars, at: index) + : scalar != "#" && scalar != "[" && scalar != "]" && urlCharacters.contains(scalar) + if keep { + out.unicodeScalars.append(scalar) + } else { + for byte in String(scalar).utf8 { out += String(format: "%%%02X", byte) } + } + index = scalars.index(after: index) + } + return out + } + /// Inline images stay text — an italic "[Image: alt]" placeholder (real /// pictures inside a paragraph would break the `.exact` text rows). Same /// grammar as standalone image blocks (`InlineSyntaxScanner`): inline @@ -908,12 +1185,12 @@ struct MarkdownRenderer: Sendable { let image = InlineSyntaxScanner.image(atStartOf: remaining, references: referenceDefinitions, pairs: pairs) else { return nil } - var attr = AttributedString(image.alt.isEmpty ? "[Image]" : "[Image: \(image.alt)]") + var attr = AttributedString(image.alt.isEmpty ? "[Image]" : "[Image: \(decodedText(image.alt))]") attr.setDualFont(size: scaled(14), italic: true, fonts: theme.fonts) attr.setDualForeground(theme.secondaryTextColor) // No link for an embedded `data:` image: clicking it would hand a // multi-megabyte URL to the "Open link?" alert. - if !InlineSyntaxScanner.isDataURI(image.url), let url = URL(string: image.url) { + if !InlineSyntaxScanner.isDataURI(image.url), let url = Self.linkURL(for: image.url) { attr.link = url } return (attr, remaining[image.end...]) @@ -927,6 +1204,9 @@ struct MarkdownRenderer: Sendable { var attr = AttributedString(String(escaped)) attr.setDualFont(size: scaled(14), fonts: theme.fonts) attr.setDualForeground(theme.textColor) + // Inline math is found in the rendered text: tagged so `\$` is never + // a delimiter and a formula gets its LaTeX escapes back. + attr[EscapedCharacterAttribute.self] = true return (attr, remaining.dropFirst(2)) } @@ -956,6 +1236,12 @@ struct MarkdownRenderer: Sendable { // MARK: - Footnote References + /// Opens the id of a footnote reference whose NUMBER is already known + /// (`[^\u{E000}2]`, written by `ReferenceLinks` into quote, alert and + /// table-cell markdown). A private-use code point: no document's own + /// footnote id starts with it in practice. + static let numberedFootnoteMarker: Unicode.Scalar = "\u{E000}" + private func tryParseFootnoteRef(_ remaining: inout Substring) -> (AttributedString, Substring)? { guard remaining.hasPrefix("[^") else { return nil } @@ -965,10 +1251,16 @@ struct MarkdownRenderer: Sendable { let fnId = String(afterBracket[.. 0 { + number = numbered } else { return nil // Unknown footnote reference, don't parse } @@ -982,9 +1274,19 @@ struct MarkdownRenderer: Sendable { let superscript = String(String(number).map { superscriptDigits[$0] ?? $0 }) var attr = AttributedString(superscript) + attr[FootnoteNumberAttribute.self] = number attr.setDualFont(size: scaled(11), bold: true, fonts: theme.fonts) attr.setDualForeground(theme.linkColor) return (attr, afterBracket[afterBracket.index(after: closeIndex)...]) } } + +/// The number a footnote reference draws as superscript digits, for +/// `headingAnchorText` (GitHub's heading id reads it as plain digits). Not +/// part of the AppKit scope, so an installed NSAttributedString never +/// carries it. +enum FootnoteNumberAttribute: AttributedStringKey { + typealias Value = Int + static let name = "qmdFootnoteNumber" +} diff --git a/QuickMD/QuickMD/MarkdownTheme.swift b/QuickMD/QuickMD/MarkdownTheme.swift index fd259a8..e5536f9 100644 --- a/QuickMD/QuickMD/MarkdownTheme.swift +++ b/QuickMD/QuickMD/MarkdownTheme.swift @@ -291,34 +291,46 @@ struct MarkdownTheme: Sendable, Identifiable { /// Matches table separator row: |---|---|---| or variants with alignment colons /// Examples: |:---|:---:|---:|, |---|---| - static let tableSeparatorPattern = #"^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"# + static let tableSeparatorPattern = #"^\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$"# /// Matches ATX-style headers: # H1, ## H2, ... ###### H6 /// Captures: (1) hash marks, (2) header text - static let headerPattern = #"^(#{1,6})\s+(.*)$"# + static let headerPattern = #"^(#{1,6})[ \t]+(.*)$"# /// Matches horizontal rules: ***, ---, ___ (3 or more) - static let horizontalRulePattern = #"^(\*{3,}|-{3,}|_{3,})\s*$"# + static let horizontalRulePattern = #"^(\*{3,}|-{3,}|_{3,})[ \t]*$"# /// Matches task list items: - [ ] or - [x] with optional indentation /// Captures: (1) indent whitespace, (2) check state (space or x), (3) content - static let taskListPattern = #"^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$"# + static let taskListPattern = #"^([ \t]*)[-*+][ \t]+\[([ xX])\][ \t]+(.*)$"# // MARK: - Escape Characters /// Characters that can be escaped with backslash in markdown - /// Example: \* renders as literal asterisk instead of italic marker - static let escapableChars: Set = ["\\", "`", "*", "_", "{", "}", "[", "]", "(", ")", "#", "+", "-", ".", "!", "|"] + /// Example: \* renders as literal asterisk instead of italic marker. + /// CommonMark's full list — every ASCII punctuation character (`\&` + /// shows "&", `\<` shows "<", `\$5` shows "$5", as on GitHub). An + /// escaped `$` is tagged `LiteralDollarAttribute`: inline math is found in + /// the RENDERED text, and a tagged dollar never opens or closes a formula. + static let escapableChars: Set = [ + "!", "\"", "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", + "?", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~", + ] // MARK: - Reference Link Definitions /// Matches reference link definitions: [id]: url or [id]: with optional title /// Captures: (1) reference ID, (2) URL - static let referenceLinkDefinitionPattern = #"^\s*\[([^\]]+)\]:\s+]+)>?"# + /// The label: no unescaped bracket, backslash escapes kept as written + /// (`[a\]b]:` defines `a\]b`, `[a[b]:` is text — CommonMark, GitHub). + static let referenceLinkDefinitionPattern = #"^[ \t]*\[((?:[^\[\]\\]|\\.)+)\]:[ \t]+]+)>?"# /// Matches footnote definitions: [^id]: text /// Captures: (1) footnote ID, (2) content - static let footnoteDefinitionPattern = #"^\s*\[\^([^\]]+)\]:\s+(.*)"# + /// The label is cmark-gfm's: one or more characters but `]`, spaces, + /// tabs and line breaks (`[^ m]: url` is a LINK definition on GitHub); + /// the text may follow the colon directly, or be empty. + static let footnoteDefinitionPattern = #"^[ \t]*\[\^([^\] \t\r\n]+)\]:[ \t]*(.*)"# // MARK: - URL Autolinking @@ -330,12 +342,12 @@ struct MarkdownTheme: Sendable, Identifiable { /// Matches setext H1 underline: one or more = signs /// Example: Title\n===== - static let setextH1Pattern = #"^=+\s*$"# + static let setextH1Pattern = #"^=+[ \t]*$"# /// Matches setext H2 underline: three or more - signs /// Example: Subtitle\n------- /// Note: Must be 3+ to distinguish from horizontal rule in some contexts - static let setextH2Pattern = #"^-{3,}\s*$"# + static let setextH2Pattern = #"^-{3,}[ \t]*$"# } // MARK: - Color Hex Extension diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index a8ef11d..fd9f36e 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -30,6 +30,8 @@ struct MarkdownView: View { @State private var fileWatcher: FileWatcher? /// The watched file disappeared from its path (moved/deleted). @State private var fileMissing = false + /// What the `fileMissing` banner says (gone, or there but unreadable). + @State private var fileProblem: FileWatcher.Problem = .missing @State private var cachedBlocks: [MarkdownBlock] = [] /// Incremented on every successful parse. Text blocks use it to invalidate /// their NSAttributedString caches — a font-size-only change leaves the @@ -115,7 +117,8 @@ struct MarkdownView: View { @State private var appliedDefaultZoom: Double /// ⌘⇧R — distraction-free reading: both sidebars, the top-right chrome pills /// and the Support/Tip Jar button step out of the way, and the text column - /// stops at `Metrics.readingMaxContentWidth` and centres itself. + /// stops at `Metrics.readingColumnCap(fontScale:)` (720 pt at 100 %, scaled + /// with the zoom) and centres itself. /// /// @State, not @AppStorage, for the same reason as `fontScale`: it belongs to /// this window and this reading session. A viewer that reopens with its @@ -139,6 +142,8 @@ struct MarkdownView: View { /// on demand. A reference in @State on purpose: the coordinator fills it, /// nothing observes it, so scrolling never re-evaluates this body (E-D1). @State private var readingPosition = DocumentReadingPosition() + /// The list's column room, for a parse at a new zoom in reading mode. + @State private var documentColumn = DocumentColumn() /// Source Edit (v1.12): the raw-Markdown editor over the rendered list, its /// buffer, saving and the close guard's answers. One per window, lives with /// the tab. Publishes only rare events (enter / leave, dirty, banner) — the @@ -286,7 +291,7 @@ struct MarkdownView: View { focusedBlockId: focusedBlockId, focusedOccInBlock: focusedOccInBlock, scrollRequest: scrollRequest, - layoutStyle: isReadingMode ? .reading : .standard, + layoutStyle: isReadingMode ? .reading(fontScale: CGFloat(renderedFontScale)) : .standard, contentWidth: $contentWidth, onHeightReport: { blockId, row, height in applyHeightReport(blockId: blockId, row: row, height: height) @@ -305,7 +310,8 @@ struct MarkdownView: View { // the focus back while covered, and leaves the // accessibility tree meanwhile (set on the AppKit view). isCovered: graphicPreview != nil || editSession.isActive, - readingPosition: readingPosition + readingPosition: readingPosition, + column: documentColumn ) .onChange(of: scrollTrigger) { _ in scrollFocusedMatchIntoView() @@ -320,8 +326,9 @@ struct MarkdownView: View { Group { if editSession.isActive { SourceEditorView(controller: editSession.editor, - style: .init(theme: theme, fontScale: fontScale, - isReadingLayout: isReadingMode)) + style: .overDocument(theme: theme, fontScale: fontScale, + shownFontScale: renderedFontScale, + isReadingMode: isReadingMode)) } } // An animated transaction from elsewhere (a toast, a sidebar) @@ -366,7 +373,7 @@ struct MarkdownView: View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.yellow) - Text("File no longer exists at \(documentURL?.path ?? "this location")") + Text(fileProblem.bannerText(path: documentURL?.path)) .font(.system(size: 12)) .lineLimit(1) .truncationMode(.middle) @@ -649,8 +656,17 @@ struct MarkdownView: View { let scale = CGFloat(fontScale) // The width the list will lay the blocks out at. 0 before the host's // first layout — then the heights task below picks it up as soon as - // the coordinator reports one. - let width = contentWidth + // the coordinator reports one. A new zoom in reading mode changes + // the column's cap with these blocks (the style is derived from + // `renderedFontScale`): measure at THAT column. Only a zoom: any + // other parse keeps the list's width, which the list keeps in step + // with the window itself. + let listWidth = contentWidth + let width = BlockLayout.Document.parseWidth( + contentWidth: listWidth, inner: documentColumn.innerWidth?() ?? 0, + readingFontScale: isReadingMode && Double(scale) != renderedFontScale ? scale : nil) + // Keyed by the link, so valid across this re-parse. + let imageSizes = heightCache.imageSizesSnapshot let out: ParsedAndMeasured = await Task.detached(priority: .userInitiated) { let blocks = MarkdownBlockParser(theme: currentTheme, fontScale: scale).parse(text) // Pre-compute per-text-block metadata on the background thread so the @@ -674,11 +690,13 @@ struct MarkdownView: View { // the rows it defers are finished on main just below. // No `heightSeeds`: `heightCache` is cleared for this parse, and // block ids are positional, so the previous document's diagram - // heights would seed the wrong diagrams. + // heights would seed the wrong diagrams. Pictures and diagrams + // that were shown before are computed (`imageSizes`, the SVG + // decoder, `MermaidGeometryStore`). let measured = width > 0 ? BlockHeightMeasurer.measure(blocks: blocks, theme: currentTheme, fontScale: scale, contentWidth: width, - math: nil) + imageSizes: imageSizes, math: nil) : MeasuredBlocks.empty return ParsedAndMeasured(parsed: ParsedDocument(blocks: blocks, textMeta: meta), measured: measured) @@ -701,6 +719,10 @@ struct MarkdownView: View { cachedBlocks = blocks renderedFontScale = Double(scale) contentVersion += 1 + // The column this parse was measured at, in the same transaction + // as the cap that gives it — unless the list published another + // width meanwhile (then the heights task re-measures at that one). + if width != listWidth, contentWidth == listWidth { contentWidth = width } textBlockMeta = parsed.textMeta installedText = text // Inline `$…$` paragraphs and display-math rows were deferred by the @@ -712,12 +734,7 @@ struct MarkdownView: View { theme: currentTheme, fontScale: scale, contentWidth: width, math: .swiftMath) : .empty - headings = blocks.compactMap { block in - if case .heading(let level, let title, let sourceLine) = block.content { - return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine) - } - return nil - } + headings = ToCEntry.entries(of: blocks) // A landing that waited for THIS parse (Source Edit saved, then // left before the re-parse arrived): requested in the transaction // that installs the blocks, so it wins over the list's own anchor @@ -725,6 +742,7 @@ struct MarkdownView: View { if let target = landing.blocksInstalled(blocks) { requestScroll(to: target, anchor: .top, animated: false) } + followCrossDocumentAnchor() // The find bar's matches index the PREVIOUS parse's blocks: count // again over these (and the measured strings just installed). rerunSearchAfterReparse() @@ -750,16 +768,21 @@ struct MarkdownView: View { // Mermaid rows: seed from the heights the WebViews actually reported, // so a re-measure doesn't send every diagram back to the 200 pt default. let seeds = heightCache.snapshot - // Inline-math paragraphs: hand the previous pass's strings back so the - // main-actor step re-wraps them instead of re-rendering every `$…$` - // segment through SwiftMath. Safe only because this task never runs - // across a content change — the guard below drops the result if a - // parse landed meanwhile, and everything that alters the characters - // (text, theme, fonts, zoom) goes through a re-parse. + // Images shown before: their rows are computed at the new width. + let imageSizes = heightCache.imageSizesSnapshot + // The previous pass's strings, handed back so they are only re-wrapped: + // quote and alert bodies (off-main, `measure` — not rendered from + // their source again) and inline-math paragraphs (the main-actor + // step — not every `$…$` segment through SwiftMath again). Safe + // only because this task never runs across a content change — the + // guard below drops the result if a parse landed meanwhile, and + // everything that alters the characters (text, theme, fonts, zoom) + // goes through a re-parse. let reusableStrings = measured.converted let raw: MeasuredBlocks = await Task.detached(priority: .userInitiated) { BlockHeightMeasurer.measure(blocks: blocks, theme: currentTheme, fontScale: scale, - contentWidth: width, heightSeeds: seeds, math: nil) + contentWidth: width, heightSeeds: seeds, + imageSizes: imageSizes, reusing: reusableStrings, math: nil) }.value // A parse that landed while we were measuring owns the state now — // its own transaction published a matching table. @@ -769,7 +792,15 @@ struct MarkdownView: View { fontScale: scale, contentWidth: width, math: .swiftMath, reusing: reusableStrings) - if firstTableForParse { rerunSearchAfterReparse() } + if firstTableForParse { + rerunSearchAfterReparse() + followCrossDocumentAnchor() + } + } + .onReceive(NotificationCenter.default.publisher(for: CrossDocumentAnchors.requested)) { note in + // `other.md#section` clicked in another window while this one shows other.md. + guard let documentURL, note.object as? String == CrossDocumentAnchors.key(for: documentURL) else { return } + followCrossDocumentAnchor() } .onChange(of: searchText) { newValue in searchDebounce?.cancel() @@ -915,6 +946,11 @@ struct MarkdownView: View { let watcher = FileWatcher() watcher.onChange = { reloadFromDisk() } watcher.onFileMissing = { + fileProblem = .missing + withAnimation(.easeInOut(duration: 0.2)) { fileMissing = true } + } + watcher.onFileUnreadable = { + fileProblem = .unreadable withAnimation(.easeInOut(duration: 0.2)) { fileMissing = true } } watcher.start(watching: url) @@ -1062,14 +1098,26 @@ struct MarkdownView: View { /// A ToC click. While editing, the EDITOR scrolls to the heading's source /// line (S-D13) — best effort until the next save, since the headings come /// from the last saved parse; the hidden list stays where the reader was. - private func selectHeading(_ targetId: String) { + private func selectHeading(_ targetId: String, animated: Bool = true) { if editSession.isActive { if let entry = headings.first(where: { $0.id == targetId }) { editSession.editor.scroll(toLine: entry.sourceLine) } return } - requestScroll(to: targetId, anchor: .top, animated: true) + requestScroll(to: targetId, anchor: .top, animated: animated) + } + + /// The fragment of an `other.md#section` link that opened (or brought + /// forward) this document, followed like an in-document link — once the + /// list has a height table for these blocks: a scroll request before that + /// finds no row and is dropped, so a new window waits for its first + /// layout (parse transaction or the first heights task). A freshly opened + /// window jumps without animation. + private func followCrossDocumentAnchor() { + guard !cachedBlocks.isEmpty, measured.table.count == cachedBlocks.count, + let fragment = CrossDocumentAnchors.shared.take(for: documentURL) else { return } + followAnchor(fragment, animated: false) } // MARK: - Block Rendering @@ -1150,7 +1198,7 @@ struct MarkdownView: View { case .image(let url, let alt, let width): ImageBlockView(url: url, alt: alt, width: width, theme: theme, documentURL: documentURL, - fontScale: scale, contentWidth: contentWidth, + fontScale: scale, contentWidth: contentWidth, heightCache: heightCache, onEnlarge: presentGraphicPreview) .padding(.vertical, Metrics.imageOuterVerticalPadding) @@ -1185,7 +1233,8 @@ struct MarkdownView: View { let section = SectionExtractor.extractSection(from: currentText, entry: entry, headings: headings) { copyToClipboard(section) } - } + }, + isReadingMode: isReadingMode ) case .mathBlock(let latex): @@ -1285,7 +1334,10 @@ struct MarkdownView: View { case .heading(let level, let title, _): // Exactly what `HeadingBlockView` hands its `TextBlockView`: the // same `renderHeader` output through the same converter (no - // inline math — a heading shows `$…$` literally, as before). + // inline math — a heading shows `$…$` literally, as before). The + // measurer kept it (`MeasuredBlocks.converted`): a ⌘C of a + // heading-heavy document no longer converts every heading again. + if let cached { return cached } return TextBlockView.makeNSAttributedString( from: MarkdownRenderer(theme: theme, fontScale: scale).renderHeader(title, level: level), hasInlineMath: false, theme: theme, fontScale: scale) @@ -1402,14 +1454,8 @@ struct MarkdownView: View { // so a stale report can never overwrite an exact height). guard row < table.kinds.count, table.kinds[row] == .reported else { return } guard row < table.heights.count, abs(table.heights[row] - height) >= 0.5 else { return } - var heights = table.heights - heights[row] = height - measured = MeasuredBlocks( - table: BlockHeightTable(heights: heights, kinds: table.kinds, - contentWidth: table.contentWidth), - converted: measured.converted, - tables: measured.tables, - mathPendingRows: measured.mathPendingRows) + // The block's height only: its space above stays as measured. + measured = measured.replacingHeight(height, at: row) } /// Recompute focusedBlockId and focusedOccInBlock from currentMatchIndex @@ -1428,9 +1474,16 @@ struct MarkdownView: View { currentMatchIndex = (currentMatchIndex - 1 + matchBlockIds.count) % matchBlockIds.count } updateFocusState() - // Only scroll when moving to a different block - if focusedBlockId != oldBlockId { + switch DocumentSearch.stepScroll(from: oldBlockId, to: focusedBlockId) { + case .toBlock: scrollTrigger += 1 + case .withinBlock: + // Another match of the same block: the list brings it to the eye + // when the block is taller than the window (a big table, a long + // code block); a block that fits stays where it is. + if let blockId = focusedBlockId { requestScroll(to: blockId, anchor: .match, animated: true) } + case nil: + break } } @@ -1552,6 +1605,10 @@ struct MarkdownView: View { // Open the resolved file URL let ext = finalURL.pathExtension.lowercased() if ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" { + // `other.md#section`: the window that shows other.md scrolls there. + if let fragment = CrossDocumentAnchors.fragment(of: url) { + CrossDocumentAnchors.shared.request(fragment, for: finalURL) + } // Force open with our own app let config = NSWorkspace.OpenConfiguration() config.activates = true @@ -1565,14 +1622,14 @@ struct MarkdownView: View { /// Scrolls to the heading an in-document link names, as a ToC click does /// (`HeadingAnchors.target`: `#` and an unclaimed `#top` are the top); an /// id no heading has beeps. - private func followAnchor(_ fragment: String) { + private func followAnchor(_ fragment: String, animated: Bool = true) { let renderer = MarkdownRenderer(theme: theme, fontScale: CGFloat(renderedFontScale)) let headings = HeadingAnchors.headings(of: cachedBlocks, renderer: renderer) switch HeadingAnchors.target(forFragment: fragment, in: headings) { case .heading(let id): - selectHeading(id) + selectHeading(id, animated: animated) case .top where !cachedBlocks.isEmpty: - requestScroll(to: cachedBlocks[0].id, anchor: .top, animated: true) + requestScroll(to: cachedBlocks[0].id, anchor: .top, animated: animated) case .top, .none: NSSound.beep() } diff --git a/QuickMD/QuickMD/ReferenceLinks.swift b/QuickMD/QuickMD/ReferenceLinks.swift new file mode 100644 index 0000000..af3c739 --- /dev/null +++ b/QuickMD/QuickMD/ReferenceLinks.swift @@ -0,0 +1,312 @@ +import Foundation + +// MARK: - Reference labels + +/// CommonMark's label matching, in ONE place for every lookup (the +/// definition pre-pass that stores, the renderer, `InlineSyntaxScanner`, +/// `ReferenceLinks`): runs of whitespace — line breaks included, as in a +/// Setext title's `[link⏎text]` — become one space, the ends are trimmed, +/// and the label is Unicode case-folded, not lowercased: GitHub (cmark's +/// `utf8proc_case_fold`) matches `[ẞ]` to `[SS]:` and `[straße]` to +/// `[STRASSE]:` (`gh api markdown`, 2026-11-04). Whitespace is cmark's +/// (space, tab, line feed, line tabulation, form feed, return), so a +/// no-break space stays a character of the label. +enum ReferenceLabel { + static func normalized(_ label: S) -> String { + var collapsed = "" + var pendingSpace = false + var ascii = true + for scalar in label.unicodeScalars { + switch scalar { + case " ", "\t", "\n", "\u{0B}", "\u{0C}", "\r": + pendingSpace = !collapsed.isEmpty + default: + if pendingSpace { collapsed.append(" "); pendingSpace = false } + if !scalar.isASCII { ascii = false } + collapsed.unicodeScalars.append(scalar) + } + } + // ASCII labels (nearly all): lowercasing IS the fold, no bridging. + return ascii ? collapsed.lowercased() : collapsed.folding(options: .caseInsensitive, locale: nil) + } + + /// The `]` closing a label whose text starts at `start` (just past its + /// `[`): the first UNESCAPED one, so `[x][a\]b]` reads the label `a\]b`. + /// An unescaped `[` inside makes it no label (CommonMark, GitHub) → nil. + /// The label is matched as written, backslashes included + /// (`[a\]b]: url` defines `a\]b`). `InlineSyntaxScanner.matchLabel` is + /// the same rule over bytes. + static func closingBracket(in text: Substring, from start: Substring.Index) -> Substring.Index? { + var index = start + while index < text.endIndex { + let character = text[index] + if character == "\\" { + let next = text.index(after: index) + if next < text.endIndex, MarkdownTheme.escapableChars.contains(text[next]) { + index = text.index(after: next) + continue + } + } else if character == "]" { + return index + } else if character == "[" { + return nil + } + index = text.index(after: index) + } + return nil + } +} + +// MARK: - Reference links inside quotes, alerts and table cells + +/// Rewrites reference-style links and images (`[text][id]`, `[text][]`, +/// `[text]`, `![alt][id]`, `![alt][]`, `![alt]`) whose definition exists into +/// their inline form (`[text]()`), for the block kinds whose RAW markdown +/// is stored and rendered later: blockquotes, alerts, table cells and +/// headings. +/// +/// Why rewrite instead of handing those renderers the definitions: paragraphs +/// are rendered inside the parser with the document's definitions, but a +/// quote, alert or table keeps its markdown and is rendered again by every +/// consumer — the views, `BlockHeightMeasurer` (whose `.exact` heights must +/// match the view), search, print — each with a definition-less +/// `MarkdownRenderer`. Resolving the references once, where the definitions +/// are known, gives every one of them the same text with no new plumbing, so +/// they cannot disagree. +/// +/// The scan mirrors `MarkdownRenderer.renderInlineFormatting` where it +/// matters: escapes and code spans are copied as written, `***…***` and +/// `~~…~~` (rendered without inline parsing) too, inline links are copied +/// whole, the reference rules are `tryParseLink`'s (full, collapsed, then +/// shortcut, ids lowercased) and `InlineSyntaxScanner.image`'s, and a link's +/// text is only rewritten when it holds an image (`[![b][img]][link]`), as +/// only then does the renderer parse it. The destination is `` with +/// `\ < > ( )` backslash-escaped, which `InlineSyntaxScanner` reads back as +/// exactly `url` (an escaped paren does not count for pairing either). +enum ReferenceLinks { + + /// What the document defines: link/image definitions (ids lowercased) + /// and footnote ids in definition order (a reference's number is its + /// 1-based position, as `tryParseFootnoteRef` numbers it). + private struct Sources { + let definitions: [String: String] + let footnoteIds: [String] + } + + /// `text` with every resolvable reference inlined. Unchanged when there is + /// nothing defined or no `[`. + /// + /// `quoteBody`: `text` is a blockquote / alert body, which the renderers + /// read through `MarkdownRenderer.joinSoftBreaks` — a paragraph's lines + /// are ONE logical line there, so a code span or a link may span lines. + /// Each soft-break group is scanned as that joined line; a group the scan + /// leaves alone keeps its original lines, a changed one becomes its joined + /// line (plus " " when it ended in a hard break), which the renderer's + /// join reproduces exactly. Otherwise (a table cell: one line, no soft + /// breaks) every line is scanned as is. + /// + /// Footnote references (`[^note]`) become `[^N]` + /// (`MarkdownRenderer.numberedFootnoteMarker` + the number): Markdown has + /// no inline form for "footnote number N", and a definition-less renderer + /// draws that form as the same superscript a paragraph's `[^note]` gets. + static func inlined(_ text: String, definitions: [String: String], footnoteIds: [String] = [], + quoteBody: Bool = false) -> String { + guard !definitions.isEmpty || !footnoteIds.isEmpty, text.contains("[") else { return text } + let sources = Sources(definitions: definitions, footnoteIds: footnoteIds) + let lines = text.components(separatedBy: "\n") + guard quoteBody else { + return lines.map { inlinedLine($0, sources: sources, linksDisabled: false) }.joined(separator: "\n") + } + let groups = MarkdownRenderer.joinSoftBreaksIndexed(lines) + var out: [String] = [] + for (index, group) in groups.enumerated() { + let end = index + 1 < groups.count ? groups[index + 1].first : lines.count + let rewritten = inlinedLine(group.line, sources: sources, linksDisabled: false) + if rewritten == group.line { + out.append(contentsOf: lines[group.first.. String { + guard !definitions.isEmpty || !footnoteIds.isEmpty, title.contains("[") else { return title } + return inlinedLine(title, sources: Sources(definitions: definitions, footnoteIds: footnoteIds), + linksDisabled: false) + } + + private static func inlinedLine(_ line: String, sources: Sources, linksDisabled: Bool) -> String { + let definitions = sources.definitions + guard line.contains("[") else { return line } + var text = line + text.makeContiguousUTF8() + let pairs = InlineSyntaxScanner.PairTable(text) + var out = "" + var remaining = text[...] + + while let first = remaining.first { + // Escapes first, as the renderer: `\[` never opens anything. + if first == "\\", remaining.count >= 2 { + let escaped = remaining[remaining.index(after: remaining.startIndex)] + if MarkdownTheme.escapableChars.contains(escaped) { + out += remaining.prefix(2) + remaining = remaining.dropFirst(2) + continue + } + } + if first == "`" { + let span = codeSpanLength(remaining) + out += remaining.prefix(span) + remaining = remaining.dropFirst(span) + continue + } + if let verbatim = unparsedSpanLength(remaining) { + out += remaining.prefix(verbatim) + remaining = remaining.dropFirst(verbatim) + continue + } + // `tryParseFootnoteRef` runs before emphasis and links. + if remaining.hasPrefix("[^"), let (numbered, rest) = footnote(remaining, sources: sources) { + out += numbered + remaining = rest + continue + } + if remaining.hasPrefix("![") { + if let inline = InlineSyntaxScanner.image(atStartOf: remaining, references: [:], pairs: pairs) { + out += remaining[.. (String, Substring)? { + let definitions = sources.definitions + guard let closeBracket = InlineSyntaxScanner.closingBracket(in: remaining, pairs: pairs) else { return nil } + let afterBracket = remaining.index(after: closeBracket) + let linkText = String(remaining[remaining.index(after: remaining.startIndex).. (String, Substring) { + let destination = inlineDestination(url) + // The renderer parses a link's text (links disabled): rewrite it + // under the same rule. + let text = inlinedLine(linkText, sources: sources, linksDisabled: true) + return ("[\(text)](\(destination))", rest) + } + + // Full / collapsed reference: [text][id], [text][] + if remaining[afterBracket...].hasPrefix("[") { + let afterSecondOpen = remaining.index(after: afterBracket) + if afterSecondOpen < remaining.endIndex, + let closeSecondBracket = ReferenceLabel.closingBracket(in: remaining, from: afterSecondOpen) { + let refId = String(remaining[afterSecondOpen.. (String, Substring)? { + let afterBracket = remaining.dropFirst(2) + guard let close = afterBracket.firstIndex(of: "]") else { return nil } + let id = String(afterBracket[.. Int { + let run = remaining.prefix(while: { $0 == "`" }).count + guard run <= 2 else { return run } + let marker = String(repeating: "`", count: run) + let afterOpening = remaining.dropFirst(run) + guard let close = afterOpening.range(of: marker) else { return 1 } + if run == 2, close.upperBound < afterOpening.endIndex, afterOpening[close.upperBound] == "`" { + return 1 + } + return remaining.distance(from: remaining.startIndex, to: close.upperBound) + } + + /// `***…***` / `___…___` and `~~…~~`: the renderer draws their content as + /// plain text, so a reference inside stays as written there too. + private static func unparsedSpanLength(_ remaining: Substring) -> Int? { + for marker in ["***", "___", "~~"] where remaining.hasPrefix(marker) { + let after = remaining.dropFirst(marker.count) + guard let close = after.range(of: marker) else { return nil } + return remaining.distance(from: remaining.startIndex, to: close.upperBound) + } + return nil + } + + /// ``, which `InlineSyntaxScanner.linkDestination` reads back as + /// exactly `url`: the characters that would end the pointy form or pair + /// up with the link's own parentheses are backslash-escaped (the scanner + /// unescapes ASCII punctuation), and so is `&` — the definition's + /// references were decoded when it was read, a second decode must not + /// turn a literal `&` into `&`. A definition is one line, so no line + /// break. + private static func inlineDestination(_ url: String) -> String { + var escaped = "<" + for character in url { + if "\\<>()&".contains(character) { escaped.append("\\") } + escaped.append(character) + } + return escaped + ">" + } +} diff --git a/QuickMD/QuickMD/Resources/mermaid-template.html b/QuickMD/QuickMD/Resources/mermaid-template.html index bc18e24..7094888 100644 --- a/QuickMD/QuickMD/Resources/mermaid-template.html +++ b/QuickMD/QuickMD/Resources/mermaid-template.html @@ -36,6 +36,21 @@ resizeDiagram = null; container.innerHTML = ''; + // What the height follows from at ANY width and zoom: the natural size and + // what the page adds around the SVG. The host computes the same formula as + // resizeDiagram (BlockLayout.Mermaid.diagramHeight), so a diagram that has + // rendered once never starts at a default height again. Same guard as + // reportHeight: hosts without the handler ignore it. + function reportGeometry(naturalWidth, naturalHeight, svgHeight) { + try { + window.webkit.messageHandlers.mermaidGeometry.postMessage({ + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + overhead: container.getBoundingClientRect().height - svgHeight + }); + } catch (e) { /* no handler in this host -- fine */ } + } + // Report height back to Swift. The zoom viewer hosts this same template // WITHOUT a mermaidHeight handler -- the report must never break rendering // (an unguarded postMessage throws, and the catch() below would replace a @@ -69,6 +84,7 @@ svg.style.maxWidth = 'none'; svg.style.width = (naturalWidth * factor) + 'px'; svg.style.height = (naturalHeight * factor) + 'px'; + if (!fitWindow) reportGeometry(naturalWidth, naturalHeight, naturalHeight * factor); } reportHeight(); }; diff --git a/QuickMD/QuickMD/ReviewPrompt.swift b/QuickMD/QuickMD/ReviewPrompt.swift index 985c2bf..bb690ac 100644 --- a/QuickMD/QuickMD/ReviewPrompt.swift +++ b/QuickMD/QuickMD/ReviewPrompt.swift @@ -43,10 +43,10 @@ struct ReviewPrompt { /// The day set only has to answer "at least 3 days"; 30 keeps it bounded /// for a reader who opens documents every day for years. static let maximumStoredDays = 30 - /// At the moment of asking, the reader must have touched a key, the mouse - /// or the trackpad within this many seconds: a window left open on an - /// unattended Mac is not reading. - static let maximumIdleAtRequest: TimeInterval = 60 + /// On every tick of the 60 s display run, the reader must have touched a + /// key, the mouse or the trackpad within this many seconds: a window left + /// open on an unattended Mac is not reading. + static let maximumIdle: TimeInterval = 60 private static let secondsPerDay: TimeInterval = 86_400 @@ -116,7 +116,7 @@ struct ReviewPrompt { static func isUserActive(secondsSinceLastInput: TimeInterval) -> Bool { secondsSinceLastInput.isFinite && secondsSinceLastInput >= 0 - && secondsSinceLastInput < maximumIdleAtRequest + && secondsSinceLastInput < maximumIdle } // MARK: Stored values, garbage-tolerant @@ -211,6 +211,19 @@ struct ReviewPromptCountdown { return now - since >= required } + /// One tick of the trigger: it counts only while the window is displayed + /// AND the reader is active (`ReviewPrompt.isUserActive`). An idle tick + /// ends the run like a hidden window does, so a complete run is 60 s of + /// display during which the reader kept using the Mac. A run can never + /// run out while they are away and then complete on the first touch + /// after they return: that touch starts a new run from zero. + mutating func advance(isDisplayed: Bool, secondsSinceLastInput: TimeInterval, + now: TimeInterval) -> Bool { + advance(isDisplayed: isDisplayed + && ReviewPrompt.isUserActive(secondsSinceLastInput: secondsSinceLastInput), + now: now) + } + /// Ends the current run (the window stopped being key); the next /// displayed tick starts a new one from zero. mutating func reset() { diff --git a/QuickMD/QuickMD/ReviewPromptTrigger.swift b/QuickMD/QuickMD/ReviewPromptTrigger.swift index fbb67ce..b3b4cee 100644 --- a/QuickMD/QuickMD/ReviewPromptTrigger.swift +++ b/QuickMD/QuickMD/ReviewPromptTrigger.swift @@ -27,10 +27,10 @@ enum ReviewPromptSession { /// unoccluded, key, no sheet or app-modal panel. A tick that fails it, losing /// key, or a gap of more than 3 s between ticks (sleep, App Nap, a stalled /// main thread — measured on the monotonic `systemUptime`) restarts the 60 s. -/// At 60 s it also needs user input within the last 60 s; if the reader is -/// idle the run is reset, so someone returning after a long break is never -/// asked on their first touch — it takes a fresh 60 s of display that ends -/// with recent input. Source Edit stops it (the task id changes and the task returns +/// Every tick also needs user input within the last 60 s: an idle tick +/// restarts the 60 s too, so someone returning after a break is asked only +/// after a full minute of display in which they kept using the Mac, never +/// right after their first touch. Source Edit stops it (the task id changes and the task returns /// while editing; leaving starts a fresh 60 s), closing the window cancels it /// with the view's task. /// @@ -91,20 +91,12 @@ struct ReviewPromptTrigger: ViewModifier { guard await Self.waitUntilKey(window) else { return } continue } - let displayedLongEnough = countdown.advance(isDisplayed: Self.isDisplayed(window), - now: ProcessInfo.processInfo.systemUptime) - if displayedLongEnough { + let readLongEnough = countdown.advance(isDisplayed: Self.isDisplayed(window), + secondsSinceLastInput: Self.secondsSinceLastInput, + now: ProcessInfo.processInfo.systemUptime) + if readLongEnough { // Another window may have asked meanwhile, or the policy changed. guard Self.canRequest(policy) else { return } - // An unattended screen is not reading. Start over rather than - // keep the run complete: a reader coming back after a long - // idle must never get the prompt on their first touch — it - // takes another 60 s of display that ends with recent input. - guard Self.userIsActive else { - countdown.reset() - try? await Task.sleep(nanoseconds: Self.tick) - continue - } ReviewPromptSession.hasRequested = true #if DEBUG reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") @@ -136,12 +128,12 @@ struct ReviewPromptTrigger: ViewModifier { !ReviewPromptSession.hasRequested && policy.shouldRequest(now: Date(), appVersion: appVersion) } - /// Any keyboard / mouse / trackpad event within `maximumIdleAtRequest`. - /// `~0` is `kCGAnyInputEventType`; an unconvertible type reads as idle. - private static var userIsActive: Bool { - guard let anyInput = CGEventType(rawValue: ~0) else { return false } - let idle = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInput) - return ReviewPrompt.isUserActive(secondsSinceLastInput: idle) + /// Seconds since the last keyboard / mouse / trackpad event in this login + /// session. `~0` is `kCGAnyInputEventType`; an unconvertible type reads as + /// "never" (infinite), which `ReviewPrompt.isUserActive` treats as idle. + private static var secondsSinceLastInput: TimeInterval { + guard let anyInput = CGEventType(rawValue: ~0) else { return .infinity } + return CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInput) } @MainActor diff --git a/QuickMD/QuickMD/SVGImageDecoder.swift b/QuickMD/QuickMD/SVGImageDecoder.swift index 76471c3..a79082a 100644 --- a/QuickMD/QuickMD/SVGImageDecoder.swift +++ b/QuickMD/QuickMD/SVGImageDecoder.swift @@ -27,6 +27,56 @@ enum SVGImageDecoder { return image } + // MARK: - Fenced-block cache + + /// Decoded ```svg blocks, by their markup (an `NSString` key: hashing does + /// not read the whole markup). NSCache is thread-safe and may evict; + /// the sizes below never are, so a row keeps its height either way. + private static let images = NSCache() + private static let lock = NSLock() + /// Decoded size per markup; `.zero` = the markup does not decode. + private static var sizes: [NSString: CGSize] = [:] + /// Entries are tiny; the cap only keeps a session-long store bounded. + static let sizeLimit = 2048 + + /// The decoded image if it is cached — what `SVGBlockView` shows at once + /// when its row is re-created, instead of the loading view. + nonisolated static func cachedImage(_ source: String) -> NSImage? { + images.object(forKey: source as NSString) + } + + /// The decoded size, if the markup was ever decoded and decodes. + nonisolated static func cachedSize(_ source: String) -> CGSize? { + lock.lock() + defer { lock.unlock() } + return sizes[source as NSString].flatMap { $0 == .zero ? nil : $0 } + } + + /// `decode`, through the cache: the view's decode and the measurer's size + /// share one decode per markup. Off-main safe. + nonisolated static func decodeCached(_ source: String) -> NSImage? { + let key = source as NSString + if let hit = images.object(forKey: key) { return hit } + lock.lock() + let known = sizes[key] + lock.unlock() + if known == .zero { return nil } + let image = decode(source) + if let image { images.setObject(image, forKey: key) } + lock.lock() + if sizes.count >= sizeLimit, sizes[key] == nil { sizes.removeAll() } + sizes[key] = image?.size ?? .zero + lock.unlock() + return image + } + + /// The size `source` decodes to (decoding and caching it if need be), nil + /// for markup that does not decode — what the measurer sizes the row by. + nonisolated static func size(of source: String) -> CGSize? { + if let known = cachedSize(source) { return known } + return decodeCached(source)?.size + } + /// The same decode for SVG that arrives as bytes — a `data:image/svg+xml` /// image or a remote `.svg` (shields.io badges are SVG) — once ImageIO has /// declined it (`ImageLoader.decodeImage`). Only bytes that START like SVG diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift index 563d209..e787cc2 100644 --- a/QuickMD/QuickMD/SectionExtractor.swift +++ b/QuickMD/QuickMD/SectionExtractor.swift @@ -9,8 +9,25 @@ import Foundation struct ToCEntry: Identifiable, Sendable { let id: String let level: Int + /// As shown (`MarkdownRenderer.shownHeadingTitle`): one line. let title: String let sourceLine: Int + + /// What the Contents sidebar lists: exactly the text the heading shows + /// (`renderHeader`'s characters — markup gone, references decoded, a code + /// span's `<` kept), the same text the heading's id is made from. + func shownTitle(renderer: MarkdownRenderer) -> String { + String(renderer.renderHeader(title, level: level).characters) + } + + /// The headings of `blocks`, in document order. + static func entries(of blocks: [MarkdownBlock]) -> [ToCEntry] { + blocks.compactMap { block in + guard case .heading(let level, let title, let sourceLine) = block.content else { return nil } + return ToCEntry(id: block.id, level: level, title: MarkdownRenderer.shownHeadingTitle(title), + sourceLine: sourceLine) + } + } } // MARK: - Contents Depth @@ -27,6 +44,19 @@ enum ToCDepth { (all, "All Headings"), (2, "Up to H2"), (3, "Up to H3"), (4, "Up to H4"), ] + /// The stored value as one of `choices`. Only `defaults write` can store + /// anything else, and a Picker whose selection matches no tag shows blank + /// (both of them did). Nearest allowed level; a tie (5) goes to the + /// deeper one, so nothing the stored value listed disappears. + static func normalized(_ stored: Int) -> Int { + let levels = choices.map(\.maxLevel) + let stored = min(max(stored, 0), 100) // no overflow in the distances + return levels.min { a, b in + let da = abs(a - stored), db = abs(b - stored) + return da != db ? da < db : a > b + } ?? all + } + static func visible(_ headings: [ToCEntry], maxLevel: Int) -> [ToCEntry] { maxLevel >= all ? headings : headings.filter { $0.level <= maxLevel } } @@ -86,12 +116,14 @@ enum HeadingAnchors { } /// The headings of `blocks` as links see them: block id and the text the - /// heading shows (`renderHeader`'s characters, as `HeadingBlockView`). + /// heading shows (`renderHeader`'s characters, as `HeadingBlockView`), + /// with a Setext title's line breaks kept for the slug to drop + /// (`MarkdownRenderer.headingAnchorText`). static func headings(of blocks: [MarkdownBlock], renderer: MarkdownRenderer) -> [(id: String, text: String)] { blocks.compactMap { block in - guard case .heading(let level, let title, _) = block.content else { return nil } - return (block.id, String(renderer.renderHeader(title, level: level).characters)) + guard case .heading(_, let title, _) = block.content else { return nil } + return (block.id, renderer.headingAnchorText(title)) } } @@ -124,10 +156,14 @@ enum HeadingAnchors { } /// The percent-decoded fragment of an in-document link (`#install`), nil - /// for any other link. Read from the link as written: a destination - /// `URL(string:)` rejects (non-ASCII or spaces on macOS 13) arrives - /// percent-encoded, its `#` as `%23`. The accepted cost: a file whose - /// name starts with `#`, linked as `%23notes.md`, reads as an anchor. + /// for any other link. Read from the link as written, decoded: link URLs + /// come from `MarkdownRenderer.linkURL(for:)`, which keeps the `#` and + /// encodes only the rest (`#Install Guide` → `#Install%20Guide`) on every + /// macOS. Until 1.14.4 the renderer's macOS 13 fallback encoded the `#` + /// itself as `%23`, and decoding before the `#` test is what still made + /// those links work; it is kept for any URL that arrives encoded that + /// way. Its cost: a file whose name starts with `#`, linked as + /// `%23notes.md`, reads as an anchor. static func inDocumentFragment(of url: URL) -> String? { guard url.scheme == nil else { return nil } let written = url.relativeString.removingPercentEncoding ?? url.relativeString @@ -165,7 +201,7 @@ enum SectionExtractor { guard end > start else { return nil } var section = Array(lines[start.. NSAttributedString?, table: (MarkdownBlock) -> TableText?) -> [SelectionPiece] { + parts(of: selection, blocks: blocks, text: text, table: table).map(\.piece) + } + + /// `pieces`, each marked with whether it CONTINUES the piece before it: + /// its row only continues the previous row (`MarkdownBlock.joinsPrevious` + /// — a long block cut into rows, a nested quote group) and that row gave + /// a piece too. `DocumentCopyBuilder.build(parts:)` joins those with a + /// line break instead of a blank line. + static func parts(of selection: DocumentSelection, blocks: [MarkdownBlock], + text: (MarkdownBlock) -> NSAttributedString?, + table: (MarkdownBlock) -> TableText?) -> [DocumentCopyBuilder.Part] { guard let span = selection.rowSpan else { return [] } - var pieces: [SelectionPiece] = [] - pieces.reserveCapacity(span.count) + var parts: [DocumentCopyBuilder.Part] = [] + parts.reserveCapacity(span.count) + var lastRowWithPiece: Int? for row in span where row < blocks.count { let block = blocks[row] - switch block.content { - case .table: - guard let built = table(block), - let covered = selection.range(inRow: row, rowLength: built.string.length), - let piece = tablePiece(built, covered: covered) else { continue } - pieces.append(piece) - case .image(_, let alt, _): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.image(alt: alt)) - case .svgImage: - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.image(alt: "")) - case .mathBlock(let latex): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.displayMath(latex: latex)) - case .mermaidDiagram(let source): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.mermaid(source: source)) - case .codeBlock, .text, .blockquote, .alert, .heading: - guard let string = text(block), - let covered = selection.range(inRow: row, rowLength: string.length) else { continue } - let part = string.attributedSubstring(from: covered) - if case .codeBlock = block.content { - pieces.append(.code(part)) - } else { - pieces.append(.text(part)) - } + guard let piece = piece(of: selection, row: row, block: block, text: text, table: table) else { continue } + parts.append(DocumentCopyBuilder.Part( + piece: piece, continuesPrevious: block.joinsPrevious && lastRowWithPiece == row - 1)) + lastRowWithPiece = row + } + return parts + } + + /// The covered part of ONE row as a copy piece; nil when the selection + /// covers nothing of it. + private static func piece(of selection: DocumentSelection, row: Int, block: MarkdownBlock, + text: (MarkdownBlock) -> NSAttributedString?, + table: (MarkdownBlock) -> TableText?) -> SelectionPiece? { + switch block.content { + case .table: + guard let built = table(block), + let covered = selection.range(inRow: row, rowLength: built.string.length) else { return nil } + return tablePiece(built, covered: covered) + case .image(_, let alt, _): + return selection.range(inRow: row, rowLength: 1) == nil ? nil : .image(alt: alt) + case .svgImage: + return selection.range(inRow: row, rowLength: 1) == nil ? nil : .image(alt: "") + case .mathBlock(let latex): + return selection.range(inRow: row, rowLength: 1) == nil ? nil : .displayMath(latex: latex) + case .mermaidDiagram(let source): + return selection.range(inRow: row, rowLength: 1) == nil ? nil : .mermaid(source: source) + case .codeBlock, .text, .blockquote, .alert, .heading: + guard let string = text(block), + let covered = selection.range(inRow: row, rowLength: string.length) else { return nil } + if block.substitutesFormula, case .codeBlock(let latex, _) = block.content, + covered.location == 0, covered.length == string.length { + // A formula shown as its source, covered whole (a drag across + // it, ⌘A): the formula, `$$…$$`, as 1.14.2 copied it. Part of + // its text copies as that text, like any selection. + return .displayMath(latex: latex) } + let part = string.attributedSubstring(from: covered) + if case .codeBlock = block.content { return .code(part) } + return .text(part) } - return pieces } /// The piece for the `covered` part of a table's string (TS-D8): @@ -139,9 +161,14 @@ enum SelectionRows { /// `text` with what belongs to the TABLE removed from its paragraph /// styles: the text blocks (the cell) and the alignment (the column's, - /// from the separator row — back to natural). The rest is kept. + /// from the separator row — back to natural). The rest is kept. A + /// front-matter list's U+2028 (one item per line) becomes a plain line + /// break: the copy reads as the cell shows, without a separator most + /// apps treat as invisible. private static func withoutCellLayout(_ text: NSAttributedString) -> NSAttributedString { let result = NSMutableAttributedString(attributedString: text) + result.mutableString.replaceOccurrences(of: FrontMatter.itemLineSeparator, with: "\n", options: [], + range: NSRange(location: 0, length: result.length)) result.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: result.length)) { value, range, _ in guard let style = value as? NSParagraphStyle, !style.textBlocks.isEmpty || style.alignment != .natural, @@ -274,6 +301,26 @@ extension SelectionRows { } } +extension SelectionRows { + /// The character a double- or triple-click in the space between two + /// blocks is about. Such a click lands where a single click there does — + /// the start of the row below (`offset` 0) or the end of the row above — + /// and is about the word at that end: the first character, or the last + /// one that is not a line break (a table's string ends in one). Nil for + /// an empty string. + static func multiClickIndex(atRowBoundary offset: Int, in string: NSString) -> Int? { + guard string.length > 0 else { return nil } + guard offset > 0 else { return 0 } + var index = min(offset, string.length) - 1 + func isLineBreak(_ index: Int) -> Bool { + let unit = string.character(at: index) + return unit == 0x0A || unit == 0x0D || unit == 0x2028 || unit == 0x2029 + } + while index > 0, isLineBreak(index) { index -= 1 } + return index + } +} + extension NSTextView { /// The character whose glyph is under `local` (view coordinates), or nil /// past the end. With `requireInside`, only when the point is ON the diff --git a/QuickMD/QuickMD/SettingsTab.swift b/QuickMD/QuickMD/SettingsTab.swift new file mode 100644 index 0000000..8fe045f --- /dev/null +++ b/QuickMD/QuickMD/SettingsTab.swift @@ -0,0 +1,38 @@ +import Foundation + +// MARK: - Settings: the remembered tab + +/// The Settings window's tabs, remembered by IDENTIFIER. +/// +/// Until 1.14.4 the `TabView` had no selection, and SwiftUI remembered the +/// selected tab by INDEX (`legacyIndexKey`): inserting "General" at 0 shifted +/// every user's remembered tab by one. The tags here survive reordering and +/// new tabs. +enum SettingsTab: String, CaseIterable { + // Declaration order = the order the tabs show = the legacy index. + case general, themes, fonts, editor + + static let defaultsKey = "settingsSelectedTab" + + /// Where SwiftUI's `Settings` scene kept the selected tab's index, read from + /// the app's own defaults domain (2026-11-04: present in the App Store + /// build's container and the E2E build, an Int). + static let legacyIndexKey = "com_apple_SwiftUI_Settings_selectedTabIndex" + + /// The tab a stored legacy index meant — in the order the tabs had when it + /// was written, which is this enum's order (the index was last remapped + /// by the "General" insertion itself). Missing or out of range → General. + static func fromLegacyIndex(_ index: Int?) -> SettingsTab { + guard let index, allCases.indices.contains(index) else { return .general } + return allCases[index] + } + + /// The tab to open with: the stored identifier, else (first launch after + /// the update) the tab SwiftUI remembered by index, so nobody's tab moves. + static func initial(in defaults: UserDefaults) -> SettingsTab { + if let raw = defaults.string(forKey: defaultsKey), let tab = SettingsTab(rawValue: raw) { + return tab + } + return fromLegacyIndex(defaults.object(forKey: legacyIndexKey) as? Int) + } +} diff --git a/QuickMD/QuickMD/SourceEditSession.swift b/QuickMD/QuickMD/SourceEditSession.swift index 67b938e..4c6cb80 100644 --- a/QuickMD/QuickMD/SourceEditSession.swift +++ b/QuickMD/QuickMD/SourceEditSession.swift @@ -247,7 +247,10 @@ final class SourceEditSession: ObservableObject { /// A carry-over is for "select a typo, fix it": a selection over more /// rows than this is not looked for (and its text is never built — ⌘A on /// a large document must not produce megabytes that cannot match). - static let selectionHintMaxRows = 3 + /// Rows are single blocks (one paragraph, one list, one loose-list item), + /// so a few short paragraphs are already several rows; the length cap + /// below is what bounds the text. + static let selectionHintMaxRows = 8 /// Longer hint texts are ignored, for the same reason. static let selectionHintMaxLength = 1000 /// Rendered characters of context on each side of the selection. diff --git a/QuickMD/QuickMD/SourceEditSupport.swift b/QuickMD/QuickMD/SourceEditSupport.swift index 5c1bf0c..5389084 100644 --- a/QuickMD/QuickMD/SourceEditSupport.swift +++ b/QuickMD/QuickMD/SourceEditSupport.swift @@ -192,7 +192,21 @@ enum SourceEditSupport { if wantBefore.isEmpty && wantAfter.isEmpty { break } searchFrom = found.location + 1 } - return best + return best ?? indentedMatch(of: needle, in: text, scope: scope) + } + + /// A multi-line selection that is not in the source literally, found + /// with the spaces and tabs at the start of each source line after the + /// first skipped: the renderer drops a fence's indentation from its + /// lines (CommonMark), so a selection inside a fenced block under a list + /// item reads "a\nb" where the source has "a\n b". The first match. + private static func indentedMatch(of needle: String, in text: NSString, scope: NSRange) -> NSRange? { + guard needle.contains("\n") else { return nil } + let pattern = needle.components(separatedBy: "\n") + .map { NSRegularExpression.escapedPattern(for: $0) } + .joined(separator: "\n[ \t]*") + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + return regex.firstMatch(in: text as String, range: scope)?.range } static func sourceRange(ofSelection selectedText: String, before: String, after: String, diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift index 37e494b..a025d65 100644 --- a/QuickMD/QuickMD/SourceEditorController.swift +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -103,14 +103,20 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// `apply(style:)`, so SwiftUI may hand the same one on every update. struct Style: Equatable { var theme: MarkdownTheme + /// The editor's font size (the zoom asked for: it follows ⌘+ at once). var fontScale: Double var isReadingLayout: Bool + /// The zoom the Reading Mode column is capped at (`columnGeometry`); + /// nil = `fontScale`. Over a rendered document, the zoom the list + /// SHOWS (`overDocument`). + var columnFontScale: Double? = nil /// `MarkdownTheme` is not `Equatable`; compare what the editor reads /// from it. Colours as well as the name: a custom theme re-imported /// under the same name may change them. static func == (lhs: Style, rhs: Style) -> Bool { lhs.fontScale == rhs.fontScale + && lhs.columnFontScale == rhs.columnFontScale && lhs.isReadingLayout == rhs.isReadingLayout && lhs.theme.name == rhs.theme.name && lhs.theme.isDark == rhs.theme.isDark @@ -120,6 +126,20 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD && lhs.hasSameTintColors(as: rhs) } + /// The editor's style over a rendered document. Two zooms, because + /// between ⌘+ and the re-parse that follows it they differ: + /// - the font follows `fontScale`, the zoom asked for, at once; + /// - the Reading Mode column follows `shownFontScale` + /// (`MarkdownView.renderedFontScale` — the zoom the list's blocks + /// were parsed at and its column is capped at), so the editor's + /// column is the list's at every moment and leaving Source Edit + /// before the parse lands does not move the text sideways. + static func overDocument(theme: MarkdownTheme, fontScale: Double, shownFontScale: Double, + isReadingMode: Bool) -> Style { + Style(theme: theme, fontScale: fontScale, isReadingLayout: isReadingMode, + columnFontScale: shownFontScale) + } + /// The colours of the block tint (`paintTint`). func hasSameTintColors(as other: Style) -> Bool { theme.keywordColor == other.theme.keywordColor @@ -437,17 +457,19 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// The rendered document's column, reproduced so the text does not jump /// sideways when the mode toggles. `VirtualBlockList.currentContentWidth()` /// computes `inner = floor(clip) − 2 × contentHorizontalPadding` (32 pt) - /// and, in reading mode, `floor(min(inner, 720))`; its cells centre that + /// and, in reading mode, `floor(min(inner, cap))` with the cap at the + /// zoom (`BlockLayout.Document.columnWidth`, `readingColumnCap`, 720 pt at + /// 100 %); its cells centre that /// column in the `floor(clip)`-wide table column, i.e. the column starts /// at `(floor(clip) − column) / 2` — 32 pt exactly in the standard layout, /// never less in reading mode. The vertical gap is the list's visible gap /// above the first block (`LayoutStyle.verticalPadding`: 24 / 48 pt). - static func columnGeometry(clipWidth: CGFloat, isReadingLayout: Bool) -> ColumnGeometry { + static func columnGeometry(clipWidth: CGFloat, isReadingLayout: Bool, fontScale: CGFloat) -> ColumnGeometry { let available = floor(max(clipWidth, 0)) let inner = available - 2 * BlockLayout.Document.contentHorizontalPadding - let column = isReadingLayout - ? max(0, floor(min(inner, BlockLayout.Document.readingMaxContentWidth))) - : max(0, inner) + let column = BlockLayout.Document.columnWidth( + inner: inner, + cap: isReadingLayout ? BlockLayout.Document.readingColumnCap(fontScale: fontScale) : nil) let vertical = isReadingLayout ? BlockLayout.Document.readingContentVerticalPadding : BlockLayout.Document.contentVerticalPadding @@ -464,7 +486,8 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD isUpdatingGeometry = true defer { isUpdatingGeometry = false } let clip = scrollView.contentView.bounds.size - let geometry = Self.columnGeometry(clipWidth: clip.width, isReadingLayout: style.isReadingLayout) + let geometry = Self.columnGeometry(clipWidth: clip.width, isReadingLayout: style.isReadingLayout, + fontScale: CGFloat(style.columnFontScale ?? style.fontScale)) let inset = NSSize(width: geometry.horizontalInset, height: geometry.verticalInset) // At least as tall as the clip, so a click below a short text still // lands in the text view (and places the caret at the end). diff --git a/QuickMD/QuickMD/SourceTint.swift b/QuickMD/QuickMD/SourceTint.swift index e8c291e..9505e5e 100644 --- a/QuickMD/QuickMD/SourceTint.swift +++ b/QuickMD/QuickMD/SourceTint.swift @@ -78,7 +78,8 @@ enum SourceTint { func isBlank(_ line: Int) -> Bool { let start = lineStarts[line] let range = NSRange(location: start, length: max(0, contentEnd(line) - start)) - // The parser's notion of blank: nothing but `.whitespaces`. + // The parser's notion of blank (`MarkdownRenderer.isBlankLine`): + // nothing but spaces and tabs. return text.rangeOfCharacter(from: nonWhitespace, options: [], range: range).location == NSNotFound } @@ -93,12 +94,9 @@ enum SourceTint { let boundary = blocks[(index + 1)...].first(where: { $0.sourceLine > first })?.sourceLine ?? lineCount let limit = min(boundary, lineCount) - 1 var last: Int - if case .heading(_, let title, _) = block.content { - last = first - if first + 1 <= limit, isSetext(line: first, title: title, in: text, contentEnd: contentEnd(first), - lineStarts: lineStarts) { - last = first + 1 - } + if case .heading = block.content { + last = setextUnderline(from: first, through: limit, in: text, lineStarts: lineStarts, + contentEnd: contentEnd) ?? first } else { last = limit while last > first, isBlank(last) { last -= 1 } @@ -112,8 +110,9 @@ enum SourceTint { return spans } - /// Non-blank characters, by the parser's `.whitespaces` trimming rule. - private static let nonWhitespace = CharacterSet.whitespaces.inverted + /// Non-blank characters, by the parser's blank-line rule: anything but a + /// space or a tab (a no-break space is text). + private static let nonWhitespace = MarkdownRenderer.markerWhitespace.inverted static func kind(of block: MarkdownBlock) -> Kind? { switch block.content { @@ -132,12 +131,27 @@ enum SourceTint { } } - /// Whether the heading on `line` is a Setext one: its line, trimmed the - /// way the parser trims it, IS the parser's title. - private static func isSetext(line: Int, title: String, in text: NSString, contentEnd: Int, - lineStarts: [Int]) -> Bool { - let start = lineStarts[line] - let content = text.substring(with: NSRange(location: start, length: max(0, contentEnd - start))) - return content.trimmingCharacters(in: .whitespaces) == title + /// The underline of the heading starting on `first` when it is a Setext + /// one — its title may run over several lines (the whole paragraph + /// above the underline, CommonMark) — or nil for an ATX heading, whose + /// one line is all of it. The first underline line before `limit`. + private static func setextUnderline(from first: Int, through limit: Int, in text: NSString, + lineStarts: [Int], contentEnd: (Int) -> Int) -> Int? { + func line(_ index: Int) -> String { + let start = lineStarts[index] + return text.substring(with: NSRange(location: start, length: max(0, contentEnd(index) - start))) + } + let opening = line(first) + guard MarkdownBlockParser.headerRegex.firstMatch(in: opening, range: NSRange(opening.startIndex..., + in: opening)) == nil, + first < limit else { return nil } + for index in (first + 1)...limit { + let marker = MarkdownBlockParser.markerLine(line(index)) + if marker.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil + || marker.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil { + return index + } + } + return nil } } diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift index a2b24a3..6e8ffeb 100644 --- a/QuickMD/QuickMD/TableTextBuilder.swift +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -44,6 +44,9 @@ struct TableTextLayout: Equatable { var bandCount: Int { cellRanges.count } + /// Column 0 of every band — a `.properties` table's key cells. + var firstColumnRanges: [NSRange] { cellRanges.compactMap(\.first) } + /// UTF-16 length of the whole string (0 for a table with no bands). var length: Int { guard let last = cellRanges.last?.last else { return 0 } @@ -360,12 +363,13 @@ enum TableTextBuilder { } /// One cell's rendered text (AppKit scope), without its "\n". A - /// `.properties` key (column 0) is literal (`keyCell`), every other cell - /// inline Markdown. + /// `.properties` key (column 0) is literal (`keyCell`), its value + /// `valueCell`, every grid cell inline Markdown. private static func renderedCell(_ markdown: String, column: Int, style: TableStyle, renderer: MarkdownRenderer) -> NSMutableAttributedString { - let attributed = style == .properties && column == 0 - ? keyCell(markdown, renderer: renderer) : renderer.renderInline(markdown) + let attributed = style == .properties + ? (column == 0 ? keyCell(markdown, renderer: renderer) : valueCell(markdown, renderer: renderer)) + : renderer.renderInline(markdown) let cell = NSMutableAttributedString( attributedString: (try? NSAttributedString(attributed, including: \.appKit)) ?? NSAttributedString(string: markdown)) @@ -389,6 +393,15 @@ enum TableTextBuilder { return cell } + /// A `.properties` value: inline Markdown (a URL is a link, `**x**` is + /// bold) except that a backslash is literal, as in the YAML it came from + /// (`C:\_tmp` shows `C:\_tmp`, not `C:_tmp`), and so is a character + /// reference (`&` stays `&`: GitHub renders front matter as plain + /// text). Screen and print. + static func valueCell(_ value: String, renderer: MarkdownRenderer) -> AttributedString { + renderer.renderInline(value, literalBackslashes: true, literalEntities: true) + } + /// Header cells are semibold. Only raises the weight: a `**bold**` run /// stays bold, italic and monospaced runs keep their face. A family /// without a semibold face (a Settings font) falls back to its bold. diff --git a/QuickMD/QuickMD/VerticalRhythm.swift b/QuickMD/QuickMD/VerticalRhythm.swift new file mode 100644 index 0000000..5bfc14c --- /dev/null +++ b/QuickMD/QuickMD/VerticalRhythm.swift @@ -0,0 +1,551 @@ +// +// VerticalRhythm.swift +// QuickMD +// +// The space between blocks: one system for the document list and for +// print/PDF. Compiled into the app AND the test target (no view code). +// + +import AppKit + +// MARK: - The rhythm + +extension BlockLayout { + + /// How much space separates two blocks. + /// + /// Every gap is a multiple of the body size at the current zoom, + /// `u = 14 × fontScale`, so the whole column scales with the text. A gap + /// depends ONLY on the two blocks around it: never on whether the source + /// had a blank line between them (the parser keeps blank lines out of the + /// rows) and never on where a long block was cut into rows. Rows are + /// CommonMark blocks, so the same pair of blocks is the same distance apart + /// everywhere, in every document. + /// + /// A gap is measured one of two ways: + /// - **edge to edge** between two non-heading blocks: from the bottom of + /// the upper block's last line box, or its box edge (the background of a + /// code block, a quote, a table), to the top of the lower one's; + /// - **optically** around a heading: from the last BASELINE above it (or a + /// box edge) to the heading's CAP TOP, and from the heading's last + /// baseline to the next block's cap top or box edge. A heading then has + /// the same air under it whatever follows (a paragraph, a list, a code + /// block, a table) and the same air above it whatever precedes. + /// + /// A row carries its gap as space ABOVE its block (`BlockHeightTable + /// .spacingAbove`); the first row has none, and neither does a row that only + /// continues the one before it (`MarkdownBlock.joinsPrevious`). + enum Spacing { + + /// Body text size at 1.0 zoom: `MarkdownRenderer`'s paragraph font. + static let bodySize: CGFloat = 14 + + /// One unit of the rhythm at `fontScale`. + static func unit(fontScale: CGFloat) -> CGFloat { bodySize * fontScale } + + /// Between two non-heading blocks, edge to edge: paragraphs, lists, + /// rules and boxes alike. 24.5 pt at 100 %. + static let blockMargin: CGFloat = 1.75 + + /// Under every heading, optical: its last baseline to the next block's + /// cap top or box edge. 31.5 pt at 100 %. + static let headingBelow: CGFloat = 2.25 + + /// A heading directly under its parent (a deeper level right after a + /// shallower one), optical. A little tighter than `headingBelow`, so + /// the two titles read as one unit. 28 pt at 100 %. + static let headingUnderHeading: CGFloat = 2.0 + + /// Above a heading of level 1…6, optical: the last baseline (or box + /// edge) above it to its cap top. Larger for the higher levels, so a + /// section break reads stronger than a subsection break. + static let headingAbove: [CGFloat] = [4.75, 4.25, 3.75, 3.5, 3.5, 3.5] + + /// `headingAbove` for `level`, clamped to 1…6. + static func headingAbove(level: Int) -> CGFloat { + headingAbove[max(1, min(level, 6)) - 1] + } + + /// What a row is, as far as spacing goes. + enum Role: Equatable { + /// Paragraphs, lists, rules: line boxes. + case text + case heading(level: Int) + /// A block with a fixed edge: code, quote, alert, table, image, + /// formula, diagram. + case box + /// The front-matter properties table at the top of a document. A + /// heading right under it is the document's title, not a section + /// break, so it gets `blockMargin`, edge to edge. + case frontMatter + } + + /// Air inside a row, between the row's edges and what the reader sees. + struct Edges: Equatable { + /// Row top → cap top of the first line (text, heading), or the box edge. + var inkAbove: CGFloat + /// Last baseline (text, heading), or the box edge → row bottom. + var inkBelow: CGFloat + /// Row top → the first line box, or the box edge (0 for text). + var boxAbove: CGFloat + /// The last line box, or the box edge → row bottom (0 for text). + var boxBelow: CGFloat + + /// A box `top` / `bottom` points inside the row's edges: its ink + /// and its box are the same edge. + static func box(top: CGFloat, bottom: CGFloat) -> Edges { + Edges(inkAbove: top, inkBelow: bottom, boxAbove: top, boxBelow: bottom) + } + + /// Text set `padding` points inside the row on both sides, with no + /// visible box around it: a quote, whose bars span the text's line + /// boxes — the eye reads the TEXT. Optical edges on its ink, box + /// edges on its line boxes, exactly as a paragraph's, so a quote's + /// text sits where a paragraph's would, under a heading and + /// between blocks. + static func text(_ ink: TextInk, padding: CGFloat) -> Edges { + Edges(inkAbove: padding + ink.firstBaseline - ink.firstCapHeight, + inkBelow: padding + ink.height - ink.lastBaseline, + boxAbove: padding, boxBelow: padding) + } + + /// Text laid out as `ink` describes, `top` points below the row's + /// top edge, in a row `rowHeight` tall (default: the text's own + /// height plus `top`). Its line boxes are the row's edges. + static func text(_ ink: TextInk, top: CGFloat = 0, rowHeight: CGFloat? = nil) -> Edges { + let height = rowHeight ?? (top + ink.height) + return Edges(inkAbove: top + ink.firstBaseline - ink.firstCapHeight, + inkBelow: height - top - ink.lastBaseline, + boxAbove: 0, boxBelow: 0) + } + } + + /// Where a laid-out string's ink starts and ends (TextKit 1, the same + /// layout `BlockHeightMeasurer.exactHeight` runs): see + /// `BlockHeightMeasurer.textInk`. + struct TextInk: Equatable { + /// `exactHeight` of the string. + let height: CGFloat + /// From the text's top to the first line's baseline. + let firstBaseline: CGFloat + /// The largest cap height among the first line's fonts. + let firstCapHeight: CGFloat + /// From the text's top to the last line's baseline. + let lastBaseline: CGFloat + } + + /// One row's input to the rhythm. + struct Row: Equatable { + let role: Role + let edges: Edges + /// `MarkdownBlock.joinsPrevious`. + let joinsPrevious: Bool + } + + /// The space to put above `row`'s block when `previous` is the row + /// before it (nil: the document's first row). + /// + /// The edges are subtracted from the target, so the reader sees the + /// target whatever air the two blocks already have inside their rows. + /// Whole points, never negative. + static func above(_ row: Row, after previous: Row?, fontScale: CGFloat) -> CGFloat { + guard let previous, !row.joinsPrevious else { return 0 } + let target: CGFloat + let optical: Bool + switch (previous.role, row.role) { + case (.heading(let upper), .heading(let lower)) where lower > upper: + target = headingUnderHeading + optical = true + case (.frontMatter, .heading): + target = blockMargin + optical = false + case (_, .heading(let level)): + target = headingAbove(level: level) + optical = true + case (.heading, _): + target = headingBelow + optical = true + default: + target = blockMargin + optical = false + } + let air = optical ? previous.edges.inkBelow + row.edges.inkAbove + : previous.edges.boxBelow + row.edges.boxAbove + return max(0, (target * unit(fontScale: fontScale) - air).rounded()) + } + + /// `above` for every row of a document, in order. + static func column(_ rows: [Row], fontScale: CGFloat) -> [CGFloat] { + var spacing: [CGFloat] = [] + spacing.reserveCapacity(rows.count) + for index in rows.indices { + spacing.append(above(rows[index], after: index > 0 ? rows[index - 1] : nil, + fontScale: fontScale)) + } + return spacing + } + } +} + +// MARK: - A row of the document list + +/// Where a block sits inside its row of `VirtualBlockList`. +/// +/// A row is the space above its block (`BlockHeightTable.spacing(_:)`) and +/// then the block, top-aligned. `NSTableView` is flipped, so `minY` is the +/// row's top edge. Every place that turns a row into a position goes through +/// here: a jump lands on the BLOCK, not on the space above it. +enum RowGeometry { + + /// The block's part of `rowRect`: everything below the space above. + static func contentRect(rowRect: NSRect, spacingAbove: CGFloat) -> NSRect { + let inset = min(max(0, spacingAbove), rowRect.height) + return NSRect(x: rowRect.minX, y: rowRect.minY + inset, + width: rowRect.width, height: rowRect.height - inset) + } + + /// The document y a `.top` jump puts at the top of the content area: the + /// block's top. The space above the block stays above the viewport, so + /// the content inset alone is the gap the reader sees — the same as above + /// the document's first block. + static func topTarget(rowRect: NSRect, spacingAbove: CGFloat) -> CGFloat { + contentRect(rowRect: rowRect, spacingAbove: spacingAbove).minY + } + + /// The document y a `.center` jump puts at the top of a content area + /// `visibleHeight` tall: the block's middle in the middle of it. + static func centerTarget(rowRect: NSRect, spacingAbove: CGFloat, visibleHeight: CGFloat) -> CGFloat { + contentRect(rowRect: rowRect, spacingAbove: spacingAbove).midY - visibleHeight / 2 + } + + /// Where a search jump (⌘G) puts the content top. The block's middle at + /// the centre — unless the block is taller than the content area (a big + /// table, a long code block): then the focused MATCH's middle + /// (`matchMidY`, document y) at the centre, so every match in the block is + /// brought to the eye and stepping between them scrolls. `needsMatch`: a + /// tall block whose match is not known yet (its view is not on display) — + /// the jump goes to the block and aims at the match when it lands. + /// + /// `withinBlock`: a step between two matches of the same block. A block + /// that fits the content area is not moved for it (nil) — it was centred + /// when the search arrived in it. + static func searchTarget(rowRect: NSRect, spacingAbove: CGFloat, visibleHeight: CGFloat, + matchMidY: CGFloat?, withinBlock: Bool = false) -> (target: CGFloat, needsMatch: Bool)? { + let block = contentRect(rowRect: rowRect, spacingAbove: spacingAbove) + guard block.height > visibleHeight else { + if withinBlock { return nil } + return (centerTarget(rowRect: rowRect, spacingAbove: spacingAbove, visibleHeight: visibleHeight), false) + } + if let matchMidY { return (matchMidY - visibleHeight / 2, false) } + return (centerTarget(rowRect: rowRect, spacingAbove: spacingAbove, visibleHeight: visibleHeight), true) + } + + /// The scroll anchor: how far below the row's BLOCK top the content top + /// is. Negative when the content top is in the space above the block. + /// Relative to the block, not to the row's edge, because that space + /// changes with zoom and with the block's neighbours; the reader's place + /// in the block does not. + static func anchorOffset(contentTop: CGFloat, rowRect: NSRect, spacingAbove: CGFloat) -> CGFloat { + contentTop - topTarget(rowRect: rowRect, spacingAbove: spacingAbove) + } + + /// The content top that puts the reader back at `anchorOffset` below the + /// row's block top — in whatever layout the row has now. + static func contentTop(anchorOffset: CGFloat, rowRect: NSRect, spacingAbove: CGFloat) -> CGFloat { + topTarget(rowRect: rowRect, spacingAbove: spacingAbove) + anchorOffset + } + + /// Whether the content top is still exactly where `anchorOffset` below the + /// row's block top puts it (to half a point) — i.e. nothing has scrolled + /// since the anchor was set, other than compensations that moved the block + /// and the content top together. + static func isAt(anchorOffset: CGFloat, contentTop: CGFloat, rowRect: NSRect, spacingAbove: CGFloat) -> Bool { + abs(contentTop - self.contentTop(anchorOffset: anchorOffset, rowRect: rowRect, + spacingAbove: spacingAbove)) < 0.5 + } + + /// Where to put the content top for a reader `offset` below a block whose + /// height in the current layout is `blockHeight`: at `offset`, unless the + /// block would then be wholly above the visible area (`topInset`, the top + /// content inset, still shows content) while its height is only an + /// estimate — an image placeholder, the diagram default. Its view would + /// never be built, never report its real height, and the reader would + /// stay in whatever follows the estimate. The content top then goes to the + /// block's last point instead, so the block is on screen and reports, and + /// the anchor is placed again at `offset` when it does (`reportAction`). + static func anchorPlacement(offset: CGFloat, blockHeight: CGFloat, topInset: CGFloat, + heightIsEstimate: Bool) -> CGFloat { + guard heightIsEstimate, offset - topInset >= blockHeight else { return offset } + return max(0, blockHeight - 1) + } + + /// What a `.reported` row's height change does to the scroll offset. + enum ReportAction: Equatable { + /// Nothing: the change is below what the reader is anchored on. + case keep + /// Move the content top by the change: it is above the anchor, so the + /// anchor stays where it is on screen. + case shift + /// The anchored block itself: place the anchor again (`anchorPlacement`) + /// against the block's new height. + case placeAnchor + } + + /// The rule for a height report of `row`. A block changes height at its + /// BOTTOM (rows are top-aligned), so the change moves everything below it. + /// + /// With an anchor (`anchorRow`: the list's last anchor while the content + /// top is still where it put it, or the target of a jump in flight) the + /// anchored block is what stays put: rows above it shift the content top + /// with them — rows in view above a jump target included, so a search hit + /// stays centred while the images above it load — and the anchored block + /// itself is placed again. Its offset was measured at its real height + /// while the block sat at an estimate; compensating would move the reader + /// by the estimate's error, a whole image. + /// + /// Without one (the reader scrolled), a block whose top is above the + /// content top — the one straddling it included — shifts the content top, + /// and a block whose top is in view needs nothing. `contentTop` must be + /// read BEFORE the table applies the change (at the end of the document + /// AppKit clamps the clip origin when the table shrinks). + static func reportAction(row: Int, anchorRow: Int?, blockTop: CGFloat, contentTop: CGFloat) -> ReportAction { + guard let anchorRow else { return blockTop < contentTop ? .shift : .keep } + if row < anchorRow { return .shift } + return row == anchorRow ? .placeAnchor : .keep + } + + /// Whether a point at `y` in a row belongs to the row BEFORE it: the upper + /// half of the space above a block is nearer the previous block, so a drag + /// that stops there ends after that block, not before this one. + static func belongsToPreviousRow(y: CGFloat, rowRect: NSRect, spacingAbove: CGFloat) -> Bool { + y < rowRect.minY + min(max(0, spacingAbove), rowRect.height) / 2 + } + + /// Whether `y` is in the upper half of the row's BLOCK — the space above + /// the block does not count. An atomic row (an image, a formula) is + /// selected once a drag passes its middle. + static func isInUpperHalf(y: CGFloat, rowRect: NSRect, spacingAbove: CGFloat) -> Bool { + y < contentRect(rowRect: rowRect, spacingAbove: spacingAbove).midY + } +} + +// MARK: - Search jump: the aim at the match + +/// A search jump into a block taller than the window whose match could not +/// be located yet (its view was not on display): the jump goes to the block, +/// and this aims it at the match once it has landed +/// (`VirtualBlockList.aimAtPendingMatch`, `RowGeometry.searchTarget`). +/// +/// Anything the reader does to the scroll position after the request — the +/// wheel or trackpad, a scroller drag, a scroll key, a drag-select at the +/// edge — takes the aim away: they have taken over, and a glide to the match +/// when the jump lands would yank the view from where they scrolled to. A +/// new table drops it too. +struct PendingMatchAim: Equatable { + /// The block the jump went to; nil when nothing is pending. + private(set) var blockId: String? + + /// A search jump was sent to `blockId`; `needsMatch`: its match is not + /// known yet (`RowGeometry.searchTarget`). + mutating func jumped(to blockId: String, needsMatch: Bool) { + self.blockId = needsMatch ? blockId : nil + } + + /// The reader moved the document themselves. + mutating func readerScrolled() { blockId = nil } + + /// Taken, given up, or a new table. + mutating func drop() { blockId = nil } + + /// Whether a scroll-wheel event is the reader's scroll: it moves the + /// document (a vertical delta), or a gesture or its momentum begins or + /// goes on. Fingers resting on the trackpad (`.mayBegin`), a gesture's + /// end and zero-delta events move nothing and leave the aim alone. + static func isReaderScroll(_ phase: NSEvent.Phase, _ momentumPhase: NSEvent.Phase, + _ deltaY: CGFloat) -> Bool { + if deltaY != 0 { return true } + let moving: NSEvent.Phase = [.began, .changed] + return !phase.isDisjoint(with: moving) || !momentumPhase.isDisjoint(with: moving) + } +} + +// MARK: - Print / PDF + +/// The rhythm for print and PDF (`PDFExportManager.generateMultiPagePDF`): +/// the same `BlockLayout.Spacing` rules, applied to the edges of the PRINTED +/// views (`MarkdownPrintableBlockView`), which have no outer paddings and no +/// heading copy button. +enum PrintRhythm { + + /// Role and edges of `block` as `MarkdownPrintableBlockView` prints it at + /// `width`, at 1.0 zoom. + /// + /// Text and headings print through SwiftUI `Text`, which ignores paragraph + /// styles, so their ink is measured without them. Every box (code, quote, + /// alert, table, image, diagram) prints its background or its picture from + /// its frame's edge; a display formula has its vertical padding. + /// Front matter (either form) is the parser's call: `isFrontMatter`. + /// + /// A printed quote has a background, so unlike the screen's it keeps its + /// box edge. A diagram printed as a picture (`diagramIsPicture`; without + /// one it prints its source as a code block) starts where its ink does, + /// `Mermaid.inkInset` inside the picture, as on screen. + static func row(for block: MarkdownBlock, renderer: MarkdownRenderer, width: CGFloat, + diagramIsPicture: Bool = false) -> BlockLayout.Spacing.Row { + typealias Spacing = BlockLayout.Spacing + let role: Spacing.Role + let edges: Spacing.Edges + switch block.content { + case .text(let attributed): + role = .text + edges = .text(BlockHeightMeasurer.textInk(text: printed(attributed), width: width)) + case .heading(let level, let title, _): + role = .heading(level: level) + edges = .text(BlockHeightMeasurer.textInk(text: printed(renderer.renderHeader(title, level: level)), + width: width)) + case .mathBlock: + role = .box + edges = .box(top: BlockLayout.Math.verticalPadding, bottom: BlockLayout.Math.verticalPadding) + case .mermaidDiagram where diagramIsPicture: + role = .box + edges = .box(top: BlockLayout.Mermaid.inkInset, bottom: BlockLayout.Mermaid.inkInset) + case .table, .codeBlock, .blockquote, .alert, .image, .svgImage, .mermaidDiagram: + role = .box + edges = .box(top: 0, bottom: 0) + } + return Spacing.Row(role: block.isFrontMatter ? .frontMatter : role, edges: edges, + joinsPrevious: block.joinsPrevious) + } + + /// The string as SwiftUI `Text` lays it out: no paragraph styles. + private static func printed(_ attributed: AttributedString) -> NSAttributedString { + let ns = NSMutableAttributedString(attributedString: + (try? NSAttributedString(attributed, including: \.appKit)) + ?? NSAttributedString(string: String(attributed.characters))) + ns.removeAttribute(.paragraphStyle, range: NSRange(location: 0, length: ns.length)) + return ns + } + + /// A block to print: its height, the space above it, and whether it must + /// not end a page (a heading keeps with the block after it). + struct Block: Equatable { + let height: CGFloat + let spacingAbove: CGFloat + let keepsWithNext: Bool + } + + /// One printed piece of a block — the whole block, or one slice of a + /// block taller than the room it gets: its part of the block (`offset`, + /// `height`), its page and its y from the top of the page's content area. + struct Slice: Equatable { + let block: Int + let offset: CGFloat + let height: CGFloat + let page: Int + let y: CGFloat + } + + /// The least of a sliced block that has to fit below a heading kept with + /// it: two lines of body text. + static let minimumFirstSlice: CGFloat = 40 + + /// Lays `blocks` out top to bottom on pages `pageHeight` tall. + /// + /// - A block gets its `spacingAbove` unless it is first on its page — the + /// page's margin already separates it from the page above. + /// - A block that does not fit below the last one starts a new page; a + /// block taller than a page starts one and is sliced, a page per slice. + /// - Keep with next: a run of headings (`keepsWithNext`, an H2 and the H3 + /// under it) never ends a page. When the block after the run starts a + /// new page, the run moves to that page with it — if the run and the + /// block fit on one page; otherwise nothing would be gained and the run + /// stays. A block taller than a page is not moved: its first slice + /// fills the rest of the run's page (moving the run first when not even + /// `minimumFirstSlice` fits there), so a heading is kept with that first + /// slice and nothing loops. + static func paginate(_ blocks: [Block], pageHeight: CGFloat) -> [Slice] { + var slices: [Slice] = [] + slices.reserveCapacity(blocks.count) + var page = 0 + var bottom: CGFloat = 0 // of the last slice on `page` + var pageIsEmpty = true + + func put(_ block: Int, offset: CGFloat = 0, height: CGFloat, at y: CGFloat) { + slices.append(Slice(block: block, offset: offset, height: height, page: page, y: y)) + bottom = y + height + pageIsEmpty = false + } + func newPage() { + page += 1 + bottom = 0 + pageIsEmpty = true + } + /// The headings that end the current page right before `block`, as + /// indices into `slices` (empty: none). + func headingRun(before block: Int) -> Range { + var first = slices.count + var expected = block - 1 + while first > 0 { + let last = slices[first - 1] + guard last.page == page, last.block == expected, last.offset == 0, + blocks[last.block].keepsWithNext else { break } + first -= 1 + expected -= 1 + } + return first..) -> Bool { !run.isEmpty && slices[run.lowerBound].y > 0 } + /// Moves `run` to the top of a new page. + func moveToNewPage(_ run: Range) { + let moved = Array(slices[run]) + slices.removeSubrange(run) + newPage() + for slice in moved { + put(slice.block, height: slice.height, + at: pageIsEmpty ? 0 : bottom + blocks[slice.block].spacingAbove) + } + } + func runHeight(_ run: Range) -> CGFloat { + guard let first = run.first else { return 0 } + return slices[run.last ?? first].y + slices[run.last ?? first].height - slices[first].y + } + + for (index, block) in blocks.enumerated() { + let y = pageIsEmpty ? 0 : bottom + block.spacingAbove + let run = headingRun(before: index) + if block.height <= pageHeight { + if pageIsEmpty || y + block.height <= pageHeight { + put(index, height: block.height, at: y) + } else if canMove(run), runHeight(run) + block.spacingAbove + block.height <= pageHeight { + moveToNewPage(run) + put(index, height: block.height, at: bottom + block.spacingAbove) + } else { + newPage() + put(index, height: block.height, at: 0) + } + continue + } + + // Taller than a page: sliced, a page per slice — except the first + // slice after a heading run, which fills the rest of the run's page. + var offset: CGFloat = 0 + if !run.isEmpty, pageHeight - y < minimumFirstSlice, canMove(run) { + moveToNewPage(run) + } + let top = pageIsEmpty ? 0 : bottom + block.spacingAbove + if !run.isEmpty, pageHeight - top >= minimumFirstSlice { + let first = pageHeight - top + put(index, height: first, at: top) + offset = first + } + while offset < block.height { + if !pageIsEmpty { newPage() } + let height = min(pageHeight, block.height - offset) + put(index, offset: offset, height: height, at: 0) + offset += height + } + } + return slices + } +} diff --git a/QuickMD/QuickMD/Views/AlertBlockView.swift b/QuickMD/QuickMD/Views/AlertBlockView.swift index 723fd78..4e25fc7 100644 --- a/QuickMD/QuickMD/Views/AlertBlockView.swift +++ b/QuickMD/QuickMD/Views/AlertBlockView.swift @@ -93,8 +93,10 @@ struct AlertBlockView: View { /// One attributed string for the whole alert body — see /// `MarkdownRenderer.renderQuotedBody` (shared with the blockquote view and - /// the height measurer). + /// the height measurer) — rendered only when the measurer has not built + /// the string (`BlockquoteView.renderedContent`: `TextBlockView` reads it + /// only without `preconverted`; this body runs on every row update). private func renderedContent() -> AttributedString { - renderer.renderQuotedBody(content) + preconverted == nil ? renderer.renderQuotedBody(content) : AttributedString() } } diff --git a/QuickMD/QuickMD/Views/BlockquoteView.swift b/QuickMD/QuickMD/Views/BlockquoteView.swift index 68a8a31..198eb4b 100644 --- a/QuickMD/QuickMD/Views/BlockquoteView.swift +++ b/QuickMD/QuickMD/Views/BlockquoteView.swift @@ -82,8 +82,12 @@ struct BlockquoteView: View { /// One attributed string for the whole quote body — see /// `MarkdownRenderer.renderQuotedBody` (shared with the alert view and the - /// height measurer). + /// height measurer) — rendered only when the measurer has not built the + /// string: `TextBlockView` reads `attributed` only without `preconverted`, + /// and this body runs on the main thread on every update of the row (it + /// comes into view, a search keystroke, ⌘G). A long quote's render is + /// hundreds of milliseconds; a paragraph's string is stored in its block. private func renderedContent() -> AttributedString { - renderer.renderQuotedBody(content) + preconverted == nil ? renderer.renderQuotedBody(content) : AttributedString() } } diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index 3943eb5..e7eb729 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -33,6 +33,9 @@ struct HeadingBlockView: View { let focusedOccurrence: Int? let onLink: (URL) -> Void let onCopySection: () -> Void + /// Reading Mode: no copy button; the title keeps its reserved strip + /// (`Metrics.titleTrailingPadding`), so nothing re-wraps. + var isReadingMode: Bool = false @State private var isHovered = false @State private var hideWorkItem: DispatchWorkItem? @@ -63,40 +66,42 @@ struct HeadingBlockView: View { cacheScope: "\(level)|\(title)", onLink: onLink ) - .padding(.trailing, Metrics.copyButtonReservedWidth) + .padding(.trailing, Metrics.titleTrailingPadding(isReadingMode: isReadingMode)) .padding(.top, geometry.titleTopInset) .frame(minHeight: geometry.minimumHeight, alignment: .top) .overlay { // The reader only reads the row width the cell already gave this // view; nothing here sizes the row. Its empty area is not hittable, // so drags on the title still reach the text view. - GeometryReader { proxy in - // `buttonTopInset` puts the icon's baseline on the title's first - // baseline, as the old `.firstTextBaseline` HStack did. - Button { - onCopySection() - } label: { - Image(systemName: "doc.on.doc") - .font(.system(size: Metrics.copyButtonIconFontSize)) - .foregroundColor(.secondary.opacity(0.7)) - .padding(Metrics.copyButtonIconPadding) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Copy section") - // The button lies on the title's text view, whose I-beam would - // otherwise show over the icon; re-set on every move, as the - // text view updates its cursor on mouse moves too. - .onContinuousHover { phase in - if case .active = phase { NSCursor.arrow.set() } + if Metrics.showsCopyButton(isReadingMode: isReadingMode) { + GeometryReader { proxy in + // `buttonTopInset` puts the icon's baseline on the title's first + // baseline, as the old `.firstTextBaseline` HStack did. + Button { + onCopySection() + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: Metrics.copyButtonIconFontSize)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(Metrics.copyButtonIconPadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Copy section") + // The button lies on the title's text view, whose I-beam would + // otherwise show over the icon; re-set on every move, as the + // text view updates its cursor on mouse moves too. + .onContinuousHover { phase in + if case .active = phase { NSCursor.arrow.set() } + } + .opacity(isHovered ? 1 : 0) + .padding(.top, geometry.buttonTopInset) + .padding(.leading, copyButtonLeading(rowWidth: proxy.size.width, headingAttr: headingAttr)) } - .opacity(isHovered ? 1 : 0) - .padding(.top, geometry.buttonTopInset) - .padding(.leading, copyButtonLeading(rowWidth: proxy.size.width, headingAttr: headingAttr)) } } .contentShape(Rectangle()) - .onHover { hovering in + .documentHover { hovering in hideWorkItem?.cancel() if hovering { isHovered = true diff --git a/QuickMD/QuickMD/Views/CodeBlockView.swift b/QuickMD/QuickMD/Views/CodeBlockView.swift index 0a9681d..19f7cef 100644 --- a/QuickMD/QuickMD/Views/CodeBlockView.swift +++ b/QuickMD/QuickMD/Views/CodeBlockView.swift @@ -137,7 +137,7 @@ struct CodeBlockView: View { .opacity(isHovered || justCopied ? 1 : 0) .padding(Metrics.copyButtonOuterPadding) } - .onHover { hovering in + .documentHover { hovering in isHovered = hovering } .task(id: cacheKey) { @@ -331,7 +331,7 @@ private struct CodeTextView: NSViewRepresentable { /// measured the text and pushed it into SwiftUI `@State` (`.frame(height:)`) /// on the next runloop turn, so every freshly created block changed size after /// appearing. See constraints.md ("Block heights are synchronous"). -class SelfSizingTextView: NSTextView { +class SelfSizingTextView: DocumentCellTextView { /// Identity reference to the last-applied attributed string. Lets the wrapper /// short-circuit `updateNSView` work when SwiftUI hands us the same instance. var cachedAttributed: NSAttributedString? diff --git a/QuickMD/QuickMD/Views/ImageBlockView.swift b/QuickMD/QuickMD/Views/ImageBlockView.swift index 77d83e8..6b9066d 100644 --- a/QuickMD/QuickMD/Views/ImageBlockView.swift +++ b/QuickMD/QuickMD/Views/ImageBlockView.swift @@ -21,6 +21,9 @@ struct ImageBlockView: View { let documentURL: URL? let fontScale: CGFloat let contentWidth: CGFloat + /// Where the image's size goes once it is known, so every later measure + /// computes this row instead of starting it at the loading view. + let heightCache: BlockHeightCache? var onEnlarge: (GraphicPreview) -> Void = { _ in } /// Display width cap and the pre-load placeholder height — see @@ -38,7 +41,7 @@ struct ImageBlockView: View { @State private var accessDeniedURL: URL? init(url: String, alt: String, width: ImageWidth? = nil, theme: MarkdownTheme, documentURL: URL?, - fontScale: CGFloat, contentWidth: CGFloat, + fontScale: CGFloat, contentWidth: CGFloat, heightCache: BlockHeightCache? = nil, onEnlarge: @escaping (GraphicPreview) -> Void = { _ in }) { self.url = url self.alt = alt @@ -47,6 +50,7 @@ struct ImageBlockView: View { self.documentURL = documentURL self.fontScale = fontScale self.contentWidth = contentWidth + self.heightCache = heightCache self.onEnlarge = onEnlarge let source = ImageSource.resolve(url, documentURL: documentURL) self.source = source @@ -58,6 +62,7 @@ struct ImageBlockView: View { ImageLoader.cachedImage(for: url, source: $0, maxPixel: ImageLoader.displayMaxPixel) } _image = State(initialValue: cached) + if let cached { heightCache?.setImageSize(cached.size, for: url) } // Same reasoning for failures known without I/O (a non-image or // oversized `data:` URL, a recorded data:/remote failure): the row // starts at the error view instead of the 100 pt spinner, so it reports @@ -75,6 +80,13 @@ struct ImageBlockView: View { accessDeniedView(for: accessDeniedURL) } else if source == nil || failure != nil { imageErrorView + } else if let known = heightCache?.imageSize(for: url) { + // Loading an image whose size this document already knows + // (the bitmap was evicted from `ImageLoader`'s cache): hold + // the picture's place, which is the row the measurer computed. + let size = pictureSize(for: known) + ProgressView() + .frame(width: size.width, height: size.height) } else { ProgressView() .frame(height: Metrics.placeholderHeight) @@ -85,10 +97,8 @@ struct ImageBlockView: View { } if !alt.isEmpty { - Text(alt) - .font(.system(size: 12 * fontScale)) - .foregroundColor(theme.secondaryTextColor) - .italic() + ImageCaption(alt: alt, color: theme.secondaryTextColor, fontScale: fontScale, + contentWidth: contentWidth) } } @@ -96,9 +106,10 @@ struct ImageBlockView: View { Button { onEnlarge(preview(for: image)) } label: { + let size = pictureSize(for: image.size) Image(nsImage: image).resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: displayWidth(for: image)) + .frame(width: size.width, height: size.height) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) @@ -110,12 +121,11 @@ struct ImageBlockView: View { /// never wider than the image's own width × zoom — the rule `SVGBlockView` /// already used. A 90 px icon stays icon-sized instead of being blown up /// (blurry) to 600 pt; large images are unchanged (the cap wins). An HTML - /// `` takes the place of the image's own width (T-C). - private func displayWidth(for image: NSImage) -> CGFloat { - let cap = Metrics.displayWidth(fontScale: fontScale, contentWidth: contentWidth) - if let width { return width.displayWidth(cap: cap, fontScale: fontScale) } - guard image.size.width > 0 else { return cap } - return min(cap, image.size.width * fontScale) + /// `` takes the place of the image's own width (T-C). The one + /// rule the measurer sizes the row with (`ImageBlock.pictureSize`). + private func pictureSize(for imageSize: CGSize) -> CGSize { + Metrics.pictureSize(imageSize: imageSize, htmlWidth: width, fontScale: fontScale, + contentWidth: contentWidth) } /// The window-filling preview re-decodes at 4096 px. Local files keep the @@ -185,6 +195,7 @@ struct ImageBlockView: View { // of a live view changes, and the old bitmap must not linger. let seeded = ImageLoader.cachedImage(for: url, source: source, maxPixel: ImageLoader.displayMaxPixel) if image !== seeded { image = seeded } + if let seeded { heightCache?.setImageSize(seeded.size, for: url) } let first = await ImageLoader.load(raw: url, source: source, maxPixel: ImageLoader.displayMaxPixel) if case .failure(.unreadable(let fileURL)) = first { @@ -208,6 +219,7 @@ struct ImageBlockView: View { private func apply(_ result: Result) { switch result { case .success(let loaded): + heightCache?.setImageSize(loaded.size, for: url) if loaded !== image { image = loaded } case .failure(let loadFailure): // Terminal: shows the error placeholder. (Before, a retry that still diff --git a/QuickMD/QuickMD/Views/MathBlockView.swift b/QuickMD/QuickMD/Views/MathBlockView.swift index a61cd39..1ec8909 100644 --- a/QuickMD/QuickMD/Views/MathBlockView.swift +++ b/QuickMD/QuickMD/Views/MathBlockView.swift @@ -42,7 +42,9 @@ extension MathRendering { textAlignment: .left ) let (error, image, _) = mathImage.asImage() - guard error == nil, let image = image else { return nil } + // Nothing drawn (an empty or spacing-only `$…$`, an accent over + // nothing): the italic literal, like a formula that fails. + guard error == nil, let image, MathInk.drawsSomething(latex, image: image) else { return nil } return image }, displayHeight: { latex, theme, fontScale in @@ -55,17 +57,92 @@ extension MathRendering { guard error == nil, let image = image else { return 0 } return image.size.height }, - // Exactly the test `MTMathUILabel.latex` applies: a parse error is what - // leaves the label (and `MathImage`) with nothing to draw. Parsing only — - // no font, no typesetting, no bitmap — so the per-parse check costs a - // fraction of the `displayHeight` call that follows for the same row. - typesets: { latex in - var error: NSError? - return MTMathListBuilder.build(fromString: latex, error: &error) != nil && error == nil - } + // What leaves `MTMathUILabel` (and `MathImage`) with nothing to draw: a + // parse error, a formula that parses to nothing, or one the typesetter + // declines (`MathInk.typesetsToSomething`). No bitmap, so the check + // costs a fraction of the `displayHeight` call that follows for the + // same row. + typesets: { latex in MathInk.typesetsToSomething(latex) } ) } +// MARK: - Formulas that typeset to nothing + +/// `MTMathListBuilder` accepts formulas that draw nothing — empty, only +/// spacing or style changes (`\,`, `\quad`, `\displaystyle`), empty groups +/// (`{}`, `\mathrm{}`, `{}^{}_{}`), an accent or a line over nothing +/// (`\hat{}`, `\overline{}`), `\left. \right.` — and this SwiftMath's +/// typesetter declines some it parsed (`\color`, `\textcolor`, +/// `\colorbox`: no line at all). `MTMathUILabel` then draws an empty row, +/// which the display path replaces with the LaTeX source +/// (`substitutingUntypesettableMath`, screen and print) and the inline paths +/// with the italic literal. Checked against SwiftMath's own rendering (an +/// ink scan of `MathImage`) on 36 formulas: no disagreement. +enum MathInk { + /// Parses, has something to draw, and the typesetter builds a line of some + /// size from it. + static func typesetsToSomething(_ latex: String) -> Bool { + var error: NSError? + guard let list = MTMathListBuilder.build(fromString: latex, error: &error), error == nil, + draws(list), + let line = MTTypesetter.createLineForMathList( + list, font: MathFont.latinModernFont.mtfont(size: BlockLayout.Math.fontSize), style: .display) + else { return false } + return line.width > 0 && line.ascent + line.descent > 0 + } + + /// For a formula `MathImage` already rendered (the inline paths, which + /// must not typeset twice): a size, and something to draw in it. + static func drawsSomething(_ latex: String, image: NSImage) -> Bool { + guard image.size.width > 0, image.size.height > 0 else { return false } + var error: NSError? + return draws(MTMathListBuilder.build(fromString: latex, error: &error)) + } + + /// An atom with a visible glyph, a radical sign (drawn even over nothing), + /// or content that has one — a fraction's parts or delimiters, what an + /// accent, line, colour or table holds; scripts count on any atom. A + /// fraction bar, accent or line over nothing is zero wide. + static func draws(_ list: MTMathList?) -> Bool { + guard let list else { return false } + return list.atoms.contains(where: draws) + } + + static func draws(_ atom: MTMathAtom) -> Bool { + if draws(atom.subScript) || draws(atom.superScript) { return true } + switch atom { + case let fraction as MTFraction: + return draws(fraction.numerator) || draws(fraction.denominator) + || !fraction.leftDelimiter.isEmpty || !fraction.rightDelimiter.isEmpty + case is MTRadical: + return true + case let accent as MTAccent: + return draws(accent.innerList) + case let inner as MTInner: + return draws(inner.innerList) + || inner.leftBoundary.map(draws) == true || inner.rightBoundary.map(draws) == true + case let line as MTOverLine: + return draws(line.innerList) + case let line as MTUnderLine: + return draws(line.innerList) + case is MTMathSpace, is MTMathStyle: + return false + case let color as MTMathColor: + return draws(color.innerList) + case let color as MTMathTextColor: + return draws(color.innerList) + case let box as MTMathColorbox: + return draws(box.innerList) + case let table as MTMathTable: + return table.cells.contains { row in row.contains { draws($0) } } + default: + // A glyph: ordinary, number, variable, operators, relations, + // delimiters, punctuation, placeholder. `\left.` is an empty one. + return !atom.nucleus.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +} + // MARK: - NSViewRepresentable Wrapper /// Wraps MTMathUILabel for use in SwiftUI. diff --git a/QuickMD/QuickMD/Views/MermaidBlockView.swift b/QuickMD/QuickMD/Views/MermaidBlockView.swift index e9845e0..fd9d3b3 100644 --- a/QuickMD/QuickMD/Views/MermaidBlockView.swift +++ b/QuickMD/QuickMD/Views/MermaidBlockView.swift @@ -14,27 +14,6 @@ import WebKit /// the height is seeded from BlockHeightCache (no scroll jump). /// /// Clicking the diagram opens a window-filling preview with pinch/button zoom. -/// Shared store of rendered-diagram snapshots, keyed by (isDark, source). -/// Written by the inline MermaidBlockView after each successful render; read -/// back by MermaidBlockView on LazyVStack re-creation AND by the PDF exporter -/// (MermaidPDFRenderer) as a fast path before spinning up an offscreen -/// WebView. NSCache evicts under memory pressure. -enum MermaidSnapshotStore { - private static let cache = NSCache() - - private static func key(source: String, isDark: Bool) -> NSString { - "\(isDark)|\(source)" as NSString - } - - static func image(source: String, isDark: Bool) -> NSImage? { - cache.object(forKey: key(source: source, isDark: isDark)) - } - - static func set(_ image: NSImage, source: String, isDark: Bool) { - cache.setObject(image, forKey: key(source: source, isDark: isDark)) - } -} - struct MermaidBlockView: View { let blockId: String let source: String @@ -43,6 +22,11 @@ struct MermaidBlockView: View { @State private var diagramHeight: CGFloat @State private var snapshot: NSImage? + /// No snapshot at this zoom and width: the latest one of this diagram at + /// another, shown over the web view until it has painted — a re-created + /// row never flashes empty for the half second WebKit takes to render. + @State private var standIn: NSImage? + @State private var webViewPainted = false let fontScale: CGFloat let contentWidth: CGFloat let onEnlarge: (GraphicPreview) -> Void @@ -63,10 +47,27 @@ struct MermaidBlockView: View { self.fontScale = fontScale self.contentWidth = contentWidth self.onEnlarge = onEnlarge - _diagramHeight = State(initialValue: heightCache.height(for: blockId) ?? Metrics.defaultHeight) + // The measurer's rule, in the measurer's order, so the first frame is + // the table's row and the cell does not report the 200 pt default: + // computed from the geometry of an earlier render, else this block's + // last height, else the default. + _diagramHeight = State(initialValue: MermaidGeometryStore.diagramHeight( + source: source, isDark: theme.isDark, fontScale: fontScale, + contentWidth: contentWidth) + ?? heightCache.height(for: blockId) + ?? Metrics.defaultHeight) let cached = MermaidSnapshotStore.image(source: "\(fontScale)|\(contentWidth)|\(source)", isDark: theme.isDark) _snapshot = State(initialValue: cached) - if let cached { _diagramHeight = State(initialValue: cached.size.height) } + _standIn = State(initialValue: cached == nil + ? MermaidSnapshotStore.latest(diagram: source, isDark: theme.isDark) : nil) + if let cached { + _diagramHeight = State(initialValue: cached.size.height) + // The web view path seeds the cache through `onHeight`; this path + // never runs it. Without the seed every re-measure after a re-parse + // (zoom, theme) — which empties the cache — measures the diagram at + // the 200 pt default again and it reports back on screen. + heightCache.set(cached.size.height, for: blockId) + } } var body: some View { @@ -81,21 +82,36 @@ struct MermaidBlockView: View { .frame(maxHeight: diagramHeight) .frame(maxWidth: .infinity) } else { - MermaidWebView( - source: source, - isDark: theme.isDark, - fontScale: fontScale, - diagramHeight: $diagramHeight, - onHeight: { height in - heightCache.set(height, for: blockId) - }, - onSnapshot: { image in - MermaidSnapshotStore.set(image, source: snapshotKey, isDark: theme.isDark) - if fontScale == 1 { - MermaidSnapshotStore.set(image, source: source, isDark: theme.isDark) - } + ZStack { + MermaidWebView( + source: source, + isDark: theme.isDark, + fontScale: fontScale, + diagramHeight: $diagramHeight, + onHeight: { height in + heightCache.set(height, for: blockId) + }, + onSnapshot: { image in + MermaidSnapshotStore.set(image, source: snapshotKey, isDark: theme.isDark) + MermaidSnapshotStore.setLatest(image, diagram: source, isDark: theme.isDark) + if fontScale == 1 { + MermaidSnapshotStore.set(image, source: source, isDark: theme.isDark) + } + }, + onPainted: { webViewPainted = true } + ) + if let standIn, !webViewPainted { + // The diagram at another zoom or width, scaled to + // the height this one will have (exact once it has + // rendered anywhere: `MermaidGeometryStore`). + Image(nsImage: standIn) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: diagramHeight) + .frame(maxWidth: .infinity) + .allowsHitTesting(false) } - ) + } .frame(height: diagramHeight) } } @@ -130,7 +146,7 @@ struct MermaidBlockView: View { .padding(6) } .padding(.vertical, Metrics.verticalPadding) - .onHover { hovering in + .documentHover { hovering in isHovered = hovering } } @@ -265,10 +281,14 @@ private struct MermaidWebView: NSViewRepresentable { @Binding var diagramHeight: CGFloat let onHeight: (CGFloat) -> Void let onSnapshot: (NSImage) -> Void + /// The page has drawn a render (the snapshot delay has passed after its + /// height message) — what a stand-in snapshot waits for. + let onPainted: () -> Void func makeNSView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.userContentController.add(context.coordinator, name: "mermaidHeight") + config.userContentController.add(context.coordinator, name: "mermaidGeometry") let webView = ScrollPassthroughWebView(frame: .zero, configuration: config) webView.setValue(false, forKey: "drawsBackground") @@ -328,8 +348,27 @@ private struct MermaidWebView: NSViewRepresentable { func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { - guard let height = message.body as? Double else { return } - let newHeight = max(CGFloat(height), 50) + if message.name == "mermaidGeometry" { + // What the diagram's height follows from at any width and zoom + // (`BlockLayout.Mermaid.diagramHeight`) — posted just before + // the height, after every render. + guard let body = message.body as? [String: Any], + let width = (body["naturalWidth"] as? NSNumber)?.doubleValue, + let height = (body["naturalHeight"] as? NSNumber)?.doubleValue, + let overhead = (body["overhead"] as? NSNumber)?.doubleValue, + let source = lastSource, let isDark = lastIsDark else { return } + MermaidGeometryStore.set(.init(naturalWidth: width, naturalHeight: height, + overhead: max(0, overhead)), + source: source, isDark: isDark) + return + } + // A diagram that rendered posted its geometry just before this + // (`reportGeometry`); a failed render posts none. + guard let height = message.body as? Double, let source = lastSource, let isDark = lastIsDark, + let newHeight = BlockLayout.Mermaid.acceptedHeight( + reported: height, + rendered: MermaidGeometryStore.geometry(source: source, isDark: isDark) != nil) + else { return } if abs(newHeight - parent.diagramHeight) > 1 { DispatchQueue.main.async { self.parent.diagramHeight = newHeight @@ -342,6 +381,7 @@ private struct MermaidWebView: NSViewRepresentable { guard let webView = message.webView else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self, weak webView] in guard let self, let webView else { return } + self.parent.onPainted() // Snapshot only a web view whose frame already matches the // height it reported. A row that is not laid out yet (created // below the fold, a cell AppKit has not placed) keeps its old diff --git a/QuickMD/QuickMD/Views/SVGBlockView.swift b/QuickMD/QuickMD/Views/SVGBlockView.swift index 26e9a58..7ae94b0 100644 --- a/QuickMD/QuickMD/Views/SVGBlockView.swift +++ b/QuickMD/QuickMD/Views/SVGBlockView.swift @@ -25,6 +25,18 @@ struct SVGBlockView: View { @State private var image: NSImage? @State private var decodeFailed = false + init(source: String, theme: MarkdownTheme, fontScale: CGFloat, contentWidth: CGFloat, + onEnlarge: @escaping (GraphicPreview) -> Void = { _ in }) { + self.source = source + self.theme = theme + self.fontScale = fontScale + self.contentWidth = contentWidth + self.onEnlarge = onEnlarge + // A re-created row (cell reuse resets `@State`) shows the decoded image + // at once — at the size the measurer gave its row — not the loading view. + _image = State(initialValue: SVGImageDecoder.cachedImage(source)) + } + /// Display width cap and the pre-decode placeholder height — shared with /// bitmap images and `BlockHeightMeasurer` (`.reported` row, like images). typealias Metrics = BlockLayout.ImageBlock @@ -35,10 +47,11 @@ struct SVGBlockView: View { Button { onEnlarge(.image(Image(nsImage: image), title: "SVG", fileURL: nil)) } label: { + let size = pictureSize(for: image.size) Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: displayWidth(for: image)) + .frame(width: size.width, height: size.height) } .buttonStyle(.plain) .help("Click to enlarge image") @@ -48,6 +61,11 @@ struct SVGBlockView: View { .font(.system(size: 12 * fontScale)) .foregroundColor(theme.secondaryTextColor) .padding(.vertical, 12) + } else if let known = SVGImageDecoder.cachedSize(source) { + // Decoded before, evicted since: hold the picture's place. + let size = pictureSize(for: known) + ProgressView() + .frame(width: size.width, height: size.height) } else { ProgressView() .frame(height: Metrics.placeholderHeight) @@ -55,9 +73,12 @@ struct SVGBlockView: View { } .task(id: source) { decodeFailed = false - image = nil + // The current markup's cached image, if any (the id also changes + // when the markup of a live view does). + image = SVGImageDecoder.cachedImage(source) + guard image == nil else { return } let decoded = await Task.detached(priority: .userInitiated) { - SVGImageDecoder.decode(source) + SVGImageDecoder.decodeCached(source) }.value guard !Task.isCancelled else { return } if let decoded { image = decoded } else { decodeFailed = true } @@ -66,10 +87,10 @@ struct SVGBlockView: View { /// The column cap from `ImageBlock.displayWidth` (fitted width × zoom, never /// wider than the column), further capped at the SVG's own declared size × - /// zoom so icons stay icon-sized. - private func displayWidth(for image: NSImage) -> CGFloat { - let cap = Metrics.displayWidth(fontScale: fontScale, contentWidth: contentWidth) - guard image.size.width > 0 else { return cap } - return min(cap, image.size.width * fontScale) + /// zoom so icons stay icon-sized — `ImageBlock.pictureSize`, the rule the + /// measurer sizes the row with. + private func pictureSize(for imageSize: CGSize) -> CGSize { + Metrics.pictureSize(imageSize: imageSize, htmlWidth: nil, fontScale: fontScale, + contentWidth: contentWidth) } } diff --git a/QuickMD/QuickMD/Views/SettingsView.swift b/QuickMD/QuickMD/Views/SettingsView.swift index 1f9134c..c745d03 100644 --- a/QuickMD/QuickMD/Views/SettingsView.swift +++ b/QuickMD/QuickMD/Views/SettingsView.swift @@ -3,16 +3,24 @@ import SwiftUI /// Settings window (⌘,): general behaviour + theme picker + document fonts + /// external editor selection. struct SettingsView: View { + /// Remembered by identifier (`SettingsTab`); absent → the index SwiftUI + /// remembered before, so an update does not move anyone's tab. + @AppStorage(SettingsTab.defaultsKey) private var selectedTab: SettingsTab = SettingsTab.initial(in: .standard) + var body: some View { - TabView { + TabView(selection: $selectedTab) { GeneralSettingsView() .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) ThemePickerView() .tabItem { Label("Themes", systemImage: "paintpalette") } + .tag(SettingsTab.themes) FontPickerView() .tabItem { Label("Fonts", systemImage: "textformat") } + .tag(SettingsTab.fonts) ExternalEditorPickerView() .tabItem { Label("Editor", systemImage: "pencil") } + .tag(SettingsTab.editor) } } } @@ -43,7 +51,8 @@ struct GeneralSettingsView: View { } Section { - Picker("Show", selection: $tocMaxLevel) { + Picker("Show", selection: Binding(get: { ToCDepth.normalized(tocMaxLevel) }, + set: { tocMaxLevel = $0 })) { ForEach(ToCDepth.choices, id: \.maxLevel) { choice in Text(choice.title).tag(choice.maxLevel) } diff --git a/QuickMD/QuickMD/Views/TableOfContentsView.swift b/QuickMD/QuickMD/Views/TableOfContentsView.swift index 0ee6c0e..061e4c7 100644 --- a/QuickMD/QuickMD/Views/TableOfContentsView.swift +++ b/QuickMD/QuickMD/Views/TableOfContentsView.swift @@ -18,7 +18,8 @@ struct TableOfContentsView: View { } var body: some View { - let shown = ToCDepth.visible(headings, maxLevel: maxLevel) + let level = ToCDepth.normalized(maxLevel) + let shown = ToCDepth.visible(headings, maxLevel: level) VStack(alignment: .leading, spacing: 0) { // Header HStack(spacing: 6) { @@ -64,7 +65,7 @@ struct TableOfContentsView: View { // Every heading is deeper than the chosen depth if shown.isEmpty { VStack(alignment: .leading, spacing: 6) { - Text("No headings up to H\(maxLevel)") + Text("No headings up to H\(level)") .foregroundColor(.secondary) Button("Show All Headings") { maxLevel = ToCDepth.all } .buttonStyle(.link) @@ -77,7 +78,8 @@ struct TableOfContentsView: View { } .background(theme.backgroundColor.opacity(0.5)) .contextMenu { - Picker("Show Headings", selection: $maxLevel) { + Picker("Show Headings", selection: Binding(get: { ToCDepth.normalized(maxLevel) }, + set: { maxLevel = $0 })) { ForEach(ToCDepth.choices, id: \.maxLevel) { choice in Text(choice.title).tag(choice.maxLevel) } @@ -102,7 +104,8 @@ private struct ToCEntryRow: View { Button { onSelect() } label: { - Text(entry.title) + // The text the heading shows (markup gone, `Q&A` → `Q&A`). + Text(entry.shownTitle(renderer: MarkdownRenderer(theme: theme))) .font(.system(size: fontSize(for: entry.level), weight: fontWeight(for: entry.level))) .foregroundColor(theme.textColor) .lineLimit(2) diff --git a/QuickMD/QuickMD/Views/TableTextView.swift b/QuickMD/QuickMD/Views/TableTextView.swift index c2f1152..4b8999d 100644 --- a/QuickMD/QuickMD/Views/TableTextView.swift +++ b/QuickMD/QuickMD/Views/TableTextView.swift @@ -119,7 +119,11 @@ private struct TableTextRepresentable: NSViewRepresentable { // counted this block on the same characters with the same matcher // (TS-D9), so the focused occurrence is the n-th highlight in // reading order. - applyNSSearchHighlight(in: textView, term: searchTerm, focusedOccurrence: focusedOccurrence) + // A focused match on a properties KEY (secondary colour) is drawn in + // the text colour, as on a value. + let keys = style == .properties ? textView.tableLayout?.firstColumnRanges : nil + applyNSSearchHighlight(in: textView, term: searchTerm, focusedOccurrence: focusedOccurrence, + focusedText: keys.map { (NSColor(theme.textColor), $0) }) textView.lastSearchTerm = searchTerm textView.lastFocusedOccurrence = focusedOccurrence } diff --git a/QuickMD/QuickMD/Views/TextBlockView.swift b/QuickMD/QuickMD/Views/TextBlockView.swift index 1f0b11c..75d424c 100644 --- a/QuickMD/QuickMD/Views/TextBlockView.swift +++ b/QuickMD/QuickMD/Views/TextBlockView.swift @@ -1,31 +1,8 @@ import SwiftUI import AppKit -// MARK: - Block Height Cache - -/// Remembers the last measured height of each block, keyed by block id. -/// LazyVStack destroys and recreates views during scroll; without this a -/// recreated view starts at a placeholder height and "jumps" when its real -/// height arrives, making upward scrolling stutter. Since 1.8.0 text and code -/// blocks size synchronously (`SelfSizingTextView.size(fitting:)`) and no longer -/// need it — Mermaid diagrams (height arrives from the WebView) still do. -/// -/// Main-thread only BY CONVENTION (all reads/writes happen from SwiftUI view -/// inits / height callbacks dispatched to main). Deliberately NOT @MainActor: -/// view initializers are nonisolated on older SDKs where the SwiftUI View -/// protocol annotates only `body`, and an isolated init call there is a hard -/// compile error (caught by CI on the GitHub runner toolchain). -final class BlockHeightCache { - private var heights: [String: CGFloat] = [:] - - func height(for id: String) -> CGFloat? { heights[id] } - func set(_ height: CGFloat, for id: String) { heights[id] = height } - func removeAll() { heights.removeAll() } - - /// A value copy for `BlockHeightMeasurer.measure(heightSeeds:)` — the - /// measurer runs off-main and must not read this reference type from there. - var snapshot: [String: CGFloat] { heights } -} +// `BlockHeightCache` lives in BlockHeightMeasurer.swift, next to the +// measurer that reads it (and the tests, which compile that file). // MARK: - Shared NSTextView Search Highlight diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift index 4986bdc..3ecf2f6 100644 --- a/QuickMD/QuickMD/Views/VirtualBlockList.swift +++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift @@ -47,45 +47,16 @@ private let listLog = Logger(subsystem: "pl.falami.studio.QuickMD", category: "V private let listSignpost = OSSignposter(subsystem: "pl.falami.studio.QuickMD", category: "VirtualBlockList") #endif -// MARK: - Block identity for anchor restoration - -/// A cheap content fingerprint of one block. -/// -/// Used only to find the scroll anchor again after the document changed shape. -/// Neither of the two identities a block already has can do that job: -/// -/// * `MarkdownBlock.id` is POSITIONAL (`text-17`), so inserting one block above -/// renumbers every block below it. -/// * `sourceLine` moves with any edit above it. -/// -/// The characters are what the reader was actually looking at, so they are the -/// identity we match on. 80 characters distinguishes paragraphs without copying -/// the document, and the kind tag in front keeps a heading from matching a -/// paragraph with the same words. -private func blockSignature(_ block: MarkdownBlock) -> String { - func head(_ text: String) -> String { String(text.prefix(80)) } - switch block.content { - case .text(let attributed): - return "t|" + head(String(attributed.characters)) - case .table(let headers, let rows, _, _): - return "b|\(rows.count)|" + head(headers.joined(separator: "\u{1F}")) - case .codeBlock(let code, let language): - return "c|\(language)|" + head(code) - case .image(let url, let alt, _): - return "i|\(head(url))|" + head(alt) - case .blockquote(let content, let level): - return "q|\(level)|" + head(content) - case .alert(let kind, let content): - return "a|\(kind.rawValue)|" + head(content) - case .heading(let level, let title, _): - return "h|\(level)|" + head(title) - case .mathBlock(let latex): - return "m|" + head(latex) - case .mermaidDiagram(let source): - return "d|" + head(source) - case .svgImage(let source): - return "s|" + head(source) - } +/// The room the document column has, asked for on demand — the same bridge +/// as `DocumentReadingPosition`: `MarkdownView` owns one per tab (`@State`, +/// reference identity, so nothing re-renders), the list's coordinator installs +/// `innerWidth` on every update. A parse at a new zoom in reading mode reads +/// it to measure at the column the zoom's cap will give +/// (`BlockLayout.Document.parseWidth`). Main-thread by convention. +final class DocumentColumn { + /// The clip's whole points − 2 × the horizontal padding; 0 before the + /// list's first layout or while it is not wired. + var innerWidth: (() -> CGFloat)? } struct VirtualBlockList: NSViewRepresentable { @@ -94,8 +65,12 @@ struct VirtualBlockList: NSViewRepresentable { enum Anchor: Equatable { /// Row top at the top of the content area (ToC). case top - /// Row centre at the centre of the content area (search). + /// Row centre at the centre of the content area (search) — the focused + /// match's, in a block taller than that area. case center + /// ⌘G to another match of the SAME block: a block taller than the + /// content area brings that match to the centre; one that fits stays. + case match } /// One programmatic scroll. `token` is the trigger: the coordinator acts when @@ -123,17 +98,23 @@ struct VirtualBlockList: NSViewRepresentable { static let standard = LayoutStyle(maxContentWidth: nil, verticalPadding: Metrics.contentVerticalPadding) - static let reading = LayoutStyle(maxContentWidth: Metrics.readingMaxContentWidth, - verticalPadding: Metrics.readingContentVerticalPadding) + /// Reading mode at `fontScale`: the column cap follows the zoom + /// (`BlockLayout.Document.readingColumnCap`), so a zoom in reading mode + /// is a style change too — together with the parse that installs the + /// zoomed blocks (`MarkdownView` derives it from `renderedFontScale`). + static func reading(fontScale: CGFloat) -> LayoutStyle { + LayoutStyle(maxContentWidth: BlockLayout.Document.readingColumnCap(fontScale: fontScale), + verticalPadding: Metrics.readingContentVerticalPadding) + } - /// The scroll view's top/bottom content inset for this style. - /// - /// Not simply `verticalPadding`: `NSTableView` centres each row inside its - /// rect, which puts HALF the intercell spacing above the first row and - /// half below the last one (measured). Subtracting that half makes the - /// gap the reader sees exactly `verticalPadding`, and makes a `.top` jump - /// to any row — row 0 included — leave that same gap above its content. - var edgeInset: CGFloat { max(0, verticalPadding - Metrics.blockSpacing / 2) } + /// The scroll view's top/bottom content inset for this style: exactly + /// `verticalPadding`. The table has no intercell spacing (the space + /// between blocks is part of each row, above its block — see + /// `RowGeometry`), the first row has no space above its block, and a + /// `.top` jump to any row puts that row's BLOCK at the content top — so + /// the gap above the first block and above any jumped-to block is this + /// inset, in every style. + var edgeInset: CGFloat { verticalPadding } } /// Row content, in block order. @@ -189,6 +170,8 @@ struct VirtualBlockList: NSViewRepresentable { /// ⌘E's reading-position handle (v1.11 E-D1). The coordinator installs a /// provider that is evaluated only when asked — no per-scroll publishing. var readingPosition: DocumentReadingPosition? + /// Answers "how wide is the column's room" on demand (`DocumentColumn`). + var column: DocumentColumn? typealias Metrics = BlockLayout.Document @@ -230,9 +213,10 @@ struct VirtualBlockList: NSViewRepresentable { tableView.usesAlternatingRowBackgroundColors = false tableView.gridStyleMask = [] tableView.focusRingType = .none - // The 8 pt that `VStack(spacing:)` used to put between blocks. Row - // heights themselves therefore stay exactly what the measurer computed. - tableView.intercellSpacing = NSSize(width: 0, height: Metrics.blockSpacing) + // None: the space between blocks is part of each row, above its block + // (`BlockHeightTable.spacingAbove`, `BlockLayout.Spacing`), so a row + // rect is exactly the space above plus the block (`RowGeometry`). + tableView.intercellSpacing = NSSize(width: 0, height: 0) tableView.rowSizeStyle = .custom tableView.usesAutomaticRowHeights = false // The coordinator sets the single column's width to the clip view's width @@ -262,9 +246,8 @@ struct VirtualBlockList: NSViewRepresentable { // The 24 pt (48 in reading mode) that used to be `.padding(.vertical:)` // on the stack. As content insets they scroll with the document exactly // as before, but AppKit — not us — owns the arithmetic (see - // `contentTopY`). The half-spacing correction lives in - // `LayoutStyle.edgeInset`; the coordinator re-applies it when the style - // changes. + // `contentTopY`). `LayoutStyle.edgeInset` is the value; the coordinator + // re-applies it when the style changes. scrollView.automaticallyAdjustsContentInsets = false let edgeInset = layoutStyle.edgeInset scrollView.contentInsets = NSEdgeInsets(top: edgeInset, left: 0, @@ -298,6 +281,7 @@ struct VirtualBlockList: NSViewRepresentable { // has the property set at all. if nsView.isAccessibilityHidden() != isCovered { nsView.setAccessibilityHidden(isCovered) } readingPosition?.provider = { [weak coordinator] in coordinator?.readingEditorLine() } + column?.innerWidth = { [weak coordinator] in coordinator?.columnInnerWidth() ?? 0 } readingPosition?.selectionProvider = { [weak coordinator] in guard let coordinator, let hint = coordinator.selection.sourceEditHint( @@ -366,6 +350,66 @@ struct VirtualBlockList: NSViewRepresentable { /// re-measure even when its natural height happens to be unchanged. private(set) var generation: Int = 0 private var rowForBlockId: [String: Int] = [:] + /// The reader's place as this list last set it — a restore, the + /// reading-mode toggle, a jump — by block id. While the content top is + /// still where it was put (`placed`; or a jump to it is in flight) this + /// IS the reader's place, and it is read instead of the geometry: + /// - `captureAnchor`: a style toggle or a restore can move the content + /// top across a row boundary (24 pt of inset, an offset into the gap + /// above the anchored block, an offset past a placeholder), and + /// re-anchoring on whatever row is under it now would tie the next + /// layout to a DIFFERENT block — the round trip then drifts by any + /// difference between the two layouts in between. Keeping the block + /// and the offset keeps every round trip exact. + /// - `reportHeight`: the anchored block stays put on screen + /// (`RowGeometry.reportAction`). + private var anchor: ReaderAnchor? + /// A programmatic animated scroll is on its way to `anchor`: reports + /// re-aim it instead of compensating a content top that is mid-flight. + private var isJumping = false + /// Bumped per animated jump, so an older animation's completion does + /// not end a newer one. + private var jumpGeneration = 0 + /// `.reported` blocks whose view has told this table its height: their + /// height is no longer an estimate (`RowGeometry.anchorPlacement`). + private var reportedBlocks: Set = [] + /// The layout style changed and the table measured for the new width + /// is not installed yet. Meanwhile the cells re-lay out at the new + /// width inside the old rows, and the clip origin can move without + /// this list moving it (seen: -24.5 and -37.5 pt between leaving + /// Reading Mode and the new table — a text view under the top inset + /// scrolling itself visible as it resized, now prevented by + /// `DocumentCellTextView` — which re-anchored the reader on the block + /// above). The anchor holds through the switch whatever moves the + /// clip; the install puts the reader back on it. + private var isSwitchingLayout = false + private var layoutSwitchGeneration = 0 + /// A search jump into a block taller than the content area whose match + /// could not be located yet (`aimAtPendingMatch`). + private var matchAim = PendingMatchAim() + /// Scroller drags and trackpad scrolls (`PendingMatchAim.readerScrolled`). + private var liveScrollObserver: NSObjectProtocol? + #if DEBUG + /// Where the last `setContentTop` left the content top, to tell a clip + /// move this list made from one something else made (logged with the + /// stack that made it — `clipBoundsChanged`). + private var expectedContentTop: CGFloat? + private var isSettingContentTop = false + private var isLiveScrolling = false + private var debugObservers: [NSObjectProtocol] = [] + #endif + + private struct ReaderAnchor { + let blockId: String + /// Where the reader is: the content top's offset below the block's + /// top, as captured from a real layout or asked for by a jump. + var offset: CGFloat + /// Where the content top actually is relative to that block top: + /// `offset`, unless the block's estimated height cannot hold it + /// (`RowGeometry.anchorPlacement`) or AppKit clamped the scroll at + /// an end of the document. + var placed: CGFloat + } // MARK: Inputs refreshed per update @@ -397,6 +441,9 @@ struct VirtualBlockList: NSViewRepresentable { /// Last width handed to the parent. 0 = never reported, which is the one /// case that skips the debounce (the height table is blocked on it). private var reportedContentWidth: CGFloat = 0 + /// Published widths the parent's binding has not received yet + /// (`publish` hands each over one run-loop turn later). + private var pendingPublishes = 0 private var widthWork: DispatchWorkItem? private var frameObserver: NSObjectProtocol? /// `updateNSView` has run at least once, so `setContentWidth` is the @@ -408,25 +455,74 @@ struct VirtualBlockList: NSViewRepresentable { deinit { widthWork?.cancel() if let frameObserver { NotificationCenter.default.removeObserver(frameObserver) } + if let liveScrollObserver { NotificationCenter.default.removeObserver(liveScrollObserver) } + #if DEBUG + debugObservers.forEach { NotificationCenter.default.removeObserver($0) } + #endif } func attach(scrollView: BlockScrollView, tableView: BlockTableView) { self.scrollView = scrollView self.tableView = tableView selection.attach(tableView: tableView) + selection.spacingAbove = { [weak self] row in self?.table.spacing(row) ?? 0 } tableView.selectionController = selection scrollView.selectionController = selection scrollView.onEndLiveResize = { [weak self] in self?.clipFrameChanged(afterLiveResize: true) } + scrollView.onReaderScroll = { [weak self] in self?.matchAim.readerScrolled() } + liveScrollObserver = NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: nil + ) { [weak self] _ in + self?.matchAim.readerScrolled() + } frameObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, object: scrollView.contentView, queue: nil ) { [weak self] _ in self?.clipFrameChanged(afterLiveResize: false) } + #if DEBUG + observeClipMoves(scrollView) + #endif + } + + #if DEBUG + /// DEBUG: logs every move of the clip origin that `setContentTop` did + /// not make — outside a jump and a live (wheel, trackpad) scroll — with + /// the stack that made it. + private func observeClipMoves(_ scrollView: NSScrollView) { + let center = NotificationCenter.default + scrollView.contentView.postsBoundsChangedNotifications = true + debugObservers.append(center.addObserver(forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, queue: nil) { [weak self] _ in + self?.clipBoundsChanged() + }) + debugObservers.append(center.addObserver(forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, queue: nil) { [weak self] _ in + self?.isLiveScrolling = true + }) + debugObservers.append(center.addObserver(forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, queue: nil) { [weak self] _ in + self?.isLiveScrolling = false + self?.expectedContentTop = self?.contentTopY() + }) } + private func clipBoundsChanged() { + guard !isSettingContentTop, !isJumping, !isLiveScrolling, + let expected = expectedContentTop else { return } + let top = contentTopY() + guard abs(top - expected) > 0.5 else { return } + expectedContentTop = top + listLog.debug("clipMoved: content top \(expected, format: .fixed(precision: 1)) → \(top, format: .fixed(precision: 1)) NOT by setContentTop\(self.isSwitchingLayout ? " (layout switch)" : "", privacy: .public)") + for (index, frame) in Thread.callStackSymbols.prefix(40).enumerated() { + listLog.debug("clipMoved #\(index): \(frame, privacy: .public)") + } + } + #endif + /// One turn later: the request arrives in the same SwiftUI update that /// removes the overlay, whose views may still hold focus until then. func restoreDocumentFocus() { @@ -448,13 +544,18 @@ struct VirtualBlockList: NSViewRepresentable { // MARK: - Model installation (D7 anchor preservation) /// The whole visible geometry of one scroll position: which row was at - /// the top of the content area, how far into it we were, and two - /// identities for finding that row again after the document changed shape - /// — its content fingerprint (primary) and the source line it started at - /// (fallback, D8). + /// the top of the content area, how far below its BLOCK's top we were, + /// and two identities for finding that row again after the document + /// changed shape — its content fingerprint (primary) and the source line + /// it started at (fallback, D8). + /// + /// The offset is from the block, not from the row's top edge: the space + /// above a block changes with zoom (and the block's neighbours), the + /// reader's place in the block does not. Negative when the content top + /// is in the space above the block. private struct CapturedAnchor { let row: Int - let offsetWithinRow: CGFloat + let offsetFromBlockTop: CGFloat let sourceLine: Int let signature: String } @@ -471,6 +572,11 @@ struct VirtualBlockList: NSViewRepresentable { let anchor = captureAnchor() let previousCount = blocks.count + self.anchor = nil + isJumping = false + isSwitchingLayout = false + matchAim.drop() + reportedBlocks = [] blocks = newBlocks table = newTable @@ -517,28 +623,16 @@ struct VirtualBlockList: NSViewRepresentable { #if DEBUG listSignpost.endInterval("reloadData", state) let elapsedMS = Double(DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1_000_000 - listLog.debug("reloadData: \(newBlocks.count) rows, version=\(newVersion), width=\(newTable.contentWidth, format: .fixed(precision: 1)), anchor=\(anchor.map { "row \($0.row)+\($0.offsetWithinRow)" } ?? "none", privacy: .public), \(elapsedMS, format: .fixed(precision: 2)) ms") + listLog.debug("reloadData: \(newBlocks.count) rows, version=\(newVersion), width=\(newTable.contentWidth, format: .fixed(precision: 1)), anchor=\(anchor.map { "row \($0.row)+\($0.offsetFromBlockTop)" } ?? "none", privacy: .public), \(elapsedMS, format: .fixed(precision: 2)) ms") #endif } - /// The part of a row rect the hosted view actually occupies. - /// - /// `NSTableView` centres a row's content inside `rect(ofRow:)`, splitting - /// `intercellSpacing.height` half above and half below (measured: a 100 pt - /// row at 8 pt spacing gets rect height 108 with the cell at +4). - /// - /// Used for `.center` only, where the block's visual middle is what the - /// reader's eye goes to. `.top` and the scroll anchor deliberately use the - /// FULL row rect: its `minY` sits half a spacing above the content, which - /// — together with the reduced content inset — is what makes every jump - /// leave the same gap as the top of the document. - private func rowContentRect(_ row: Int) -> NSRect { - guard let tableView else { return .zero } - var rect = tableView.rect(ofRow: row) - let spacing = tableView.intercellSpacing.height - rect.origin.y += spacing / 2 - rect.size.height = max(0, rect.size.height - spacing) - return rect + /// The document y of row `row`'s BLOCK top: below the space above it. + /// What a `.top` jump scrolls to and what the scroll anchor is relative + /// to (`RowGeometry.topTarget`). + private func blockTopY(_ row: Int) -> CGFloat { + guard let tableView else { return 0 } + return RowGeometry.topTarget(rowRect: tableView.rect(ofRow: row), spacingAbove: table.spacing(row)) } /// Where the reader is, or nil when that question has no answer. @@ -552,7 +646,7 @@ struct VirtualBlockList: NSViewRepresentable { /// for reasons that have nothing to do with the scroll position — /// which is how a hidden tab used to end up anchored on its LAST row and /// scrolled to the end of its document. - private func captureAnchor() -> CapturedAnchor? { + private func captureAnchor(preferringLastAnchor: Bool = true) -> CapturedAnchor? { guard let scrollView, let tableView, !blocks.isEmpty, tableView.numberOfRows > 0 else { #if DEBUG @@ -584,6 +678,11 @@ struct VirtualBlockList: NSViewRepresentable { } let top = contentTopY() + if preferringLastAnchor, let anchor, let row = anchoredRow(contentTop: top) { + return CapturedAnchor(row: row, offsetFromBlockTop: anchor.offset, + sourceLine: blocks[row].sourceLine, + signature: blocks[row].anchorSignature) + } var row = tableView.row(at: NSPoint(x: 0, y: max(0, top))) if row < 0 { // −1 means "no row at that point", which is only *legitimately* @@ -604,9 +703,11 @@ struct VirtualBlockList: NSViewRepresentable { } guard row >= 0, row < blocks.count else { return nil } return CapturedAnchor(row: row, - offsetWithinRow: top - tableView.rect(ofRow: row).minY, + offsetFromBlockTop: RowGeometry.anchorOffset( + contentTop: top, rowRect: tableView.rect(ofRow: row), + spacingAbove: table.spacing(row)), sourceLine: blocks[row].sourceLine, - signature: blockSignature(blocks[row])) + signature: blocks[row].anchorSignature) } /// ⌘E's target line (E-D1): the selection's first row, else the row the @@ -614,7 +715,7 @@ struct VirtualBlockList: NSViewRepresentable { /// answers nil (plain file open) instead of a placeholder row. func readingEditorLine() -> Int? { DocumentReadingPosition.editorLine(selection: selection.selection, - topRow: captureAnchor()?.row, + topRow: captureAnchor(preferringLastAnchor: false)?.row, blocks: blocks) } @@ -649,9 +750,56 @@ struct VirtualBlockList: NSViewRepresentable { } guard row >= 0, row < tableView.numberOfRows else { return } #if DEBUG - listLog.debug("restoreAnchor: by \(matchedBy, privacy: .public), row \(anchor.row) → \(row), offset=\(anchor.offsetWithinRow, format: .fixed(precision: 1))") + listLog.debug("restoreAnchor: by \(matchedBy, privacy: .public), row \(anchor.row) → \(row), offset=\(anchor.offsetFromBlockTop, format: .fixed(precision: 1))") + #endif + place(row: row, offset: anchor.offsetFromBlockTop, animated: false) + } + + /// Puts the reader `offset` below row `row`'s block top in the CURRENT + /// layout — or inside the block, while its estimated height cannot hold + /// that offset (`RowGeometry.anchorPlacement`) — and makes that the + /// list's `anchor`. Animated = a jump: `isJumping` until it lands. + private func place(row: Int, offset: CGFloat, animated: Bool) { + guard let tableView, let scrollView, row >= 0, row < blocks.count, + row < tableView.numberOfRows else { return } + let rect = tableView.rect(ofRow: row) + let spacing = table.spacing(row) + let blockTop = RowGeometry.topTarget(rowRect: rect, spacingAbove: spacing) + let placement = RowGeometry.anchorPlacement( + offset: offset, blockHeight: RowGeometry.contentRect(rowRect: rect, spacingAbove: spacing).height, + topInset: scrollView.contentInsets.top, + heightIsEstimate: row < table.kinds.count && table.kinds[row] == .reported + && !reportedBlocks.contains(blocks[row].id)) + var completion: (() -> Void)? + if animated { + jumpGeneration += 1 + let jump = jumpGeneration + isJumping = true + completion = { [weak self] in + guard let self, self.jumpGeneration == jump else { return } + self.isJumping = false + self.aimAtPendingMatch() + } + } else { + isJumping = false + } + let reached = setContentTop(blockTop + placement, animated: animated, completion: completion) + anchor = ReaderAnchor(blockId: blocks[row].id, offset: offset, placed: reached - blockTop) + #if DEBUG + listLog.debug("place: row \(row) offset=\(offset, format: .fixed(precision: 1)) placed=\(reached - blockTop, format: .fixed(precision: 1))\(animated ? " (jump)" : "", privacy: .public)") #endif - setContentTop(tableView.rect(ofRow: row).minY + anchor.offsetWithinRow, animated: false) + } + + /// The row of `anchor` while it still holds: a jump to it is in + /// flight, or the content top is where it was put. Nil once the reader + /// has scrolled away (or the anchor's block is gone). + private func anchoredRow(contentTop: CGFloat) -> Int? { + guard let anchor, let tableView, let row = rowForBlockId[anchor.blockId], + row < tableView.numberOfRows, row < blocks.count else { return nil } + if isJumping || isSwitchingLayout { return row } + return RowGeometry.isAt(anchorOffset: anchor.placed, contentTop: contentTop, + rowRect: tableView.rect(ofRow: row), spacingAbove: table.spacing(row)) + ? row : nil } /// The row whose content fingerprint equals `signature`, closest to @@ -668,7 +816,7 @@ struct VirtualBlockList: NSViewRepresentable { for (index, block) in blocks.enumerated() { // Past the anchor, distance only grows: nothing left can win. if index > row, index - row >= bestDistance { break } - guard blockSignature(block) == signature else { continue } + guard block.anchorSignature == signature else { continue } let distance = abs(index - row) if distance < bestDistance { best = index @@ -689,15 +837,14 @@ struct VirtualBlockList: NSViewRepresentable { guard let row = rowForBlockId[blockId], row < table.heights.count else { return } // Defensive: an exact row cannot know better than the measurer. guard row < table.kinds.count, table.kinds[row] == .reported else { return } + reportedBlocks.insert(blockId) guard abs(height - table.heights[row]) >= 0.5 else { return } // The coordinator's own table is patched SYNCHRONOUSLY so that a // second report for the same row in the same layout pass compares - // against this value rather than against the estimate. - var heights = table.heights - heights[row] = height - table = BlockHeightTable(heights: heights, kinds: table.kinds, - contentWidth: table.contentWidth) + // against this value rather than against the estimate. The block's + // height only: the space above it stays as measured. + table = table.replacingHeight(height, at: row) // Everything that touches AppKit waits a turn. This runs from // `onPreferenceChange`, i.e. from INSIDE a SwiftUI layout pass, and @@ -723,32 +870,51 @@ struct VirtualBlockList: NSViewRepresentable { // at, not against the value this particular report replaced: // several reports can coalesce into one turn, and the // compensation has to cancel the shift the table view is actually - // about to make. `rect(ofRow:)` includes the intercell spacing - // (see `rowContentRect`). - let applied = tableView.rect(ofRow: row).height - tableView.intercellSpacing.height + // about to make. `rect(ofRow:)` includes the space above the + // block, which the report does not change. + let applied = tableView.rect(ofRow: row).height - self.table.spacing(row) let delta = target - applied guard abs(delta) >= 0.5 else { self.onHeightReport(blockId, row, target) return } - // A row whose top is above the top of the content area slides the - // reader's content when it changes height — the row STRADDLING - // the viewport top included, which is why this is geometry and - // not `row < firstVisibleRow` (that row is "visible", yet growing - // it pushes everything the reader can see downwards). - let startsAboveViewport = tableView.rect(ofRow: row).minY < self.contentTopY() + // Read BEFORE `noteHeightOfRows`: at the end of the document a + // shrinking row shortens the table and AppKit clamps the clip + // origin by itself right there — reading the content top after + // that applied the shrink twice (the last line ended up on the + // window's bottom edge). Which reports move the scroll offset, + // and how: `RowGeometry.reportAction`. + let top = self.contentTopY() + let anchorRow = self.anchoredRow(contentTop: top) + let action = RowGeometry.reportAction(row: row, anchorRow: anchorRow, + blockTop: self.blockTopY(row), contentTop: top) NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0 tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integer: row)) NSAnimationContext.endGrouping() - if startsAboveViewport { - self.setContentTop(self.contentTopY() + delta, animated: false) + switch action { + case .keep: + break + case .shift where self.isJumping || self.isSwitchingLayout, .placeAnchor: + // Re-aim a jump in flight at its target's new place, put the + // reader back on the anchor during a layout switch (the clip + // may have moved under it), or into the block that just + // reported. + if let anchorRow, let anchor = self.anchor { + self.place(row: anchorRow, offset: anchor.offset, animated: self.isJumping) + } + case .shift: + let reached = self.setContentTop(top + delta, animated: false) + if let anchorRow, self.anchor != nil { + // Moved with the block, unless AppKit clamped it. + self.anchor?.placed = reached - self.blockTopY(anchorRow) + } } #if DEBUG - listLog.debug("reportHeight: row \(row) (\(blockId, privacy: .public)) \(applied, format: .fixed(precision: 1)) → \(target, format: .fixed(precision: 1))\(startsAboveViewport ? " (compensated)" : "")") + listLog.debug("reportHeight: row \(row) (\(blockId, privacy: .public)) \(applied, format: .fixed(precision: 1)) → \(target, format: .fixed(precision: 1)) at top \(top, format: .fixed(precision: 1)), anchor row \(anchorRow ?? -1): \(String(describing: action), privacy: .public)\(self.isJumping ? " (jump)" : "", privacy: .public)") #endif // The parent holds the authoritative table (it feeds the next @@ -763,23 +929,88 @@ struct VirtualBlockList: NSViewRepresentable { guard let tableView, let scrollView, let row = rowForBlockId[request.blockId], row < tableView.numberOfRows else { return } + // The jump target is the reader's anchor from here on: a block above + // it that reports its real height while the scroll is in flight, or + // after it landed, moves the content top with it (`reportHeight`). let target: CGFloat + var needsMatch = false switch request.anchor { case .top: - // Full row rect: with the reduced content inset this leaves the - // same gap above the heading as the top of the document has. - target = tableView.rect(ofRow: row).minY - case .center: - // Centre of the CONTENT area (between the insets), not of the - // clip view — with equal top/bottom insets these coincide. + // The block's top, below the space above it: the content inset + // is then the gap above it, as above the document's first block. + target = blockTopY(row) + case .center, .match: + // The BLOCK's middle (not its row's, which includes the space + // above it) at the centre of the CONTENT area (between the + // insets), not of the clip view — with equal top/bottom insets + // these coincide. A block taller than that area: its focused + // match (`RowGeometry.searchTarget`). let insets = scrollView.contentInsets let contentHeight = scrollView.contentView.bounds.height - insets.top - insets.bottom - target = rowContentRect(row).midY - contentHeight / 2 + guard let search = RowGeometry.searchTarget(rowRect: tableView.rect(ofRow: row), + spacingAbove: table.spacing(row), + visibleHeight: contentHeight, + matchMidY: focusedMatchMidY(row: row), + withinBlock: request.anchor == .match) + else { return } + target = search.target + needsMatch = search.needsMatch } #if DEBUG - listLog.debug("scrollRequest: \(request.blockId, privacy: .public) row \(row) anchor=\(request.anchor == .top ? "top" : "center", privacy: .public) target=\(target, format: .fixed(precision: 1))") + listLog.debug("scrollRequest: \(request.blockId, privacy: .public) row \(row) anchor=\(String(describing: request.anchor), privacy: .public) target=\(target, format: .fixed(precision: 1))") #endif - setContentTop(target, animated: request.animated) + place(row: row, offset: target - blockTopY(row), animated: request.animated) + matchAim.jumped(to: request.blockId, needsMatch: needsMatch) + if needsMatch, !isJumping { + // No glide to wait for (not animated, or already there). + DispatchQueue.main.async { [weak self] in self?.aimAtPendingMatch() } + } + } + + /// The focused match's middle (document y) in `row`, from its text view + /// — nil when the row is not the focused block or its view is not on + /// display. + private func focusedMatchMidY(row: Int) -> CGFloat? { + guard let tableView, row < blocks.count, !searchText.isEmpty, + let occurrence = focusedOccInBlock, focusedBlockId == blocks[row].id, + let textView = selection.displayedTextView(for: blocks[row].id, in: tableView), + let rect = textView.searchMatchRect(term: searchText, occurrence: occurrence) else { return nil } + return textView.convert(rect, to: tableView).midY + } + + /// A search jump into a block taller than the content area landed on + /// the block, whose view was not on display to ask where the match is: + /// now it is — glide on to the match. The row's cells are built by + /// the next layout pass; one is forced, and failing that it is asked + /// again on the next turns of the run loop. + private func aimAtPendingMatch(retries: Int = 2) { + guard let blockId = matchAim.blockId else { return } + // Not if the reader has scrolled on meanwhile (their scroll drops + // `matchAim`; the anchor check is the belt to those braces). + guard let tableView, let scrollView, let row = rowForBlockId[blockId], + anchoredRow(contentTop: contentTopY()) == row else { + matchAim.drop() + return + } + var matchMidY = focusedMatchMidY(row: row) + if matchMidY == nil { + tableView.layoutSubtreeIfNeeded() + matchMidY = focusedMatchMidY(row: row) + } + guard let matchMidY else { + if retries > 0 { + DispatchQueue.main.async { [weak self] in self?.aimAtPendingMatch(retries: retries - 1) } + } else { + matchAim.drop() + } + return + } + matchAim.drop() + let insets = scrollView.contentInsets + let contentHeight = scrollView.contentView.bounds.height - insets.top - insets.bottom + let target = matchMidY - contentHeight / 2 + guard abs(target - contentTopY()) > 0.5 else { return } + place(row: row, offset: target - blockTopY(row), animated: true) } // MARK: - Scroll offset arithmetic @@ -797,8 +1028,16 @@ struct VirtualBlockList: NSViewRepresentable { + scrollView.contentInsets.top } - private func setContentTop(_ documentY: CGFloat, animated: Bool) { - guard let scrollView, let tableView else { return } + /// Scrolls so the content top is at `documentY`, as far as AppKit's + /// clamp allows; returns the content top it goes to. `completion` runs + /// when it is there (at once when nothing animates). + @discardableResult + private func setContentTop(_ documentY: CGFloat, animated: Bool, + completion: (() -> Void)? = nil) -> CGFloat { + guard let scrollView, let tableView else { + completion?() + return documentY + } let clipView = scrollView.contentView let rawY = documentY + tableView.frame.origin.y - scrollView.contentInsets.top // AppKit's own clamp: honours the content insets, the document @@ -810,23 +1049,39 @@ struct VirtualBlockList: NSViewRepresentable { #if DEBUG listLog.debug("setContentTop: documentY=\(documentY, format: .fixed(precision: 1)) tableOrigin=\(tableView.frame.origin.y, format: .fixed(precision: 1)) tableH=\(tableView.frame.height, format: .fixed(precision: 1)) clipOrigin=\(clipView.bounds.origin.y, format: .fixed(precision: 1)) clipH=\(clipView.bounds.height, format: .fixed(precision: 1)) insetTop=\(scrollView.contentInsets.top, format: .fixed(precision: 1)) rawY=\(rawY, format: .fixed(precision: 1)) constrained=\(constrained.y, format: .fixed(precision: 1)) animated=\(animated)") #endif + let reached = constrained.y - tableView.frame.origin.y + scrollView.contentInsets.top guard abs(constrained.y - clipView.bounds.origin.y) > 0.01 - || abs(constrained.x - clipView.bounds.origin.x) > 0.01 else { return } + || abs(constrained.x - clipView.bounds.origin.x) > 0.01 else { + completion?() + return reached + } if animated { NSAnimationContext.runAnimationGroup({ context in context.duration = 0.25 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) clipView.animator().setBoundsOrigin(constrained) - }, completionHandler: { [weak scrollView] in - guard let scrollView else { return } - scrollView.reflectScrolledClipView(scrollView.contentView) + }, completionHandler: { [weak scrollView, weak self] in + if let scrollView { scrollView.reflectScrolledClipView(scrollView.contentView) } + #if DEBUG + self?.expectedContentTop = self?.contentTopY() + #endif + completion?() }) scrollView.reflectScrolledClipView(clipView) } else { + #if DEBUG + isSettingContentTop = true + defer { isSettingContentTop = false } + #endif clipView.scroll(to: constrained) scrollView.reflectScrolledClipView(clipView) + completion?() } + #if DEBUG + expectedContentTop = reached + #endif + return reached } // MARK: - contentWidth reporting (D4) @@ -872,11 +1127,18 @@ struct VirtualBlockList: NSViewRepresentable { /// and `rootView(for:)` expresses the same `min` as a SwiftUI frame — so /// layout width == measurement width by construction, at any clip width. private func currentContentWidth() -> CGFloat { + let inner = columnInnerWidth() + guard inner != 0 else { return 0 } + return BlockLayout.Document.columnWidth(inner: inner, cap: layoutStyle.maxContentWidth) + } + + /// The room the column has before any cap: the clip's whole points + /// minus the horizontal padding on both sides; 0 before the first + /// layout. + func columnInnerWidth() -> CGFloat { let available = quantizedClipWidth() guard available > 0 else { return 0 } - let inner = available - 2 * Metrics.contentHorizontalPadding - guard let cap = layoutStyle.maxContentWidth else { return max(0, inner) } - return max(0, floor(min(inner, cap))) + return available - 2 * Metrics.contentHorizontalPadding } // MARK: - Layout style (reading mode) @@ -914,9 +1176,9 @@ struct VirtualBlockList: NSViewRepresentable { // (a 24 pt jolt on every toggle); keeping the raw clip origin // means restoring the document offset minus that delta. let delta = layoutStyle.edgeInset - previousInset - let top = tableView.map { $0.rect(ofRow: anchor.row).minY } ?? 0 - setContentTop(top + anchor.offsetWithinRow - delta, animated: false) + place(row: anchor.row, offset: anchor.offsetFromBlockTop - delta, animated: false) } + beginLayoutSwitch() } #if DEBUG listLog.debug("layoutStyle: cap=\(style.maxContentWidth.map { "\(Int($0))" } ?? "none", privacy: .public) padding=\(style.verticalPadding, format: .fixed(precision: 0))") @@ -929,6 +1191,20 @@ struct VirtualBlockList: NSViewRepresentable { scheduleWidthReport() } + /// `isSwitchingLayout` until the next install — or until the width + /// report finds nothing to re-measure, or two seconds, whichever comes + /// first, so a switch that never brings a new table cannot pin the + /// anchor for good. + private func beginLayoutSwitch() { + isSwitchingLayout = true + layoutSwitchGeneration += 1 + let switchGeneration = layoutSwitchGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self, self.layoutSwitchGeneration == switchGeneration else { return } + self.isSwitchingLayout = false + } + } + private func applyContentInsets() { guard let scrollView else { return } let inset = layoutStyle.edgeInset @@ -973,6 +1249,17 @@ struct VirtualBlockList: NSViewRepresentable { func syncWidthIfNeeded(parentValue: CGFloat) { isWired = true syncColumnWidth() + if reportedContentWidth > 0, parentValue > 0, pendingPublishes == 0, + abs(parentValue - reportedContentWidth) >= 1 { + // The parent measured a parse at a width it worked out itself + // (a zoom in reading mode, `BlockLayout.Document.parseWidth`): + // that is the width its table has now. Take it as reported, and + // report the real column if the window moved meanwhile. + reportedContentWidth = parentValue + let width = currentContentWidth() + if width > 0, abs(width - parentValue) >= 1 { scheduleWidthReport() } + return + } guard reportedContentWidth == 0 else { return } let width = currentContentWidth() guard width > 0, !isLiveResizing else { return } @@ -997,7 +1284,11 @@ struct VirtualBlockList: NSViewRepresentable { return } let width = self.currentContentWidth() - guard width > 0, abs(width - self.reportedContentWidth) >= 1 else { return } + guard width > 0, abs(width - self.reportedContentWidth) >= 1 else { + // Same width: no new table is coming to end a layout switch. + self.isSwitchingLayout = false + return + } self.publish(width: width) } widthWork = work @@ -1013,7 +1304,11 @@ struct VirtualBlockList: NSViewRepresentable { listLog.debug("contentWidth: \(width, format: .fixed(precision: 1))") #endif // Never inside an AppKit layout pass — SwiftUI state, one turn later. - DispatchQueue.main.async { [weak self] in self?.setContentWidth(width) } + pendingPublishes += 1 + DispatchQueue.main.async { [weak self] in + self?.setContentWidth(width) + self?.pendingPublishes -= 1 + } } // MARK: - Cell content @@ -1027,12 +1322,12 @@ struct VirtualBlockList: NSViewRepresentable { /// row's height can never influence the height reported for it. fileprivate func rootView(for row: Int) -> AnyView { let block = blocks[row] - // `blockSpacing`, not 0: a block view whose body is a TUPLE — today - // only `ImageBlockView` (image + italic alt caption) — has its + // `captionSpacing`, not 0: a block view whose body is a TUPLE — + // today only `ImageBlockView` (image + italic alt caption) — has its // elements flattened into whatever stack contains it, and in 1.8.0 // that stack was the document's `VStack(spacing: 8)`. Single-view // bodies, which is every other kind, are unaffected by the spacing. - let base = VStack(alignment: .leading, spacing: Metrics.blockSpacing) { + let base = VStack(alignment: .leading, spacing: BlockLayout.ImageBlock.captionSpacing) { content(block) } // The hosting view is an environment boundary (same reason @@ -1066,6 +1361,10 @@ struct VirtualBlockList: NSViewRepresentable { .frame(maxWidth: cellCap, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .center) + // The space above the block (`BlockLayout.Spacing`) is the row's + // top padding, OUTSIDE the height reporter: a report is the block's + // height alone, like every height in the table. + let spacing = table.spacing(row) let reports = row < table.kinds.count && table.kinds[row] == .reported if reports { return AnyView( @@ -1074,6 +1373,7 @@ struct VirtualBlockList: NSViewRepresentable { report: { [weak self] id, height in self?.reportHeight(blockId: id, height: height) })) + .padding(.top, spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, Metrics.contentHorizontalPadding) .id(block.id) @@ -1081,6 +1381,7 @@ struct VirtualBlockList: NSViewRepresentable { } return AnyView( column + .padding(.top, spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, Metrics.contentHorizontalPadding) .id(block.id) @@ -1099,6 +1400,7 @@ struct VirtualBlockList: NSViewRepresentable { /// the column its tint spans and whether its row is tinted right now. private func configureSelectionTint(_ cell: BlockHostingCell, row: Int) { cell.selectionTintColumnCap = cellColumnCap + cell.spacingAbove = table.spacing(row) cell.isSelectionTinted = selection.wantsTint(row: row) } @@ -1131,8 +1433,9 @@ struct VirtualBlockList: NSViewRepresentable { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { guard row >= 0, row < table.heights.count else { return 1 } - // AppKit rejects non-positive row heights. - return max(1, table.heights[row]) + // The space above the block, then the block. AppKit rejects + // non-positive row heights. + return max(1, table.rowHeight(row)) } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, @@ -1214,6 +1517,16 @@ private struct RowHeightReporter: ViewModifier { /// or two of the user letting go instead of waiting out the debounce. final class BlockScrollView: NSScrollView { var onEndLiveResize: (() -> Void)? + /// The reader scrolled: wheel or trackpad here, a scroll key or a + /// drag-select at the edge (`BlockTableView.scrollVertically`). + var onReaderScroll: (() -> Void)? + + override func scrollWheel(with event: NSEvent) { + if PendingMatchAim.isReaderScroll(event.phase, event.momentumPhase, event.scrollingDeltaY) { + onReaderScroll?() + } + super.scrollWheel(with: event) + } /// Clicks in the content insets above the first / below the last row land /// on the clip view, whose responder chain ends here, not at the table — /// they still belong to the document (a click there clears the selection, @@ -1426,8 +1739,10 @@ final class BlockTableView: NSTableView { enclosingScrollView?.verticalLineScroll ?? 10 } + /// The reader's own scroll (keys, a drag-select at the edge). func scrollVertically(by dy: CGFloat) { guard let scrollView = enclosingScrollView else { return } + (scrollView as? BlockScrollView)?.onReaderScroll?() let clipView = scrollView.contentView let proposed = NSRect(origin: NSPoint(x: clipView.bounds.origin.x, y: clipView.bounds.origin.y + dy), @@ -1500,6 +1815,12 @@ final class BlockHostingCell: NSTableCellView { didSet { if selectionTintColumnCap != oldValue { needsLayout = true } } } + /// The row's space above its block (`BlockHeightTable.spacing(_:)`): the + /// tint covers the block, not the gap above it. + var spacingAbove: CGFloat = 0 { + didSet { if spacingAbove != oldValue { needsLayout = true } } + } + init(rootView: AnyView) { hostingView = NSHostingView(rootView: rootView) super.init(frame: .zero) @@ -1521,8 +1842,13 @@ final class BlockHostingCell: NSTableCellView { // padding on both sides, then the column cap, centred. let padding = BlockLayout.Document.contentHorizontalPadding let width = max(0, min(bounds.width - 2 * padding, selectionTintColumnCap)) - tintView.frame = NSRect(x: (bounds.width - width) / 2, y: 0, - width: width, height: bounds.height) + // The block's part of the cell: below the space above it. The + // cell is not flipped, so "below" is the lower part of `bounds`. + var block = RowGeometry.contentRect(rowRect: NSRect(origin: .zero, size: bounds.size), + spacingAbove: spacingAbove) + if !isFlipped { block.origin.y = 0 } + tintView.frame = NSRect(x: (bounds.width - width) / 2, y: block.minY, + width: width, height: block.height) } } @@ -1588,6 +1914,44 @@ extension EnvironmentValues { } } +/// Hover for the buttons a block shows under the pointer (heading copy, code +/// copy, diagram zoom). SwiftUI also reports a hover for a row that lies +/// OUTSIDE the clip view: with the pointer resting just below the window, the +/// rows the document scrolls past that point became "hovered", and no mouse +/// move ever ended it — copy icons stayed on every few headings after a +/// search jump or a keyboard scroll. So the hover counts only while the +/// pointer is really over the visible document +/// (`SelectionController.pointerIsOverVisibleDocument`), checked on every +/// move (continuous hover), so a real hover after such a report still shows. +/// Outside the document list (no `blockSelection`) it is a plain hover. +struct DocumentHover: ViewModifier { + @Environment(\.blockSelection) private var blockSelection + let onChange: (Bool) -> Void + @State private var reported = false + + func body(content: Content) -> some View { + content.onContinuousHover { phase in + let hovering: Bool + switch phase { + case .active: + hovering = blockSelection?.controller?.pointerIsOverVisibleDocument() ?? true + case .ended: + hovering = false + } + guard hovering != reported else { return } + reported = hovering + onChange(hovering) + } + } +} + +extension View { + /// `onHover` for a block's hover buttons (`DocumentHover`). + func documentHover(perform action: @escaping (Bool) -> Void) -> some View { + modifier(DocumentHover(onChange: action)) + } +} + /// The document's ONE selection and everything that edits, draws and copies it /// (S-D4). Owned by `VirtualBlockList.Coordinator`; main-thread only (every /// entry point is an AppKit event, a menu action or a representable update). @@ -1608,6 +1972,9 @@ final class SelectionController: NSObject { var selectableTable: (MarkdownBlock, Int) -> TableText? = { _, _ in nil } /// See `VirtualBlockList.onCopySelection`. var onCopy: (DocumentCopyOutput) -> Void = { _ in } + /// The space above row N's block — part of its row rect (`RowGeometry`). + /// Set once by the coordinator, which owns the height table. + var spacingAbove: (Int) -> CGFloat = { _ in 0 } // MARK: Model @@ -1640,6 +2007,21 @@ final class SelectionController: NSObject { keyObservers.forEach { NotificationCenter.default.removeObserver($0) } } + /// Whether the pointer is over the part of the document the reader sees + /// (the clip view's bounds) in this list's window, with no other window + /// above it there. `DocumentHover` asks: SwiftUI also reports a hover for + /// a row OUTSIDE the clip view, under a pointer resting just below the + /// window. + func pointerIsOverVisibleDocument() -> Bool { + guard let tableView, let window = tableView.window, + let clipView = tableView.enclosingScrollView?.contentView else { return false } + let screenPoint = NSEvent.mouseLocation + guard NSWindow.windowNumber(at: screenPoint, belowWindowWithWindowNumber: 0) + == window.windowNumber else { return false } + let point = clipView.convert(window.convertPoint(fromScreen: screenPoint), from: nil) + return clipView.bounds.contains(point) + } + func attach(tableView: BlockTableView) { self.tableView = tableView guard keyObservers.isEmpty else { return } @@ -1863,11 +2245,11 @@ final class SelectionController: NSObject { func selectionOutput() -> DocumentCopyOutput? { guard let selection else { return nil } let version = contentVersion - let pieces = SelectionRows.pieces(of: selection, blocks: blocks, - text: { selectableText($0, version) }, - table: { selectableTable($0, version) }) - guard !pieces.isEmpty else { return nil } - let output = DocumentCopyBuilder.build(pieces) + let parts = SelectionRows.parts(of: selection, blocks: blocks, + text: { selectableText($0, version) }, + table: { selectableTable($0, version) }) + guard !parts.isEmpty else { return nil } + let output = DocumentCopyBuilder.build(parts: parts) return output.plain.isEmpty ? nil : output } @@ -1947,6 +2329,9 @@ final class SelectionController: NSObject { /// The row's text view, when the point maps through one. let textView: SelfSizingTextView? let pointInTextView: NSPoint? + /// In the space between two blocks: `point` is the end of the row above + /// (upper half) or the start of the row below (lower half). + var inGap = false } /// Maps a window point to a document position THROUGH THE TABLE: row under @@ -1957,11 +2342,15 @@ final class SelectionController: NSObject { /// through the view that received the mouse-down, which a drag can scroll /// out of the prepared area and the table can recycle for another row. /// - /// Points between rows belong to a row (`rect(ofRow:)` includes half the - /// intercell spacing on each side); points above or below a row's text - /// (the gap, an alert's title, a code block's padding) map to the start or - /// the end of that text; points left or right of it to the line's start or - /// end. Above the first row / below the last: the document's ends. + /// Points between two blocks belong to the NEARER one: a row rect is the + /// space above its block plus the block (`RowGeometry`), and the upper half + /// of that space maps to the end of the row before, the lower half to the + /// start of this row — as when half the gap was in each row rect. Points + /// above or below a row's text (an alert's title, a code block's padding) + /// map to the start or the end of that text; points left or right of it to + /// the line's start or end. Above the first row (its rect starts at the + /// table's top; the first row has no space above) / below the last: the + /// document's ends. private func hit(atWindowPoint windowPoint: NSPoint) -> Hit? { guard let tableView, !blocks.isEmpty, tableView.numberOfRows == blocks.count else { return nil } let point = tableView.convert(windowPoint, from: nil) @@ -1977,14 +2366,22 @@ final class SelectionController: NSObject { textView: nil, pointInTextView: nil) } - let block = blocks[row] let rowRect = tableView.rect(ofRow: row) + let spacing = spacingAbove(row) + if row > 0, RowGeometry.belongsToPreviousRow(y: point.y, rowRect: rowRect, spacingAbove: spacing) { + return Hit(point: SelectionPoint(row: row - 1, offset: rowLength(row - 1)), isAtomic: false, + textView: nil, pointInTextView: nil, inGap: true) + } + let inGap = row > 0 && point.y < RowGeometry.topTarget(rowRect: rowRect, spacingAbove: spacing) + let block = blocks[row] + let upperHalf = RowGeometry.isInUpperHalf(y: point.y, rowRect: rowRect, spacingAbove: spacing) if SelectionRows.isAtomic(block) { - // Upper half = before the row, lower half = after it: dragging - // DOWN selects the row once the pointer is past its middle, and so - // does dragging UP — symmetrical, like a very tall character. - return Hit(point: SelectionPoint(row: row, offset: point.y < rowRect.midY ? 0 : 1), - isAtomic: true, textView: nil, pointInTextView: nil) + // Upper half of the BLOCK = before the row, lower half = after it: + // dragging DOWN selects the row once the pointer is past its + // middle, and so does dragging UP — symmetrical, like a very tall + // character. + return Hit(point: SelectionPoint(row: row, offset: upperHalf ? 0 : 1), + isAtomic: true, textView: nil, pointInTextView: nil, inGap: inGap) } if let textView = registeredTextView(for: block.id, in: tableView) { @@ -1994,13 +2391,55 @@ final class SelectionController: NSObject { isTable: textView is TableTextView, characterIndex: textView.selectionCharacterIndex(at:)) return Hit(point: SelectionPoint(row: row, offset: offset), isAtomic: false, - textView: textView, pointInTextView: local) + textView: textView, pointInTextView: local, inGap: inGap) } // A text-kind row with no registered text view to ask (not yet // registered): its whole string, by half. - return Hit(point: SelectionPoint(row: row, offset: point.y < rowRect.midY ? 0 : rowLength(row)), - isAtomic: false, textView: nil, pointInTextView: nil) + return Hit(point: SelectionPoint(row: row, offset: upperHalf ? 0 : rowLength(row)), + isAtomic: false, textView: nil, pointInTextView: nil, inGap: inGap) + } + + /// What a double- (word) or triple-click (paragraph; in a table, the + /// cell) selects, from where it was pressed. In the space between two + /// blocks it acts where a single click there lands (`hit`): the end of + /// the row above (upper half) or the start of the row below (lower half) + /// — the last or the first word, as a text view selects the word next to + /// its insertion point — and a neighbouring image, diagram or formula + /// whole. Nil when there is nothing to select (no text view to ask). + private func multiClickUnit(for start: Hit, clickCount: Int, + in tableView: NSTableView) -> (start: SelectionPoint, end: SelectionPoint)? { + let granularity: NSSelectionGranularity = clickCount == 2 ? .selectByWord : .selectByParagraph + let row = start.point.row + guard row >= 0, row < blocks.count else { return nil } + if start.inGap { + if SelectionRows.isAtomic(blocks[row]) { + return (SelectionPoint(row: row, offset: 0), SelectionPoint(row: row, offset: 1)) + } + guard let textView = registeredTextView(for: blocks[row].id, in: tableView), + let storage = textView.textStorage, + let index = SelectionRows.multiClickIndex(atRowBoundary: start.point.offset, + in: storage.string as NSString) else { return nil } + let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), + granularity: granularity) + return (SelectionPoint(row: row, offset: range.location), + SelectionPoint(row: row, offset: NSMaxRange(range))) + } + // Double-click = word, triple-click = paragraph, in that row — in a + // table the cell (one paragraph per cell, TS-D7). The character is + // checked against the hook's answer (`start`), so a click in a cell's + // padding is about that cell, not TextKit's nearest glyph. + guard let textView = start.textView, let local = start.pointInTextView, + let index = textView.multiClickCharacterIndex(at: local, insertion: start.point.offset, + inTableCell: textView is TableTextView) else { return nil } + let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), + granularity: granularity) + return (SelectionPoint(row: row, offset: range.location), SelectionPoint(row: row, offset: NSMaxRange(range))) + } + + /// The text view of `blockId`'s row, if it is on display in `tableView`. + func displayedTextView(for blockId: String, in tableView: NSTableView) -> SelfSizingTextView? { + registeredTextView(for: blockId, in: tableView) } /// The registered view for `blockId` — if it is really on display in THIS @@ -2050,19 +2489,7 @@ final class SelectionController: NSObject { anchor = existing.anchor anchorIsAtomic = false setSelection(DocumentSelection(anchor: anchor, focus: start.point)) - } else if clickCount >= 2, let textView = start.textView, let local = start.pointInTextView, - let index = textView.multiClickCharacterIndex(at: local, insertion: start.point.offset, - inTableCell: textView is TableTextView) { - // Double-click = word, triple-click = paragraph, in that row — in - // a table the cell (one paragraph per cell, TS-D7). The character - // is checked against the hook's answer (`start`), so a click in a - // cell's padding is about that cell, not TextKit's nearest glyph. - let granularity: NSSelectionGranularity = clickCount == 2 ? .selectByWord : .selectByParagraph - let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), - granularity: granularity) - let row = start.point.row - let selected = (start: SelectionPoint(row: row, offset: range.location), - end: SelectionPoint(row: row, offset: NSMaxRange(range))) + } else if clickCount >= 2, let selected = multiClickUnit(for: start, clickCount: clickCount, in: tableView) { unit = selected setSelection(DocumentSelection(anchor: selected.start, focus: selected.end)) } else { diff --git a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift index a66c69d..b3d38b9 100644 --- a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift +++ b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift @@ -106,7 +106,6 @@ final class BlockHeightMeasurerTests: XCTestCase { ("ordered list", "1. first\n2. second item long enough to wrap at the narrow widths under test\n3. third"), ("footnote block", "Text with a ref[^a] and another[^b].\n\n[^a]: First footnote body, long enough to wrap somewhere.\n[^b]: Second footnote body."), ("inline math", "Einstein wrote $E = mc^2$ and also $x^2 + y^2 = z^2$ in one paragraph that wraps at the narrower widths."), - ("blank-separated blocks", "## H\n\n- a\n- b\n\nText after blank.\n\n\n## Next\n\nLast.\n\n"), ] for fixture in fixtures { @@ -138,20 +137,6 @@ final class BlockHeightMeasurerTests: XCTestCase { } } - /// A blank line between a heading and its paragraph or list (or after the - /// list) adds no height: the text rows of the loose document measure - /// exactly like those of the tight one (1.14.3). - func testBlankLinesAroundBlocksAddNoHeight() { - let loose = parse("## Para\n\nText after blank.\n\n## List\n\n- one\n- two\n\n## End") - let tight = parse("## Para\nText after blank.\n## List\n- one\n- two\n## End") - XCTAssertEqual(loose.map(\.id), tight.map(\.id)) - for width in widths { - let looseHeights = measure(loose, width: width).table.heights - let tightHeights = measure(tight, width: width).table.heights - XCTAssertEqual(looseHeights, tightHeights, "@\(width)") - } - } - // MARK: - .blockquote parity func testBlockquoteParityAcrossLevelsAndWidths() { @@ -220,9 +205,11 @@ final class BlockHeightMeasurerTests: XCTestCase { } } - /// An alert with a marker but no body skips the body and its VStack spacing. + /// An alert with a marker but no body text skips the body and its VStack + /// spacing. (Its body is an empty nested quote: the marker alone is a + /// plain quote, as on GitHub.) func testAlertWithoutBodyHasNoBodyChrome() { - let blocks = parse("> [!NOTE]") + let blocks = parse("> [!NOTE]\n> >") let measured = measure(blocks, width: 600) guard let index = blocks.firstIndex(where: { if case .alert = $0.content { return true } else { return false } }) else { return XCTFail("fixture did not parse as an alert") @@ -448,6 +435,13 @@ final class BlockHeightMeasurerTests: XCTestCase { XCTAssertNotNil(measured.converted[block.id], "missing conversion for \(block.id)") case .alert(_, let content) where !content.isEmpty: XCTAssertNotNil(measured.converted[block.id], "missing conversion for \(block.id)") + case .heading(let level, let title, _): + // `HeadingBlockView` hosts a `TextBlockView` too: the title as it + // converts it, kept for copy and search. + let shown = BlockTextConverter.makeNSAttributedString( + from: MarkdownRenderer(theme: theme, fontScale: 1).renderHeader(title, level: level), + hasInlineMath: false, theme: theme, fontScale: 1, math: .none) + XCTAssertEqual(measured.converted[block.id], shown, "heading \(block.id)") default: XCTAssertNil(measured.converted[block.id], "unexpected conversion for \(block.id)") } @@ -506,7 +500,16 @@ final class BlockHeightMeasurerTests: XCTestCase { guard let index = blocks.firstIndex(where: { if case .image = $0.content { return true } else { return false } }) else { return XCTFail("fixture did not parse as an image") } + // The loading view as drawn: the 100 pt placeholder AND the caption + // under it, each with the row's padding (`ImageBlock.rowHeight`). XCTAssertEqual(measured.table.heights[index], + BlockLayout.ImageBlock.placeholderHeight + + 4 * BlockLayout.Document.imageOuterVerticalPadding + + BlockLayout.ImageBlock.captionSpacing + + BlockLayout.ImageBlock.captionHeight(alt: "alt", fontScale: 1, contentWidth: 600), + accuracy: tolerance) + let uncaptioned = parse("![](picture.png)") + XCTAssertEqual(measure(uncaptioned, width: 600).table.heights[0], BlockLayout.ImageBlock.placeholderHeight + 2 * BlockLayout.Document.imageOuterVerticalPadding, accuracy: tolerance) @@ -932,6 +935,84 @@ extension BlockHeightMeasurerTests { } } + /// A formula that PARSES but draws nothing — only spacing, empty groups, + /// an accent over nothing, `\color` (which this SwiftMath parses but its + /// typesetter declines) — took the formula path and drew an empty row. It + /// now falls back like an unparseable one: the same substitution, which + /// screen and print share. The production verdict + /// (`MathInk.typesetsToSomething`, in the app target: SwiftMath loads its + /// fonts from the main bundle, which the test runner is not) agreed with + /// SwiftMath's own rendering on 36 formulas in a headless check; its + /// verdicts for these drive the engine here. (`$$ $$` and `$$\n$$` never + /// become a block at all.) + func testAFormulaThatDrawsNothingBecomesALatexCodeBlock() { + let drawsNothing: Set = [#"\,"#, #"\color{red}{x}"#, #"{}^{}_{}"#, #"\hat{}"#] + let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 40 }, + typesets: { !drawsNothing.contains($0) }) + let document = "$$\n\\,\n$$\n\n$$\\color{red}{x}$$\n\n$$\n{}^{}_{}\n$$\n\n$$\\hat{}$$\n\n$$x$$" + let parsed = parse(document) + let blocks = engine.substitutingUntypesettableMath(in: parsed) + XCTAssertEqual(blocks.map(\.id), parsed.map(\.id)) + XCTAssertEqual(blocks.count, 5) + for (index, latex) in [#"\,"#, #"\color{red}{x}"#, #"{}^{}_{}"#, #"\hat{}"#].enumerated() { + guard case .codeBlock(let code, let language) = blocks[index].content else { + XCTFail("\(latex) is still \(blocks[index].content)") + continue + } + XCTAssertEqual(code, latex, "the source, verbatim") + XCTAssertEqual(language, "latex") + } + guard case .mathBlock(let working) = blocks[4].content else { + return XCTFail("a formula that draws stays display math") + } + XCTAssertEqual(working, "x") + } + + /// A formula shown as its source copies as the formula it is when the + /// selection covers it whole — ⌘A, a drag across it — `$$…$$`, as 1.14.2 + /// copied it; a selection inside its text copies that text, like any + /// selection. A user's own ```latex block still copies as code, and search + /// still counts in the same string the row shows. + func testASubstitutedFormulaCopiesAsTheFormulaWhenCoveredWhole() { + let blocks = refusingNot.substitutingUntypesettableMath(in: parse(Self.failingFormulaDocument) + + parse("```latex\n\\not\\le\n```")) + guard case .codeBlock(let latex, _) = blocks[1].content, blocks[1].substitutesFormula else { + return XCTFail("fixture: row 1 was not substituted") + } + XCTAssertFalse(blocks[4].substitutesFormula, "a user's own latex fence") + func text(_ block: MarkdownBlock) -> NSAttributedString? { + switch block.content { + case .text(let attributed): + return BlockTextConverter.makeNSAttributedString(from: attributed, hasInlineMath: false, + theme: theme, fontScale: 1, math: .none) + case .codeBlock(let code, _): + return BlockTextConverter.plainCode(code, theme: theme, fontScale: 1) + default: + return nil + } + } + func copy(_ anchor: SelectionPoint, _ focus: SelectionPoint) -> String { + DocumentCopyBuilder.build(SelectionRows.pieces(of: DocumentSelection(anchor: anchor, focus: focus), + blocks: blocks, text: text, table: { _ in nil })).plain + } + let length = (latex as NSString).length + let outro = text(blocks[3])!.length + // ⌘A: intro, the formula as $$…$$, the working formula, outro, the fence. + let all = DocumentSelection.selectAll(rowCount: blocks.count) { text(blocks[$0])?.length ?? 1 }! + XCTAssertEqual(copy(all.anchor, all.focus), + "Intro paragraph.\n\n$$\n\(latex)\n$$\n\n$$\nx = \\frac{1}{2}\n$$\n\nOutro paragraph.\n\n\\not\\le") + // A drag from the intro into the outro covers the formula whole. + XCTAssertTrue(copy(SelectionPoint(row: 0, offset: 6), SelectionPoint(row: 3, offset: outro)) + .contains("\n\n$$\n\(latex)\n$$\n\n")) + // The formula's own text, start to end, from inside: still whole. + XCTAssertEqual(copy(SelectionPoint(row: 1, offset: 0), SelectionPoint(row: 1, offset: length)), + "$$\n\(latex)\n$$") + // Part of it: those characters. + XCTAssertEqual(copy(SelectionPoint(row: 1, offset: 0), SelectionPoint(row: 1, offset: 6)), "V(e_1)") + // Search counts in what the row shows. + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "not", theme: theme).matchBlockIds.count, 3) + } + func testDocumentWithoutDisplayMathNeverConsultsTheEngine() { let parsed = parse("Text with $x$ inline only.\n\n```latex\n\\not\\le\n```") let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 0 }, diff --git a/QuickMD/QuickMDTests/DocumentSelectionTests.swift b/QuickMD/QuickMDTests/DocumentSelectionTests.swift index 6d065c0..ad67abd 100644 --- a/QuickMD/QuickMDTests/DocumentSelectionTests.swift +++ b/QuickMD/QuickMDTests/DocumentSelectionTests.swift @@ -492,6 +492,36 @@ final class DocumentSelectionTests: XCTestCase { XCTAssertEqual(DocumentClipboard.summary(for: "", locale: english), "Copied 0 characters") } + // MARK: - Double- and triple-click in the space between two blocks + + /// A double-click in the gap between two blocks acts where a single click + /// there lands: the upper half on the end of the row above, the lower + /// half on the start of the row below. It selects the word there (the + /// paragraph with a triple-click), as a text view selects the word next to + /// its insertion point — it never just collapses the selection. + func testAMultiClickInAGapSelectsTheWordAtTheRowsEnd() { + func unit(_ string: String, atBoundary offset: Int, _ granularity: NSSelectionGranularity) -> String? { + let textView = NSTextView() + textView.configureForSelfSizing() + textView.string = string + guard let index = SelectionRows.multiClickIndex(atRowBoundary: offset, + in: textView.string as NSString) else { return nil } + let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), + granularity: granularity) + return (textView.string as NSString).substring(with: range) + } + let row = "Paragraph above the gap ends here." + let end = (row as NSString).length + XCTAssertEqual(unit(row, atBoundary: end, .selectByWord), ".") + XCTAssertEqual(unit("A row ending in a word", atBoundary: 22, .selectByWord), "word") + XCTAssertEqual(unit("First word of the row below", atBoundary: 0, .selectByWord), "First") + XCTAssertEqual(unit("Line one\nline two", atBoundary: 17, .selectByParagraph), "line two") + // A table's string ends in a cell's line break: the cell's last word. + XCTAssertEqual(unit("a\tb\nc\tlast\n", atBoundary: 11, .selectByWord), "last") + XCTAssertNil(SelectionRows.multiClickIndex(atRowBoundary: 0, in: "")) + XCTAssertEqual(SelectionRows.multiClickIndex(atRowBoundary: 3, in: "x\n\n"), 0) + } + // MARK: - Auto-copy decision (S-D10) private func selection(_ from: Int, _ to: Int) -> DocumentSelection { @@ -534,10 +564,10 @@ final class DocumentSelectionTests: XCTestCase { XCTAssertFalse(SelectionAutoCopy.shouldCopy(after: .click, selection: selection(0, 5), enabled: true)) } - func testAutoCopySettingDefaultsToOff() { - let suite = "pl.falami.studio.QuickMD.tests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suite) else { return XCTFail("no defaults suite") } - defer { defaults.removePersistentDomain(forName: suite) } + func testAutoCopySettingDefaultsToOff() throws { + let suite = try TemporaryDefaults("AutoCopy") + defer { suite.remove() } + let defaults = suite.defaults XCTAssertFalse(SelectionAutoCopy.isEnabled(in: defaults)) defaults.set(true, forKey: SelectionAutoCopy.defaultsKey) XCTAssertTrue(SelectionAutoCopy.isEnabled(in: defaults)) diff --git a/QuickMD/QuickMDTests/FileWatcherTests.swift b/QuickMD/QuickMDTests/FileWatcherTests.swift index 0e194ce..fdb83e0 100644 --- a/QuickMD/QuickMDTests/FileWatcherTests.swift +++ b/QuickMD/QuickMDTests/FileWatcherTests.swift @@ -169,10 +169,17 @@ final class FileWatcherTests: XCTestCase { } /// The file comes back but cannot be opened (mode 000 → EACCES): the poll - /// stops instead of failing every second, and the watcher stays missing — + /// stops instead of failing every second and reports it unreadable, once — /// restoring the permissions later is NOT noticed (pre-poll behaviour). func testPermissionDeniedAfterReturnStopsThePoll() throws { + try XCTSkipIf(getuid() == 0, "root reads a mode-000 file") var missingCount = 0 + var unreadableCount = 0 + let denied = expectation(description: "a poll found it unreadable") + watcher.onFileUnreadable = { + unreadableCount += 1 + denied.fulfill() + } let missing = expectation(description: "onFileMissing after delete") watcher.onFileMissing = { missingCount += 1 @@ -187,16 +194,50 @@ final class FileWatcherTests: XCTestCase { XCTAssertEqual(chmod(fileURL.path, 0), 0) defer { chmod(fileURL.path, 0o644) } - // One poll sees it, fails to open it, stops. - let denied = expectation(description: "a poll ran") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { denied.fulfill() } - wait(for: [denied], timeout: 3) + // One poll sees it, fails to open it, reports it, stops. + wait(for: [denied], timeout: 10) XCTAssertEqual(chmod(fileURL.path, 0o644), 0) let settle = expectation(description: "no poll after the denial") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { settle.fulfill() } wait(for: [settle], timeout: 3) XCTAssertEqual(missingCount, 1) + XCTAssertEqual(unreadableCount, 1, "back, but off-limits: unreadable, not missing") + } + + /// `chmod 000` on the open document (or a sandbox denial): the file is + /// there, so the window must not say it no longer exists. + func testUnreadableFileIsNotReportedAsMissing() throws { + try XCTSkipIf(getuid() == 0, "root reads a mode-000 file") + watcher.onFileMissing = { XCTFail("an unreadable file is not a missing one") } + watcher.onChange = { XCTFail("nothing changed") } + var unreadableCount = 0 + var replaced: XCTestExpectation? + watcher.onFileUnreadable = { + unreadableCount += 1 + replaced?.fulfill() + } + XCTAssertEqual(chmod(fileURL.path, 0), 0) + defer { chmod(fileURL.path, 0o644) } + + watcher.start(watching: fileURL) + XCTAssertEqual(unreadableCount, 1, "reported at once, synchronously") + + // An atomic replace by an unreadable file: the same answer. + XCTAssertEqual(chmod(fileURL.path, 0o644), 0) + watcher.start(watching: fileURL) + XCTAssertEqual(unreadableCount, 1, "armed") + let replacement = tempDir.appendingPathComponent("replacement.md") + try "locked".write(to: replacement, atomically: false, encoding: .utf8) + XCTAssertEqual(chmod(replacement.path, 0), 0) + replaced = expectation(description: "the replace is reported unreadable") + XCTAssertEqual(rename(replacement.path, fileURL.path), 0) + wait(for: [replaced!], timeout: 10) + XCTAssertEqual(unreadableCount, 2) + + XCTAssertEqual(FileWatcher.Problem.missing.bannerText(path: "/a/b.md"), "File no longer exists at /a/b.md") + XCTAssertEqual(FileWatcher.Problem.unreadable.bannerText(path: "/a/b.md"), "File can\u{2019}t be read at /a/b.md") + XCTAssertEqual(FileWatcher.Problem.unreadable.bannerText(path: nil), "File can\u{2019}t be read at this location") } func testStopWhileMissingCancelsThePoll() throws { diff --git a/QuickMD/QuickMDTests/FontTests.swift b/QuickMD/QuickMDTests/FontTests.swift index 6be3bd8..6421e03 100644 --- a/QuickMD/QuickMDTests/FontTests.swift +++ b/QuickMD/QuickMDTests/FontTests.swift @@ -75,9 +75,9 @@ final class FontTests: XCTestCase { } func testUserDefaultsRoundTrip() throws { - let suite = "pl.falami.studio.QuickMD.FontTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try TemporaryDefaults("FontTests") + defer { suite.remove() } + let defaults = suite.defaults XCTAssertEqual(DocumentFonts.fromUserDefaults(defaults), .system) defaults.set(Self.body, forKey: DocumentFonts.bodyDefaultsKey) diff --git a/QuickMD/QuickMDTests/FrontMatterTests.swift b/QuickMD/QuickMDTests/FrontMatterTests.swift index b20c803..51ceba0 100644 --- a/QuickMD/QuickMDTests/FrontMatterTests.swift +++ b/QuickMD/QuickMDTests/FrontMatterTests.swift @@ -33,18 +33,73 @@ final class FrontMatterTests: XCTestCase { func testBlockSequencesIndentedOrAtColumnZero() { XCTAssertEqual(properties("tags:\n - a\n - b"), ["tags=a, b"]) XCTAssertEqual(properties("tags:\n- a\n- b\nnext: x"), ["tags=a, b", "next=x"]) - XCTAssertEqual(properties("tags:\n - \"quoted, item\"\n - 'it''s'"), ["tags=quoted, item, it's"]) + XCTAssertEqual(properties("tags:\n - \"quoted, item\"\n - 'it''s'"), ["tags=quoted, item\u{2028}it's"]) XCTAssertEqual(properties("tags:\n - a\n # between items\n\n - b"), ["tags=a, b"]) } func testFlowSequences() { XCTAssertEqual(properties("aliases: [Review 45]"), ["aliases=Review 45"]) - XCTAssertEqual(properties("tags: [a, b, \"c, d\"]"), ["tags=a, b, c, d"]) + XCTAssertEqual(properties("tags: [a, b, \"c, d\"]"), ["tags=a\u{2028}b\u{2028}c, d"]) XCTAssertEqual(properties("tags: ['x', y ] "), ["tags=x, y"]) XCTAssertEqual(properties("tags: []"), ["tags="]) XCTAssertEqual(properties("tags: [ ]"), ["tags="]) } + /// An item that itself contains a comma would read as two items in a + /// ", " list: then (and only then) every item of THAT list gets its own + /// line — U+2028 inside the cell's one paragraph. + func testListWithACommaInAnItemShowsOneItemPerLine() throws { + XCTAssertEqual(properties("authors:\n - \"Doe, Jane\"\n - Smith\ntags: [x, y]\nplaces: ['Paris, FR', Rome]"), + ["authors=Doe, Jane\u{2028}Smith", "tags=x, y", "places=Paris, FR\u{2028}Rome"]) + XCTAssertEqual(properties("a:\n - 1,5\n - 2"), ["a=1,5\u{2028}2"], "any comma, not only \", \"") + XCTAssertEqual(properties("note: one, two"), ["note=one, two"], "a scalar is not a list") + + // The table cell keeps it as one paragraph with a line break inside. + let blocks = parse("---\nauthors: [\"Doe, Jane\", Smith]\n---\n") + guard case .table(let headers, let rows, let alignments, let style) = blocks.first?.content else { + return XCTFail("expected a properties table") + } + let table = TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, style: style, + theme: MarkdownTheme.cached(for: .light), fontScale: 1) + let valueRange = table.layout.cellRanges[0][1] + XCTAssertEqual((table.string.string as NSString).substring(with: valueRange), "Doe, Jane\u{2028}Smith") + let wide = TableTextBuilder.height(of: table.string, width: 600) + let oneLine = TableTextBuilder.build(headers: [], rows: [["authors", "Doe Jane, Smith"]], + alignments: alignments, style: style, + theme: MarkdownTheme.cached(for: .light), fontScale: 1) + XCTAssertGreaterThan(wide, TableTextBuilder.height(of: oneLine.string, width: 600) + 10, "two lines") + } + + /// A YAML scalar's backslash is a character, not a Markdown escape: the + /// value shows as written (`C:\_tmp`, not `C:_tmp`) on screen, in search + /// and in print (`valueCell`), while its other inline Markdown renders. + /// Grid cells keep Markdown escapes. + func testValueBackslashesShowAsWritten() throws { + let theme = MarkdownTheme.cached(for: .light) + let blocks = parse("---\npath: C:\\_tmp\\new\nnote: a **bold** \\[x\\] \\\\\n---\n| a |\n|---|\n| \\_g\\_ |\n") + guard case .table(let headers, let rows, let alignments, .properties) = blocks.first?.content else { + return XCTFail("expected a properties table") + } + let table = TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + style: .properties, theme: theme, fontScale: 1) + let string = table.string.string as NSString + XCTAssertEqual(string.substring(with: table.layout.cellRanges[0][1]), "C:\\_tmp\\new") + XCTAssertEqual(string.substring(with: table.layout.cellRanges[1][1]), "a bold \\[x\\] \\\\") + let boldFont = try XCTUnwrap(table.string.attribute(.font, at: table.layout.cellRanges[1][1].location + 2, + effectiveRange: nil) as? NSFont) + XCTAssertTrue(boldFont.fontDescriptor.symbolicTraits.contains(.bold), "other inline Markdown renders") + XCTAssertEqual(TableTextBuilder.plainString(headers: headers, rows: rows, alignments: alignments, + style: .properties, theme: theme, fontScale: 1), + table.string.string, "search counts what is shown") + + let renderer = MarkdownRenderer(theme: theme) + XCTAssertEqual(String(TableTextBuilder.valueCell("C:\\_tmp", renderer: renderer).characters), "C:\\_tmp", + "print draws the same value") + + guard case .table(_, let gridRows, _, .grid) = blocks[1].content else { return XCTFail("grid table") } + XCTAssertEqual(String(renderer.renderInline(gridRows[0][0]).characters), "_g_", "grid keeps escapes") + } + func testQuotedScalars() { XCTAssertEqual(properties(#"a: "say \"hi\" \\ there""#), [#"a=say "hi" \ there"#]) XCTAssertEqual(properties("a: 'it''s # not a comment'"), ["a=it's # not a comment"]) diff --git a/QuickMD/QuickMDTests/HeadingLayoutTests.swift b/QuickMD/QuickMDTests/HeadingLayoutTests.swift index 8b84afc..8e3725f 100644 --- a/QuickMD/QuickMDTests/HeadingLayoutTests.swift +++ b/QuickMD/QuickMDTests/HeadingLayoutTests.swift @@ -239,6 +239,60 @@ final class HeadingLayoutTests: XCTestCase { } } + // MARK: - Reading Mode: no copy button, same strip + + /// The `HeadingBlockView` composition in a given mode, from the same + /// `Metrics` decisions the view takes (the view is not compiled here). + private func modeHeadingHeight(_ ns: NSAttributedString, firstFont: NSFont?, width: CGFloat, + isReadingMode: Bool) -> CGFloat { + let geometry = Metrics.titleGeometry(titleFirstBaseline: Metrics.titleFirstBaseline(font: firstFont)) + return hostedHeight( + TextKitHeightView(attributed: ns) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, Metrics.titleTrailingPadding(isReadingMode: isReadingMode)) + .padding(.top, geometry.titleTopInset) + .frame(minHeight: geometry.minimumHeight, alignment: .top) + .overlay(alignment: .topTrailing) { + if Metrics.showsCopyButton(isReadingMode: isReadingMode) { + copyButton.padding(.top, geometry.buttonTopInset) + } + } + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + ) + } + + /// Reading Mode hides the copy icon but keeps its reserved strip: the + /// title wraps at the same width, so the heading's height — the view's and + /// the measurer's — is identical in both modes, and toggling the mode + /// never re-wraps a heading. + func testReadingModeHidesTheCopyButtonButKeepsTheHeadingHeight() { + XCTAssertTrue(Metrics.showsCopyButton(isReadingMode: false)) + XCTAssertFalse(Metrics.showsCopyButton(isReadingMode: true), "no copy icon in Reading Mode") + XCTAssertEqual(Metrics.titleTrailingPadding(isReadingMode: true), Metrics.copyButtonReservedWidth) + XCTAssertEqual(Metrics.titleTrailingPadding(isReadingMode: false), Metrics.copyButtonReservedWidth) + + let theme = MarkdownTheme.cached(for: .light) + let markdown = "# Heading\n## \(Self.longTitle)\n###### \(Self.longTitle)" + let blocks = MarkdownBlockParser(theme: theme).parse(markdown) + for width: CGFloat in [260, 720] { + let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: width, math: nil).table.heights + for (index, block) in blocks.enumerated() { + guard case .heading(let level, let title, _) = block.content else { return XCTFail("heading") } + let (_, ns) = headingString(title, level: level, theme: theme, fontScale: 1) + let font = MarkdownRenderer(theme: theme).headerAppKitFont(level: level) + let standard = modeHeadingHeight(ns, firstFont: font, width: width, isReadingMode: false) + let reading = modeHeadingHeight(ns, firstFont: font, width: width, isReadingMode: true) + XCTAssertEqual(reading, standard, "H\(level) w=\(width): the view's height") + // The measurer has no mode input: its height is the one row + // height for both, and it still matches the view. + XCTAssertEqual(measured[index], standard, accuracy: 1 + 2 * CGFloat(lineCount(ns, width: width)), + "H\(level) w=\(width): measurer vs view") + } + } + } + // MARK: - Copy button: right after the title (as in v1.10) func testCopyButtonLeadingFollowsTheTitleAndStopsAtTheEdge() { diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift index 70c2d95..85a788f 100644 --- a/QuickMD/QuickMDTests/ParserTests.swift +++ b/QuickMD/QuickMDTests/ParserTests.swift @@ -335,6 +335,39 @@ final class ParserTests: XCTestCase { XCTAssertTrue(rows.allSatisfy { $0.count == 3 }) } + /// GFM spec (tables extension, example 204): "The remainder of the table's + /// rows may vary in the number of cells. If a number of cells fewer than + /// the number of cells in the header row, empty cells are inserted. If + /// greater, the excess is ignored". Dropping the excess is by design. + func testBodyRowCellCountFollowsGFM() { + let md = """ + | abc | def | + | --- | --- | + | bar | + | bar | baz | boo | + """ + guard case .table(let headers, let rows, _, .grid) = parse(md)[0].content else { + return XCTFail("expected table") + } + XCTAssertEqual(headers, ["abc", "def"]) + XCTAssertEqual(rows, [["bar", ""], ["bar", "baz"]]) + } + + /// A pipe followed by a combining mark is ONE Swift Character; GFM splits on + /// the code point, so the cell still ends there and the mark opens the next + /// cell. Escaped pipes before a mark stay in the cell, as before. + func testPipeBeforeCombiningMarkStillSplits() { + let md = "| a |\u{0301}b | c |\n| --- | --- | --- |\n| x |\u{0301}y | z |\n| e \\|\u{0301} f | g |" + guard case .table(let headers, let rows, _, .grid) = parse(md)[0].content else { + return XCTFail("expected table") + } + XCTAssertEqual(headers.map { Array($0.unicodeScalars) }, + ["a", "\u{0301}b", "c"].map { Array($0.unicodeScalars) }) + XCTAssertEqual(rows.map { $0.map { Array($0.unicodeScalars) } }, + [["x", "\u{0301}y", "z"], ["e |\u{0301} f", "g", ""]] + .map { $0.map { Array($0.unicodeScalars) } }) + } + func testHeaderlessTableKeepsColumnsFromSeparator() { let md = """ | | | @@ -381,6 +414,500 @@ final class ParserTests: XCTestCase { XCTAssertEqual(rows[4], ["`p\\|q`", "c"]) } + // MARK: - A line that is only one linked image (B16) + + /// `[![alt](img)](url)` alone in its paragraph is the picture, like + /// `![alt](img)` and `` — an `.image` block on that line + /// (Section copy, search of the alt and copy work as for a plain image + /// line). The link target is not kept (as for ``). Text beside + /// it, or two linked images (a badge row), stay a paragraph. + func testLineOfOneLinkedImageIsAnImageBlock() { + let md = """ + # Title + [![Logo](img/logo.png)](https://example.com) + + [![Badge][shield]][site]\u{0020}\u{0020} + + ![Plain](img/plain.png) + + text before [![a](a.png)](https://a.example) + + [![a](a.png)](https://a.example) text after + + [![a](a.png)](https://a.example) [![b](b.png)](https://b.example) + + [![a](a.png)][undefined] + + [shield]: https://img.shields.io/x.svg + [site]: https://example.com/site + """ + let blocks = parse(md) + let images: [(url: String, alt: String, line: Int)] = blocks.compactMap { + guard case .image(let url, let alt, nil) = $0.content else { return nil } + return (url, alt, $0.sourceLine) + } + XCTAssertEqual(images.map(\.url), ["img/logo.png", "https://img.shields.io/x.svg", "img/plain.png"]) + XCTAssertEqual(images.map(\.alt), ["Logo", "Badge", "Plain"]) + XCTAssertEqual(images.map(\.line), [1, 3, 5], "each on its own source line") + + let paragraphs = blocks.compactMap(plainText) + XCTAssertEqual(paragraphs.count, 4, "\(paragraphs)") + XCTAssertTrue(paragraphs[0].hasPrefix("text before [Image: a]")) + XCTAssertEqual(paragraphs[1], "[Image: a] text after") + XCTAssertEqual(paragraphs[2], "[Image: a] [Image: b]", "a badge row stays a paragraph (1.15)") + XCTAssertTrue(paragraphs[3].contains("[undefined]"), "no link → not a linked image") + + // Exactly what a plain `![Logo](img/logo.png)` line parses to, id aside. + guard case .image(let url, let alt, let width) = parse("![Logo](img/logo.png)")[0].content, + case .image(let linkedURL, let linkedAlt, let linkedWidth) = + parse("[![Logo](img/logo.png)](https://example.com)")[0].content else { return XCTFail("images") } + XCTAssertEqual([url, alt], [linkedURL, linkedAlt]) + XCTAssertNil(width); XCTAssertNil(linkedWidth) + } + + /// After the vertical-rhythm merge (rows rendered at the end of the + /// parse, ordered hangs over the whole document), checked against GitHub + /// (`gh api markdown`): a linked image indented under a list stands in + /// like a plain image and keeps the list going (`10.` hangs the items + /// above it); a line of ` ` is a paragraph (`

 

`), not a + /// blank line; `1. x` is paragraph text "1. x", never a list item + /// (references are decoded after block parsing). + func testMergedRowRenderingWithLinkedImagesAndReferences() { + func hangs(_ md: String) -> [CGFloat] { + parse(md).compactMap { block -> CGFloat? in + guard case .text(let attributed) = block.content, + let style = attributed.runs.first?[AttributeScopes.AppKitAttributes.ParagraphStyleAttribute.self] + else { return nil } + return style.headIndent + } + } + let linked = "1. one\n2. two\n\n [![b](b.png)](https://x)\n\n10. ten" + let plain = "1. one\n2. two\n\n ![b](b.png)\n\n10. ten" + let linkedHangs = hangs(linked) + XCTAssertEqual(linkedHangs.count, 2) + XCTAssertEqual(linkedHangs, hangs(plain), "a linked image stands in like a plain image") + XCTAssertEqual(linkedHangs[0], linkedHangs[1], accuracy: 0.01, "one list: 1. and 10. share the widest hang") + let split = hangs("1. one\n2. two\n\n[![b](b.png)](https://x)\n\n10. ten") + XCTAssertNotEqual(split[0], split[1], "control: an unindented picture ends the list") + + let blocks = parse("a\n\n \n\n1. x") + XCTAssertEqual(blocks.compactMap(plainText), ["a", "\u{00A0}", "1. x"]) + guard case .text(let item) = blocks[2].content else { return XCTFail("text") } + XCTAssertNil(item.runs.first?[AttributeScopes.AppKitAttributes.ParagraphStyleAttribute.self]?.headIndent + .nonZero, "no list hang") + } + + /// An image line whose alt holds many inner `[a](` that never close: the + /// bracket scan must stay linear (a depth scan per `](` to the end of the + /// line made it quadratic, review round 7). Relative, not absolute: 8× + /// the input may take at most ~3× the linear share. + func testImageLineWithUnclosedInnerLinksParsesInLinearTime() { + let parser = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)) + func time(_ count: Int) -> TimeInterval { + let line = "![x" + String(repeating: "[a](", count: count) + "]" + _ = parser.parse(line) // warm + var best = TimeInterval.infinity + for _ in 0..<3 { + let start = Date() + _ = parser.parse(line) + best = min(best, Date().timeIntervalSince(start)) + } + return best + } + let small = time(5_000) + let large = time(40_000) + XCTAssertLessThan(large, max(small, 0.005) * 24, "5k: \(small) s, 40k: \(large) s (quadratic would be ~64×)") + } + + /// Badges one per line are ONE paragraph on GitHub (`gh api markdown`: + /// `

⏎…

`), the same as a badge row on one + /// line: linked placeholders, not a column of pictures (badge rows: 1.15). + /// So is a linked image with text on the line before or after it. One + /// that a blank line, a block start or the document's end closes is the + /// picture. + func testLinkedImagesOnePerLineStayOneParagraph() { + let readme = """ + [![Platform](https://img.shields.io/platform.svg)](https://www.apple.com/macos) + [![Swift](https://img.shields.io/swift.svg)](https://swift.org) + [![License](https://img.shields.io/license.svg)](LICENSE) + + [![Donation goal](https://qmd.app/goal.svg)](https://qmd.app/#goal) + """ + let blocks = parse(readme) + XCTAssertEqual(images(blocks).map(\.alt), ["Donation goal"]) + let paragraphs = blocks.compactMap(plainText) + XCTAssertEqual(paragraphs.count, 1, "\(paragraphs)") + XCTAssertEqual(paragraphs.first, "[Image: Platform] [Image: Swift] [Image: License]") + guard case .text(let row) = blocks[0].content else { return XCTFail("text") } + XCTAssertEqual(Set(row.runs.compactMap(\.link).map(\.absoluteString)), + ["https://www.apple.com/macos", "https://swift.org", "LICENSE"]) + + // Text right before or after (no blank line): one paragraph with it. + XCTAssertTrue(images(parse("Built with:\n[![Swift](s.svg)](https://swift.org)")).isEmpty) + XCTAssertTrue(images(parse("[![Swift](s.svg)](https://swift.org)\nand more")).isEmpty) + // A block that may interrupt a paragraph closes it: a heading, a + // bullet, an item numbered 1, a fence, a quote, HTML of kinds 1-6, a + // rule, a table (GitHub: `

` and then that block). + for next in ["# Next", "- item", "1. item", "```", "> q", "
", "***", "| a | b |\n|---|---|\n| 1 | 2 |"] { + XCTAssertEqual(images(parse("[![Logo](l.png)](https://x)\n\(next)")).map(\.alt), ["Logo"], next) + } + // Lines that cannot interrupt a paragraph go on with it (GitHub: one + // `

`): an item not numbered 1, an empty item, a lone inline tag; + // a setext underline makes the line a heading. + for next in ["2. item", "*", "
", "---", "==="] { + XCTAssertTrue(images(parse("[![Logo](l.png)](https://x)\n\(next)")).isEmpty, next) + } + // At the end of the document, after a blank line, a heading or a rule. + XCTAssertEqual(images(parse("text\n\n[![Logo](l.png)](https://x)")).map(\.alt), ["Logo"]) + XCTAssertEqual(images(parse("# Title\n[![Logo](l.png)](https://x)\n\ntext")).map(\.alt), ["Logo"]) + XCTAssertEqual(images(parse("***\n[![Logo](l.png)](https://x)")).map(\.alt), ["Logo"]) + XCTAssertEqual(images(parse("text\n\n---\n[![Logo](l.png)](https://x)")).map(\.alt), ["Logo"]) + } + + // MARK: - Reference links in quotes, alerts and table cells + + /// Quotes, alerts and table cells keep their markdown and are rendered + /// later by renderers without the document's definitions, so the parser + /// inlines resolvable references (`ReferenceLinks`) — they used to show + /// as `[text][id]`. + func testReferenceLinksResolveInQuotesAlertsAndTableCells() { + let md = """ + > See [the docs][docs], ![logo][img] and [Docs]. + + > [!NOTE] + > Read [the guide][]. + + | A | B | + |---|---| + | [x][docs] | ![i][img] | + | [![build][img]](https://ci.example) | | + + [docs]: https://example.com/docs_(v2) + [img]: img/logo.png + [the guide]: https://example.com/guide + """ + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) // no definitions + func links(_ attributed: AttributedString) -> [String] { + attributed.runs.compactMap { $0.link?.absoluteString } + } + + guard case .blockquote(let quote, 1) = blocks[0].content else { return XCTFail("quote") } + XCTAssertEqual(quote, "See [the docs](), ![logo]() " + + "and [Docs]().") + let shown = renderer.renderQuotedBody(quote) + XCTAssertEqual(String(shown.characters), "See the docs, [Image: logo] and Docs.") + XCTAssertEqual(links(shown).first, "https://example.com/docs_(v2)") + + guard case .alert(.note, let alert) = blocks[1].content else { return XCTFail("alert") } + XCTAssertEqual(links(renderer.renderQuotedBody(alert)), ["https://example.com/guide"]) + + guard case .table(_, let rows, _, .grid) = blocks[2].content else { return XCTFail("table") } + XCTAssertEqual(rows[0], ["[x]()", "![i]()"]) + XCTAssertEqual(links(renderer.renderInline(rows[0][0])), ["https://example.com/docs_(v2)"]) + // Review R2: the README badge — an image reference inside an INLINE link. + let badge = renderer.renderInline(rows[1][0]) + XCTAssertEqual(String(badge.characters), "[Image: build]") + XCTAssertEqual(links(badge), ["https://ci.example"]) + } + + /// Footnote references in quotes, alerts and table cells drew `[^note]` + /// literally; they now draw the paragraph's superscript number. + func testFootnoteReferencesResolveInQuotesAlertsAndTableCells() { + let md = """ + Body[^b]. + + > Quoted[^a] and [^missing]. + + > [!TIP] + > Tip[^b]. + + | A | + |---| + | cell[^a] `[^a]` | + + [^a]: First. + [^b]: Second. + """ + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) // no definitions + guard case .text(let body) = blocks[0].content else { return XCTFail("paragraph") } + XCTAssertEqual(String(body.characters), "Body\u{00B2}.", "the paragraph's numbering (definition order)") + guard case .blockquote(let quote, _) = blocks[1].content, + case .alert(_, let alert) = blocks[2].content, + case .table(_, let rows, _, .grid) = blocks[3].content else { return XCTFail("blocks") } + XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "Quoted\u{00B9} and [^missing].") + XCTAssertEqual(String(renderer.renderQuotedBody(alert).characters), "Tip\u{00B2}.") + XCTAssertEqual(String(renderer.renderInline(rows[0][0]).characters), "cell\u{00B9} [^a]", "code span kept") + } + + /// Quote and alert bodies are rendered with soft breaks joined, so a code + /// span or a link can span source lines: the rewrite scans each joined + /// group (review R1). Code must never show rewritten Markdown, and the + /// whole link text must be linked. + func testQuoteBodyReferencesSpanningLinesMatchTheDefinitionAwareRenderer() { + let theme = MarkdownTheme.cached(for: .light) + let definitions = ["d": "https://e.x/a", "img": "img/logo.png"] + let footnotes = [(id: "n", content: "x")] + let aware = MarkdownRenderer(theme: theme, referenceDefinitions: definitions, footnoteDefinitions: footnotes) + let plain = MarkdownRenderer(theme: theme) + let bodies = [ + "Use `git log [d]\n--oneline` to list.\nRead [the\nguide][d] first.", + "line one [d] \nhard break [the\nguide][d]\n\nnew paragraph ![x][img][^n]", + "- item [d]\n continued [d]\n- second\n## heading [d]\nafter\\\nback [d]", + "no references at all\njust text", + ] + for body in bodies { + let inlined = ReferenceLinks.inlined(body, definitions: definitions, footnoteIds: ["n"], quoteBody: true) + let expected = aware.renderQuotedBody(body) + let actual = plain.renderQuotedBody(inlined) + XCTAssertEqual(String(actual.characters), String(expected.characters), body) + XCTAssertEqual(actual.runs.map { $0.link?.absoluteString }, expected.runs.map { $0.link?.absoluteString }, body) + } + XCTAssertEqual(ReferenceLinks.inlined(bodies[3], definitions: definitions, quoteBody: true), bodies[3], + "untouched groups keep their lines") + + // The parser path (the reported repro). + let blocks = parse("> Use `git log [d]\n> --oneline` to list.\n> Read [the\n> guide][d] first.\n\n[d]: https://e.x/a") + guard case .blockquote(let quote, _) = blocks[0].content else { return XCTFail("quote") } + let shown = plain.renderQuotedBody(quote) + XCTAssertEqual(String(shown.characters), "Use git log [d] --oneline to list. Read the guide first.") + let linked = shown.runs.filter { $0.link != nil }.map { String(shown[$0.range].characters) } + XCTAssertEqual(linked, ["the guide"]) + } + + /// Merge check (vertical-rhythm × reference inlining): a quote's lazy + /// lines and collapsed blank runs are part of the stored body the + /// references are resolved in; a definition taken out of a quote + /// (`DefinitionPlacement`) has its destination's character references + /// decoded like any other; a quote's footnote keeps the document's number. + func testQuoteReferencesSurviveLazyLinesBlankRunsAndQuoteDefinitions() { + let md = "> [q]: https://e.x/a&b\n>\n> See [q] and\nlazy [the\n> guide][d][^b].\n>\n>\n>\n> Then [q].\n\n" + + "Body[^a].\n\n[d]: https://e.x/d&e\n[^a]: First.\n[^b]: Second.\n" + let blocks = parse(md) + guard case .blockquote(let quote, _) = blocks[0].content else { return XCTFail("quote") } + let plain = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + let shown = plain.renderQuotedBody(quote) + let links = shown.runs.compactMap { run -> String? in + run.link.map { "\(String(shown[run.range].characters))=\($0.absoluteString)" } + } + XCTAssertEqual(links, ["q=https://e.x/a&b", "the guide=https://e.x/d&e", "q=https://e.x/a&b"]) + XCTAssertTrue(String(shown.characters).contains("guide\u{00B2}."), String(shown.characters)) + XCTAssertFalse(quote.contains("\n\n\n"), "one paragraph break per blank run") + } + + /// Merge check (H8/H9 × reference inlining): footnote and link + /// definitions taken out of a quote leave marker lines the body never + /// sees, and a quote-level footnote is numbered with the document's + /// (definition order) in the quote, an alert and a paragraph alike. + func testQuoteLevelDefinitionsNumberAndResolveInEveryBody() { + let md = """ + > [^q]: Quoted note. + > [r]: https://e.x/r + > Text[^q] and [r] and[^a]. + + > [!TIP] + > Tip[^a] [r]. + + Body[^a][^q]. + + [^a]: Second. + """ + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + guard case .blockquote(let quote, _) = blocks[0].content, + case .alert(_, let alert) = blocks[1].content, + case .text(let body) = blocks[2].content else { return XCTFail("blocks: \(blocks.map(\.content))") } + let shownQuote = renderer.renderQuotedBody(quote) + XCTAssertEqual(String(shownQuote.characters), "Text\u{00B9} and r and\u{00B2}.") + XCTAssertEqual(shownQuote.runs.compactMap { $0.link?.absoluteString }, ["https://e.x/r"]) + XCTAssertEqual(String(renderer.renderQuotedBody(alert).characters), "Tip\u{00B2} r.") + XCTAssertEqual(String(body.characters), "Body\u{00B2}\u{00B9}.") + guard case .text(let notes) = blocks.last?.content else { return XCTFail("footnotes block") } + XCTAssertTrue(String(notes.characters).contains("1. Quoted note.\n2. Second."), String(notes.characters)) + } + + /// R21a: footnote labels match like link labels (`ReferenceLabel`): + /// `[^Note]`, `[^NOTE]` and `[^note]` are all footnote 1, `[^ẞ]` finds + /// `[^ss]:`, and of `[^dup]:` / `[^DUP]:` the first wins — the second is + /// not listed either. GitHub (`gh api markdown`, 2026-11-04). They stayed + /// literal (an exact `==`), and both duplicates were listed. + func testFootnoteLabelsMatchLikeGitHub() { + let md = """ + # H[^Note] + + A[^Note] B[^NOTE] C[^note] D[^Dup] E[^ẞ] + + > Q[^NOTE] [^DUP] + + | c | + |---| + | T[^NoTe] | + + [^note]: First. + [^dup]: Dup one. + [^DUP]: Dup two. + [^ss]: Fold. + """ + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + guard case .heading(let level, let title, _) = blocks[0].content, + case .text(let paragraph) = blocks[1].content, + case .blockquote(let quote, _) = blocks[2].content, + case .table(_, let rows, _, _) = blocks[3].content, + case .text(let notes) = blocks.last?.content else { return XCTFail("blocks: \(blocks.map(\.content))") } + XCTAssertEqual(String(renderer.renderHeader(title, level: level).characters), "H\u{00B9}") + XCTAssertEqual(String(paragraph.characters), "A\u{00B9} B\u{00B9} C\u{00B9} D\u{00B2} E\u{00B3}") + XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "Q\u{00B9} \u{00B2}") + XCTAssertEqual(String(renderer.renderInline(rows[0][0]).characters), "T\u{00B9}") + let listed = String(notes.characters) + XCTAssertTrue(listed.hasSuffix("1. First.\n2. Dup one.\n3. Fold."), listed) + XCTAssertFalse(listed.contains("Dup two"), listed) + } + + /// R21b: a label of whitespace only (`[ ]:`, `[\t]:`) defines nothing — + /// the line is paragraph text and `[ ]` / `[x][ ]` stay literal (GitHub, + /// `gh api markdown`, 2026-11-04). It was taken out as a definition of "". + func testWhitespaceOnlyLabelDefinesNothing() { + let blocks = parse("[ ] and [x][ ] and [y][\t]\n\n[ ]: https://empty\n\n[\t]: https://tab\n") + let texts = blocks.compactMap { block -> AttributedString? in + guard case .text(let text) = block.content else { return nil } + return text + } + XCTAssertEqual(texts.map { String($0.characters) }, + ["[ ] and [x][ ] and [y][\t]", "[ ]: https://empty", "[\t]: https://tab"]) + // Only the bare URLs link (autolinks, as on GitHub). + XCTAssertEqual(texts.map { text in text.runs.compactMap { $0.link?.absoluteString } }, + [[], ["https://empty"], ["https://tab"]]) + } + + /// R21c: a label may hold backslash-escaped brackets, matched as + /// written: `[a\]b]: url` is a definition, found by `[a\]b]`, + /// `[y][a\]b]`, `[w][A\]B]`, `![a\]b]`, in a quote and a heading; an + /// UNESCAPED `[` makes no label (`[a[b]:` is text, `[v][a[b]` literal). + /// GitHub (`gh api markdown`, 2026-11-04). The definition pattern + /// stopped at the first `]` and the full form's label too. + func testEscapedBracketsInLabelsLikeGitHub() { + let md = #""" + # [w][A\]B] + + [a\]b] and [y][a\]b] and [a\[c] and [z][a\\] and [w][A\]B] + + > Quoted [q][a\]b]. + + ![a\]b] + + [v][a[b] + + [a\]b]: https://esc + [a\[c]: https://open + [a\\]: https://bs + + [a[b]: https://ab + """# + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + // One entry per link: an escape splits a link's text into runs. + func links(_ text: AttributedString) -> [String] { + text.runs[\.link].compactMap { link, range in + link.map { "\(String(text[range].characters))=\($0.absoluteString)" } + } + } + guard blocks.count == 6, + case .heading(let level, let title, _) = blocks[0].content, + case .text(let paragraph) = blocks[1].content, + case .blockquote(let quote, _) = blocks[2].content, + case .image(let url, _, _) = blocks[3].content, + case .text(let literal) = blocks[4].content, + case .text(let notDefinition) = blocks[5].content else { + return XCTFail("blocks: \(blocks.map(\.content))") + } + XCTAssertEqual(links(renderer.renderHeader(title, level: level)), ["w=https://esc"]) + XCTAssertEqual(links(paragraph), ["a]b=https://esc", "y=https://esc", "a[c=https://open", "z=https://bs", + "w=https://esc"]) + XCTAssertEqual(links(renderer.renderQuotedBody(quote)), ["q=https://esc"]) + XCTAssertEqual(url, "https://esc") + XCTAssertEqual(String(literal.characters), "[v][a[b]") + XCTAssertEqual(links(literal), []) + XCTAssertEqual(String(notDefinition.characters), "[a[b]: https://ab") + } + + /// R19: reference labels match as CommonMark's (`ReferenceLabel`) in + /// every lookup — whitespace runs (tab, line break) are one space, the + /// ends trimmed, Unicode case folding (`ẞ` = `SS`, `straße` = `STRASSE`, + /// `ΣΑΣ` = `σας`); a no-break space is no whitespace; the first + /// definition of a label wins. Expected links from `gh api markdown` + /// (2026-11-04). Lowercasing alone and exact whitespace left all of + /// these but `[dup]` literal, and `[dup]` took the LAST definition. + func testReferenceLabelsMatchLikeGitHub() { + let md = "Use [link\ntext] shortcut\n---\n\n" + + "[foo bar] [a\u{00A0}b] [dup] [x][ΣΑΣ] [y][a\n B]\n\n" + + "> [straße] and [A\tB]\n\n" + + "| c |\n|---|\n| [a b] |\n\n" + + "![ẞ]\n\n" + + "[ Foo Bar ]: https://fb\n[a b]: https://ab\n[dup]: https://first\n[DUP]: https://second\n" + + "[SS]: https://ss.png\n[STRASSE]: https://strasse\n[σας]: https://sas\n[link text]: https://lt\n" + let blocks = parse(md) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + func links(_ text: AttributedString) -> [String] { + text.runs.compactMap { run in + run.link.map { "\(String(text[run.range].characters))=\($0.absoluteString)" } + } + } + guard blocks.count == 5, + case .heading(let level, let title, _) = blocks[0].content, + case .text(let paragraph) = blocks[1].content, + case .blockquote(let quote, _) = blocks[2].content, + case .table(_, let rows, _, _) = blocks[3].content, + case .image(let url, let alt, _) = blocks[4].content else { + return XCTFail("blocks: \(blocks.map(\.content))") + } + XCTAssertEqual(links(renderer.renderHeader(title, level: level)), ["link text=https://lt"]) + XCTAssertEqual(links(paragraph), ["foo bar=https://fb", "dup=https://first", "x=https://sas", + "y=https://ab"]) + XCTAssertTrue(String(paragraph.characters).contains("[a\u{00A0}b]"), "a no-break space is no whitespace") + XCTAssertEqual(links(renderer.renderQuotedBody(quote)), ["straße=https://strasse", "A\tB=https://ab"]) + XCTAssertEqual(links(renderer.renderInline(rows[0][0])), ["a b=https://ab"]) + XCTAssertEqual(url, "https://ss.png") + XCTAssertEqual(alt, "ẞ") + + XCTAssertEqual(ReferenceLabel.normalized(" \tFoo \n\u{0C} BAR\r "), "foo bar") + XCTAssertEqual(ReferenceLabel.normalized("ff İ DŽ"), ReferenceLabel.normalized("FF i\u{0307} dž")) + } + + /// The rewrite must draw exactly what the paragraph renderer (which HAS the + /// definitions) draws for the same line: characters and links. + func testReferenceInliningMatchesTheDefinitionAwareRenderer() { + let theme = MarkdownTheme.cached(for: .light) + let definitions = ["docs": "https://example.com/docs_(v2)", "img": "img/My Pic.png", + "a b": "https://example.com/ab", "bad": "https://example.com/a\\\\c", "paren": "https://e.x/a)b(c"] + let footnotes = [(id: "n1", content: "x"), (id: "docs", content: "y")] + let withDefinitions = MarkdownRenderer(theme: theme, referenceDefinitions: definitions, + footnoteDefinitions: footnotes) + let plain = MarkdownRenderer(theme: theme) + let lines = [ + "[docs] and [Docs][] and [text][DOCS] and [A B]", + "`[docs]` in code, ``[docs]`` too, \\[docs] escaped, \\[x\\][docs]", + "***[docs]*** and ~~[docs]~~ stay plain, **[docs]** is a link", + "[![badge][img]][docs] and ![img] and ![alt][]", + "[inline](https://x.y/(a)) next to [docs]", + "[![build][img]](https://ci.example/b) and [![x](i.png)](https://ci.example/c) and [t ![i][img]]()", + "[unknown][nope] [unknown] [docs][nope]", + "[bad] and [paren] escape what the <…> form needs", + "a ` lone backtick [docs] then ``` three ``` [docs]", + "note[^n1] and [^docs] then [^none] `[^n1]` **b[^n1]** [![x][img]][docs][^n1]", + ] + for line in lines { + let inlined = ReferenceLinks.inlined(line, definitions: definitions, footnoteIds: footnotes.map(\.id)) + let expected = withDefinitions.renderInline(line) + let actual = plain.renderInline(inlined) + XCTAssertEqual(String(actual.characters), String(expected.characters), line) + XCTAssertEqual(actual.runs.compactMap { $0.link?.absoluteString }, + expected.runs.compactMap { $0.link?.absoluteString }, line) + } + XCTAssertEqual(ReferenceLinks.inlined("[docs]\n[img]", definitions: [:]), "[docs]\n[img]") + } + // MARK: - Blockquotes func testBlockquoteNestingLevels() { @@ -548,7 +1075,10 @@ final class ParserTests: XCTestCase { ![nope][] """) XCTAssertTrue(images(blocks).isEmpty) - XCTAssertEqual(blocks.count, 1, "all three lines stay paragraph text") + // Three paragraphs, so three text rows: since the vertical rhythm a row + // is one block (it used to be every text line up to the 30-line cap). + XCTAssertEqual(blocks.count, 3, "all three lines stay paragraph text") + XCTAssertTrue(blocks.allSatisfy { $0.id.hasPrefix("text-") }, "\(blocks.map(\.id))") } func testShortcutImageWithoutAnyDefinitionsStaysText() { @@ -662,10 +1192,17 @@ final class ParserTests: XCTestCase { XCTAssertEqual(level, 2) } + /// An alert needs a body (GitHub): the marker alone is a plain quote; a + /// nested quote right after it is a body, even an empty one, and the + /// alert's own body is then empty. func testAlertWithEmptyBodyParses() { - let blocks = parse("> [!WARNING]") + guard case .blockquote(let marker, 1) = parse("> [!WARNING]").first?.content else { + return XCTFail("a marker alone is a plain quote") + } + XCTAssertEqual(marker, "[!WARNING]") + let blocks = parse("> [!WARNING]\n> >") guard case .alert(let kind, let content) = blocks.first?.content else { - return XCTFail("header-only alert should still parse") + return XCTFail("an alert whose body is a nested quote should still parse") } XCTAssertEqual(kind, .warning) XCTAssertTrue(content.isEmpty) @@ -842,107 +1379,6 @@ final class ParserTests: XCTestCase { } } - // MARK: - No blank line drawn above or below a block (1.14.3) - // - // Blank source lines at either end of a text run are block separators. The - // renderer turns each blank line into empty paragraphs, so before 1.14.3 - // `## H\n\ntext` drew a whole empty line above "text" that `## H\ntext` - // did not (GitHub/CommonMark render both alike). - - /// The `.text` blocks of `markdown` as (rendered string, sourceLine). - private func textBlocks(_ markdown: String) -> [(text: String, line: Int)] { - parse(markdown).compactMap { block in - plainText(block).map { ($0, block.sourceLine) } - } - } - - /// The renderer's string for `markdown` — what a text block holding exactly - /// those lines shows. - private func rendered(_ markdown: String) -> String { - String(MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)).render(markdown).characters) - } - - func testBlankLineAfterHeadingAddsNoEmptyParagraph() { - let loose = textBlocks("## Para\n\nText after blank.") - let tight = textBlocks("## Para\nText after blank.") - XCTAssertEqual(loose.map(\.text), tight.map(\.text)) - XCTAssertEqual(loose.map(\.text), [rendered("Text after blank.")]) - XCTAssertEqual(loose.map(\.line), [2], "the paragraph's own line, not the blank above it") - XCTAssertEqual(tight.map(\.line), [1]) - } - - func testBlankLinesAroundAListAddNoEmptyParagraphs() { - let loose = textBlocks("## Tight\n\n- one\n- two\n\n\n## Next") - let tight = textBlocks("## Tight\n- one\n- two\n## Next") - XCTAssertEqual(loose.map(\.text), tight.map(\.text)) - XCTAssertEqual(loose.map(\.text), [rendered("- one\n- two")]) - XCTAssertEqual(loose.map(\.line), [2]) - } - - func testTrailingBlankLinesBeforeTheNextBlockAreDropped() { - // Whitespace-only lines are blank too; the document ends in blanks. - for markdown in ["Text.\n\n## Next", "Text.\n \n\t\n```\ncode\n```", "Text.\n\n\n"] { - let blocks = textBlocks(markdown) - XCTAssertEqual(blocks.map(\.text), [rendered("Text.")], markdown.debugDescription) - XCTAssertEqual(blocks.map(\.line), [0], markdown.debugDescription) - } - } - - func testLeadingWhitespaceOnlyLinesAreDropped() { - let blocks = textBlocks("> quote\n \n\n\t\nText.") - XCTAssertEqual(blocks.map(\.text), [rendered("Text.")]) - XCTAssertEqual(blocks.map(\.line), [4]) - } - - func testBlankLinesInsideATextBlockStay() { - // Paragraph separation inside the block is exactly what it was. - let inner = "First paragraph.\n\nSecond paragraph.\n\n\n- item\n\nLast." - let blocks = textBlocks("# H\n\n" + inner + "\n\n# Next") - XCTAssertEqual(blocks.map(\.text), [rendered(inner)]) - XCTAssertEqual(blocks.map(\.line), [2]) - } - - func testCRLFInputTrimsLikeLF() { - let lf = "## X\n\nText.\n\n- a\n- b\n\n## Y\n" - let crlf = lf.replacingOccurrences(of: "\n", with: "\r\n") - let normalized = textBlocks(MarkdownDocument.normalizeLineEndings(crlf)) - let expected = textBlocks(lf) - XCTAssertEqual(normalized.map(\.text), expected.map(\.text)) - XCTAssertEqual(normalized.map(\.line), expected.map(\.line)) - XCTAssertEqual(expected.map(\.text), [rendered("Text.\n\n- a\n- b")]) - } - - func testDocumentOfOnlyBlankLinesHasNoBlocks() { - for markdown in ["", "\n", "\n\n\n", " \n\t\n ", - MarkdownDocument.normalizeLineEndings("\r\n\r\n")] { - XCTAssertTrue(parse(markdown).isEmpty, markdown.debugDescription) - } - } - - func testChunksNeitherStartNorEndWithBlankLines() { - // 80 paragraphs separated by runs of 1–3 blank lines (some - // whitespace-only): the run is cut into several chunks, the cuts land - // on blank lines, and no chunk may carry them at either end. - var lines: [String] = [] - for n in 1...80 { - lines.append("paragraph \(n)") - for blank in 0..<(n % 3 + 1) { lines.append(blank == 1 ? " " : "") } - } - let blocks = textBlocks("# H\n" + lines.joined(separator: "\n")) - XCTAssertGreaterThan(blocks.count, 1, "the run must be chunked") - for block in blocks { - XCTAssertFalse(block.text.hasPrefix("\n"), "chunk at line \(block.line) starts blank") - XCTAssertFalse(block.text.hasSuffix("\n\n"), "chunk at line \(block.line) ends blank") - XCTAssertTrue(block.text.hasPrefix("paragraph "), "line \(block.line)") - XCTAssertEqual(lines[block.line - 1], block.text.components(separatedBy: "\n")[0], - "sourceLine is the first line the chunk renders") - } - // Every paragraph is still rendered exactly once. - let paragraphs = blocks.flatMap { $0.text.components(separatedBy: "\n") } - .filter { $0.hasPrefix("paragraph ") } - XCTAssertEqual(paragraphs, (1...80).map { "paragraph \($0)" }) - } - func testHeadingStoredSourceLineMatchesAssociatedValue() { // 0 `[ref]: …` (filtered), 1 blank, 2 `# ATX`, 3 `body`, 4 blank, // 5 `Setext`, 6 `------` @@ -1023,8 +1459,10 @@ final class ParserTests: XCTestCase { XCTAssertEqual(blocks.count, 1, "\(blocks.map(\.id))") XCTAssertEqual(textLines(blocks[0]), ["Apple", "fruit", "Orange", "citrus"]) // …separated by a blank line, so the groups read apart on screen and in print. + // Five lines, not six: a text row no longer ends in a line break (the + // final "\n" laid out an empty line under every row). let raw = (plainText(blocks[0]) ?? "").components(separatedBy: "\n") - XCTAssertEqual(raw.count, 6, "\(raw)") + XCTAssertEqual(raw.count, 5, "\(raw)") XCTAssertTrue(raw[2].isEmpty, "expected a blank line between groups: \(raw)") } @@ -1205,3 +1643,7 @@ final class ParserTests: XCTestCase { XCTAssertEqual(gapBlocks[1].sourceLine, 81) } } + +private extension CGFloat { + var nonZero: CGFloat? { self == 0 ? nil : self } +} diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift index f37ba12..7f77870 100644 --- a/QuickMD/QuickMDTests/RendererTests.swift +++ b/QuickMD/QuickMDTests/RendererTests.swift @@ -711,9 +711,9 @@ final class RendererTests: XCTestCase { } func testDefaultZoomStoredReadsItsOwnSuite() throws { - let suite = "QuickMDTests.DefaultZoom.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + let suite = try TemporaryDefaults("DefaultZoom") + defer { suite.remove() } + let defaults = suite.defaults XCTAssertEqual(DefaultZoom.stored(in: defaults), 1.0, "missing key") defaults.set(1.25, forKey: DefaultZoom.defaultsKey) @@ -730,4 +730,386 @@ final class RendererTests: XCTestCase { XCTAssertEqual(MarkdownZoom.bigger.applied(to: 1.25, defaultScale: 1.25), 1.5) XCTAssertEqual(MarkdownZoom.smaller.applied(to: 1.25, defaultScale: 1.25), 1.1) } + + // MARK: - Character references (decoded like GitHub) + + /// Expected strings from `gh api markdown` (2026-11-04): named references + /// from the full HTML5 table (`≧̸` is two code points), numeric ones + /// per CommonMark (0, a surrogate or > U+10FFFF → U+FFFD), a reference + /// without its `;` or with an unknown name stays as written, and decoding + /// happens after inline parsing (`*x*` is "*x*", not emphasis). + func testCharacterReferencesDecodeLikeGitHub() { + XCTAssertEqual(HTMLEntities.decode("AT&T © a b → # � � � &bogus; & ≧̸ A"), + "AT&T © a\u{00A0}b → # \u{FFFD} \u{FFFD} \u{FFFD} &bogus; & \u{2267}\u{0338} A") + XCTAssertEqual(HTMLEntities.decode("∳ � � &#; &#x; & ;"), + "\u{2233} � � &#; &#x; & ;", "too many digits, no digits: literal") + XCTAssertEqual(HTMLEntities.decode(" "), "\n\t") + XCTAssertEqual(HTMLEntities.decode("no ampersand"), "no ampersand") + + let line = "AT&T `&` **<b>** ~~>~~ ***>*** [&](http://x) ![a&b](i.png) *x*" + XCTAssertEqual(rendered(line), "AT&T & > > & [Image: a&b] *x*") + let attributed = renderer.renderInline("*x*") + XCTAssertEqual(attributed.runs.count, 1, "a decoded asterisk opens no emphasis") + } + + /// Every kind that renders inline text decodes — paragraph, list, quote, + /// alert, table cell, heading, footnote — and never a code block; a + /// front-matter VALUE keeps its references, as GitHub shows front matter + /// as plain text. + func testCharacterReferencesDecodeInEveryTextKindButCodeAndFrontMatter() { + let theme = MarkdownTheme.cached(for: .light) + let md = """ + --- + title: A&B + --- + Para © 2026[^n] + + - item → next + + > quote & more + + > [!NOTE] + > alert … + + | h&h | + |---| + | cell x | + + ## Q&A + + ``` + code & stays + ``` + + ![a&b](pic.png) + + [^n]: Note & text + """ + let blocks = MarkdownBlockParser(theme: theme).parse(md) + let plain = MarkdownRenderer(theme: theme) + var shown: [String] = [] + for block in blocks { + switch block.content { + case .text(let attributed): shown.append(String(attributed.characters)) + case .blockquote(let content, _), .alert(_, let content): + shown.append(String(plain.renderQuotedBody(content).characters)) + case .table(let headers, let rows, let alignments, let style): + shown.append(TableTextBuilder.plainString(headers: headers, rows: rows, alignments: alignments, + style: style, theme: theme, fontScale: 1)) + case .heading(let level, let title, _): + shown.append(String(plain.renderHeader(title, level: level).characters)) + case .codeBlock(let code, _): shown.append(code) + case .image(_, let alt, _): shown.append("image:" + alt) + default: break + } + } + let all = shown.joined(separator: "|") + for expected in ["title\nA&B\n", "Para © 2026", "item → next", "quote & more", "alert …", + "h&h\ncell\u{00A0}x\n", "Q&A", "code & stays", "image:a&b", "Note & text"] { + XCTAssertTrue(all.contains(expected), "\(expected.debugDescription) in \(all.debugDescription)") + } + } + + + /// Review R5: CommonMark lets a backslash escape ANY ASCII punctuation + /// (`\&` is the text "&"); expected from `gh api markdown`. `\$` + /// is the one exception (keeps its backslash: inline math is found in the + /// rendered text). The Contents sidebar shows exactly the heading's text. + func testEveryASCIIPunctuationEscapesAndTheToCShowsTheHeadingText() { + XCTAssertEqual(rendered("a \\& \\< \\~x\\~ \\\" \\@ \\^ \\, b"), "a & < ~x~ \" @ ^ , b") + XCTAssertEqual(rendered("\\$5"), "$5", "an escaped dollar (R9: tagged, never math)") + let heading = String(renderer.renderHeader("\\& `<x>`", level: 1).characters) + XCTAssertEqual(heading, "& <x>") + + let entry = ToCEntry(id: "heading-0", level: 1, title: "\\& `<x>` **Q&A**", sourceLine: 0) + XCTAssertEqual(entry.shownTitle(renderer: renderer), "& <x> Q&A", "the ToC row = the heading") + XCTAssertEqual(entry.shownTitle(renderer: renderer), + String(renderer.renderHeader(entry.title, level: 1).characters)) + } + + + // MARK: - LaTeX escapes inside inline math (review R11) + + /// Inline math is found in the RENDERED text, after every `\` + /// escape ran; the formula must still reach the engine with its LaTeX + /// escapes (`$50\%$` typesets "50%": a bare `%` starts a TeX comment). + /// Text outside the formula keeps Markdown's escapes. The copy tag carries + /// the same LaTeX, and print uses the same `pieces(of:)`. + func testInlineMathKeepsItsLaTeXEscapes() throws { + for (source, latex) in [("$50\\%$", "50\\%"), ("$a\\,b$", "a\\,b"), ("$\\{x\\}$", "\\{x\\}"), + ("$a\\_b$", "a\\_b"), ("$a\\$b$", "a\\$b"), ("$a\\;b\\:c$", "a\\;b\\:c")] { + XCTAssertEqual(shownParagraph("x " + source + " y").formulas, [latex], source) + } + let mixed = shownParagraph("Price \\$5, \\* and $a\\,b + \\{c\\}$ then \\_") + XCTAssertEqual(mixed.formulas, ["a\\,b + \\{c\\}"]) + XCTAssertEqual(mixed.text, "Price $5, * and \u{FFFC} then _") + + // The copy tag (DocumentCopyBuilder writes `$$`) is the LaTeX too. + let theme = MarkdownTheme.cached(for: .light) + guard case .text(let attributed)? = MarkdownBlockParser(theme: theme).parse("t $50\\%$").first?.content else { + return XCTFail("paragraph") + } + let ns = BlockTextConverter.makeNSAttributedString( + from: attributed, hasInlineMath: true, theme: theme, fontScale: 1, + math: MathRendering(inlineImage: { _, _, _ in NSImage(size: NSSize(width: 1, height: 1)) }, + displayHeight: { _, _, _ in 0 })) + var tags: [String] = [] + ns.enumerateAttribute(.qmdInlineMathSource, in: NSRange(location: 0, length: ns.length)) { value, _, _ in + if let value = value as? String { tags.append(value) } + } + XCTAssertEqual(tags, ["50\\%"]) + XCTAssertEqual(InlineMathSegmenter.pieces(of: attributed).map(\.length), [2, 5], "spans in the rendered text: $50%$") + } + + // MARK: - Formatting inside link text (review R13) + + /// A link's text is inline Markdown, as on GitHub (`gh api markdown`): + /// `Docs`, `plain`, + /// `npm install`, `old`, + /// `a b c`, `https://x.com`, + /// `a & b` — and the whole visible text carries the link. + func testLinkTextIsInlineMarkdown() { + let line = "[**Docs**](u) [*plain*](v) [`npm install`](u) [~~old~~](u) [a **b** c](u) [https://x.com](u) [a & b](u)" + let attributed = renderer.renderInline(line) + XCTAssertEqual(String(attributed.characters), "Docs plain npm install old a b c https://x.com a & b") + func run(_ text: String) -> AttributedString.Runs.Run? { + attributed.runs.first { String(attributed[$0.range].characters) == text } + } + func font(_ text: String) -> NSFont? { run(text)?[AttributeScopes.AppKitAttributes.FontAttribute.self] } + XCTAssertTrue(font("Docs")?.fontDescriptor.symbolicTraits.contains(.bold) ?? false) + XCTAssertTrue(font("plain")?.fontDescriptor.symbolicTraits.contains(.italic) ?? false) + XCTAssertTrue(font("npm install")?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false) + XCTAssertNotNil(run("old")?[AttributeScopes.AppKitAttributes.StrikethroughStyleAttribute.self]) + XCTAssertTrue(font("b")?.fontDescriptor.symbolicTraits.contains(.bold) ?? false) + // Every visible character outside the spaces between links is linked; + // the autolink-looking text links to the OUTER destination. + for linked in ["Docs", "plain", "npm install", "old", "a ", "b", " c", "a & b"] { + XCTAssertNotNil(run(linked)?.link, linked) + } + XCTAssertEqual(run("https://x.com")?.link?.absoluteString, "u") + } + + // MARK: - Escapes in link text (review R12) + + /// A link's text with a backslash escape is parsed like the image case: + /// GitHub shows "cost $5 and $6" (no formula) and "a * b"; a link without + /// escapes or images keeps its exact plain text. + func testLinkTextEscapesAreParsed() { + let cost = shownParagraph("[cost \\$5 and \\$6](https://x.example)") + XCTAssertEqual(cost.text, "cost $5 and $6") + XCTAssertEqual(cost.formulas, []) + let star = renderer.renderInline("[a \\* b](u) and [*plain*](v)") + XCTAssertEqual(String(star.characters), "a * b and plain", "R13: link text is inline Markdown") + XCTAssertEqual(Set(star.runs.compactMap { $0.link?.absoluteString }), ["u", "v"]) + XCTAssertTrue(star.runs.filter { String(star[$0.range].characters).contains("*") }.allSatisfy { $0.link != nil }, + "the whole link text stays linked") + // The same in a quote, through ReferenceLinks' rewrite. + let blocks = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)) + .parse("> [a \\* b][d] and [c \\$5][d]\n\n[d]: https://d.example") + guard case .blockquote(let quote, _) = blocks[0].content else { return XCTFail("quote") } + XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "a * b and c $5") + } + + // MARK: - Code spans outrank brackets (review R16) + + /// A `]` or `[` inside a code span pairs with nothing (CommonMark); cases + /// and expected output from `gh api markdown`. (A ```` ``` ```` run stays + /// literal in QuickMD's inline code, so that GitHub case is not here.) + func testCodeSpansOutrankBrackets() { + func check(_ markdown: String, text: String, links: [String], code: [String], + file: StaticString = #filePath, line: UInt = #line) { + let attributed = renderer.renderInline(markdown) + XCTAssertEqual(String(attributed.characters), text, markdown, file: file, line: line) + XCTAssertEqual(Set(attributed.runs.compactMap { $0.link?.absoluteString }), Set(links), markdown, + file: file, line: line) + let monospaced = attributed.runs.filter { + $0[AttributeScopes.AppKitAttributes.FontAttribute.self]?.fontDescriptor.symbolicTraits + .contains(.monoSpace) ?? false + }.map { String(attributed[$0.range].characters) } + XCTAssertEqual(monospaced, code, markdown, file: file, line: line) + } + check("[a `](` b](u)", text: "a ]( b", links: ["u"], code: ["]("]) + check("[x `]` y]", text: "[x ] y]", links: [], code: ["]"]) + check("[a ``]`` b](u)", text: "a ] b", links: ["u"], code: ["]"]) + check("[a ` b](u)", text: "a ` b", links: ["u"], code: []) + check("[a \\` b](u) c`", text: "a ` b c`", links: ["u"], code: []) + check("`[a](u)`", text: "[a](u)", links: [], code: ["[a](u)"]) + + // A standalone image whose alt holds a code span with `]`. + let blocks = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse("![alt `]` x](i.png)") + guard case .image(let url, let alt, _)? = blocks.first?.content else { return XCTFail("image") } + XCTAssertEqual(url, "i.png") + XCTAssertEqual(alt, "alt `]` x") + } + + /// The renderer's runs as "text" / "`code`" pieces joined by "|". + private func runs(_ markdown: String) -> String { + let attributed = renderer.renderInline(markdown) + return attributed.runs.map { run in + let text = String(attributed[run.range].characters) + let mono = run[AttributeScopes.AppKitAttributes.FontAttribute.self]?.fontDescriptor.symbolicTraits + .contains(.monoSpace) ?? false + return (run.link.map { "<\($0.absoluteString)>" } ?? "") + (mono ? "`\(text)`" : text) + }.joined(separator: "|") + } + + /// The renderer never starts a code span inside a link destination it + /// consumed, so the bracket scans must not either: a backtick in a URL + /// must not swallow a later link's `[` (4c0afbf did). The reviewer's + /// corpus; GitHub's result (`gh api markdown`) noted where QuickMD's own + /// inline rules differ (emphasis/strikethrough before code — 1.15, R17; + /// 3+ backtick code spans are literal in QuickMD). + func testLinkDestinationsHideBackticksFromTheBracketScan() { + // GitHub: two links "a" and "b `c". + XCTAssertEqual(runs("[a](u`x) and [b `c](v)"), "a| and |b `c") + // GitHub: both links, a trailing backtick. + XCTAssertEqual(runs("[a]() [b](v) `"), "a| |b| `") + // GitHub-equal. + XCTAssertEqual(runs("[a `](` b](u)"), "a |`](`| b") + XCTAssertEqual(runs("![a`b](i.png) [c](u) x`y"), "![a|`b](i.png) [c](u) x`|y") + XCTAssertEqual(runs("[x](u) `` [y]` `` [z](v)"), "x| |`[y]``| |z") + XCTAssertEqual(runs("[a ``b]`` c](u)"), "a |`b]`| c") + XCTAssertEqual(runs("[a \\`b](u) c`"), "a |`|b| c`") + XCTAssertEqual(runs("[^n`] [d](u) `"), "[^n|`] [d](u) `") + XCTAssertEqual(runs("`[` [a](u) `]`"), "`[`| |a| |`]`") + // QuickMD's own rules (GitHub differs, not this fix): no link in + // either, as on GitHub, but emphasis / strikethrough win over code. + XCTAssertEqual(runs("***a `b*** [c` d](u)"), "a `b| [c` d](u)") + XCTAssertEqual(runs("~~x`~~ [y](u) z`"), "x`| [y](u) z`") + // GitHub: "[a " + code "b](u) c" (QuickMD keeps ``` runs literal). + XCTAssertEqual(runs("[a ```b](u) c```"), "a ```b| c```") + } + + // MARK: - Plain link text fast path (review R15) + + /// Link text with no inline syntax skips the inline pass; the run it gets + /// must be exactly the parsed one (characters and every attribute), at any + /// zoom, and text with syntax must take the parsed path. + func testPlainLinkTextFastPathEqualsTheParsedPath() { + let plain = ["Install", "Read the guide", "Zażółć gęślą", "a & b", "AT&T ©", "50% off $5", + "x < y > z", "e-mail: a@b.c", "1. not a list", "# not a heading", "", " spaced ", "emoji 🚀"] + for scale in [1.0, 1.5] as [CGFloat] { + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .dark), fontScale: scale) + for text in plain { + XCTAssertFalse(MarkdownRenderer.linkTextNeedsParsing(text), text) + XCTAssertEqual(renderer.renderLinkText(text, parse: false), renderer.renderLinkText(text, parse: true), + "\(text) @\(scale)") + let link = renderer.renderInline("[\(text)](https://x.example)") + XCTAssertEqual(link, { + var parsed = renderer.renderLinkText(text, parse: true) + parsed.setDualForeground(renderer.theme.linkColor) + parsed.setDualUnderline() + parsed.link = URL(string: "https://x.example") + return parsed + }(), text) + } + } + for text in ["**b**", "`c`", "a_b", "~~s~~", "![i](p)", "[^1]", "a \\* b", "see https://x.y", "http://a"] { + XCTAssertTrue(MarkdownRenderer.linkTextNeedsParsing(text), text) + } + } + + // MARK: - Formula source in headings (review R14) + + /// Headings show a formula's SOURCE (no inline math there yet), and it + /// keeps its LaTeX escapes: `# H \% $a\,b$` shows "H % $a\,b$". The + /// heading view, ids, the Contents sidebar and search all read this text. + func testHeadingFormulaSourceKeepsItsEscapes() { + let title = "H \\% $a\\,b$ and $50\\%$" + let shown = String(renderer.renderHeader(title, level: 1).characters) + XCTAssertEqual(shown, "H % $a\\,b$ and $50\\%$") + XCTAssertEqual(String(renderer.renderHeader("Price \\$5 and \\$6", level: 2).characters), "Price $5 and $6", + "escaped dollars are no formula") + let entry = ToCEntry(id: "heading-0", level: 1, title: title, sourceLine: 0) + XCTAssertEqual(entry.shownTitle(renderer: renderer), shown) + let theme = MarkdownTheme.cached(for: .light) + let blocks = MarkdownBlockParser(theme: theme).parse("# " + title) + XCTAssertEqual(HeadingAnchors.headings(of: blocks, renderer: renderer).map(\.text), [shown]) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "a\\,b").matchBlockIds.count, 1, + "search finds what the heading shows") + } + + // MARK: - References in link destinations (review R10) + + /// A destination's character references are decoded (hrefs from + /// `gh api markdown`: `a?b=1&c=2` → `a?b=1&c=2`, `` → + /// `a%20b%C2%A9`), in inline links, images and reference definitions; a + /// definition inlined into a quote is decoded exactly once. + func testLinkDestinationsDecodeCharacterReferences() { + let theme = MarkdownTheme.cached(for: .light) + let md = """ + [x](a?b=1&c=2) [z]() [r] [twice] `[c](a&b)` + + ![i](p&q.png) + + > quoted [r] and [twice] + + [r]: /u?x=1&y=2 + [twice]: /v?k=&amp; + """ + let blocks = MarkdownBlockParser(theme: theme).parse(md) + func links(_ attributed: AttributedString) -> [String] { + attributed.runs.compactMap { $0.link?.absoluteString } + } + guard case .text(let paragraph) = blocks[0].content else { return XCTFail("paragraph") } + XCTAssertEqual(links(paragraph), ["a?b=1&c=2", "a%20b%C2%A9", "/u?x=1&y=2", "/v?k=&"]) + XCTAssertTrue(String(paragraph.characters).hasSuffix("[c](a&b)"), "code span untouched") + guard case .image(let url, _, _) = blocks[1].content else { return XCTFail("image") } + XCTAssertEqual(url, "p&q.png") + guard case .blockquote(let quote, _) = blocks[2].content else { return XCTFail("quote") } + XCTAssertEqual(links(renderer.renderQuotedBody(quote)), ["/u?x=1&y=2", "/v?k=&"], + "inlined definitions are not decoded a second time") + } + + // MARK: - Escaped dollars (review R9) + + /// The text a paragraph's row shows, with every formula as "⟨latex⟩" (a + /// stub engine records what it was asked to typeset). + private func shownParagraph(_ markdown: String) -> (text: String, formulas: [String]) { + let theme = MarkdownTheme.cached(for: .light) + var formulas: [String] = [] + let engine = MathRendering(inlineImage: { latex, _, _ in + formulas.append(latex) + return NSImage(size: NSSize(width: 1, height: 1)) + }, displayHeight: { _, _, _ in 0 }) + let blocks = MarkdownBlockParser(theme: theme).parse(markdown) + guard case .text(let attributed)? = blocks.first?.content else { return ("", []) } + let plain = String(attributed.characters) + let ns = BlockTextConverter.makeNSAttributedString( + from: attributed, hasInlineMath: BlockTextConverter.containsInlineMath(plain), + theme: theme, fontScale: 1, math: engine) + return (ns.string, formulas) + } + + /// `\$` is a dollar sign (GitHub: "Price $5 and $10"), never a delimiter; + /// inside a formula it stays `\$` in the LaTeX; a dollar made by `$` + /// IS a delimiter (GitHub renders `$x$` as math); a code span keeps + /// `\$` as written. Print (`PrintableMath.text`) and search use the same + /// split (`literalDollars`). + func testEscapedDollarsAreNeverMathDelimiters() { + let price = shownParagraph("Price \\$5 and \\$10") + XCTAssertEqual(price.text, "Price $5 and $10") + XCTAssertEqual(price.formulas, []) + + let inside = shownParagraph("Cost $a\\$b$ here") + XCTAssertEqual(inside.formulas, ["a\\$b"], "the escaped dollar goes back into the LaTeX") + XCTAssertEqual(inside.text, "Cost \u{FFFC} here") + + let entity = shownParagraph("Then $x^2$ is math") + XCTAssertEqual(entity.formulas, ["x^2"], "a reference-made dollar is a delimiter") + + let code = shownParagraph("Use `\\$HOME` and \\$PATH") + XCTAssertEqual(code.text, "Use \\$HOME and $PATH") + XCTAssertEqual(code.formulas, []) + + // Table cells, quotes and alerts have no inline math: the dollar shows. + let theme = MarkdownTheme.cached(for: .light) + let cell = TableTextBuilder.plainString(headers: ["a"], rows: [["\\$5 and \\$10"]], alignments: [.leading], + style: .grid, theme: theme, fontScale: 1) + XCTAssertTrue(cell.contains("$5 and $10\n")) + XCTAssertEqual(String(renderer.renderQuotedBody("Pay \\$5").characters), "Pay $5") + // The segmenter itself: offsets of escaped dollars never delimit. + XCTAssertEqual(InlineMathSegmenter.split("a $5 and $6", escaped: [2, 9]), [.text("a $5 and $6")]) + } + } diff --git a/QuickMD/QuickMDTests/ReviewPromptTests.swift b/QuickMD/QuickMDTests/ReviewPromptTests.swift index 7ee5cf6..e4acad4 100644 --- a/QuickMD/QuickMDTests/ReviewPromptTests.swift +++ b/QuickMD/QuickMDTests/ReviewPromptTests.swift @@ -20,29 +20,21 @@ final class ReviewPromptTests: XCTestCase { private let base = Date(timeIntervalSince1970: 1_767_261_600) private let version = "1.15.0" - /// Per-test directory that holds the suite's plist. - private var suiteDirectory: URL! - - /// The suite name is an ABSOLUTE PATH (CFPreferences then stores the - /// domain at `.plist`), inside a per-test temporary directory. - /// A plain suite name leaks `~/Library/Preferences/.plist`: - /// `removePersistentDomain` makes cfprefsd write an EMPTY plist there - /// seconds later (measured: ~20 s after the test process ended), so - /// neither deleting the file in tearDown nor `synchronize()` first wins - /// the race. A late write here lands in the temporary directory. + /// The per-test suite, stored in a temporary directory, never in + /// `~/Library/Preferences` (see `TemporaryDefaults`). + private var suite: TemporaryDefaults! + override func setUpWithError() throws { try super.setUpWithError() - suiteDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("QuickMD-ReviewPromptTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: suiteDirectory, withIntermediateDirectories: true) - suiteName = suiteDirectory.appendingPathComponent("defaults").path - defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + suite = try TemporaryDefaults("ReviewPromptTests") + suiteName = suite.suiteName + defaults = suite.defaults policy = ReviewPrompt(defaults: defaults, calendar: calendar) } override func tearDownWithError() throws { - defaults.removePersistentDomain(forName: suiteName) - try? FileManager.default.removeItem(at: suiteDirectory) + suite.remove() + suite = nil defaults = nil policy = nil try super.tearDownWithError() @@ -277,17 +269,62 @@ final class ReviewPromptTests: XCTestCase { XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 60)) } - /// The trigger's idle path: a run that completed while the reader was - /// idle is reset, so the first tick after they come back does NOT - /// complete — it takes another full 60 s of display. - func testCompletedRunResetForIdleNeedsAFullNewRun() { + // MARK: - Reading: display AND recent input on every tick + + /// Feeds the trigger's ticks one second apart, the window displayed, with + /// the reader's input at `inputs` (monotonic seconds). Returns the first + /// tick at which the run completed, or nil. + private func firstCompletion(_ countdown: inout ReviewPromptCountdown, + from start: TimeInterval, through end: TimeInterval, + inputs: [TimeInterval]) -> TimeInterval? { + var now = start + while now <= end { + let lastInput = inputs.filter { $0 <= now }.max() + let idle = lastInput.map { now - $0 } ?? .infinity + if countdown.advance(isDisplayed: true, secondsSinceLastInput: idle, now: now) { return now } + now += 1 + } + return nil + } + + /// A window left open on an unattended Mac never completes a run. + func testAnUnattendedWindowNeverCompletesARun() { var countdown = ReviewPromptCountdown(required: 60) - XCTAssertTrue(tick(&countdown, from: uptime, through: uptime + 60)) - XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 600)) - countdown.reset() - XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 61), "first tick after idle") - XCTAssertFalse(tick(&countdown, from: uptime + 62, through: uptime + 120)) - XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 121)) + XCTAssertNil(firstCompletion(&countdown, from: uptime, through: uptime + 600, + inputs: [uptime - 1_000])) + XCTAssertNil(firstCompletion(&countdown, from: uptime + 601, through: uptime + 900, inputs: [])) + } + + /// The E2E case (2026-11-04, App Store build): 150 s unattended, then a + /// single mouse move and nothing more. The old trigger asked 33.6 s after + /// that move, because the 60 s run had been counting while the reader was + /// away. One touch must never be enough. + func testASingleTouchAfterABreakIsNotEnough() { + var countdown = ReviewPromptCountdown(required: 60) + let touch = uptime + 150 + XCTAssertNil(firstCompletion(&countdown, from: uptime, through: touch + 300, + inputs: [uptime - 1_000, touch])) + } + + /// A reader who came back and keeps using the Mac (a scroll every 20 s) + /// completes the run exactly one minute after coming back, not before. + func testAnActiveReaderCompletesAMinuteAfterComingBack() { + var countdown = ReviewPromptCountdown(required: 60) + let back = uptime + 150 + let inputs = [uptime - 1_000] + stride(from: back, through: back + 200, by: 20).map { $0 } + XCTAssertEqual(firstCompletion(&countdown, from: uptime, through: back + 200, inputs: inputs), + back + 60) + } + + /// A pause that leaves a full minute without input ends the run before it + /// completes: the minute is counted again from the first touch after it. + func testAPauseOfAMinuteRestartsTheRun() { + var countdown = ReviewPromptCountdown(required: 60) + let start = uptime + 10 // one touch: active through start + 59, idle 60 s at start + 60 + let resume = start + 100 + let inputs = [start] + stride(from: resume, through: resume + 200, by: 20).map { $0 } + XCTAssertEqual(firstCompletion(&countdown, from: uptime, through: resume + 200, inputs: inputs), + resume + 60) } func testCountdownSurvivesTimeGoingBack() { diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index de3241d..066e486 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -127,6 +127,41 @@ final class SearchTests: XCTestCase { return textView } + /// A front-matter key is secondary-colour text; the FOCUSED match on a key + /// is drawn in the theme's text colour (on the orange focus the secondary + /// colour read poorly), exactly what a focused value shows. Unfocused key + /// matches and value matches keep their colours; the next call clears it. + func testFocusedMatchOnAPropertiesKeyUsesTheTextColour() throws { + let table = onlyTable("---\nauthor: Ann\nnote: by author\n---\n") + XCTAssertEqual(table.style, .properties) + let built = build(table) + let textView = tableTextView(built) + let layoutManager = try XCTUnwrap(textView.layoutManager) + let keys = built.layout.firstColumnRanges + XCTAssertEqual(keys.map { (built.string.string as NSString).substring(with: $0) }, ["author", "note"]) + let textColor = NSColor(theme.textColor) + func temporaryForeground(at index: Int) -> NSColor? { + layoutManager.temporaryAttribute(.foregroundColor, atCharacterIndex: index, effectiveRange: nil) as? NSColor + } + let valueMatch = (built.string.string as NSString).range(of: "author", options: .backwards) + + applyNSSearchHighlight(in: textView, term: "author", focusedOccurrence: 0, + focusedText: (textColor, keys)) + XCTAssertEqual(temporaryForeground(at: keys[0].location), textColor, "focused key match") + XCTAssertEqual(temporaryForeground(at: NSMaxRange(keys[0]) - 1), textColor) + XCTAssertNil(temporaryForeground(at: valueMatch.location), "unfocused value match untouched") + + applyNSSearchHighlight(in: textView, term: "author", focusedOccurrence: 1, + focusedText: (textColor, keys)) + XCTAssertNil(temporaryForeground(at: keys[0].location), "an unfocused key keeps its secondary colour") + XCTAssertNil(temporaryForeground(at: valueMatch.location), "a value already has the text colour") + + applyNSSearchHighlight(in: textView, term: "author", focusedOccurrence: 0, + focusedText: (textColor, keys)) + applyNSSearchHighlight(in: textView, term: "author", focusedOccurrence: 0) + XCTAssertNil(temporaryForeground(at: keys[0].location), "cleared for a view reused by a grid table") + } + /// The temporary background runs `applyNSSearchHighlight` left, in order. private func highlights(in textView: NSTextView) -> [(range: NSRange, focused: Bool)] { guard let layoutManager = textView.layoutManager, let length = textView.textStorage?.length else { return [] } @@ -477,9 +512,10 @@ final class SearchTests: XCTestCase { XCTAssertEqual(count("hidden", in: quote), 0, "link URL") XCTAssertEqual(count("\\", in: quote), 0, "escape") XCTAssertEqual(count("bold code label a*b", in: quote), 1, "rendered text across styles") - // HTML entities are drawn as written (the renderer does not decode - // them), so the source IS what shows — counted and highlighted alike. - XCTAssertEqual(count("&", in: quote), 1) + // Character references are drawn decoded (`HTMLEntities`, as on + // GitHub): the drawn "&" is counted, the source "&" is not. + XCTAssertEqual(count("&", in: quote), 0) + XCTAssertEqual(count("a*b & done", in: quote), 1) let alert = "> [!WARNING]\n> **bold** [label](https://hidden.example)" XCTAssertEqual(count("**", in: alert), 0) @@ -603,4 +639,42 @@ extension SearchTests { XCTAssertEqual(results.matchBlockIds, [blocks[0].id, blocks[0].id]) XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "land").matchBlockIds, [blocks[0].id]) } + + // MARK: - ⌘G within a block + + /// A step to another match of the SAME block used to scroll nothing — in + /// a block taller than the window the next match could be off screen. + func testAStepToAnotherMatchOfTheSameBlockScrollsWithinIt() { + XCTAssertEqual(DocumentSearch.stepScroll(from: "a", to: "a"), .withinBlock) + XCTAssertEqual(DocumentSearch.stepScroll(from: "a", to: "b"), .toBlock) + XCTAssertEqual(DocumentSearch.stepScroll(from: nil, to: "b"), .toBlock) + XCTAssertNil(DocumentSearch.stepScroll(from: "a", to: nil)) + } + + /// Where the list centres a match: the rect of the occurrence the + /// highlight paints orange, in the text view's coordinates (its container + /// inset included). + func testTheMatchRectIsWhereTheFocusedHighlightIs() throws { + let lines = (0..<120).map { $0 == 3 || $0 == 100 ? "row \($0) needle here" : "row \($0) filler text" } + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 10)) + textView.textContainerInset = NSSize(width: 7, height: 11) + textView.isVerticallyResizable = true + textView.string = lines.joined(separator: "\n") + textView.sizeToFit() + + let first = try XCTUnwrap(textView.searchMatchRect(term: "NEEDLE", occurrence: 0)) + let second = try XCTUnwrap(textView.searchMatchRect(term: "NEEDLE", occurrence: 1)) + XCTAssertNil(textView.searchMatchRect(term: "needle", occurrence: 2)) + XCTAssertNil(textView.searchMatchRect(term: "absent", occurrence: 0)) + + let ranges = searchMatchRanges(in: textView.string, term: "needle") + for (rect, range) in zip([first, second], ranges) { + XCTAssertGreaterThan(rect.width, 0) + let index = textView.characterIndexForInsertion(at: NSPoint(x: rect.midX, y: rect.midY)) + XCTAssertTrue(NSLocationInRange(index, NSRange(location: range.location, length: range.length + 1)), + "\(index) not in \(range)") + } + XCTAssertGreaterThan(second.midY - first.midY, 90 * 12) + XCTAssertGreaterThanOrEqual(first.minY, 11) + } } diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift index beb40c2..fd18069 100644 --- a/QuickMD/QuickMDTests/SectionExtractorTests.swift +++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift @@ -7,13 +7,7 @@ import XCTest final class SectionExtractorTests: XCTestCase { private func headings(of markdown: String) -> [ToCEntry] { - let blocks = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(markdown) - return blocks.compactMap { block in - if case .heading(let level, let title, let sourceLine) = block.content { - return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine) - } - return nil - } + ToCEntry.entries(of: MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(markdown)) } func testSectionEndsAtNextSameLevelHeading() { @@ -115,6 +109,16 @@ final class SectionExtractorTests: XCTestCase { XCTAssertEqual(ToCDepth.choices.first?.maxLevel, ToCDepth.all, "All Headings is the default and first") } + /// A value only `defaults write` can store (outside 2, 3, 4, 6) left both + /// Pickers without a selected item; it reads as the nearest choice. + func testContentsDepthOutsideTheChoicesReadsAsTheNearest() { + let tags = Set(ToCDepth.choices.map(\.maxLevel)) + for stored in [-3, 0, 1, 2, 3, 4, 5, 6, 7, 99, Int.max, Int.min] { + XCTAssertTrue(tags.contains(ToCDepth.normalized(stored)), "\(stored) must be a Picker tag") + } + XCTAssertEqual([0, 1, 2, 3, 4, 5, 6, 7, 42].map(ToCDepth.normalized), [2, 2, 2, 3, 4, 6, 6, 6, 6]) + } + // MARK: - Heading anchors (#43) private func anchorHeadings(of markdown: String) -> [(id: String, text: String)] { @@ -166,6 +170,159 @@ final class SectionExtractorTests: XCTestCase { ]) } + /// Character references in a heading show as their characters (GitHub + /// decodes them before its slug) — ids from GitHub's renderer + /// (`gh api markdown`, 2026-11-04). A code span keeps the reference, and a + /// decoded `*` is a literal `*`, not emphasis. + func testHeadingEntitiesAreDecodedLikeGitHub() { + let headings = anchorHeadings(of: """ + # Q&A <tag> © + ## Tom & Jerry + ## `a & b` + ## R&D  x + ## a *b* + ## [Q&A](#x) here + ## **Tom & Jerry** + ## 5 < 6 + """) + XCTAssertEqual(headings.map(\.text), [ + "Q&A ©", "Tom & Jerry", "a & b", "R&D \u{00A0}x", "a *b*", + "Q&A here", "Tom & Jerry", "5 < 6", + ]) + XCTAssertEqual(HeadingAnchors.ids(for: headings.map(\.text)), [ + "qa-tag-", "tom--jerry", "a-amp-b", "rd-x", "a-b", "qa-here", "tom--jerry-1", "5--6", + ]) + + // The heading view, search, print and the measurer all draw `renderHeader`. + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + let italic = renderer.renderHeader("a *b*", level: 2) + XCTAssertEqual(italic.runs.count, 1, "no emphasis run from a decoded asterisk") + } + + /// A Setext title over several lines: GitHub keeps its line breaks in + /// the heading's text, and the id DROPS them — `Alpha⏎Beta` is + /// `alphabeta`, after a hard break too (gfm/h1_*.html). The title was + /// joined with spaces, which the slug made `-` (`alpha-beta`), so + /// `[go](#alphabeta)` went nowhere. Shown — the heading, the Contents + /// sidebar, search — each break is a space; Copy Section is the source. + func testAMultiLineSetextTitleHasGitHubsId() { + let md = "Alpha\nBeta\n===\n\nAlphaBeta\n===\n\nAlpha\nBeta\n---\n\nPara one\nline two\n---\n\n" + + "One \nTwo\\\nThree\n---\n\n**Bold\nend**\n===\n\n[go](#alphabeta)\n" + let anchors = anchorHeadings(of: md) + XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), [ + "alphabeta", "alphabeta-1", "alphabeta-2", "para-oneline-two", "onetwothree", "boldend", + ]) + XCTAssertEqual(HeadingAnchors.target(forFragment: "alphabeta", in: anchors), .heading(anchors[0].id)) + XCTAssertEqual(HeadingAnchors.target(forFragment: "para-oneline-two", in: anchors), + .heading(anchors[3].id)) + + let theme = MarkdownTheme.cached(for: .light) + let blocks = MarkdownBlockParser(theme: theme).parse(md) + let renderer = MarkdownRenderer(theme: theme) + let shown = blocks.compactMap { block -> String? in + guard case .heading(let level, let title, _) = block.content else { return nil } + return String(renderer.renderHeader(title, level: level).characters) + } + XCTAssertEqual(shown, ["Alpha Beta", "AlphaBeta", "Alpha Beta", "Para one line two", "One Two Three", + "Bold end"]) + let entries = headings(of: md) + XCTAssertEqual(entries.map(\.title), ["Alpha Beta", "AlphaBeta", "Alpha Beta", "Para one line two", + "One Two Three", "**Bold end**"]) + XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: entries[0], headings: entries), + "Alpha\nBeta\n===") + XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: entries[4], headings: entries), + "One \nTwo\\\nThree\n---") + } + + /// A line break inside a code span is a space (CommonMark), so a code + /// span across a Setext title's break keeps one in the id: `code-span`. + /// Outside a code span the break is dropped, also between two spans + /// (GitHub, gfm/h6_*.html). The span kept the break, which the slug + /// dropped: `codespan`. + func testACodeSpanAcrossASetextBreakKeepsASpaceInTheId() { + let md = "`code\nspan`\n===\n\n`code`\n`span`\n===\n\n``a\nb``\n===\n\nUse `a\nb` and\nmore\n---\n" + let anchors = anchorHeadings(of: md) + XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), + ["code-span", "codespan", "a-b", "use-a-b-andmore"]) + XCTAssertEqual(HeadingAnchors.target(forFragment: "code-span", in: anchors), .heading(anchors[0].id)) + XCTAssertEqual(headings(of: md).map(\.title), ["`code span`", "`code` `span`", "``a b``", "Use `a b` and more"]) + } + + /// Merge check: every consumer of a multi-line Setext title reads one + /// text. The heading, Contents and the id's source differ only in the + /// kept line break: entities decoded, a link's text, and a formula's + /// source WITH its LaTeX escapes (R14) — also in the id's text. + func testMultiLineSetextTitleIsOneTextForEveryConsumer() { + let md = "Q&A \\% $a\\,b$\n[the *link*](https://e.x) <end>\n===\n\nOne $x\\,\ny$ two\n---\n" + let theme = MarkdownTheme.cached(for: .light) + let blocks = MarkdownBlockParser(theme: theme).parse(md) + let renderer = MarkdownRenderer(theme: theme) + let shown = blocks.compactMap { block -> String? in + guard case .heading(let level, let title, _) = block.content else { return nil } + return String(renderer.renderHeader(title, level: level).characters) + } + XCTAssertEqual(shown[0], "Q&A % $a\\,b$ the link ") + let anchors = anchorHeadings(of: md) + XCTAssertEqual(anchors.map { $0.text.replacingOccurrences(of: "\n", with: " ") }, shown) + XCTAssertEqual(anchors[0].text, "Q&A % $a\\,b$\nthe link ") + XCTAssertEqual(headings(of: md).map { $0.shownTitle(renderer: renderer) }, shown) + } + + /// R18: a FULL reference link in a heading (`# [Link text][r]`) showed + /// its source and its id kept the label (`link-textr`). Headings are + /// stored raw and drawn by definition-less renderers, so references are + /// inlined at parse time (`ReferenceLinks.inlinedHeading`) — the view, + /// a click, the id, search, Contents and print all read the same title. + /// GitHub (`gh api markdown`, 2026-11-04) links every form below, keeps + /// the code span, and its id reads the footnote's `1` as `1`. + func testReferenceLinksInHeadingsResolveLikeGitHub() { + let md = """ + # [Link text][r] + + # [Coll][] + + # [Short] + + [Link + text][r] + === + + # Note[^1] here + + # **[Bold][r]** and `[c][r]` + + # [A & B][r] + + [r]: https://e.x/r + [coll]: https://e.x/c + [short]: https://e.x/s + [^1]: Foot. + """ + let theme = MarkdownTheme.cached(for: .light) + let renderer = MarkdownRenderer(theme: theme) + let blocks = MarkdownBlockParser(theme: theme).parse(md) + let rendered = blocks.compactMap { block -> AttributedString? in + guard case .heading(let level, let title, _) = block.content else { return nil } + return renderer.renderHeader(title, level: level) + } + let shown = rendered.map { String($0.characters) } + XCTAssertEqual(shown, ["Link text", "Coll", "Short", "Link text", "Note\u{00B9} here", + "Bold and [c][r]", "A & B"]) + XCTAssertEqual(rendered.map { heading in heading.runs.compactMap { $0.link?.absoluteString } }, [ + ["https://e.x/r"], ["https://e.x/c"], ["https://e.x/s"], ["https://e.x/r"], [], + ["https://e.x/r"], ["https://e.x/r"], + ]) + + let anchors = anchorHeadings(of: md) + XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), [ + "link-text", "coll", "short", "linktext", "note1-here", "bold-and-cr", "a--b", + ]) + XCTAssertEqual(headings(of: md).map { $0.shownTitle(renderer: renderer) }, shown) + XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: headings(of: md)[0], + headings: headings(of: md)), "# [Link text][r]", + "copy is the source") + } + func testFragmentFindsItsHeading() { let headings = anchorHeadings(of: """ # Guide @@ -209,4 +366,119 @@ final class SectionExtractorTests: XCTestCase { XCTAssertNil(fragment("file:///tmp/a.md#install")) XCTAssertNil(fragment("docs/install")) } + + /// Review R3: `URL(string:)` comes first, so every destination macOS + /// accepts keeps its URL exactly (IDN hosts included on 14+); our encoder + /// only runs when the parse fails — on macOS 13 that includes `[` `]` in a + /// path and a second `#`, which must still become links. + func testLinkURLFallsBackOnlyWhenTheParseFails() throws { + guard #available(macOS 14, *) else { throw XCTSkip("needs the strict-parse API to emulate macOS 13") } + let strict: (String) -> URL? = { URL(string: $0, encodingInvalidCharacters: false) } + let cases: [(String, String)] = [ + ("docs/a[1].md", "docs/a%5B1%5D.md"), + ("https://x/?a[]=1", "https://x/?a%5B%5D=1"), + ("a#b#c", "a#b%23c"), + ("README.md#Install Guide", "README.md#Install%20Guide"), + ("https://example.com/docs_(v2)#top", "https://example.com/docs_(v2)#top"), + ] + for (destination, expected) in cases { + let url = try XCTUnwrap(MarkdownRenderer.linkURL(for: destination, parse: strict), destination) + XCTAssertEqual(url.absoluteString, expected, destination) + } + XCTAssertEqual(try XCTUnwrap(MarkdownRenderer.linkURL(for: "a#b#c", parse: strict)) + .fragment(percentEncoded: false), "b#c") + + // The default parse (this OS): whatever URL(string:) gives, unchanged. + for destination in ["https://bücher.de/x", "docs/a[1].md", "https://x/?a[]=1", "README.md#Install Guide", + "a%20b.md#x", "mailto:a@b.c"] { + XCTAssertEqual(MarkdownRenderer.linkURL(for: destination), URL(string: destination), destination) + } + } + + /// `other.md#section`: the fragment is parked for the other document's + /// window (which takes it once laid out, or at once when already open). + func testCrossDocumentAnchorIsHandedToTheOtherDocument() throws { + let anchors = CrossDocumentAnchors() + let link = try XCTUnwrap(MarkdownRenderer.linkURL(for: "other.md#Install Guide")) + let fragment = try XCTUnwrap(CrossDocumentAnchors.fragment(of: link)) + XCTAssertEqual(fragment, "Install Guide") + XCTAssertNil(CrossDocumentAnchors.fragment(of: try XCTUnwrap(MarkdownRenderer.linkURL(for: "other.md")))) + XCTAssertEqual(CrossDocumentAnchors.fragment(of: try XCTUnwrap(MarkdownRenderer.linkURL(for: "other.md#"))), "", + "an empty fragment is the top, as in-document") + + let target = URL(fileURLWithPath: "/docs/guide/other.md") + let posted = expectation(forNotification: CrossDocumentAnchors.requested, object: nil) { note in + note.object as? String == "/docs/guide/other.md" + } + let now = Date() + anchors.request(fragment, for: URL(fileURLWithPath: "/docs/guide/./sub/../other.md"), now: now) + wait(for: [posted], timeout: 1) + + XCTAssertNil(anchors.take(for: URL(fileURLWithPath: "/docs/guide/another.md"), now: now)) + XCTAssertNil(anchors.take(for: nil, now: now)) + XCTAssertEqual(anchors.take(for: target, now: now.addingTimeInterval(2)), "Install Guide") + XCTAssertNil(anchors.take(for: target, now: now), "taken once") + + anchors.request("intro", for: target, now: now) + XCTAssertNil(anchors.take(for: target, now: now.addingTimeInterval(CrossDocumentAnchors.lifetime + 1)), + "a stale request never moves a window opened much later") + XCTAssertNil(anchors.take(for: target, now: now), "and it is gone") + } + + /// Review R4: the link may name the file through a symlinked folder while + /// the window shows the real path (or the other way round). + func testCrossDocumentAnchorKeyResolvesSymlinks() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("quickmd-anchors-\(UUID().uuidString)") + let real = root.appendingPathComponent("real") + try fm.createDirectory(at: real, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let file = real.appendingPathComponent("other.md") + try "# Intro".write(to: file, atomically: false, encoding: .utf8) + let alias = root.appendingPathComponent("alias") + try fm.createSymbolicLink(at: alias, withDestinationURL: real) + + let anchors = CrossDocumentAnchors() + anchors.request("intro", for: alias.appendingPathComponent("other.md")) + XCTAssertEqual(anchors.take(for: file), "intro", "parked via the symlink, taken by the real path") + anchors.request("intro", for: file) + XCTAssertEqual(anchors.take(for: alias.appendingPathComponent("./other.md")), "intro") + } + + /// A link destination becomes the same URL on every macOS: macOS 13's + /// `URL(string:)` rejects spaces / non-ASCII (14+ encodes them), and the + /// old 13 fallback encoded the `#` too, so `README.md#Install Guide` was a + /// file named "README.md#Install Guide". Every result must also parse + /// WITHOUT the OS encoding anything (strict parse on 14+), i.e. the + /// string handed to `URL(string:)` is already valid as macOS 13 sees it. + func testLinkURLKeepsTheFragmentDelimiterOnEveryMacOS() throws { + func link(_ destination: String) throws -> URL { + let url = try XCTUnwrap(MarkdownRenderer.linkURL(for: destination), destination) + if #available(macOS 14, *) { + XCTAssertNotNil(URL(string: url.absoluteString, encodingInvalidCharacters: false), + "\(destination) must not rely on the OS encoding it") + } + return url + } + + let sameFile = try link("README.md#Install Guide") + XCTAssertEqual(sameFile.relativeString, "README.md#Install%20Guide") + XCTAssertEqual(sameFile.path, "README.md") + XCTAssertEqual(sameFile.fragment(percentEncoded: false), "Install Guide") + + let other = try link("Zażółć notes.md#Sekcja Ł#2") + XCTAssertEqual(other.path, "Zażółć notes.md") + XCTAssertEqual(other.fragment(percentEncoded: false), "Sekcja Ł#2", "a later # belongs to the fragment") + + XCTAssertEqual(HeadingAnchors.inDocumentFragment(of: try link("#Install Guide")), "Install Guide") + + let web = try link("https://example.com/a b?q=x y#frag z") + XCTAssertEqual(web.host, "example.com") + XCTAssertEqual(web.fragment(percentEncoded: false), "frag z") + + XCTAssertEqual(try link("100% done.md").relativeString, "100%25%20done.md", "a bare % is not an escape") + XCTAssertEqual(try link("a%20b.md#x").relativeString, "a%20b.md#x", "valid URLs are untouched") + XCTAssertEqual(try link("https://example.com/docs_(v2)#top").absoluteString, + "https://example.com/docs_(v2)#top") + } } diff --git a/QuickMD/QuickMDTests/SettingsTabTests.swift b/QuickMD/QuickMDTests/SettingsTabTests.swift new file mode 100644 index 0000000..b0416b6 --- /dev/null +++ b/QuickMD/QuickMDTests/SettingsTabTests.swift @@ -0,0 +1,27 @@ +import XCTest + +/// Settings remembers its tab by identifier (`SettingsTab`), migrating the +/// index SwiftUI used to remember. +final class SettingsTabTests: XCTestCase { + + /// The Settings tab was remembered by INDEX; the update reads that index + /// once (when the identifier key is absent) and maps it to the same tab. + func testSettingsTabMigratesTheLegacyIndex() throws { + XCTAssertEqual(SettingsTab.allCases, [.general, .themes, .fonts, .editor], "legacy index order") + XCTAssertEqual([0, 1, 2, 3].map(SettingsTab.fromLegacyIndex), [.general, .themes, .fonts, .editor]) + XCTAssertEqual([-1, 4, 99].map(SettingsTab.fromLegacyIndex), [.general, .general, .general]) + XCTAssertEqual(SettingsTab.fromLegacyIndex(nil), .general) + + let suite = try TemporaryDefaults("SettingsTab") + defer { suite.remove() } + let defaults = suite.defaults + XCTAssertEqual(SettingsTab.initial(in: defaults), .general, "fresh install") + defaults.set(2, forKey: SettingsTab.legacyIndexKey) + XCTAssertEqual(SettingsTab.initial(in: defaults), .fonts, "the tab SwiftUI remembered") + defaults.set(SettingsTab.editor.rawValue, forKey: SettingsTab.defaultsKey) + XCTAssertEqual(SettingsTab.initial(in: defaults), .editor, "the identifier wins once written") + defaults.set("removed-tab", forKey: SettingsTab.defaultsKey) + XCTAssertEqual(SettingsTab.initial(in: defaults), .fonts, "an unknown identifier falls back") + XCTAssertEqual(SettingsTab.legacyIndexKey, "com_apple_SwiftUI_Settings_selectedTabIndex") + } +} diff --git a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift index 2edf212..6cea7b8 100644 --- a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift +++ b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift @@ -573,37 +573,107 @@ final class SourceEditorControllerTests: XCTestCase { } /// The list's arithmetic (`VirtualBlockList.currentContentWidth()` and its - /// centred cell column), spelled out independently. - private func listColumn(clip: CGFloat, reading: Bool) -> (left: CGFloat, width: CGFloat) { + /// centred cell column), spelled out independently: reading mode caps + /// the column at 720 pt × the zoom, in whole points. + private func listColumn(clip: CGFloat, reading: Bool, scale: CGFloat) -> (left: CGFloat, width: CGFloat) { let available = floor(clip) let inner = available - 2 * 32 - let width = reading ? max(0, floor(min(inner, 720))) : max(0, inner) + let width = reading ? max(0, floor(min(inner, (720 * scale).rounded()))) : max(0, inner) return (32 + (inner - width) / 2, width) } func testColumnGeometryMatchesTheRenderedList() { - for clip in [300, 640, 783.5, 800, 1000.6, 1600] as [CGFloat] { + for clip in [300, 640, 783.5, 800, 1000.6, 1600, 2400] as [CGFloat] { for reading in [false, true] { - let geometry = SourceEditorController.columnGeometry(clipWidth: clip, isReadingLayout: reading) - let list = listColumn(clip: clip, reading: reading) - XCTAssertEqual(geometry.columnWidth, list.width, "clip \(clip) reading \(reading)") - XCTAssertEqual(geometry.horizontalInset, list.left, "clip \(clip) reading \(reading)") - XCTAssertGreaterThanOrEqual(geometry.horizontalInset, 32) - XCTAssertEqual(geometry.verticalInset, reading ? 48 : 24) + for scale: CGFloat in [0.5, 1, 1.5, 2, 3] { + let geometry = SourceEditorController.columnGeometry(clipWidth: clip, isReadingLayout: reading, + fontScale: scale) + let list = listColumn(clip: clip, reading: reading, scale: scale) + let label = "clip \(clip) reading \(reading) × \(scale)" + XCTAssertEqual(geometry.columnWidth, list.width, label) + XCTAssertEqual(geometry.horizontalInset, list.left, label) + XCTAssertGreaterThanOrEqual(geometry.horizontalInset, 32) + XCTAssertEqual(geometry.verticalInset, reading ? 48 : 24) + // The ONE definition the list uses. + XCTAssertEqual(geometry.columnWidth, BlockLayout.Document.columnWidth( + inner: floor(clip) - 64, + cap: reading ? BlockLayout.Document.readingColumnCap(fontScale: scale) : nil), label) + } } } - let standard = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: false) + let standard = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: false, fontScale: 1) XCTAssertEqual(standard, .init(horizontalInset: 32, columnWidth: 936, verticalInset: 24)) - let reading = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: true) + let reading = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: true, fontScale: 1) XCTAssertEqual(reading, .init(horizontalInset: 140, columnWidth: 720, verticalInset: 48)) + let zoomed = SourceEditorController.columnGeometry(clipWidth: 1600, isReadingLayout: true, fontScale: 2) + XCTAssertEqual(zoomed, .init(horizontalInset: 80, columnWidth: 1440, verticalInset: 48)) + let narrow = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: true, fontScale: 0.5) + XCTAssertEqual(narrow, .init(horizontalInset: 320, columnWidth: 360, verticalInset: 48)) + } + + /// ⌘+ in Reading Mode with the editor open: the list keeps showing the + /// blocks of the old zoom, with that zoom's column cap, until the zoom's + /// parse lands. The editor's COLUMN follows the zoom the document is + /// shown at (1.14.4 took the requested one: a 1440 pt column over a + /// 720 pt list column, and leaving Source Edit then moved the text + /// sideways); its FONT follows the requested zoom at once + /// (`testTheEditorFontFollowsTheRequestedZoomAtOnce`). + func testTheEditorColumnFollowsTheZoomTheDocumentIsShownAt() { + let requested = 2.0, shown = 1.0 + let (controller, scrollView, _) = makeEditor( + "text", width: 1800, + style: .overDocument(theme: light, fontScale: requested, shownFontScale: shown, isReadingMode: true)) + let inner = floor(scrollView.contentView.bounds.width) - 64 + func listColumn(_ scale: Double) -> CGFloat { + BlockLayout.Document.columnWidth(inner: inner, + cap: BlockLayout.Document.readingColumnCap(fontScale: CGFloat(scale))) + } + XCTAssertEqual(listColumn(shown), 720) + XCTAssertEqual(listColumn(requested), 1440) // the column the editor used to take + XCTAssertEqual(controller.textView.textContainer!.size.width, listColumn(shown)) + // The zoom's parse lands: the list and the editor move together. + controller.apply(style: .overDocument(theme: light, fontScale: requested, shownFontScale: requested, + isReadingMode: true)) + XCTAssertEqual(controller.textView.textContainer!.size.width, listColumn(requested)) + } + + /// The editor's text changes size the moment ⌘+ is pressed, as before + /// 636de0e — not when the zoom's re-parse lands (about 2 s on an + /// 11.7K-line document, and only once at the end of ⌘+⌘+⌘+, whose earlier + /// parses are superseded). Only the Reading Mode column waits for it. + func testTheEditorFontFollowsTheRequestedZoomAtOnce() { + let (controller, scrollView, _) = makeEditor( + "text", width: 1800, + style: .overDocument(theme: light, fontScale: 1, shownFontScale: 1, isReadingMode: true)) + let base = BlockLayout.Code.codeFontSize + XCTAssertEqual(controller.textView.font?.pointSize, base) + let inner = floor(scrollView.contentView.bounds.width) - 64 + for requested in [1.25, 1.5, 2.0] { + // ⌘+ pressed, the parse still out: new font, the list's column. + controller.apply(style: .overDocument(theme: light, fontScale: requested, shownFontScale: 1, + isReadingMode: true)) + XCTAssertEqual(controller.textView.font?.pointSize, base * CGFloat(requested), "× \(requested)") + XCTAssertEqual(controller.textView.textContainer!.size.width, 720, "× \(requested)") + } + // The last parse lands: the column follows; the font stays. + controller.apply(style: .overDocument(theme: light, fontScale: 2, shownFontScale: 2, isReadingMode: true)) + XCTAssertEqual(controller.textView.font?.pointSize, base * 2) + XCTAssertEqual(controller.textView.textContainer!.size.width, + BlockLayout.Document.columnWidth(inner: inner, + cap: BlockLayout.Document.readingColumnCap(fontScale: 2))) + // Outside Reading Mode there is no cap to wait for. + let standard = SourceEditorController.Style.overDocument(theme: light, fontScale: 1.5, shownFontScale: 1, + isReadingMode: false) + XCTAssertEqual(standard.fontScale, 1.5) + XCTAssertEqual(standard.columnFontScale, 1) } func testTextContainerFollowsTheViewWidthAndTheLayoutStyle() { let (controller, scrollView, window) = makeEditor("text", width: 1000) let view = controller.textView - func check(reading: Bool, line: UInt = #line) { + func check(reading: Bool, scale: CGFloat = 1, line: UInt = #line) { let clip = scrollView.contentView.bounds.width - let list = listColumn(clip: clip, reading: reading) + let list = listColumn(clip: clip, reading: reading, scale: scale) XCTAssertEqual(view.textContainer!.size.width, list.width, line: line) XCTAssertEqual(view.textContainerInset.width, list.left, line: line) XCTAssertEqual(view.textContainerInset.height, reading ? 48 : 24, line: line) @@ -615,6 +685,12 @@ final class SourceEditorControllerTests: XCTestCase { window.setContentSize(NSSize(width: 1400, height: 600)) window.contentView!.layoutSubtreeIfNeeded() check(reading: true) + // A zoom in reading mode widens the column with the text (1.5 × 720 + // = 1080 in a 1400 pt window), as the rendered list does. + controller.apply(style: style(scale: 1.5, reading: true)) + check(reading: true, scale: 1.5) + controller.apply(style: style(scale: 0.5, reading: true)) + check(reading: true, scale: 0.5) controller.apply(style: style(reading: false)) check(reading: false) window.setContentSize(NSSize(width: 501, height: 600)) @@ -665,7 +741,7 @@ final class SourceEditorControllerTests: XCTestCase { func testReadingModeInsetChangeDoesNotShiftTheText() { let (controller, scrollView, _) = makeEditor(wrappingLines(2_000), width: 700) XCTAssertEqual(SourceEditorController.columnGeometry(clipWidth: scrollView.contentView.bounds.width, - isReadingLayout: true).columnWidth, + isReadingLayout: true, fontScale: 1).columnWidth, controller.textView.textContainer!.size.width, "same column in both layouts") controller.scroll(toLine: 1_200) let storage = controller.textView.textStorage!.mutableString diff --git a/QuickMD/QuickMDTests/SourceTintTests.swift b/QuickMD/QuickMDTests/SourceTintTests.swift index dbdd0b8..a37e626 100644 --- a/QuickMD/QuickMDTests/SourceTintTests.swift +++ b/QuickMD/QuickMDTests/SourceTintTests.swift @@ -50,6 +50,14 @@ final class SourceTintTests: XCTestCase { [("Title\n=====", .heading), ("Sub\n---", .heading)]) } + /// The parser trims a Setext title of spaces and tabs only + /// (`MarkdownBlockParser.markerLine`); the tint compared it with the line + /// trimmed of every Unicode space, so a title starting with a no-break + /// space lost its underline from the tint. + func testASetextTitleStartingWithANoBreakSpaceTintsItsUnderline() { + assertTinted("\u{00A0}Title\n===\nBody\n", [("\u{00A0}Title\n===", .heading)]) + } + func testFencedCodeKeepsInnerBlankLinesAndDropsTrailingOnes() { assertTinted("```swift\nlet a = 1\n\nlet b = 2\n```\n\n \nAfter\n", [("```swift\nlet a = 1\n\nlet b = 2\n```", .verbatim)]) diff --git a/QuickMD/QuickMDTests/TableSelectionTests.swift b/QuickMD/QuickMDTests/TableSelectionTests.swift index 2d16651..9fa2226 100644 --- a/QuickMD/QuickMDTests/TableSelectionTests.swift +++ b/QuickMD/QuickMDTests/TableSelectionTests.swift @@ -193,6 +193,26 @@ final class TableSelectionTests: XCTestCase { XCTAssertEqual(output?.plain, "a b\tc") } + /// A front-matter list shown one item per line (an item holds a comma): + /// no U+2028 reaches the pasteboard. One cell copies as lines, as shown; + /// the TSV of the table (and across cells) keeps one row per property and + /// joins those items with "; " — a space would merge "Doe, Jane" and + /// "Smith" into one comma list again. + func testFrontMatterListCopiesWithoutLineSeparators() { + let fixture = tableBlock("---\nauthors: [\"Doe, Jane\", Smith]\ntags: [a, b]\n---\n") + let layout = fixture.text.layout + let value = layout.cellRanges[0][1] + XCTAssertEqual(copy(value, of: fixture)?.plain, "Doe, Jane\nSmith", "one cell: as shown") + let whole = copy(NSRange(location: 0, length: fixture.text.string.length), of: fixture) + XCTAssertEqual(whole?.plain, "authors\tDoe, Jane; Smith\ntags\ta, b") + XCTAssertEqual(copy(span(layout, from: (0, 0, 0), to: (0, 1, value.length)), of: fixture)?.plain, + "authors\tDoe, Jane; Smith") + for output in [copy(value, of: fixture), whole] { + XCTAssertFalse(output?.plain.contains("\u{2028}") ?? true) + XCTAssertFalse(output?.rtf.string.contains("\u{2028}") ?? true) + } + } + func testWholeTableIsExactlyThe112TSV() { let fixture = tableBlock() let length = fixture.text.string.length diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift index 3347ac4..58b33ff 100644 --- a/QuickMD/QuickMDTests/TableTextTests.swift +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -20,8 +20,7 @@ final class TableTextTests: XCTestCase { private var theme: MarkdownTheme { MarkdownTheme.cached(for: .light) } - /// The prototype's fixtures (scratchpad/table-proto, 2026-10-22) plus a - /// header-only table. + /// The table prototype's fixtures (2026-10-22) plus a header-only table. private static let fixtures: [(name: String, markdown: String)] = [ ("simple", """ | Name | Role | Since | diff --git a/QuickMD/QuickMDTests/TemporaryDefaults.swift b/QuickMD/QuickMDTests/TemporaryDefaults.swift new file mode 100644 index 0000000..b4616c3 --- /dev/null +++ b/QuickMD/QuickMDTests/TemporaryDefaults.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest + +/// A throwaway `UserDefaults` suite for one test, stored OUTSIDE +/// `~/Library/Preferences`. +/// +/// Why not `UserDefaults(suiteName: "pl.falami.studio.QuickMD.tests.")`: +/// that domain lives at `~/Library/Preferences/.plist`, and +/// `removePersistentDomain` only empties it — cfprefsd writes the (empty) +/// plist seconds later (measured ~20 s after the test process ended), so the +/// file is left behind on every run (324 of them piled up by 2026-11-04). +/// Deleting the file in teardown loses that race as well. +/// +/// The suite name here is an ABSOLUTE PATH inside a per-instance temporary +/// directory: CFPreferences then keeps the domain at `.plist`, so even +/// a late cfprefsd write lands in the temporary directory, which `remove()` +/// deletes together with the plist. +final class TemporaryDefaults { + /// The suite name (an absolute path) — what `persistentDomain(forName:)` + /// and `removePersistentDomain(forName:)` take. + let suiteName: String + let defaults: UserDefaults + /// The directory that holds the suite's plist. + let directory: URL + + /// Where CFPreferences stores this suite. + var plistURL: URL { URL(fileURLWithPath: suiteName + ".plist") } + + init(_ label: String = "tests") throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("QuickMD-\(label)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + suiteName = directory.appendingPathComponent("defaults").path + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw CocoaError(.featureUnsupported) + } + self.defaults = defaults + } + + /// Empties the domain and deletes its plist with the directory. Safe to + /// call more than once. + func remove() { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: directory) + } +} + +final class TemporaryDefaultsTests: XCTestCase { + /// The plist is written where the helper says — inside its own temporary + /// directory — and nothing of it survives `remove()`. With a plain suite + /// name (the old tests) the flushed plist sits in ~/Library/Preferences + /// instead and this fails. + func testPlistStaysInItsTemporaryDirectory() throws { + let suite = try TemporaryDefaults("Probe") + defer { suite.remove() } + suite.defaults.set(42, forKey: "probe") + XCTAssertTrue(CFPreferencesAppSynchronize(suite.suiteName as CFString)) + + XCTAssertTrue(suite.plistURL.path.hasPrefix(suite.directory.path + "/")) + XCTAssertTrue(FileManager.default.fileExists(atPath: suite.plistURL.path), + "the domain is stored at .plist") + let library = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences") + .appendingPathComponent((suite.suiteName as NSString).lastPathComponent + ".plist") + XCTAssertFalse(FileManager.default.fileExists(atPath: library.path)) + + suite.remove() + XCTAssertFalse(FileManager.default.fileExists(atPath: suite.directory.path)) + } +} diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift new file mode 100644 index 0000000..2886ccd --- /dev/null +++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift @@ -0,0 +1,2229 @@ +import XCTest +import SwiftUI +import AppKit + +/// The vertical rhythm (`BlockLayout.Spacing`, VerticalRhythm.swift): rows are +/// CommonMark blocks, blank lines never add space, every gap is a multiple of +/// the body size, and print/PDF uses the same rules. +/// +/// Gaps are checked as the READER sees them — the space above a row PLUS the +/// air the two blocks already have inside their rows (`realizedGap`) — against +/// the rules restated here independently (`expectedTarget`), not against +/// `BlockLayout.Spacing.above` itself. +final class VerticalRhythmTests: XCTestCase { + + // MARK: - Fixtures + + private typealias Spacing = BlockLayout.Spacing + private var theme: MarkdownTheme { MarkdownTheme.cached(for: .light) } + /// Rounding to whole points can move a gap by up to half a point. + private let tolerance: CGFloat = 0.51 + + /// Same deterministic stand-in for SwiftMath as `BlockHeightMeasurerTests`. + private let math = MathRendering( + inlineImage: { _, _, _ in NSImage(size: NSSize(width: 24, height: 12)) }, + displayHeight: { _, _, _ in 40 } + ) + + private func parse(_ markdown: String, fontScale: CGFloat = 1.0) -> [MarkdownBlock] { + MarkdownBlockParser(theme: theme, fontScale: fontScale).parse(markdown) + } + + private func measure(_ blocks: [MarkdownBlock], fontScale: CGFloat = 1.0, + width: CGFloat = 640) -> MeasuredBlocks { + BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: fontScale, + contentWidth: width, math: math) + } + + /// What the reader sees between row `index - 1` and row `index`: optical + /// (baseline / cap top) around a heading unless the heading follows front + /// matter, edge to edge otherwise. + private func realizedGap(_ rows: [Spacing.Row], _ spacing: [CGFloat], _ index: Int) -> CGFloat { + let previous = rows[index - 1], next = rows[index] + if isOptical(previous.role, next.role) { + return previous.edges.inkBelow + spacing[index] + next.edges.inkAbove + } + return previous.edges.boxBelow + spacing[index] + next.edges.boxAbove + } + + private func isOptical(_ previous: Spacing.Role, _ next: Spacing.Role) -> Bool { + if case .frontMatter = previous { return false } + if case .heading = previous { return true } + if case .heading = next { return true } + return false + } + + /// The approved values, in body-size units. + private func expectedTarget(_ previous: Spacing.Role, _ next: Spacing.Role) -> CGFloat { + switch (previous, next) { + case (.heading(let upper), .heading(let lower)) where lower > upper: return 2.0 + case (.frontMatter, .heading): return 1.75 + case (_, .heading(let level)): return [4.75, 4.25, 3.75, 3.5, 3.5, 3.5][level - 1] + case (.heading, _): return 2.25 + default: return 1.75 + } + } + + /// Every gap of `markdown` at `fontScale` equals its target. + private func assertEveryGapOnTarget(_ markdown: String, fontScale: CGFloat = 1.0, + file: StaticString = #filePath, line: UInt = #line) { + let measured = measure(parse(markdown, fontScale: fontScale), fontScale: fontScale) + let rows = measured.rhythm + let spacing = measured.table.spacingAbove + XCTAssertEqual(spacing.first, 0, "first row", file: file, line: line) + let u = 14 * fontScale + for index in rows.indices.dropFirst() where !rows[index].joinsPrevious { + let expected = expectedTarget(rows[index - 1].role, rows[index].role) * u + XCTAssertEqual(realizedGap(rows, spacing, index), expected, accuracy: tolerance, + "row \(index - 1) \(rows[index - 1].role) → row \(index) \(rows[index].role) at \(fontScale)", + file: file, line: line) + } + } + + private func plain(_ block: MarkdownBlock) -> String { + guard case .text(let attributed) = block.content else { return "" } + return String(attributed.characters) + } + + // MARK: - Blank lines + + func testBlankLinesBetweenBlocksAreNeutral() { + let pairs: [(String, String)] = [ + ("## Heading\n\nParagraph text.", "## Heading\nParagraph text."), + ("# Title\n\n\n\nParagraph text.", "# Title\nParagraph text."), + ("Paragraph text.\n\n- one\n- two", "Paragraph text.\n- one\n- two"), + ("Paragraph text.\n\n1. one\n2. two", "Paragraph text.\n1. one\n2. two"), + ("```\ncode\n```\n\nParagraph text.", "```\ncode\n```\nParagraph text."), + ("Paragraph text.\n\n---\n\nMore text.", "Paragraph text.\n\n---\nMore text."), + ] + for (loose, tight) in pairs { + let a = parse(loose), b = parse(tight) + XCTAssertEqual(a.count, b.count, loose) + XCTAssertEqual(a.map(plain), b.map(plain), loose) + let ma = measure(a), mb = measure(b) + XCTAssertEqual(ma.table.heights, mb.table.heights, loose) + XCTAssertEqual(ma.table.spacingAbove, mb.table.spacingAbove, loose) + } + } + + func testTextRowsCarryNoBlankLinesOrTrailingBreak() { + let blocks = parse("First paragraph.\n\n\n\nSecond paragraph.\n\n") + XCTAssertEqual(blocks.map(plain), ["First paragraph.", "Second paragraph."]) + XCTAssertEqual(blocks.map(\.sourceLine), [0, 4]) + } + + /// CommonMark: a blank line is nothing but spaces and tabs. A line of + /// no-break spaces (U+00A0) is text — `CharacterSet.whitespaces` counted + /// it blank. After text it continues the paragraph; alone it is a + /// paragraph of its own, one empty-looking line (GitHub: `

 

`). + /// It ends no block and separates none. + func testALineOfNoBreakSpacesIsTextNotABlankLine() { + let nbsp = "\u{00A0}" + let continued = parse("Para one\n\(nbsp)\nPara two") + XCTAssertEqual(continued.count, 1) + XCTAssertEqual(plain(continued[0]), "Para one \(nbsp) Para two") + let alone = parse("Para one\n\n\(nbsp)\(nbsp)\n\nPara two") + XCTAssertEqual(alone.map(plain), ["Para one", "\(nbsp)\(nbsp)", "Para two"]) + XCTAssertEqual(measure(alone).table.heights[1], measure(alone).table.heights[0], "one line") + // No separator in a list: one tight list. + XCTAssertEqual(parse("- a\n\(nbsp)\n- b").count, 1) + // A quote body: one paragraph. + guard case .blockquote(let body, _) = parse("> a\n> \(nbsp)\n> b")[0].content else { + return XCTFail("not a quote") + } + XCTAssertEqual(String(MarkdownRenderer(theme: theme).renderQuotedBody(body).characters), "a \(nbsp) b") + // Spaces and tabs are still a blank line. + XCTAssertEqual(parse("Para one\n \t \nPara two").count, 2) + XCTAssertTrue(MarkdownRenderer.isBlankLine(" \t ")) + XCTAssertTrue(MarkdownRenderer.isBlankLine("")) + XCTAssertFalse(MarkdownRenderer.isBlankLine(nbsp)) + XCTAssertFalse(MarkdownRenderer.isBlankLine(" \(nbsp)\t")) + } + + func testRowsAreCommonMarkBlocks() { + let blocks = parse("Intro line one\nintro line two.\n- item a\n- item b\n\nOutro.\n***\nAfter the rule.") + XCTAssertEqual(blocks.map(plain).count, 5, "\(blocks.map(plain))") + XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 5, 6, 7]) + XCTAssertTrue(blocks.allSatisfy { !$0.joinsPrevious }) + } + + /// CommonMark: only a bullet, or an ordered item numbered 1 (`1.` or + /// `1)`), interrupts a paragraph. `1984. …` after paragraph text continues + /// the paragraph — its text, in its row, with no gap. + func testOnlyAnItemThatMayInterruptAParagraphStartsARow() { + for line in ["- item", "* item", "+ item", "- [ ] task", "1. first", "1) first"] { + XCTAssertEqual(parse("Paragraph text.\n" + line).count, 2, line) + } + for line in ["1984. A year", "2. second", "2) second", "-", "1."] { + XCTAssertEqual(parse("Paragraph text.\n" + line).count, 1, line) + } + // CommonMark: the rest of the paragraph, drawn as its text — no item + // marker, no line of its own — and searched and copied as such. + let blocks = parse("Paragraph text.\n1984. A year\n1985) Another") + XCTAssertEqual(plain(blocks[0]), "Paragraph text. 1984. A year 1985) Another") + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "text. 1984. A year 1985)").matchBlockIds.count, 1) + // After a hard break: a line of its own, still text. + XCTAssertEqual(plain(parse("Paragraph text. \n1984. A year")[0]), "Paragraph text.\n1984. A year") + // A list that starts a row is still a list, whatever its number or + // delimiter (drawn `n.`, as GitHub draws both). + XCTAssertEqual(parse("Text.\n\n3. third\n4. fourth").count, 2) + XCTAssertTrue(plain(parse("1984) A year")[0]).hasPrefix("1984. A year"), plain(parse("1984) A year")[0])) + // Inside a list, an item of any number is the next item. + XCTAssertTrue(plain(parse("1. one\n1984. two")[0]).contains("\n1984. two")) + } + + // MARK: - Headings + + func testEveryBlockUnderAHeadingGetsTheSameOpticalGap() { + let followers = [ + "Paragraph text.", + "- bullet one\n- bullet two", + "1. first\n2. second", + "```swift\nlet x = 1\n```", + "> A quote.", + "> [!NOTE]\n> An alert.", + "| a | b |\n|---|---|\n| 1 | 2 |", + "$$\nx^2\n$$", + "```mermaid\ngraph TD; A-->B\n```", + "![alt](missing.png)", + "***", + ] + for follower in followers { + let measured = measure(parse("## Heading\n\n" + follower)) + XCTAssertEqual(measured.rhythm.count, 2, follower) + XCTAssertEqual(realizedGap(measured.rhythm, measured.table.spacingAbove, 1), 2.25 * 14, + accuracy: tolerance, follower) + // …and the same air above a heading, whatever precedes it. + let above = measure(parse(follower + "\n\n## Heading")) + XCTAssertEqual(realizedGap(above.rhythm, above.table.spacingAbove, above.rhythm.count - 1), 4.25 * 14, + accuracy: tolerance, follower) + } + } + + func testHeadingTargetsPerLevelAndUnderAParent() { + for level in 1...6 { + let hashes = String(repeating: "#", count: level) + assertEveryGapOnTarget("Paragraph text.\n\n\(hashes) Heading \(level)\n\nParagraph text.") + } + assertEveryGapOnTarget("# Title\n## Section\n### Subsection\nText.\n## Next section\n### Deeper\n#### Deepest") + let under = measure(parse("# Title\n## Section")) + XCTAssertEqual(realizedGap(under.rhythm, under.table.spacingAbove, 1), 2.0 * 14, accuracy: tolerance) + } + + func testFrontMatterToTitleIsOneMarginEdgeToEdge() { + // The properties table, and the yaml code block the parser falls back + // to when `FrontMatter` cannot read the YAML (a nested map here) — + // on screen and in print. + let renderer = MarkdownRenderer(colorScheme: .light) + for markdown in ["---\ntitle: Notes\ntags: [a, b]\n---\n# Notes\n\nText.", + "---\nauthor:\n name: X\n---\n# Notes\n\nText."] { + let blocks = parse(markdown) + XCTAssertTrue(blocks[0].isFrontMatter, markdown) + let measured = measure(blocks) + XCTAssertEqual(measured.rhythm.first?.role, .frontMatter, markdown) + XCTAssertEqual(measured.rhythm.count, 3) + XCTAssertEqual(realizedGap(measured.rhythm, measured.table.spacingAbove, 1), 1.75 * 14, + accuracy: tolerance, markdown) + let printRows = blocks.map { PrintRhythm.row(for: $0, renderer: renderer, width: 532) } + XCTAssertEqual(printRows.first?.role, .frontMatter, markdown) + XCTAssertEqual(realizedGap(printRows, Spacing.column(printRows, fontScale: 1), 1), 1.75 * 14, + accuracy: tolerance, markdown) + } + if case .codeBlock(_, let language) = parse("---\nauthor:\n name: X\n---\n# Notes")[0].content { + XCTAssertEqual(language, "yaml") + } else { + XCTFail("the fallback is a yaml code block") + } + } + + func testAYamlFenceOnTheFirstLineIsAnOrdinaryCodeBlock() { + let blocks = parse("```yaml\ntitle: Notes\n```\n# Notes") + XCTAssertFalse(blocks[0].isFrontMatter) + XCTAssertEqual(measure(blocks).rhythm.first?.role, .box) + XCTAssertFalse(parse("# Notes\n\n---\ntitle: x\n---")[1...].contains { $0.isFrontMatter }) + } + + /// Where the eye sees a block start under a heading: the heading's last + /// baseline to the next block's first ink — measured independently of + /// the rhythm's own edges. + private func eyeGapUnderHeading(_ follower: String, fontScale: CGFloat, + firstInk: (MarkdownBlock, MeasuredBlocks) -> CGFloat) -> CGFloat { + let blocks = parse("## Heading\n\n" + follower, fontScale: fontScale) + let measured = measure(blocks, fontScale: fontScale) + return measured.rhythm[0].edges.inkBelow + measured.table.spacingAbove[1] + firstInk(blocks[1], measured) + } + + /// The owner's measurement: `## Quotes` → the quote's first line read + /// 35 pt, `## Loose ordered list` → its first line 30.5 pt. The quote's + /// bar starts above its text; the eye reads the text. + func testAQuoteUnderAHeadingSitsWhereAParagraphWould() { + for scale: CGFloat in [1, 1.5] { + let capTop = { (block: MarkdownBlock, measured: MeasuredBlocks, padding: CGFloat, width: CGFloat) in + let ink = BlockHeightMeasurer.textInk(text: measured.converted[block.id]!, width: width) + return padding + ink.firstBaseline - ink.firstCapHeight + } + let paragraph = eyeGapUnderHeading("Some paragraph text.", fontScale: scale) { + capTop($0, $1, 0, BlockLayout.clampedWidth(640)) + } + let quote = eyeGapUnderHeading("> Some quoted text.", fontScale: scale) { + capTop($0, $1, BlockLayout.Quote.verticalPadding, + BlockLayout.Quote.bodyWidth(contentWidth: 640, level: 1)) + } + XCTAssertEqual(quote, paragraph, accuracy: 1, "at \(scale)") + // …and above a heading, from the quote's last baseline. + assertEveryGapOnTarget("> A quote.\n\n## Next", fontScale: scale) + } + } + + /// A diagram has no frame: the eye sees it start at its first ink, + /// Mermaid's padding inside the picture. + func testADiagramUnderAHeadingStartsWhereAParagraphWould() { + for scale: CGFloat in [1, 1.5] { + let paragraph = eyeGapUnderHeading("Some paragraph text.", fontScale: scale) { block, measured in + let ink = BlockHeightMeasurer.textInk(text: measured.converted[block.id]!, + width: BlockLayout.clampedWidth(640)) + return ink.firstBaseline - ink.firstCapHeight + } + let diagram = eyeGapUnderHeading("```mermaid\ngraph TD; A-->B\n```", fontScale: scale) { _, _ in + BlockLayout.Document.mermaidOuterVerticalPadding + BlockLayout.Mermaid.verticalPadding + + BlockLayout.Mermaid.inkInset * scale + } + XCTAssertEqual(diagram, paragraph, accuracy: 1, "at \(scale)") + } + } + + // MARK: - Between other blocks + + func testNonHeadingPairsAreOneMarginApart() { + assertEveryGapOnTarget(""" + First paragraph. + + Second paragraph. + - a list + - of items + + ``` + code + ``` + > A quote. + + | a | b | + |---|---| + | 1 | 2 | + + > [!TIP] + > An alert. + + $$ + y = x + $$ + + *** + Last paragraph. + """) + } + + func testGapsScaleWithFontScale() { + let document = """ + # Title + Intro paragraph. + + - list one + - list two + + ``` + code + ``` + + ## Section + | a | b | + |---|---| + | 1 | 2 | + + ### Subsection + > A quote. + """ + for fontScale: CGFloat in [1.0, 1.25, 1.5] { + assertEveryGapOnTarget(document, fontScale: fontScale) + } + let at100 = measure(parse(document)).table.spacingAbove + let at150 = measure(parse(document, fontScale: 1.5), fontScale: 1.5).table.spacingAbove + XCTAssertGreaterThan(at150.reduce(0, +), at100.reduce(0, +) * 1.4) + } + + func testFirstRowHasNoSpaceAbove() { + for markdown in ["# Title", "Text.", "```\ncode\n```", "> quote", "---\ntitle: x\n---\nText."] { + XCTAssertEqual(measure(parse(markdown)).table.spacingAbove.first, 0, markdown) + } + } + + // MARK: - Cuts + + func testLongListIsCutSeamlessly() { + let list = (1...70).map { "- item \($0)" }.joined(separator: "\n") + let blocks = parse("Intro.\n\n" + list) + XCTAssertEqual(blocks.count, 4) + XCTAssertEqual(blocks.map(\.joinsPrevious), [false, false, true, true]) + let limit = MarkdownBlockParser.textChunkLineLimit + XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 2 + limit, 2 + 2 * limit]) + let measured = measure(blocks) + XCTAssertEqual(measured.table.spacingAbove[2], 0) + XCTAssertEqual(measured.table.spacingAbove[3], 0) + XCTAssertGreaterThan(measured.table.spacingAbove[1], 0) + + // A copy across the cuts reads as one list: a line break at each cut. + let selection = DocumentSelection(anchor: SelectionPoint(row: 1, offset: 0), + focus: SelectionPoint(row: 3, offset: measured.converted[blocks[3].id]!.length)) + let output = DocumentCopyBuilder.build(parts: SelectionRows.parts( + of: selection, blocks: blocks, text: { measured.converted[$0.id] }, table: { _ in nil })) + XCTAssertFalse(output.plain.contains("\n\n")) + XCTAssertEqual(output.plain.components(separatedBy: "\n").count, 70) + } + + func testNestedQuoteGroupsJoinTheirQuote() { + // The empty `>` line closes the inner quote's paragraph: without it + // `> outer again` would continue that paragraph lazily (CommonMark). + let blocks = parse("Text.\n\n> outer\n>> inner\n>\n> outer again\n\n> separate quote") + XCTAssertEqual(blocks.count, 5) + XCTAssertEqual(blocks.map(\.joinsPrevious), [false, false, true, true, false]) + let measured = measure(blocks) + XCTAssertEqual(Array(measured.table.spacingAbove[2...3]), [0, 0]) + XCTAssertGreaterThan(measured.table.spacingAbove[4], 0) + } + + func testCopyJoinsSeparateBlocksWithABlankLine() { + let blocks = parse("One.\n\nTwo.") + let measured = measure(blocks) + let selection = DocumentSelection(anchor: SelectionPoint(row: 0, offset: 0), + focus: SelectionPoint(row: 1, offset: 4)) + let output = DocumentCopyBuilder.build(parts: SelectionRows.parts( + of: selection, blocks: blocks, text: { measured.converted[$0.id] }, table: { _ in nil })) + XCTAssertEqual(output.plain, "One.\n\nTwo.") + } + + // MARK: - Ordered lists split into rows + + func testLooseOrderedListKeepsOneTextEdge() { + let loose = parse((1...10).map { "\($0). item \($0)" }.joined(separator: "\n\n")) + let tight = parse((1...10).map { "\($0). item \($0)" }.joined(separator: "\n")) + XCTAssertEqual(loose.count, 10) + XCTAssertEqual(tight.count, 1) + let measured = measure(loose) + let headIndents = loose.map { block -> CGFloat in + let style = measured.converted[block.id]?.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + return (style as? NSParagraphStyle)?.headIndent ?? -1 + } + let tightString = measure(tight).converted[tight[0].id]! + let tightIndent = (tightString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)?.headIndent + XCTAssertEqual(Set(headIndents).count, 1, "\(headIndents)") + XCTAssertEqual(headIndents.first, tightIndent) + } + + /// The paragraph head indent of every ordered item in the text rows, in + /// reading order — where each item's text starts. + private func orderedItemIndents(_ blocks: [MarkdownBlock]) throws -> [CGFloat] { + var indents: [CGFloat] = [] + for block in blocks { + guard case .text(let attributed) = block.content else { continue } + let string = try NSAttributedString(attributed, including: \.appKit) + let text = string.string as NSString + text.enumerateSubstrings(in: NSRange(location: 0, length: text.length), options: .byParagraphs) { + paragraph, range, _, _ in + guard let paragraph, paragraph.range(of: #"^\s*\d+\.\s"#, options: .regularExpression) != nil + else { return } + let style = string.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) + indents.append((style as? NSParagraphStyle)?.headIndent ?? -1) + } + } + return indents + } + + /// A list longer than a text row may be (`textChunkLineLimit`) is cut into + /// rows; 1.14.4 measured the widest marker per ROW, so items 1–8 above + /// the cut hung under "8. " and items 9–12 below it under "12. " — two + /// text edges in one list. The widest marker is the LIST's. + func testAnOrderedListCutIntoRowsKeepsOneTextEdge() throws { + let items = (1...12).map { n in + "\(n). item \(n)\n - sub a\n - sub b\n - sub c" + } + let blocks = parse(items.joined(separator: "\n")) + XCTAssertEqual(blocks.count, 2) + XCTAssertEqual(blocks.map(\.joinsPrevious), [false, true]) + let indents = try orderedItemIndents(blocks) + XCTAssertEqual(indents.count, 12) + XCTAssertEqual(Set(indents).count, 1, "\(indents)") + } + + /// The steps of a list whose items each hold a code block: every step is + /// a row of its own between two code blocks, and 1.14.4 hung each under + /// its OWN number — the digits are proportional, so "1. " to "9. " each + /// started the text at a different x and "10. " further right still. + func testOrderedStepsWithCodeBlocksKeepOneTextEdge() throws { + let steps = (1...11).map { "\($0). Step \($0)\n ```sh\n run \($0)\n ```" } + let blocks = parse(steps.joined(separator: "\n")) + XCTAssertEqual(blocks.filter { if case .codeBlock = $0.content { return true }; return false }.count, 11) + let indents = try orderedItemIndents(blocks) + XCTAssertEqual(indents.count, 11) + XCTAssertEqual(Set(indents).count, 1, "\(indents)") + + // A block NOT under the list (not indented) ends it: the list after + // it hangs on its own. + let separate = try orderedItemIndents(parse("1. a\n2. b\n\n```\ncode\n```\n\n10. c")) + XCTAssertEqual(separate.count, 3) + XCTAssertEqual(separate[0], separate[1]) + XCTAssertNotEqual(separate[1], separate[2]) + } + + /// ⌘G into a block taller than the window whose view is not on display: + /// the jump goes to the block and aims at the match when it lands. The + /// reader scrolling inside the block during the glide used to leave the + /// aim in place (only "is the anchor row still there" was checked, and + /// the glide ends on it), so the view was yanked to the match. Their + /// scroll takes the aim away now; nothing else does until it is taken. + func testASearchJumpsAimAtItsMatchIsDroppedWhenTheReaderScrolls() { + var aim = PendingMatchAim() + aim.jumped(to: "code-3", needsMatch: true) + XCTAssertEqual(aim.blockId, "code-3") // lands untouched: aims + aim.readerScrolled() + XCTAssertNil(aim.blockId) // a wheel during the glide: stays where they are + aim.jumped(to: "code-3", needsMatch: false) + XCTAssertNil(aim.blockId) // the match was known: centred at once + aim.jumped(to: "table-1", needsMatch: true) + aim.jumped(to: "code-9", needsMatch: true) + XCTAssertEqual(aim.blockId, "code-9") // the latest jump's + aim.drop() + XCTAssertNil(aim.blockId) // a new table + } + + /// An alert marker is `[!KIND]` with spaces or tabs around it (GitHub, + /// gh api markdown in gfm mode): a no-break space after it leaves a + /// plain quote; two trailing spaces still make the alert. + func testAnAlertMarkerTakesSpacesAndTabsOnly() throws { + XCTAssertEqual(try quoteShape("> [!NOTE]\u{00A0}\n> body\n"), ["q1:[!NOTE]\u{00A0}\nbody"]) + XCTAssertEqual(try quoteShape("> [!NOTE] \n> body\n"), ["alert:note:body"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\t\n> body\n"), ["alert:note:body"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> body\n"), ["alert:note:body"]) + XCTAssertNil(AlertKind(markerLine: "\u{2003}[!TIP]")) + XCTAssertEqual(AlertKind(markerLine: " [!TIP] "), .tip) + } + + /// Which wheel events are the reader's scroll and drop a jump's aim: the + /// ones that move the document. Fingers resting on the trackpad + /// (`.mayBegin`), a gesture's end and zero-delta events used to drop it + /// too, so ⌘G with fingers on the trackpad landed on the block's centre. + func testOnlyAWheelEventThatScrollsDropsTheAim() { + let scrolls = PendingMatchAim.isReaderScroll + XCTAssertFalse(scrolls([.mayBegin], [], 0)) // fingers resting + XCTAssertFalse(scrolls([], [], 0)) // a zero-delta event + XCTAssertFalse(scrolls([.ended], [], 0)) // the gesture lifts + XCTAssertFalse(scrolls([.cancelled], [], 0)) + XCTAssertFalse(scrolls([.stationary], [], 0)) + XCTAssertFalse(scrolls([], [.ended], 0)) // momentum runs out + XCTAssertTrue(scrolls([], [], -3)) // a mouse wheel notch + XCTAssertTrue(scrolls([.began], [], 0)) // a gesture starts + XCTAssertTrue(scrolls([.changed], [], 1.5)) + XCTAssertTrue(scrolls([], [.began], 0)) // momentum + XCTAssertTrue(scrolls([], [.changed], -12)) + } + + // MARK: - Block markers: spaces and tabs only + + /// Parses on a worker thread: a parser that never returns fails the test + /// after `seconds` instead of stalling the suite (its thread keeps + /// spinning, so a failure here is worth stopping the run for). + private func parseReturning(_ markdown: String, within seconds: Double = 5, + file: StaticString = #filePath, line: UInt = #line) -> [MarkdownBlock]? { + final class Box: @unchecked Sendable { var blocks: [MarkdownBlock]? } + let box = Box() + let done = DispatchSemaphore(value: 0) + let theme = self.theme + Thread.detachNewThread { + box.blocks = MarkdownBlockParser(theme: theme).parse(markdown) + done.signal() + } + guard done.wait(timeout: .now() + seconds) == .success else { + XCTFail("the parser did not return within \(seconds) s", file: file, line: line) + return nil + } + return box.blocks + } + + private func plain(_ block: MarkdownBlock) -> String? { + if case .text(let attributed) = block.content { return String(attributed.characters) } + return nil + } + + /// A quote marker after a no-break, em or ideographic space: the block + /// loop entered the quote branch on a line trimmed of EVERY Unicode + /// space, the quote's own loop (spaces and tabs only, 9bbf6ae) refused + /// the same line without consuming it, and the branch re-entered it for + /// ever — 100 % CPU, the document never shown. CommonMark indents with + /// spaces and tabs only, and GitHub shows each such line as paragraph + /// text (`gh api markdown`: `

> quoted

`). + func testAQuoteMarkerAfterAUnicodeSpaceIsTextAndTheParserReturns() throws { + for space in ["\u{00A0}", "\u{2003}", "\u{3000}"] { + let name = String(format: "U+%04X", space.unicodeScalars.first!.value) + let blocks = try XCTUnwrap(parseReturning("Intro\n\n\(space)> quoted\n"), name) + XCTAssertEqual(blocks.map(plain), ["Intro", "\(space)> quoted"], name) + // Inside a quote run too: no marker, so the line is the quote + // paragraph's lazy continuation, text and all (GitHub: + // `

a > b

`), and the parser goes on. + let inside = try XCTUnwrap(parseReturning("> a\n\(space)> b\n\nAfter."), name) + if case .blockquote(let content, _) = inside.first?.content { + XCTAssertEqual(content, "a\n\(space)> b", name) + } else { + XCTFail("\(name): \(String(describing: inside.first?.content))") + } + XCTAssertEqual(inside.last.flatMap(plain), "After.", name) + } + // Spaces still indent a quote marker. + let indented = try XCTUnwrap(parseReturning("Intro\n\n > quoted\n")) + if case .blockquote(let content, _) = indented.last?.content { + XCTAssertEqual(content, "quoted") + } else { + XCTFail("\(String(describing: indented.last?.content))") + } + } + + /// Every other block marker the same way, as GitHub reads them + /// (`gh api markdown`, 2026-11-04): a no-break space before a fence, + /// `$$`, a setext underline, a table separator, a list marker, a rule or + /// a link definition — or after `#` — leaves the line paragraph text. + func testBlockMarkersAfterAUnicodeSpaceAreText() throws { + let nbsp = "\u{00A0}" + func kinds(_ markdown: String) throws -> [String] { + try XCTUnwrap(parseReturning(markdown)).map { block in + switch block.content { + case .text: return "text" + case .codeBlock: return "code" + case .mathBlock: return "math" + case .heading: return "heading" + case .table: return "table" + case .blockquote, .alert: return "quote" + default: return "other" + } + } + } + // A fence: `

``` code

` then the bare ``` opens an empty one. + XCTAssertEqual(try kinds("\(nbsp)```\ncode\n```\n"), ["text", "code"]) + // Not a closing fence: one before it, one with a no-break space after. + let closers = try XCTUnwrap(parseReturning("```\ncode\n\(nbsp)```\nmid\n```\(nbsp)\nend\n```\n")) + XCTAssertEqual(closers.count, 1) + if case .codeBlock(let code, _) = closers.first?.content { + XCTAssertEqual(code, "code\n\(nbsp)```\nmid\n```\(nbsp)\nend") + } else { + XCTFail("\(String(describing: closers.first?.content))") + } + XCTAssertFalse(try kinds("\(nbsp)$$\nx^2\n$$\n").contains("math")) + XCTAssertEqual(try kinds("Title\n\(nbsp)===\n"), ["text"]) + XCTAssertEqual(try kinds("#\(nbsp)Title\n"), ["text"]) + XCTAssertEqual(try kinds("| a | b |\n\(nbsp)|---|---|\n| 1 | 2 |\n"), ["text"]) + // Text, not an item or a rule: the characters are drawn as written. + let blocks = try XCTUnwrap(parseReturning("\(nbsp)- item\n\npara\n\n\(nbsp)---\n\n1.\(nbsp)one\n")) + XCTAssertEqual(blocks.map(plain), ["\(nbsp)- item", "para", "\(nbsp)---", "1.\(nbsp)one"]) + XCTAssertFalse(MarkdownRenderer.isListItem("\(nbsp)- item")) + XCTAssertFalse(MarkdownRenderer.isListItem("1.\(nbsp)one")) + XCTAssertFalse(MarkdownRenderer.isThematicBreak("\(nbsp)---")) + XCTAssertFalse(MarkdownRenderer.isThematicBreak("---\(nbsp)")) + // A link definition is not one: it stays in the text. + let definition = try XCTUnwrap(parseReturning("[x]\n\n\(nbsp)[x]: https://example.com\n")) + XCTAssertEqual(definition.count, 2) + XCTAssertEqual(definition.last.flatMap(plain), "\(nbsp)[x]: https://example.com") + + // Spaces and tabs still indent every one of them. + XCTAssertEqual(try kinds(" ```\ncode\n ```\n"), ["code"]) + XCTAssertEqual(try kinds("Title\n ===\n"), ["heading"]) + XCTAssertEqual(try kinds("# Title\n"), ["heading"]) + XCTAssertEqual(try kinds("#\tTitle\n"), ["heading"]) + XCTAssertEqual(try kinds("| a | b |\n |---|---|\n| 1 | 2 |\n"), ["table"]) + XCTAssertTrue(MarkdownRenderer.isListItem(" - item")) + XCTAssertTrue(MarkdownRenderer.isListItem("1.\tone")) + XCTAssertTrue(MarkdownRenderer.isThematicBreak(" ---\t")) + } + + // MARK: - Quote laziness + + /// The parse in short: `q:` for a quote, `alert::`, + /// `t:`, `c:`, `h:`, `img`, `table`. + private func quoteShape(_ markdown: String, file: StaticString = #filePath, + line: UInt = #line) throws -> [String] { + try XCTUnwrap(parseReturning(markdown, file: file, line: line), file: file, line: line).map { block in + switch block.content { + case .blockquote(let content, let level): return "q\(level):\(content)" + case .alert(let kind, let content): return "alert:\(kind.rawValue):\(content)" + case .text(let attributed): return "t:" + String(attributed.characters) + case .codeBlock(let code, _): return "c:\(code)" + case .heading(_, let title, _): return "h:" + MarkdownRenderer.shownHeadingTitle(title) + case .image: return "img" + case .table: return "table" + default: return "other" + } + } + } + + /// CommonMark laziness: a line without the quote marker that would be a + /// paragraph's continuation continues the paragraph the quote ends in — + /// in a nested quote, a list item or an alert too, and on a line with + /// fewer markers than the quote. Our parser ended the quote and made the + /// line a paragraph of its own. Every expected shape is GitHub's + /// (`gh api markdown`). + func testALazyLineContinuesTheParagraphAQuoteEndsIn() throws { + // Continues: a plain line, a hard-wrapped quote, `===`, an indented + // line, an item that may not start a list here (none may: a lazy + // line is checked against the document, not the paragraph). + XCTAssertEqual(try quoteShape("> a\nb\n"), ["q1:a\nb"]) + XCTAssertEqual(try quoteShape("> line one\nline two\nline three\n\nAfter\n"), + ["q1:line one\nline two\nline three", "t:After"]) + XCTAssertEqual(try quoteShape("> a\n===\n"), ["q1:a\n==="]) + XCTAssertEqual(try quoteShape("> a\n b\n"), ["q1:a\n b"]) + XCTAssertEqual(try quoteShape("> a\n<span>x</span>\n").first, "q1:a\n<span>x</span>") + XCTAssertEqual(try quoteShape("> a\nb\n> c\n"), ["q1:a\nb\nc"]) + // In a list item, a nested quote, an alert; and with fewer markers. + XCTAssertEqual(try quoteShape("> - a\nb\n"), ["q1:- a\nb"]) + XCTAssertEqual(try quoteShape("> > a\nb\n"), ["q2:a\nb"]) + XCTAssertEqual(try quoteShape("> > a\n> b\n"), ["q2:a\nb"]) + XCTAssertEqual(try quoteShape("> > > a\n> b\n"), ["q3:a\nb"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> text\nlazy\n"), ["alert:note:text\nlazy"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\nlazy\n"), ["alert:note:lazy"]) + + // Ends the quote: a blank line, an empty quote line, a block start. + XCTAssertEqual(try quoteShape("> a\n\nb\n"), ["q1:a", "t:b"]) + XCTAssertEqual(try quoteShape("> a\n>\nb\n"), ["q1:a", "t:b"]) + XCTAssertEqual(try quoteShape("> a\n```\ncode\n```\n"), ["q1:a", "c:code"]) + XCTAssertEqual(try quoteShape("> a\n~~~\ncode\n~~~\n"), ["q1:a", "c:code"]) + XCTAssertEqual(try quoteShape("> a\n# H\n"), ["q1:a", "h:H"]) + XCTAssertEqual(try quoteShape("> a\n---\n").count, 2) + XCTAssertEqual(try quoteShape("> a\n***\n").count, 2) + XCTAssertEqual(try quoteShape("> a\n- b\n"), ["q1:a", "t:• b"]) + XCTAssertEqual(try quoteShape("> a\n2. b\n").first, "q1:a") + XCTAssertEqual(try quoteShape("> a\n2. b\n").count, 2) + XCTAssertEqual(try quoteShape("> a\n<div>x</div>\n").first, "q1:a") + XCTAssertEqual(try quoteShape("> a\n<!-- c -->\n").first, "q1:a") + XCTAssertEqual(try quoteShape("> > a\n> - b\n"), ["q2:a", "q1:- b"]) + // Our own block starts end it too: a picture line, `$$` (GitHub reads + // both as the paragraph's text; here they stay the picture and the + // formula they are everywhere else). + XCTAssertEqual(try quoteShape("> a\n![img](x.png)\n"), ["q1:a", "img"]) + XCTAssertEqual(try quoteShape("> a\n$$\nx\n$$\n").first, "q1:a") + + // Nothing to continue: the quote does not end in a paragraph. + XCTAssertEqual(try quoteShape("> ```\n> code\nb\n"), ["q1:```\ncode", "t:b"]) + XCTAssertEqual(try quoteShape("> # H\nb\n"), ["q1:# H", "t:b"]) + XCTAssertEqual(try quoteShape("> a\n> ===\nb\n"), ["q1:a\n===", "t:b"]) + XCTAssertEqual(try quoteShape("> | x | y |\n> |---|---|\n> | 1 | 2 |\nlazy\n"), + ["q1:| x | y |\n|---|---|\n| 1 | 2 |", "t:lazy"]) + } + + /// A lazy line is paragraph text, never structure: a lazy `|---|` or + /// `===` read as a table separator or a Setext underline closed the + /// quote's paragraph, and the NEXT lazy line fell out of the quote. + /// GitHub keeps every one of them in the paragraph (gfm/g5_*.html). + func testALazyLineIsNeverReadAsStructure() throws { + XCTAssertEqual(try quoteShape("> a\n| x | y |\n|---|---|\n| 1 | 2 |\n"), + ["q1:a\n| x | y |\n|---|---|\n| 1 | 2 |"]) + XCTAssertEqual(try quoteShape("> a\nb\n===\nc\n"), ["q1:a\nb\n===\nc"]) + XCTAssertEqual(try quoteShape("> > a\n> ===\n> c\n"), ["q2:a\n===\nc"]) + XCTAssertEqual(try quoteShape("> > a\n> | x |\n> |---|\n> c\n"), ["q2:a\n| x |\n|---|\nc"]) + // Marked lines still are structure: a marked table, then an + // unmarked row, ends the quote (GitHub too). + XCTAssertEqual(try quoteShape("> | x | y |\n> |---|---|\n> | 1 | 2 |\nlazy\n"), + ["q1:| x | y |\n|---|---|\n| 1 | 2 |", "t:lazy"]) + XCTAssertEqual(try quoteShape("> a\n> ===\nb\n"), ["q1:a\n===", "t:b"]) + } + + // MARK: - Link and footnote definitions + + /// A definition inside a fenced code block is the block's text: the + /// pre-pass took it out wherever it stood, so the code block came out + /// EMPTY and the definition linked `[x]` in the rest of the document. + /// GitHub (gfm/def_fence*.html, def_fn_fence.html) keeps it as code — + /// `~~~`, a longer opening fence and an unclosed one alike. A `$$` block + /// is read the way the block loop reads it. + func testADefinitionInsideAFencedBlockIsCode() throws { + XCTAssertEqual(try quoteShape("```\n[x]: https://example.com\n```\n\n[x]\n"), + ["c:[x]: https://example.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("~~~~\n[x]: https://example.com\n~~~\nstill code\n~~~~\n\n[x]\n"), + ["c:[x]: https://example.com\n~~~\nstill code", "t:[x]"]) + XCTAssertEqual(try quoteShape("```\n[x]: https://example.com\n\n[x]"), // unclosed: to the end + ["c:[x]: https://example.com\n\n[x]"]) + XCTAssertEqual(try quoteShape("Text[^1]\n\n```\n[^1]: in code\n```\n"), + ["t:Text[^1]", "c:[^1]: in code"]) + // A fence inside a `$$` block is the formula's; the definition after + // the block is one. + XCTAssertEqual(try quoteShape("$$\n```\n$$\n\n[x]: http://e.com\n\n[x]\n"), ["other", "t:x"]) + // Outside the fence it still is one. + XCTAssertEqual(try quoteShape("```\ncode\n```\n\n[x]: http://e.com\n\n[x]\n"), ["c:code", "t:x"]) + } + + /// A link definition cannot interrupt a paragraph (CommonMark): on a + /// line that would continue one — plain text, a list item's, a quote's + /// lazy line, a table's — it is that text, and `[x]` stays literal. + /// The pre-pass took it out anywhere. A footnote definition MAY + /// interrupt a paragraph (GitHub). Every shape: gfm/def_*.html, fn_*.html. + /// A link definition that starts a paragraph of a quote is a definition + /// (GitHub, gfm/h4_*.html): `> [x]: url` defines `x` and leaves the + /// quote empty — it was the quote's text. After the quote's text it is + /// that text (G7), in the quote's fence code, in its table a row, after + /// an alert's marker the alert's text. + /// A run of blank lines in a quote or an alert is ONE paragraph break, + /// at every nesting level (GitHub, gfm/h5_*.html; at the top level our + /// spacing comes from structure, not from blank lines): two blank `>` + /// lines drew two gaps. Blank lines at a quote's start or end add + /// nothing; inside the quote's fence they are code. The body every + /// consumer reads (view, measurer, search, copy, print) has one. + func testARunOfBlankQuoteLinesIsOneParagraphBreak() throws { + XCTAssertEqual(try quoteShape("> a\n>\n>\n> b\n"), ["q1:a\n\nb"]) + XCTAssertEqual(try quoteShape("> a\n>\n>\n>\n> b\n>\n>\n> c\n"), ["q1:a\n\nb\n\nc"]) + XCTAssertEqual(try quoteShape("> > a\n> >\n> >\n> > b\n"), ["q2:a\n\nb"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> a\n>\n>\n> b\n"), ["alert:note:a\n\nb"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n>\n>\n> a\n>\n>\n"), ["alert:note:a"]) + XCTAssertEqual(try quoteShape(">\n>\n> a\n>\n>\n\nafter\n"), ["q1:a", "t:after"]) + XCTAssertEqual(try quoteShape("> a\n>\n>\n> > b\n> >\n> >\n> c\n"), ["q1:a", "q2:b", "q1:c"]) + // A definition taken out (H4) leaves no gap of its own. + XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n>\n> more\n\n[x]\n"), + ["q1:text\n\nmore", "t:x"]) + // Code keeps its blank lines. + XCTAssertEqual(try quoteShape("> ```\n> a\n>\n>\n> b\n> ```\n"), ["q1:```\na\n\n\nb\n```"]) + + let theme = MarkdownTheme.cached(for: .light) + let blocks = try XCTUnwrap(parseReturning("> a\n>\n>\n> b\n\n> [!TIP]\n> c\n>\n>\n> d\n")) + let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: 600, math: nil) + XCTAssertEqual(blocks.map { measured.converted[$0.id]?.string }, ["a\n \nb", "c\n \nd"]) + let all = try XCTUnwrap(DocumentSelection.selectAll(rowCount: blocks.count, + lengths: { measured.converted[blocks[$0].id]?.length ?? 0 })) + let parts = SelectionRows.parts(of: all, blocks: blocks, text: { measured.converted[$0.id] }, + table: { _ in nil }) + XCTAssertEqual(DocumentCopyBuilder.build(parts: parts).plain, "a\n\nb\n\nc\n\nd") + } + + func testALinkDefinitionMayStartAQuoteParagraph() throws { + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n\n[x]\n"), ["t:x"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> text\n\n[x]\n"), ["q1:text", "t:x"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [y]: http://f.com\n\n[x] [y]\n"), ["t:x y"]) + XCTAssertEqual(try quoteShape("> > [x]: http://e.com\n\n[x]\n"), ["t:x"]) + XCTAssertEqual(try quoteShape("Para\n> [x]: http://e.com\n\n[x]\n"), ["t:Para", "t:x"]) + XCTAssertEqual(try quoteShape("> # H\n> [x]: http://e.com\n\n[x]\n"), ["q1:# H", "t:x"]) + XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n\n[x]\n"), ["q1:text", "t:x"]) + // Text where GitHub has text. + XCTAssertEqual(try quoteShape("> text\n> [x]: http://e.com\n\n[x]\n"), + ["q1:text\n[x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("> ```\n> [x]: http://e.com\n> ```\n\n[x]\n").last, "t:[x]") + XCTAssertEqual(try quoteShape("> | a |\n> |---|\n> | 1 |\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]") + XCTAssertEqual(try quoteShape("> [!NOTE]\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]") + XCTAssertEqual(try quoteShape("> > a\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]") + // What follows keeps its source line. + let blocks = try XCTUnwrap(parseReturning("> [x]: http://e.com\n> text\n\n[x]\n")) + XCTAssertEqual(blocks.map(\.sourceLine), [1, 3]) + } + + /// A deeper `>` opens a nested quote, whose first line starts a new + /// paragraph: a link definition there is a definition, also right under + /// the quote's text (GitHub, gfm/h7_*.html). The pre-pass read every + /// level as one quote, so `> a` then `>> [x]: url` was the nested + /// quote's text and `[x]` stayed literal. Fewer markers still continue + /// the paragraph lazily, and so does a nested quote a lazy line kept + /// open; inside the quote's fence a deeper `>` is code. + func testADefinitionOpeningADeeperQuoteIsADefinition() throws { + XCTAssertEqual(try quoteShape("> a\n>> [x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"]) + XCTAssertEqual(try quoteShape("> a\n> > [x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"]) + XCTAssertEqual(try quoteShape("> a\n> > b\n> > > [x]: http://e.com\n\n[x]\n"), ["q1:a", "q2:b", "t:x"]) + XCTAssertEqual(try quoteShape("> | a |\n> |---|\n> | 1 |\n>> [x]: http://e.com\n\n[x]\n").last, "t:x") + XCTAssertEqual(try quoteShape("> > ```\n> > c\n> > ```\n> [x]: http://e.com\n\n[x]\n").last, "t:x") + // Text where GitHub has text. + XCTAssertEqual(try quoteShape(">> a\n> [x]: http://e.com\n\n[x]\n"), ["q2:a\n[x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("> > a\n> b\n> > [x]: http://e.com\n\n[x]\n").last, "t:[x]") + XCTAssertEqual(try quoteShape("> ```\n>> [x]: http://e.com\n> ```\n\n[x]\n").last, "t:[x]") + } + + /// A link definition taken out of a quote opens a paragraph of that + /// quote — of definitions only, which draws nothing — and a lazy line + /// right after it goes on in that quote (GitHub, gfm/h8_*.html). The + /// pre-pass dropped the line, so the lazy line landed outside the quote + /// or in the outer one. More definitions may follow (marked, lazy or at + /// fewer markers); after them `===` is text. Nothing follows: nothing is + /// drawn, as before. + func testALazyLineAfterAQuoteDefinitionStaysInTheQuote() throws { + XCTAssertEqual(try quoteShape("> [y]: http://e.com\nlazy\n\n[y]\n"), ["q1:lazy", "t:y"]) + XCTAssertEqual(try quoteShape("> a\n>\n> [y]: http://e.com\nlazy\n\n[y]\n"), ["q1:a\n\nlazy", "t:y"]) + XCTAssertEqual(try quoteShape("> a\n>> [z]: http://e.com\ncode\n\n[z]\n"), ["q1:a", "q2:code", "t:z"]) + XCTAssertEqual(try quoteShape("> a\n>> [z]: http://e.com\n> b\n\n[z]\n"), ["q1:a", "q2:b", "t:z"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> text\nlazy\n\n[x]\n"), ["q1:text\nlazy", "t:x"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n[y]: http://f.com\nlazy\n\n[x] [y]\n"), + ["q1:lazy", "t:x y"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [y]: http://f.com\nlazy\n\n[x] [y]\n"), + ["q1:lazy", "t:x y"]) + XCTAssertEqual(try quoteShape("> > [x]: http://e.com\n> [y]: http://f.com\n\n[x] [y]\n"), ["t:x y"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> ===\nlazy\n\n[x]\n"), ["q1:===\nlazy", "t:x"]) + XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [!NOTE]\n> body\n\n[x]\n"), ["alert:note:body", "t:x"]) + // Nothing follows, or a block does: no quote drawn. + XCTAssertEqual(try quoteShape("> [y]: http://e.com\n\n[y]\n"), ["t:y"]) + XCTAssertEqual(try quoteShape("> [y]: http://e.com\n# H\n\n[y]\n"), ["h:H", "t:y"]) + + // The quote starts at its first drawn line; every later block keeps + // its source line; no definition is drawn. + let markdown = "> a\n>> [z]: http://e.com\ncode\n\n> [y]: http://f.com\nlazy\n\n[z] [y]\n" + let blocks = try XCTUnwrap(parseReturning(markdown)) + XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 5, 7]) + let theme = MarkdownTheme.cached(for: .light) + let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: 600, math: nil) + let shown = blocks.compactMap { measured.converted[$0.id]?.string } + XCTAssertEqual(shown, ["a", "code", "lazy", "z y"]) + XCTAssertEqual(blocks.map(\.joinsPrevious), [false, true, false, false]) + } + + /// A FOOTNOTE definition on a quote line is a footnote (GitHub, + /// gfm/h9_*.html): in a quote, a nested or deeper one, after an alert's + /// body, after the quote's text (it may interrupt a paragraph, as at the + /// top level). The pre-pass tried only the LINK pattern on what follows + /// the markers, so `> [^m]: two` became a reference `^m` to `two` and + /// `[^m]` a link. Like a link definition (H8) it leaves a marker that + /// draws nothing and keeps a lazy line in the quote; at fewer markers it + /// is never lazy (it starts a block); in the quote's fence it is code. + func testAFootnoteDefinitionInAQuoteIsAFootnote() throws { + let note = "t:────────────────────────────────\n1. two in quote" + XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> > [^m]: two in quote\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> a\n>> [^m]: two in quote\n"), ["t:x ¹", "q1:a", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> [!NOTE]\n> body\n>\n> [^m]: two in quote\n"), + ["t:x ¹", "alert:note:body", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> text\n> [^m]: two in quote\n"), ["t:x ¹", "q1:text", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\nlazy\n"), ["t:x ¹", "q1:lazy", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\n>\n> after\n"), ["t:x ¹", "q1:after", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> > a\n> [^m]: two in quote\nlazy\n"), + ["t:x ¹", "q2:a", "q1:lazy", note]) + // In the quote's fence it is code. + XCTAssertEqual(try quoteShape("x [^m]\n\n> ```\n> [^m]: two in quote\n> ```\n"), + ["t:x [^m]", "q1:```\n[^m]: two in quote\n```"]) + // `[^]` is no footnote label: a link, as on GitHub (h9_caret_empty). + XCTAssertEqual(try quoteShape("[^]: http://e.com\n\n[^]\n"), ["t:^"]) + // The quote's first drawn line is its source line; the marker is the footnote's. + let blocks = try XCTUnwrap(parseReturning("x [^m]\n\n> [^m]: two in quote\nlazy\n")) + XCTAssertEqual(blocks.map(\.sourceLine).prefix(2), [0, 3]) + } + + /// An alert needs a body (GitHub, gfm/h10_*.html): `> [!NOTE]` alone, + /// or followed only by blank `>` lines or definitions the pre-pass took + /// out, is a plain quote "[!NOTE]" — it was an alert with an empty box. + /// Any block after the marker is a body: text, a lazy line, a list, a + /// heading, a nested quote (even an empty one). + func testAnAlertNeedsABody() throws { + XCTAssertEqual(try quoteShape("> [!NOTE]\n"), ["q1:[!NOTE]"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n>\n"), ["q1:[!NOTE]"]) + XCTAssertEqual(try quoteShape("> [!TIP]\n\ntext\n"), ["q1:[!TIP]", "t:text"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n>\n> [x]: http://e.com\n\n[x]\n"), ["q1:[!NOTE]", "t:x"]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> [!NOTE]\n> [^m]: note\n").prefix(2), ["t:x ¹", "q1:[!NOTE]"]) + // A body: an alert. + XCTAssertEqual(try quoteShape("> [!NOTE]\n> text\n"), ["alert:note:text"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n>\n> text\n"), ["alert:note:text"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\nlazy\n"), ["alert:note:lazy"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> - item\n"), ["alert:note:- item"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> # H\n"), ["alert:note:# H"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> [x]: http://e.com\n\n[x]\n"), + ["alert:note:[x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> > nested\n"), ["alert:note:", "q2:nested"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n> >\n"), ["alert:note:"]) + XCTAssertEqual(try quoteShape("> [!NOTE]\n>> [x]: http://e.com\n\n[x]\n"), ["alert:note:", "t:x"]) + } + + /// A footnote label is cmark-gfm's: one or more characters but `]`, + /// spaces, tabs and line breaks (a no-break space, `^`, `[`, `\` and + /// punctuation are fine); the text may follow the colon directly, or be + /// empty (GitHub, gfm/h11_*.html). The pattern took any label, so + /// `[^ m]: url` was a footnote where GitHub reads a LINK definition, and + /// it needed a space after the colon, so `[^m]:note` was neither. At + /// every level, footnote before link (constraint 6). + func testAFootnoteLabelIsGitHubs() throws { + let note = "t:────────────────────────────────\n1. note" + // Whitespace in the label: a link definition. + XCTAssertEqual(try quoteShape("x [^ m]\n\n[^ m]: http://e.com\n"), ["t:x ^ m"]) + XCTAssertEqual(try quoteShape("x [^a b]\n\n[^a b]: http://e.com\n"), ["t:x ^a b"]) + XCTAssertEqual(try quoteShape("x [^a\tb]\n\n[^a\tb]: http://e.com\n"), ["t:x ^a\tb"]) + XCTAssertEqual(try quoteShape("x [^ m]\n\n> [^ m]: http://e.com\n"), ["t:x ^ m"]) + XCTAssertEqual(try quoteShape("x [^]\n\n[^]: http://e.com\n"), ["t:x ^"]) + // Footnotes. + XCTAssertEqual(try quoteShape("x [^m]\n\n[^m]:note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]:note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^m]\n\n[^m]:\n").first, "t:x ¹") + XCTAssertEqual(try quoteShape("x [^a\u{00A0}b]\n\n[^a\u{00A0}b]: note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^a.b-c_d]\n\n[^a.b-c_d]: note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^a^b]\n\n[^a^b]: note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^a\\b]\n\n[^a\\b]: note\n"), ["t:x ¹", note]) + XCTAssertEqual(try quoteShape("x [^ä€]\n\n[^ä€]: note\n"), ["t:x ¹", note]) + } + + func testALinkDefinitionCannotInterruptAParagraph() throws { + XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("- a\n[x]: http://e.com\n\n[x]\n"), ["t:• a [x]: http://e.com", "t:[x]"]) + XCTAssertEqual(try quoteShape("| a |\n|---|\n| 1 |\n[x]: http://e.com\n\n[x]\n").last, "t:[x]") + // Where no paragraph goes on, it is a definition. + XCTAssertEqual(try quoteShape("Para\n\n[x]: http://e.com\n\n[x]\n"), ["t:Para", "t:x"]) + XCTAssertEqual(try quoteShape("# H\n[x]: http://e.com\n\n[x]\n"), ["h:H", "t:x"]) + XCTAssertEqual(try quoteShape("---\n[x]: http://e.com\n\n[x]\n").last, "t:x") + XCTAssertEqual(try quoteShape("Title\n===\n[x]: http://e.com\n\n[x]\n"), ["h:Title", "t:x"]) + XCTAssertEqual(try quoteShape("```\ncode\n```\n[x]: http://e.com\n\n[x]\n"), ["c:code", "t:x"]) + XCTAssertEqual(try quoteShape("[x]: http://e.com\n[y]: http://f.com\n\n[x] [y]\n"), ["t:x y"]) + XCTAssertEqual(try quoteShape("> a\n>\n[x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"]) + XCTAssertEqual(try quoteShape("> ```\n> code\n[x]: http://e.com\n\n[x]\n"), ["q1:```\ncode", "t:x"]) + // Footnote definitions interrupt a paragraph, as before. + XCTAssertEqual(try quoteShape("Text[^1]\n\nPara\n[^1]: note\n").prefix(2), ["t:Text¹", "t:Para"]) + XCTAssertEqual(try quoteShape("Text[^1]\n\n> a\n[^1]: note\n").prefix(2), ["t:Text¹", "q1:a"]) + } + + // MARK: - Indented fences + + /// A fence indented N spaces drops up to N spaces from each of its lines + /// (CommonMark; GitHub, gfm/b1_*.html). It kept them: every code block + /// of a numbered-steps list was drawn, searched and copied shifted right. + func testAnIndentedFenceDropsItsIndentationFromItsLines() throws { + XCTAssertEqual(try quoteShape("1. Step\n\n ```bash\n cmd\n nested\n ```\n"), + ["t:1. Step", "c:cmd\n nested"]) + XCTAssertEqual(try quoteShape(" ```\n code\n code2\nflush\n ```\n"), ["c:code\n code2\nflush"]) + XCTAssertEqual(try quoteShape("1. Step\n ```\n cmd\n ```\n"), ["t:1. Step", "c:cmd"]) + XCTAssertEqual(try quoteShape("```\n keep\n```\n"), ["c: keep"]) + XCTAssertEqual(try quoteShape(" ~~~\n a\n\n b\n ~~~\n"), ["c:a\n\nb"]) + // Search counts, and copy reads, what is drawn. + let blocks = try XCTUnwrap(parseReturning("1. Step\n\n ```\n let needle = 1\n ```\n")) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: " let").matchBlockIds, []) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "let needle").matchBlockIds, [blocks[1].id]) + } + + /// A tab that reaches past the fence's indentation keeps the columns it + /// has left, as spaces (CommonMark; GitHub, gfm/h3_*.html): the tab was + /// kept whole, a code line drawn two columns too deep. A later tab stays. + func testAnIndentedFenceKeepsWhatATabHasLeft() throws { + XCTAssertEqual(try quoteShape("- a\n\n ```\n\tTab\n ```\n"), ["t:• a", "c: Tab"]) + XCTAssertEqual(try quoteShape(" ```\n\tTab\n ```\n"), ["c: Tab"]) + XCTAssertEqual(try quoteShape(" ```\n\tTab\n ```\n"), ["c: Tab"]) + XCTAssertEqual(try quoteShape(" ```\n\t\tTab\n ```\n"), ["c: \tTab"]) + XCTAssertEqual(try quoteShape(" ```\n \tTab\n ```\n"), ["c: Tab"]) + // A tab inside the indentation goes, one after it stays whole. + XCTAssertEqual(MarkdownBlockParser.removingIndentation("\tx", upTo: 4), "x") + XCTAssertEqual(MarkdownBlockParser.removingIndentation(" \tx", upTo: 2), "\tx") + XCTAssertEqual(MarkdownBlockParser.removingIndentation("x", upTo: 2), "x") + } + + /// Source Edit finds a selection made in such a block in the source, + /// whose lines still carry the indentation: a multi-line selection is + /// matched with the spaces and tabs at the start of each source line + /// skipped (a literal match finds it no longer). + func testASelectionInAnIndentedFenceIsFoundInTheSource() { + let source = "1. Step\n\n ```\n cmd\n nested\n ```\n" as NSString + let range = SourceEditSupport.sourceRange(ofSelection: "cmd\n nested", before: "", after: "", + in: source, lines: 3..<5) + XCTAssertEqual(range.map { source.substring(with: $0) }, "cmd\n nested") + // A literal match wins where there is one. + let flat = "```\ncmd\n nested\n```\n" as NSString + let literal = SourceEditSupport.sourceRange(ofSelection: "cmd\n nested", before: "", after: "", + in: flat, lines: 1..<3) + XCTAssertEqual(literal.map { flat.substring(with: $0) }, "cmd\n nested") + } + + // MARK: - Setext headings over a paragraph + + /// A Setext underline makes the WHOLE paragraph above it the heading + /// (CommonMark; GitHub, gfm/g4_*.html), its lines joined like soft + /// breaks, on the paragraph's first line. Only the last line was the + /// heading; the lines above stayed a paragraph. A paragraph that is a + /// list item's is no heading (its `===` goes on as text, its `---` is a + /// rule), and a quote's lazy lines are the quote's (F6). + func testASetextUnderlineMakesTheWholeParagraphTheHeading() throws { + XCTAssertEqual(try quoteShape("Para one\nline two\n===\n"), ["h:Para one line two"]) + XCTAssertEqual(try quoteShape("one\ntwo\nthree\n---\n"), ["h:one two three"]) + let markdown = "Intro\n\nPara one\nline two\n---\n\nAfter\n" + let blocks = try XCTUnwrap(parseReturning(markdown)) + XCTAssertEqual(try quoteShape(markdown), ["t:Intro", "h:Para one line two", "t:After"]) + XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 6]) + if case .heading(let level, _, let line) = blocks[1].content { + XCTAssertEqual(level, 2) + XCTAssertEqual(line, 2) + } + // After blocks that close a paragraph. + XCTAssertEqual(try quoteShape("# H\nline a\nline b\n===\n"), ["h:H", "h:line a line b"]) + XCTAssertEqual(try quoteShape("```\nx\n```\nline a\nline b\n===\n"), ["c:x", "h:line a line b"]) + XCTAssertEqual(try quoteShape("***\nline a\nline b\n===\n").last, "h:line a line b") + // A list item's paragraph is no heading. + XCTAssertEqual(try quoteShape("- a\nb\n===\n"), ["t:• a b ==="]) + XCTAssertEqual(try quoteShape("- a\nb\n---\n").first, "t:• a b") + XCTAssertEqual(try quoteShape("- a\nb\n---\n").count, 2) + XCTAssertEqual(try quoteShape("Para\n- item\nb\n===\n"), ["t:Para", "t:• item b ==="]) + XCTAssertEqual(try quoteShape("1984. A year\nline\n===\n"), ["t:1984. A year line ==="]) + XCTAssertEqual(try quoteShape("> a\nb\n===\n"), ["q1:a\nb\n==="]) + + // The tint spans the paragraph and its underline; Section copy + // starts at its first line; the anchor is the whole title's. + let ns = markdown as NSString + let spans = SourceTint.spans(for: blocks, lineStarts: SourceEditSupport.lineStarts(in: ns), in: ns) + XCTAssertEqual(spans.filter { $0.kind == .heading }.map { ns.substring(with: $0.range) }, + ["Para one\nline two\n---"]) + let entries = ToCEntry.entries(of: blocks) + XCTAssertEqual(SectionExtractor.extractSection(from: markdown, entry: entries[0], headings: entries), + "Para one\nline two\n---\n\nAfter") + // GitHub's id drops the line break (gfm/h1_words.html). + let anchors = HeadingAnchors.headings(of: blocks, renderer: MarkdownRenderer(theme: .cached(for: .light))) + XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), ["para-oneline-two"]) + } + + // MARK: - Long quotes + + /// A width change lays a quote or alert body out again WITHOUT rendering + /// it: the measurer takes the string the previous measurement of the same + /// parse built (`reusing`, the caller's `measured.converted`), as a + /// paragraph's string is stored in its block. It rendered the body again + /// on every width change and reading-mode toggle — a 5,000-line quote + /// cost ~530 ms per re-measure (-O) against ~160 ms for the same text as + /// a paragraph. The heights and the rhythm are a fresh measurement's. + func testAWidthChangeLaysAQuoteOutAgainWithoutRenderingIt() throws { + let long = "words that wrap at a narrow column but not at a wide one, and a few more of them" + let markdown = "Para\n\n> \(long)\nand a lazy line\n\n> > nested \(long)\n\n" + + "> [!NOTE]\n> \(long)\n\n> [!TIP]\n> >\n\nEnd\n" + let blocks = try XCTUnwrap(parseReturning(markdown)) + let theme = MarkdownTheme.cached(for: .light) + let first = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: 700, math: nil) + let again = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: 260, reusing: first.converted, math: nil) + let fresh = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, + contentWidth: 260, math: nil) + XCTAssertNotEqual(first.table.heights, fresh.table.heights) // the wrap moved + XCTAssertEqual(again.table.heights, fresh.table.heights) + XCTAssertEqual(again.rhythm, fresh.rhythm) + let bodies = blocks.filter { block in + switch block.content { + case .blockquote: return true + case .alert(_, let content): return !content.isEmpty + default: return false + } + } + XCTAssertEqual(bodies.count, 3) + for block in bodies { + let reused = try XCTUnwrap(again.converted[block.id], block.id) + XCTAssertTrue(reused === first.converted[block.id], block.id) + XCTAssertEqual(reused, fresh.converted[block.id], block.id) + } + // Only those: a paragraph's string is built from its block again. + let paragraph = try XCTUnwrap(blocks.first) + XCTAssertFalse(again.converted[paragraph.id] === first.converted[paragraph.id]) + } + + // MARK: - Tables + + /// A block start ends a table's body (GFM: GitHub closes the table for + /// any of them — a fence, a quote, an ATX heading, a list item, an HTML + /// block; gfm/h2_*.html — and `$$` opens a formula here). The body took + /// every line with a `|`: a fence line with one became a row while the + /// definition pre-pass read it as a fence, and the fence's closing line + /// then opened a fence that ran to the end of the document. + func testABlockStartEndsATablesBody() throws { + let table = "| a |\n|---|\n| 1 |\n" + func bodyRows(_ markdown: String) throws -> [Int] { + try XCTUnwrap(parseReturning(markdown)).compactMap { block in + guard case .table(_, let rows, _, _) = block.content else { return nil } + return rows.count + } + } + XCTAssertEqual(try quoteShape(table + "```x|\ncode\n```\nafter\n"), ["table", "c:code", "t:after"]) + XCTAssertEqual(try quoteShape(table + "~~~ |\ncode\n~~~\n"), ["table", "c:code"]) + XCTAssertEqual(try quoteShape(table + "> q | r\n"), ["table", "q1:q | r"]) + XCTAssertEqual(try quoteShape(table + "# H | x\n"), ["table", "h:H | x"]) + XCTAssertEqual(try quoteShape(table + "- b | c\n"), ["table", "t:• b | c"]) + XCTAssertEqual(try quoteShape(table + "2. b | c\n"), ["table", "t:2. b | c"]) + XCTAssertEqual(try bodyRows(table + "<div>|</div>\n"), [1]) + XCTAssertEqual(try bodyRows(table + "$$ |\nx\n$$\n\n[x]: http://e.com\n\n[x]\n"), [1]) + XCTAssertEqual(try quoteShape(table + "$$ |\nx\n$$\n\n[x]: http://e.com\n\n[x]\n").last, "t:x") + // A row stays a row. + XCTAssertEqual(try bodyRows(table + "b | c\n| 3 |\n"), [3]) + // In a quote too: the item's paragraph goes on lazily, a heading's + // does not (gfm/h2_quote_table_*_lazy.html). + let quoted = "> | a |\n> |---|\n> | 1 |\n" + XCTAssertEqual(try quoteShape(quoted + "> - b | c\nlazy\n"), ["q1:| a |\n|---|\n| 1 |\n- b | c\nlazy"]) + XCTAssertEqual(try quoteShape(quoted + "> # H | x\nlazy\n"), ["q1:| a |\n|---|\n| 1 |\n# H | x", "t:lazy"]) + } + + /// The lines a quote absorbed are the quote's: its source line is its + /// first, the next block starts after them, the tint spans them, search + /// finds and copy reads them in the quote — and a long hard-wrapped quote + /// with a marker on its first line only parses (the hang guard) into one + /// quote row. + func testTheLinesAQuoteAbsorbedAreTheQuotes() throws { + let markdown = "Intro\n\n> Quoted start\nlazy middle word\nlazy end\n\nAfter\n" + let blocks = try XCTUnwrap(parseReturning(markdown)) + XCTAssertEqual(blocks.count, 3) + XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 6]) + let ns = markdown as NSString + let spans = SourceTint.spans(for: blocks, lineStarts: SourceEditSupport.lineStarts(in: ns), in: ns) + XCTAssertEqual(spans.map { ns.substring(with: $0.range) }, ["> Quoted start\nlazy middle word\nlazy end"]) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "middle").matchBlockIds, [blocks[1].id]) + let measured = measure(blocks) + XCTAssertEqual(measured.converted[blocks[1].id].map(\.string), "Quoted start lazy middle word lazy end") + + let long = "> first\n" + String(repeating: "a hard-wrapped line of the quote\n", count: 5_000) + "\nAfter\n" + let longBlocks = try XCTUnwrap(parseReturning(long, within: 10)) + XCTAssertEqual(longBlocks.count, 2) + if case .blockquote(let content, _) = longBlocks[0].content { + XCTAssertEqual(content.components(separatedBy: "\n").count, 5_001) + } else { + XCTFail("\(longBlocks[0].content)") + } + } + + // MARK: - Anchor signatures + + /// The scroll anchor is found again after a reload by the block's content + /// (`anchorSignature`). A table's was its row count and header: every + /// header-less table (blank header cells) and the front-matter properties + /// table (no header) of the same length had the same one, so a reload + /// could put the reader on the wrong table. + func testHeaderlessTablesAndFrontMatterHaveDistinctAnchorSignatures() { + let markdown = """ + --- + title: Notes + author: Ann + --- + + | | | + |---|---| + | alpha | 1 | + | beta | 2 | + + Between. + + | | | + |---|---| + | gamma | 3 | + | delta | 4 | + """ + let tables = parse(markdown).filter { if case .table = $0.content { return true }; return false } + XCTAssertEqual(tables.count, 3) + XCTAssertEqual(Set(tables.map(\.anchorSignature)).count, 3, "\(tables.map(\.anchorSignature))") + // Stable across a re-parse, and still distinctive with an edit above. + let again = parse("Edited above.\n\n" + markdown) + .filter { if case .table = $0.content { return true }; return false } + XCTAssertEqual(again.map(\.anchorSignature).suffix(2), tables.map(\.anchorSignature).suffix(2)) + // A table with a header keeps it in front. + let headed = parse("| Name | Value |\n|---|---|\n| a | 1 |")[0] + XCTAssertTrue(headed.anchorSignature.hasPrefix("b|1|Name\u{1F}Value\u{1F}a"), headed.anchorSignature) + } + + // MARK: - Partial re-measures carry the column + + @MainActor + func testMeasureMathRowsDerivesTheSameColumnAsAFullMeasure() { + let blocks = parse("## Heading\n\nInline $x^2$ math on the first line.\n\n$$\ny = x\n$$\n\nAfter.\n\n### Next\nText.") + let deferred = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1.25, + contentWidth: 640, math: nil) + XCTAssertFalse(deferred.mathPendingRows.isEmpty) + let finished = BlockHeightMeasurer.measureMathRows(deferred.mathPendingRows, in: deferred, blocks: blocks, + theme: theme, fontScale: 1.25, contentWidth: 640, + math: math) + let direct = measure(blocks, fontScale: 1.25) + XCTAssertEqual(finished.table, direct.table) + XCTAssertEqual(finished.rhythm, direct.rhythm) + } + + func testHeightReportChangesTheBlockNotItsSpacing() { + let measured = measure(parse("Text.\n\n## Heading\n\nMore text.")) + let patched = measured.replacingHeight(123, at: 1) + XCTAssertEqual(patched.table.heights[1], 123) + XCTAssertEqual(patched.table.spacingAbove, measured.table.spacingAbove) + XCTAssertEqual(patched.rhythm, measured.rhythm) + XCTAssertEqual(patched.table.rowHeight(1), measured.table.spacing(1) + 123) + XCTAssertEqual(measured.replacingHeight(5, at: 99).table, measured.table) + } + + // MARK: - Row geometry (VirtualBlockList) + + func testRowGeometryPutsJumpsAndHitsOnTheBlock() { + let row = NSRect(x: 0, y: 100, width: 500, height: 60) + XCTAssertEqual(RowGeometry.contentRect(rowRect: row, spacingAbove: 20), + NSRect(x: 0, y: 120, width: 500, height: 40)) + XCTAssertEqual(RowGeometry.contentRect(rowRect: row, spacingAbove: 0), row) + XCTAssertEqual(RowGeometry.contentRect(rowRect: row, spacingAbove: 90).height, 0) + // `.top`: the block's top, not the row's. + XCTAssertEqual(RowGeometry.topTarget(rowRect: row, spacingAbove: 20), 120) + // `.center`: the block's middle (140) at the middle of a 400 pt area. + XCTAssertEqual(RowGeometry.centerTarget(rowRect: row, spacingAbove: 20, visibleHeight: 400), -60) + // The gap above the block: upper half → the previous row. + XCTAssertTrue(RowGeometry.belongsToPreviousRow(y: 109.9, rowRect: row, spacingAbove: 20)) + XCTAssertFalse(RowGeometry.belongsToPreviousRow(y: 110, rowRect: row, spacingAbove: 20)) + XCTAssertFalse(RowGeometry.belongsToPreviousRow(y: 100, rowRect: row, spacingAbove: 0)) + // An atomic block splits at ITS middle. + XCTAssertTrue(RowGeometry.isInUpperHalf(y: 139.9, rowRect: row, spacingAbove: 20)) + XCTAssertFalse(RowGeometry.isInUpperHalf(y: 140, rowRect: row, spacingAbove: 20)) + } + + /// ⌘G into a block taller than the window (a big table, a long code + /// block) used to centre the BLOCK: a match near its end stayed below the + /// window. The match is what is centred now; a block that fits is still + /// centred as a whole, and a step within it does not move it. + func testASearchJumpIntoABlockTallerThanTheWindowCentresTheMatch() throws { + let visible: CGFloat = 800 + let tall = NSRect(x: 0, y: 1000, width: 600, height: 3016) // block 1016...4016 + let matchMidY: CGFloat = 3700 + + let jump = try XCTUnwrap(RowGeometry.searchTarget(rowRect: tall, spacingAbove: 16, visibleHeight: visible, + matchMidY: matchMidY)) + XCTAssertEqual(jump.target, 3300) + XCTAssertFalse(jump.needsMatch) + XCTAssertTrue((jump.target...(jump.target + visible)).contains(matchMidY)) + // The old target (the block's middle) left that match off screen. + let blockCentre = RowGeometry.centerTarget(rowRect: tall, spacingAbove: 16, visibleHeight: visible) + XCTAssertFalse((blockCentre...(blockCentre + visible)).contains(matchMidY)) + + // Stepping to another match of the same tall block moves to it. + let step = try XCTUnwrap(RowGeometry.searchTarget(rowRect: tall, spacingAbove: 16, visibleHeight: visible, + matchMidY: 1900, withinBlock: true)) + XCTAssertEqual(step.target, 1500) + + // The match not known yet (the block's view is not on display): the + // block first, then the match when it lands. + let blind = try XCTUnwrap(RowGeometry.searchTarget(rowRect: tall, spacingAbove: 16, visibleHeight: visible, + matchMidY: nil)) + XCTAssertEqual(blind.target, blockCentre) + XCTAssertTrue(blind.needsMatch) + + // A block that fits: centred as before, the match ignored; a step + // within it does not scroll. + let short = NSRect(x: 0, y: 100, width: 500, height: 60) + let fits = try XCTUnwrap(RowGeometry.searchTarget(rowRect: short, spacingAbove: 20, visibleHeight: 400, + matchMidY: 150)) + XCTAssertEqual(fits.target, -60) + XCTAssertFalse(fits.needsMatch) + XCTAssertNil(RowGeometry.searchTarget(rowRect: short, spacingAbove: 20, visibleHeight: 400, + matchMidY: 150, withinBlock: true)) + } + + // MARK: - Scroll anchor across layouts (Reading Mode, width, zoom, jumps) + + /// `VirtualBlockList.Coordinator`'s scrolling over a model column, step by + /// step as the coordinator runs it: capture → new table → place the + /// anchor (`install`), the reading-mode toggle, jumps, and the height + /// reports of the `.reported` rows that are on screen. The decisions are + /// the coordinator's own (`RowGeometry.isAt`, `anchorPlacement`, + /// `reportAction`); `current == false` replays the rules of the branch + /// before this fix, as a control: re-anchor on the row under the content + /// top every time, place the offset as is, compensate any block whose top + /// is above the content top. + private struct ListModel { + struct Row { + var spacing: CGFloat + /// What the list lays the block out at (the height table). + var height: CGFloat + /// What the block's view measures. + var real: CGFloat + /// `.reported`: its view reports `real` once it is on screen. + var reported: Bool + + static func exact(_ spacing: CGFloat, _ height: CGFloat) -> Row { + Row(spacing: spacing, height: height, real: height, reported: false) + } + static func reported(_ spacing: CGFloat, _ estimate: CGFloat, real: CGFloat) -> Row { + Row(spacing: spacing, height: estimate, real: real, reported: true) + } + } + + var rows: [Row] + var contentTop: CGFloat + var current = true + var inset: CGFloat = 24 + var clipHeight: CGFloat = 828 + var anchor: (row: Int, offset: CGFloat, placed: CGFloat)? + /// A jump in flight: the content top is still on its way to here. + var jumpTarget: CGFloat? + /// Rows whose view reported since the last table (`reportedBlocks`). + var confirmed: Set<Int> = [] + /// A layout switch is under way (`isSwitchingLayout`): from the + /// style toggle to the install of the table for the new width. + var switching = false + /// The style toggle pins the anchor until the install (false: the + /// rules of 4f67351, sticky anchor without the pin). + var pinsLayoutSwitch = true + /// Height reports that changed a row (`reportHeight` past its 0.5 pt + /// threshold): every one is a swing of the table. + var reports = 0 + + var rects: [NSRect] { + var y: CGFloat = 0 + return rows.map { row in + defer { y += row.spacing + row.height } + return NSRect(x: 0, y: y, width: 500, height: row.spacing + row.height) + } + } + func blockTop(_ row: Int) -> CGFloat { + RowGeometry.topTarget(rowRect: rects[row], spacingAbove: rows[row].spacing) + } + /// Where block `row`'s top is on screen, from the top of the clip view. + func screenY(_ row: Int) -> CGFloat { blockTop(row) - (contentTop - inset) } + func rowUnderContentTop() -> Int { + rects.firstIndex { contentTop >= $0.minY && contentTop < $0.maxY } ?? rows.count - 1 + } + /// The rows AppKit has views for: those in the clip view, whose top + /// inset shows content too. + var rowsOnScreen: [Int] { + let top = contentTop - inset + return rects.indices.filter { rects[$0].maxY > top && rects[$0].minY < top + clipHeight } + } + + func anchoredRow() -> Int? { + guard current, let anchor else { return nil } + if jumpTarget != nil || switching { return anchor.row } + return RowGeometry.isAt(anchorOffset: anchor.placed, contentTop: contentTop, + rowRect: rects[anchor.row], spacingAbove: rows[anchor.row].spacing) + ? anchor.row : nil + } + func capture() -> (row: Int, offset: CGFloat) { + if let row = anchoredRow(), let anchor { return (row, anchor.offset) } + let row = rowUnderContentTop() + return (row, RowGeometry.anchorOffset(contentTop: contentTop, rowRect: rects[row], + spacingAbove: rows[row].spacing)) + } + mutating func place(_ row: Int, _ offset: CGFloat) { + let placed = current + ? RowGeometry.anchorPlacement(offset: offset, blockHeight: rows[row].height, topInset: inset, + heightIsEstimate: rows[row].reported && !confirmed.contains(row)) + : offset + if jumpTarget != nil { + jumpTarget = blockTop(row) + placed + } else { + contentTop = blockTop(row) + placed + } + anchor = (row, offset, placed) + } + mutating func report(_ row: Int) { + reports += 1 + let top = contentTop + let delta = rows[row].real - rows[row].height + let blockTop = blockTop(row) + let anchorRow = anchoredRow() + rows[row].height = rows[row].real + guard current else { + if blockTop < top, jumpTarget == nil { contentTop = top + delta } + return + } + switch RowGeometry.reportAction(row: row, anchorRow: anchorRow, blockTop: blockTop, contentTop: top) { + case .keep: + break + case .shift where jumpTarget != nil || switching, .placeAnchor: + if let anchor { place(anchor.row, anchor.offset) } + case .shift: + contentTop = top + delta + } + } + /// Every view on screen reports; a report can bring new rows on screen. + mutating func settle() { + for _ in 0..<50 { + confirmed.formUnion(rowsOnScreen.filter { rows[$0].reported }) + guard let row = rowsOnScreen.first(where: { + rows[$0].reported && abs(rows[$0].real - rows[$0].height) >= 0.5 + }) else { return } + report(row) + } + XCTFail("the reports never settled") + } + /// A new table (zoom, width): capture, install, place, reports. + mutating func install(_ new: [Row], settling: Bool = true) { + let captured = capture() + rows = new + anchor = nil + jumpTarget = nil + switching = false + confirmed = [] + place(captured.row, captured.offset) + if settling { settle() } + } + /// `applyLayoutStyle` mid-document: the anchor minus the inset delta. + mutating func toggleStyle(inset newInset: CGFloat) { + let captured = capture() + let delta = newInset - inset + inset = newInset + place(captured.row, captured.offset - delta) + switching = current && pinsLayoutSwitch + } + /// Something other than the list moves the clip origin. + mutating func clipMoved(by dy: CGFloat) { contentTop += dy } + /// A `.center` jump that lands after the views on screen reported. + mutating func centerJump(to row: Int) { + let target = RowGeometry.centerTarget(rowRect: rects[row], spacingAbove: rows[row].spacing, + visibleHeight: clipHeight - 2 * inset) + jumpTarget = target + anchor = (row, target - blockTop(row), target - blockTop(row)) + settle() // still at the start of the glide + contentTop = jumpTarget ?? target + jumpTarget = nil + settle() // landed + } + /// The block's middle on screen minus the content area's middle. + func offCentre(_ row: Int) -> CGFloat { + let middle = blockTop(row) + rows[row].height / 2 + return (middle - contentTop) - (clipHeight - 2 * inset) / 2 + } + } + + /// fixture.md around the image, as at 100 % zoom (`scale` 1), wrapped at + /// `wrap` lines per short paragraph (2 = Reading Mode's 720 pt): the + /// image is a placeholder in every new table (`imageEstimate`) and + /// `imageReal` once loaded; the diagram starts at the 200 pt default. + private func fixtureRows(scale s: CGFloat = 1, wrap: CGFloat = 1, imageReal: CGFloat = 655, + imageEstimate: CGFloat = 116, diagram: CGFloat = 94, + diagramEstimate: CGFloat = 224) -> [ListModel.Row] { + let paragraph = { (spacing: CGFloat) in ListModel.Row.exact(spacing * s, 17 * s * wrap) } + var rows: [ListModel.Row] = [.reported(0, 39 * s, real: 39 * s)] // # Rhythm fixture + rows += (0..<10).map { _ in paragraph(25) } // the long list + rows += [ + .reported(50 * s, 33 * s, real: 33 * s), // ## Image and caption + .reported(16 * s, imageEstimate, real: imageReal), // the image + .exact(17 * s, 17 * s), // its caption + .reported(50 * s, 33 * s, real: 33 * s), // ## Math + paragraph(19), + .reported(13 * s, 64 * s, real: 64 * s), // a formula + paragraph(13), + .exact(21 * s, 66 * s), // the latex fallback + paragraph(21), + .reported(50 * s, 33 * s, real: 33 * s), // ## Diagram + .reported(12 * s, diagramEstimate, real: diagram + 24), // the diagram + .exact(5 * s, 79 * s), // a table + .reported(50 * s, 39 * s, real: 39 * s), // # Second part + ] + rows += (0..<12).map { _ in paragraph(25) } + return rows + } + private let image = 12, textAfterFormulas = 19, secondPart = 23 + + /// The debug log of the drift (`full.log`): at 819 pt every height real, + /// the content top 11.5 pt above "Text after the formulas"; Reading Mode + /// re-measures at 720 (the image back at its placeholder, the diagram + /// seeded); between leaving Reading Mode and the 819 table the clip moves + /// by -37.5 pt without the list moving it; the 819 re-measure has the + /// image at its placeholder again. The reader comes back to the point. + func testReadingModeRoundTripSurvivesAClipMoveDuringTheSwitch() { + for current in [true, false] { + var rows = fixtureRows() + for index in rows.indices { rows[index].height = rows[index].real } + var list = ListModel(rows: rows, contentTop: 0, current: current) + list.contentTop = list.blockTop(textAfterFormulas) - 11.5 + list.confirmed = Set(rows.indices) + let seen = list.screenY(textAfterFormulas) + + list.toggleStyle(inset: 48) + list.install(fixtureRows(wrap: 2, diagramEstimate: 118)) + list.toggleStyle(inset: 24) + list.clipMoved(by: -37.5) + list.install(fixtureRows(diagramEstimate: 118)) + + if current { + XCTAssertEqual(list.screenY(textAfterFormulas), seen, accuracy: 1) + XCTAssertEqual(list.contentTop - list.blockTop(textAfterFormulas), -11.5, accuracy: 0.001) + } else { + // Re-anchored by geometry on the latex block: 37.5 pt off. + XCTAssertEqual(list.screenY(textAfterFormulas) - seen, 37.5, accuracy: 1) + } + } + } + + /// fixture.md row by row as the app measures it (41 rows): the measurer's + /// table at `width` (819 = the window, 720 = Reading Mode), with the two + /// formulas as SwiftMath and the 1.14.3 fallback leave them (65 pt; a + /// ```latex block, 21 pt above and below) and the diagram at + /// `diagramEstimate` (224 = the default, 94 = seeded). It reproduces the + /// debug log of the round trip to the point: table 3452 / 3322 / 3492 pt, + /// "Second part" at 1958 / 1828 / 1998. + private func searchFixtureRows(width: CGFloat, diagramEstimate: CGFloat) -> [ListModel.Row] { + let wrapped: CGFloat = width < 800 ? 34 : 17 // the ordered items + var rows: [ListModel.Row] = [ + .reported(0, 39, real: 39), // 0 # Rhythm fixture + .exact(19, 17), .exact(21, 66), .exact(21, 17), // 1-3 intro, bash, "Features:" + .exact(25, 34), // 4 the tight list + .reported(50, 33, real: 33), // 5 ## Quotes + .exact(17, 21), .exact(0, 21), .exact(0, 21), // 6-8 + .reported(48, 33, real: 33), // 9 ## Loose ordered list + .exact(19, wrapped), // 10 + ] + rows += (0..<9).map { _ in .exact(25, wrapped) } // 11-19 + rows += [ + .reported(50, 33, real: 33), // 20 ## Image and caption + .reported(16, 116, real: 655), // 21 the image (never on screen here) + .exact(17, 17), // 22 its caption + .reported(50, 33, real: 33), // 23 ## Math + .exact(19, 17), .exact(13, 65), .exact(13, 17), // 24-26 + .exact(21, 66), // 27 the formula SwiftMath refuses + .exact(21, 17), // 28 "Text after the formulas." + .reported(50, 33, real: 33), // 29 ## Diagram + .reported(4, diagramEstimate, real: 94), // 30 the diagram + .exact(0, 79), // 31 the table + .reported(50, 39, real: 39), // 32 # Second part + .reported(15, 29, real: 29), .reported(16, 25, real: 25), + .exact(19, 17), // 35 + .reported(50, 33, real: 33), // 36 ## Long list + .exact(19, 510), .exact(0, 510), .exact(0, 170), // 37-39 + .exact(25, 17), // 40 + ] + return rows + } + + // MARK: - Pictures and diagrams shown once are measured exactly + + /// The fixture's picture rows as the GUI reported them (debug logs of the + /// zoom and Reading Mode round trips): the 1024 px icon with its "QuickMD + /// icon" caption, and the "Start → End" flowchart — by width and zoom. + private let guiPictureRows: [(width: CGFloat, scale: CGFloat, image: CGFloat, diagram: CGFloat)] = [ + (819, 1.0, 655, 94), (819, 1.1, 716, 101), (819, 1.25, 809, 111.5), (720, 1.0, 655, 94), + ] + private let fixtureDiagram = "flowchart LR\n A[Start] --> B[End]" + + /// Once the icon has been shown (its size known) and the diagram has + /// rendered (its geometry known), the measurer gives exactly the rows the + /// views reported in the GUI, at every width and zoom of the round trips — + /// so no re-measure sends them back to the 116 / 224 pt placeholders and no + /// view reports. Before the icon is known, the row is the loading view, + /// 155 — also what the GUI reported. + func testPicturesShownOnceAreMeasuredAsTheirViewsReportThem() throws { + let blocks = parse("![QuickMD icon](icon.png)\n\n```mermaid\n\(fixtureDiagram)\n```") + guard case .mermaidDiagram(let source) = blocks[1].content else { return XCTFail("not a diagram") } + MermaidGeometryStore.set(.init(naturalWidth: 240, naturalHeight: 70, overhead: 0), + source: source, isDark: theme.isDark) + let sizes: [NSString: CGSize] = ["icon.png": CGSize(width: 1024, height: 1024)] + for row in guiPictureRows { + let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: row.scale, + contentWidth: row.width, imageSizes: sizes, math: math) + XCTAssertEqual(measured.table.heights[0], row.image, "image at \(row.width) × \(row.scale)") + XCTAssertEqual(measured.table.heights[1], row.diagram, accuracy: 0.001, + "diagram at \(row.width) × \(row.scale)") + } + let unknown = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1, contentWidth: 819, + math: math) + XCTAssertEqual(unknown.table.heights[0], 155) + // The cache keeps image sizes across a re-parse. + let cache = BlockHeightCache() + cache.setImageSize(CGSize(width: 1024, height: 1024), for: "icon.png") + cache.set(70, for: blocks[1].id) + cache.removeAll() + XCTAssertNil(cache.height(for: blocks[1].id)) + XCTAssertEqual(cache.imageSizesSnapshot["icon.png"], CGSize(width: 1024, height: 1024)) + } + + /// The picture rule the views frame the picture with: the 600 pt cap × + /// zoom within the column, never past the picture's own width × zoom or + /// an HTML width, at its aspect ratio, whole points. + func testAPictureIsDrawnAtTheSizeTheMeasurerUses() { + typealias Image = BlockLayout.ImageBlock + let icon = CGSize(width: 1024, height: 1024) + XCTAssertEqual(Image.pictureSize(imageSize: icon, htmlWidth: nil, fontScale: 1, contentWidth: 819), + CGSize(width: 600, height: 600)) + XCTAssertEqual(Image.pictureSize(imageSize: icon, htmlWidth: nil, fontScale: 1.25, contentWidth: 819), + CGSize(width: 750, height: 750)) + XCTAssertEqual(Image.pictureSize(imageSize: icon, htmlWidth: nil, fontScale: 1.5, contentWidth: 819), + CGSize(width: 819, height: 819)) + XCTAssertEqual(Image.pictureSize(imageSize: CGSize(width: 90, height: 30), htmlWidth: nil, fontScale: 1.5, + contentWidth: 819), CGSize(width: 135, height: 45)) + XCTAssertEqual(Image.pictureSize(imageSize: CGSize(width: 1000, height: 333), htmlWidth: nil, fontScale: 1, + contentWidth: 819).height, 200) // 199.8 + let html = Image.pictureSize(imageSize: icon, htmlWidth: .points(200), fontScale: 1.1, contentWidth: 819) + XCTAssertEqual(html.width, 220, accuracy: 0.001) + XCTAssertEqual(html.height, 220) + XCTAssertEqual(Image.pictureSize(imageSize: icon, htmlWidth: .fraction(0.5), fontScale: 1, + contentWidth: 819), CGSize(width: 300, height: 300)) + } + + /// The caption as the row reporter sees it — `ImageCaption` hosted in a + /// window, its `GeometryReader` height — is the measurer's + /// `captionHeight`, at the zoom ladder, three widths and a caption that + /// wraps. + func testTheCaptionIsAsTallAsTheMeasurerSays() { + final class Box { var height: CGFloat = -1 } + struct Probe: View { + let caption: ImageCaption + let box: Box + var body: some View { + VStack(alignment: .leading, spacing: 0) { caption } + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .background(GeometryReader { proxy in + Color.clear + .onAppear { box.height = proxy.size.height } + .onChange(of: proxy.size.height) { box.height = $0 } + }) + } + } + let captions = ["QuickMD icon", + String(repeating: "A longer caption that wraps onto several lines in a narrow column. ", + count: 4)] + for scale: CGFloat in [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 3] { + for width: CGFloat in [819, 720, 300] { + for alt in captions { + let box = Box() + let caption = ImageCaption(alt: alt, color: .secondary, fontScale: scale, contentWidth: width) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 900), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let host = NSHostingView(rootView: Probe(caption: caption, box: box)) + host.frame = NSRect(x: 0, y: 0, width: width, height: 900) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + host.layoutSubtreeIfNeeded() + XCTAssertEqual(box.height, + BlockLayout.ImageBlock.captionHeight(alt: alt, fontScale: scale, contentWidth: width), + "\(alt.prefix(12)) at \(width) × \(scale)") + window.close() + } + } + } + } + + /// An SVG's size is known from its markup: the measurer decodes it once + /// (cached for the view, which then never shows the loading view on a + /// re-created row) and sizes the row as the view draws it. Markup that + /// does not decode stays at the loading view and reports. + func testAnSVGIsMeasuredAtItsDecodedSize() { + let markup = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"100\"><rect width=\"200\" height=\"100\"/></svg>" + let blocks = parse("```svg\n\(markup)\n```") + guard case .svgImage(let source) = blocks[0].content else { return XCTFail("not an SVG block") } + let pad = 2 * BlockLayout.Document.imageOuterVerticalPadding + for (scale, width, picture) in [(1.0, 819.0, 100.0), (1.5, 819.0, 150.0), (1.0, 150.0, 75.0)] as [(CGFloat, CGFloat, CGFloat)] { + let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: scale, + contentWidth: width, math: math) + XCTAssertEqual(measured.table.heights[0], picture + pad, "at \(width) × \(scale)") + } + XCTAssertNotNil(SVGImageDecoder.cachedImage(source)) + XCTAssertEqual(SVGImageDecoder.cachedSize(source), CGSize(width: 200, height: 100)) + let broken = parse("```svg\n<svg this is not svg\n```") + let measured = BlockHeightMeasurer.measure(blocks: broken, theme: theme, fontScale: 1, contentWidth: 819, + math: math) + XCTAssertEqual(measured.table.heights[0], BlockLayout.ImageBlock.placeholderHeight + pad) + } + + /// A diagram's height at any width and zoom from the geometry its web view + /// posted — the template's own formula: × zoom while it fits, never wider + /// than the view, plus the page's overhead. No floor (1.14.4 had 50 pt). + func testADiagramsHeightFollowsTheTemplatesFormula() { + typealias Mermaid = BlockLayout.Mermaid + let small = Mermaid.Geometry(naturalWidth: 240, naturalHeight: 70, overhead: 0) + XCTAssertEqual(Mermaid.diagramHeight(small, fontScale: 1.25, contentWidth: 819), 87.5) + XCTAssertEqual(Mermaid.diagramHeight(small, fontScale: 0.5, contentWidth: 819), 35) + // A flat diagram in a narrow column: fitted to 120 of 480 px. + let flat = Mermaid.Geometry(naturalWidth: 480, naturalHeight: 100, overhead: 0) + XCTAssertEqual(Mermaid.diagramHeight(flat, fontScale: 1, contentWidth: 120), 25) + let wide = Mermaid.Geometry(naturalWidth: 1600, naturalHeight: 400, overhead: 4) + XCTAssertEqual(Mermaid.diagramHeight(wide, fontScale: 1, contentWidth: 800), 204) // fitted: 0.5 + XCTAssertEqual(Mermaid.diagramHeight(wide, fontScale: 1.5, contentWidth: 800), 204) // still fitted + XCTAssertEqual(Mermaid.diagramHeight(wide, fontScale: 0.5, contentWidth: 800), 104) // 0.5 × fitted + XCTAssertEqual(Mermaid.diagramHeight(wide, fontScale: 1, contentWidth: 800.7), 204) // innerWidth: whole px + // Never rendered: the measurer's default, and nothing in the store. + XCTAssertNil(MermaidGeometryStore.geometry(source: "graph TD; Never-->Rendered", isDark: false)) + } + + /// What the row takes from the height the web view reports: a diagram + /// that rendered is as tall as it is — the 25 pt flat one stayed 50 pt + /// tall in 1.14.4, 25 pt of it blank, and the measurer (exact since the + /// placeholder fix) and the view disagreed. A failed render (Mermaid's + /// error text) keeps a minimum; a report of nothing is not taken. + func testARenderedDiagramTakesItsReportedHeightExactly() { + typealias Mermaid = BlockLayout.Mermaid + XCTAssertEqual(Mermaid.acceptedHeight(reported: 25, rendered: true), 25) + XCTAssertEqual(Mermaid.acceptedHeight(reported: 87.5, rendered: true), 87.5) + XCTAssertNil(Mermaid.acceptedHeight(reported: 0, rendered: true)) + XCTAssertNil(Mermaid.acceptedHeight(reported: .nan, rendered: true)) + XCTAssertEqual(Mermaid.acceptedHeight(reported: 31, rendered: false), Mermaid.failedRenderMinimumHeight) + XCTAssertEqual(Mermaid.acceptedHeight(reported: 0, rendered: false), Mermaid.failedRenderMinimumHeight) + XCTAssertEqual(Mermaid.acceptedHeight(reported: 120, rendered: false), 120) + // The view and the measurer agree on a flat diagram. + let flat = Mermaid.Geometry(naturalWidth: 480, naturalHeight: 100, overhead: 0) + XCTAssertEqual(Mermaid.acceptedHeight(reported: 25, rendered: true), + Mermaid.diagramHeight(flat, fontScale: 1, contentWidth: 120)) + } + + /// A diagram view without a snapshot at its zoom and width finds the + /// latest one of the same diagram in the same theme as its stand-in, so a + /// re-created row never shows the empty box the web view is for the half + /// second it takes to render (seen while recording). Another diagram, or + /// the other theme, has none; the exact key and the PDF exporter's key + /// are untouched. + func testADiagramWithoutASnapshotOfItsOwnFindsTheLatestOneAsAStandIn() { + let diagram = "flowchart LR\n Stand-->In" + let image = NSImage(size: NSSize(width: 240, height: 70)) + MermaidSnapshotStore.set(image, source: "1.0|819.0|\(diagram)", isDark: false) + MermaidSnapshotStore.setLatest(image, diagram: diagram, isDark: false) + XCTAssertNil(MermaidSnapshotStore.image(source: "1.25|819.0|\(diagram)", isDark: false)) + XCTAssertTrue(MermaidSnapshotStore.latest(diagram: diagram, isDark: false) === image) + XCTAssertNil(MermaidSnapshotStore.latest(diagram: diagram, isDark: true)) + XCTAssertNil(MermaidSnapshotStore.latest(diagram: "flowchart LR\n Other-->Diagram", isDark: false)) + XCTAssertNil(MermaidSnapshotStore.image(source: diagram, isDark: false)) + XCTAssertTrue(MermaidSnapshotStore.image(source: "1.0|819.0|\(diagram)", isDark: false) === image) + } + + /// The zoom round trip and the Reading Mode round trip with the reader in + /// the image, the rows as the measurer now gives them: no height report at + /// all (the table never swings) and the reader back on the very point. The + /// control, with the 116 / 224 pt placeholders every re-measure used to + /// start from, reports on every step. + func testRoundTripsWithPicturesShownOnceNeverSwingTheTable() throws { + let blocks = parse("![QuickMD icon](icon.png)\n\n```mermaid\n\(fixtureDiagram)\n```") + guard case .mermaidDiagram(let source) = blocks[1].content else { return XCTFail("not a diagram") } + MermaidGeometryStore.set(.init(naturalWidth: 240, naturalHeight: 70, overhead: 0), + source: source, isDark: theme.isDark) + let sizes: [NSString: CGSize] = ["icon.png": CGSize(width: 1024, height: 1024)] + func rows(width: CGFloat, scale: CGFloat, measured: Bool) -> [ListModel.Row] { + let gui = guiPictureRows.first { $0.width == width && $0.scale == scale }! + let table = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: scale, + contentWidth: width, imageSizes: sizes, math: math).table + return fixtureRows(scale: scale, wrap: width < 800 ? 2 : 1, imageReal: gui.image, + imageEstimate: measured ? table.heights[0] : 116, + diagram: gui.diagram - 24, diagramEstimate: measured ? table.heights[1] : 224) + } + for measured in [true, false] { + var start = rows(width: 819, scale: 1, measured: true) + for index in start.indices { start[index].height = start[index].real } + var list = ListModel(rows: start, contentTop: 0) + list.contentTop = list.blockTop(image) + 300 + let top = list.contentTop + for scale: CGFloat in [1.1, 1.25, 1.1, 1] { + list.install(rows(width: 819, scale: scale, measured: measured)) + } + list.toggleStyle(inset: 48) + list.install(rows(width: 720, scale: 1, measured: measured)) + list.toggleStyle(inset: 24) + list.install(rows(width: 819, scale: 1, measured: measured)) + if measured { + XCTAssertEqual(list.reports, 0) + XCTAssertEqual(list.contentTop, top, accuracy: 0.001) + } else { + XCTAssertGreaterThanOrEqual(list.reports, 6) + } + } + } + + /// The failing GUI run (f1dbg.log), step by step: ⌘F "Second part" and a + /// zoom round trip left the anchor on the H1 at -351.5 with the diagram + /// still at its default; it reports 94 and the content top shifts to + /// 1476.5. Reading Mode on (1452.5), the 720 table (1622.5), Reading Mode + /// off (1646.5) — then, before the 819 table, the latex row's text view + /// re-lays out under the top inset and AppKit scrolls the document by + /// -24.5 pt (now prevented: `DocumentCellTextView`). The pin must hold + /// whatever moves the clip. The reader must come + /// back to row 32 at -351.5, the very point it left. + func testReadingModeRoundTripFromASearchJumpLandsWhereItLeft() { + let secondPart = 32 + for pins in [true, false] { + var list = ListModel(rows: searchFixtureRows(width: 819, diagramEstimate: 224), contentTop: 0) + list.pinsLayoutSwitch = pins + list.contentTop = list.blockTop(secondPart) - 351.5 + list.anchor = (secondPart, -351.5, -351.5) + XCTAssertEqual(list.contentTop, 1606.5) + list.settle() + XCTAssertEqual(list.contentTop, 1476.5) + let seen = list.screenY(secondPart) + + list.toggleStyle(inset: 48) + XCTAssertEqual(list.contentTop, 1452.5) + list.install(searchFixtureRows(width: 720, diagramEstimate: 94)) + XCTAssertEqual(list.contentTop, 1622.5) + list.toggleStyle(inset: 24) + XCTAssertEqual(list.contentTop, 1646.5) + list.clipMoved(by: -24.5) + list.install(searchFixtureRows(width: 819, diagramEstimate: 94)) + + if pins { + XCTAssertEqual(list.anchor?.row, secondPart) + XCTAssertEqual(list.anchor?.offset, -351.5) + XCTAssertEqual(list.contentTop, 1476.5) + XCTAssertEqual(list.screenY(secondPart), seen, accuracy: 0.001) + } else { + // The log, to the point: re-captured by geometry as "row 27 + + // 38" in the 720 table and restored at 1452.0, 24.5 pt off. + XCTAssertEqual(list.anchor?.row, 27) + XCTAssertEqual(list.anchor?.offset, 38) + XCTAssertEqual(list.contentTop, 1452.0) + XCTAssertEqual(list.screenY(secondPart) - seen, 24.5, accuracy: 0.001) + } + } + } + + /// The -24.5 / -37.5 pt itself: AppKit's NSTextView keeps its visible part + /// visible while it resizes (`_setFrameSize:forceScroll:` → + /// `scrollRectToVisible:`), and a text view lying in the list's top content + /// inset is visible to it but not to `scrollRectToVisible:` — every width + /// change scrolled the document until that text sat at the content top. + /// `DocumentCellTextView` (the base of `SelfSizingTextView`) never scrolls + /// on a frame change; a plain NSTextView in the same place still does (the + /// control), and an explicit request still scrolls. + func testATextRowUnderTheTopInsetDoesNotScrollTheDocumentWhenItResizes() { + final class Flipped: NSView { override var isFlipped: Bool { true } } + func clipMove(_ textView: NSTextView, _ change: (NSTextView) -> Void) -> CGFloat { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = NSEdgeInsets(top: 24, left: 0, bottom: 24, right: 0) + let document = Flipped(frame: NSRect(x: 0, y: 0, width: 800, height: 5000)) + scrollView.documentView = document + window.contentView = scrollView + // The latex row's text: its top 0.5 pt below the clip's top edge, + // all of it inside the 24 pt inset (content top 1067.5). + textView.configureForSelfSizing() + textView.frame = NSRect(x: 20, y: 1044, width: 700, height: 17) + textView.textStorage?.setAttributedString(NSAttributedString(string: "V(e_1) \\not\\le V(e_2)")) + document.addSubview(textView) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 1043.5)) + scrollView.reflectScrolledClipView(scrollView.contentView) + scrollView.layoutSubtreeIfNeeded() + let before = scrollView.contentView.bounds.origin.y + change(textView) + scrollView.layoutSubtreeIfNeeded() + return scrollView.contentView.bounds.origin.y - before + } + let wider = { (view: NSTextView) in view.setFrameSize(NSSize(width: 790, height: 17)) } + let reframed = { (view: NSTextView) in view.frame = NSRect(x: 20, y: 1044, width: 300, height: 34) } + + XCTAssertEqual(clipMove(NSTextView(), wider), -23.5, accuracy: 0.5, "the control: AppKit scrolls") + XCTAssertEqual(clipMove(DocumentCellTextView(), wider), 0) + XCTAssertEqual(clipMove(DocumentCellTextView(), reframed), 0) + XCTAssertNotEqual(clipMove(DocumentCellTextView()) { _ = $0.scrollToVisible($0.bounds) }, 0) + } + + /// A model list on fixture.md with every view reported, the content top + /// `offset` below the image's top. + private func listInsideTheImage(at offset: CGFloat, current: Bool) -> ListModel { + var rows = fixtureRows() + for index in rows.indices { rows[index].height = rows[index].real } + var list = ListModel(rows: rows, contentTop: 0, current: current) + list.contentTop = list.blockTop(image) + offset + list.anchor = nil + return list + } + + /// ⌘+ ⌘+ ⌘− ⌘− with the viewport top inside the image, only its bottom + /// edge visible. Every zoom re-parses: the image comes back as a 116 pt + /// placeholder. The reader stays in the image at every step and comes + /// back to the very same point. + func testZoomRoundTripKeepsAReaderInsideAnImage() { + for current in [true, false] { + var list = listInsideTheImage(at: 600, current: current) + let start = list.contentTop + var lostTheImage = false + for scale: CGFloat in [1.1, 1.25, 1.1, 1] { + list.install(fixtureRows(scale: scale)) + if list.rowUnderContentTop() != image { lostTheImage = true } + } + if current { + XCTAssertFalse(lostTheImage) + XCTAssertEqual(list.contentTop, start, accuracy: 0.001) + } else { + // The regression: placed 600 pt into a 116 pt placeholder, the + // image is off screen, never reports, and the reader is left + // in the blocks after it. + XCTAssertTrue(lostTheImage) + XCTAssertGreaterThan(abs(list.contentTop - start), 10) + } + } + } + + // MARK: - Reading column follows the zoom + + /// Reading mode's column is 720 pt at 100 % and scales with the text, in + /// whole points; the window still caps it. 1.14.4 kept 720 at every zoom: + /// ~45 characters a line at 200 %. + func testTheReadingColumnScalesWithTheZoom() { + typealias Document = BlockLayout.Document + XCTAssertEqual(Document.readingColumnCap(fontScale: 1), 720) + XCTAssertEqual(Document.readingColumnCap(fontScale: 2), 1440) + XCTAssertEqual(Document.readingColumnCap(fontScale: 0.5), 360) + XCTAssertEqual(Document.readingColumnCap(fontScale: 3), 2160) + XCTAssertEqual(Document.readingColumnCap(fontScale: 1.1), 792) // 792.0000000000001 + // 200 %: the full 1440 where the window has room, the window otherwise. + XCTAssertEqual(Document.columnWidth(inner: 1600 - 64, cap: Document.readingColumnCap(fontScale: 2)), 1440) + XCTAssertEqual(Document.columnWidth(inner: 1000 - 64, cap: Document.readingColumnCap(fontScale: 2)), 936) + XCTAssertEqual(Document.columnWidth(inner: 1000 - 64, cap: Document.readingColumnCap(fontScale: 1)), 720) + XCTAssertEqual(Document.columnWidth(inner: 935.5, cap: nil), 935.5) + XCTAssertEqual(Document.columnWidth(inner: -10, cap: 720), 0) + } + + /// A zoom in reading mode re-parses; the parse is measured at the column + /// the zoom's cap gives — the width the list publishes once the parse is + /// installed with that cap — so the zoomed text is laid out at the width + /// its heights were measured at, measured once per zoom. + func testAZoomInReadingModeMeasuresItsParseAtTheNewColumn() { + typealias Document = BlockLayout.Document + let blocks = parse(String(repeating: "A line of prose that wraps in every column it is given. ", count: 12)) + for inner: CGFloat in [1600 - 64, 1000 - 64] { + for scale: CGFloat in [0.5, 1, 1.5, 2, 3] { + let column = Document.columnWidth(inner: inner, cap: Document.readingColumnCap(fontScale: scale)) + let width = Document.parseWidth(contentWidth: 720, inner: inner, readingFontScale: scale) + XCTAssertEqual(width, column, "inner \(inner) × \(scale)") + XCTAssertEqual(measure(blocks, fontScale: scale, width: width).table.contentWidth, column) + } + } + // The standard layout: the list's width, whatever the zoom. + XCTAssertEqual(Document.parseWidth(contentWidth: 936, inner: 936, readingFontScale: nil), 936) + // No layout yet, or the list not wired: nothing to derive from. + XCTAssertEqual(Document.parseWidth(contentWidth: 0, inner: 1536, readingFontScale: 2), 0) + XCTAssertEqual(Document.parseWidth(contentWidth: 720, inner: 0, readingFontScale: 2), 720) + } + + /// ⌘+ ⌘+ ⌘− ⌘− in reading mode with the viewport top inside the image: + /// every zoom is a style change (the cap) AND a new table, in one + /// update — the reader comes back to the very same point, as in the + /// standard layout. + func testAZoomRoundTripInReadingModeKeepsAReaderInsideAnImage() { + var rows = fixtureRows(wrap: 2, imageReal: 576) + for index in rows.indices { rows[index].height = rows[index].real } // every view reported + var list = ListModel(rows: rows, contentTop: 0) + list.inset = 48 + list.contentTop = list.blockTop(image) + 520 // its bottom 56 pt on screen + let start = list.contentTop + XCTAssertEqual(list.rowUnderContentTop(), image) + for scale: CGFloat in [1.1, 1.25, 1.1, 1] { + list.toggleStyle(inset: 48) + list.install(fixtureRows(scale: scale, wrap: 2, imageReal: min(655, 576 * scale))) + XCTAssertEqual(list.rowUnderContentTop(), image, "× \(scale)") + } + XCTAssertEqual(list.contentTop, start, accuracy: 0.001) + } + + /// Reading Mode on and off with the viewport top inside the image (the + /// debug log: `row 21 + 579.5` on the way in, `row 29 + 0.0` on the way + /// out). At 720 pt the image is smaller and the short paragraphs wrap. + func testReadingModeRoundTripKeepsAReaderInsideAnImage() { + for current in [true, false] { + var list = listInsideTheImage(at: 603.5, current: current) + let start = list.contentTop + list.toggleStyle(inset: 48) + list.install(fixtureRows(wrap: 2, imageReal: 576)) + if current { + // Still on the image: its bottom edge in the top inset. + XCTAssertEqual(list.anchor?.row, image) + XCTAssertEqual(list.contentTop - list.blockTop(image), 579.5, accuracy: 0.001) + } + list.toggleStyle(inset: 24) + list.install(fixtureRows()) + if current { + XCTAssertEqual(list.contentTop, start, accuracy: 0.001) + } else { + XCTAssertGreaterThan(abs(list.contentTop - start), 10) + } + } + } + + /// A second re-measure before the image had a chance to report (a width + /// change right after a zoom): the anchor is still the image and its + /// offset, not the row the placeholder left under the content top. + func testAnchorInsideAPlaceholderSurvivesAnotherReinstall() { + var list = listInsideTheImage(at: 550, current: true) + let start = list.contentTop + list.install(fixtureRows(), settling: false) + list.install(fixtureRows(), settling: false) + list.settle() + XCTAssertEqual(list.contentTop, start, accuracy: 0.001) + } + + /// Reading Mode on and off with the content top in the space above a + /// block: the toggle's 24 pt move crosses into the row above, and the + /// narrow layout has less space between those two blocks. Sticky anchor + /// = exact; re-anchoring on the row under the content top drifts. + func testReadingModeRoundTripWithTheReaderInAGap() { + let wide: [ListModel.Row] = [.exact(0, 39), .exact(19, 17), .exact(25, 17), .exact(25, 17), .exact(21, 66), + .exact(21, 17), .exact(50, 33), .exact(12, 186), .exact(5, 79)] + var narrow = wide + for row in 1...3 { narrow[row].height = 34; narrow[row].real = 34 } + narrow[5].spacing = 13 + var drifted = false + for start in stride(from: CGFloat(150), through: 330, by: 7.5) { + for current in [true, false] { + var list = ListModel(rows: wide, contentTop: start, current: current) + list.toggleStyle(inset: 48) + let block = list.capture().row + let seen = list.screenY(block) + list.install(narrow) + if current { + XCTAssertEqual(list.screenY(block), seen, accuracy: 0.001, "entering at \(start)") + } + list.toggleStyle(inset: 24) + list.install(wide) + if current { + XCTAssertEqual(list.contentTop, start, accuracy: 0.001, "round trip from \(start)") + } else if abs(list.contentTop - start) > 0.5 { + drifted = true + } + } + } + XCTAssertTrue(drifted) + } + + /// ⌘F "Second part" from the top of the document: the image above the + /// heading loads while the scroll is in flight, the diagram above it + /// reports after it landed. The heading ends in the middle (before: at + /// the bottom of the window, in 1.14.4 as well). + func testSearchJumpStaysCentredWhileTheBlocksAboveReport() { + for current in [true, false] { + var list = ListModel(rows: fixtureRows(), contentTop: 0, current: current) + list.centerJump(to: secondPart) + if current { + XCTAssertEqual(list.offCentre(secondPart), 0, accuracy: 0.001) + } else { + XCTAssertGreaterThan(list.offCentre(secondPart), 300) + } + } + } + + func testAnchorIsRelativeToTheBlockNotTheRowEdge() { + let rect = NSRect(x: 0, y: 1000, width: 500, height: 80) + // In the gap above the block: negative, and it stays the same distance + // above the block when the gap itself changes (zoom, new neighbours). + let offset = RowGeometry.anchorOffset(contentTop: 1010, rowRect: rect, spacingAbove: 30) + XCTAssertEqual(offset, -20) + let regapped = NSRect(x: 0, y: 2000, width: 500, height: 95) + XCTAssertEqual(RowGeometry.contentTop(anchorOffset: offset, rowRect: regapped, spacingAbove: 45), 2025) + } + + func testAnAnchorPastAnEstimateIsPlacedInsideTheBlock() { + // 600 pt into an image whose placeholder is 116 pt: on its last point. + XCTAssertEqual(RowGeometry.anchorPlacement(offset: 600, blockHeight: 116, topInset: 24, heightIsEstimate: true), 115) + // Still partly visible in the top inset: as is. + XCTAssertEqual(RowGeometry.anchorPlacement(offset: 130, blockHeight: 116, topInset: 24, heightIsEstimate: true), 130) + // A measured block keeps the offset (the toggle's 24 pt can pass its end). + XCTAssertEqual(RowGeometry.anchorPlacement(offset: 600, blockHeight: 116, topInset: 24, heightIsEstimate: false), 600) + // In the gap above the block. + XCTAssertEqual(RowGeometry.anchorPlacement(offset: -20, blockHeight: 116, topInset: 24, heightIsEstimate: true), -20) + } + + // MARK: - Height reports + + func testReportsMoveTheContentTopAroundTheAnchor() { + // No anchor (the reader scrolled): blocks above the content top, the + // straddling one included, shift it; blocks in view don't. + XCTAssertEqual(RowGeometry.reportAction(row: 3, anchorRow: nil, blockTop: 100, contentTop: 500), .shift) + XCTAssertEqual(RowGeometry.reportAction(row: 3, anchorRow: nil, blockTop: 480, contentTop: 500), .shift) + XCTAssertEqual(RowGeometry.reportAction(row: 3, anchorRow: nil, blockTop: 500, contentTop: 500), .keep) + XCTAssertEqual(RowGeometry.reportAction(row: 3, anchorRow: nil, blockTop: 620, contentTop: 500), .keep) + // An anchor: above it shifts — in view above a jump target too —, the + // anchored block is placed again, below it nothing. + XCTAssertEqual(RowGeometry.reportAction(row: 3, anchorRow: 7, blockTop: 620, contentTop: 500), .shift) + XCTAssertEqual(RowGeometry.reportAction(row: 7, anchorRow: 7, blockTop: 480, contentTop: 500), .placeAnchor) + XCTAssertEqual(RowGeometry.reportAction(row: 8, anchorRow: 7, blockTop: 480, contentTop: 500), .keep) + } + + func testTableRowHeightIsSpacingPlusBlock() { + let measured = measure(parse("# Title\n\nText.\n\n```\ncode\n```")) + let table = measured.table + for row in 0..<table.count { + XCTAssertEqual(table.rowHeight(row), table.heights[row] + table.spacingAbove[row]) + } + XCTAssertEqual(table.spacing(-1), 0) + XCTAssertEqual(table.spacing(table.count), 0) + XCTAssertEqual(table.rowHeight(table.count), 0) + } + + // MARK: - Print / PDF + + func testPrintGapsMatchTheScreen() { + let markdown = """ + --- + title: Report + --- + # Report + Intro paragraph. + + ## Data + | a | b | + |---|---| + | 1 | 2 | + + Text after the table. + ``` + code + ``` + ### Notes + > A quote. + + $$ + y = x + $$ + - one + - two + """ + let printTheme = MarkdownTheme.exportTheme(for: .light) + let blocks = MarkdownBlockParser(theme: printTheme, fontScale: 1).parse(markdown) + let renderer = MarkdownRenderer(colorScheme: .light) + let printRows = blocks.enumerated().map { + PrintRhythm.row(for: $0.element, renderer: renderer, width: 532) + } + let printSpacing = Spacing.column(printRows, fontScale: 1) + let screen = BlockHeightMeasurer.measure(blocks: blocks, theme: printTheme, fontScale: 1, + contentWidth: 532, math: math) + XCTAssertEqual(printRows.map(\.role), screen.rhythm.map(\.role)) + XCTAssertEqual(printSpacing.first, 0) + for index in blocks.indices.dropFirst() { + let expected = expectedTarget(printRows[index - 1].role, printRows[index].role) * 14 + XCTAssertEqual(realizedGap(printRows, printSpacing, index), expected, accuracy: tolerance, "print \(index)") + XCTAssertEqual(realizedGap(screen.rhythm, screen.table.spacingAbove, index), expected, + accuracy: tolerance, "screen \(index)") + } + } + + private func printBlocks(_ blocks: [(CGFloat, CGFloat, Bool)]) -> [PrintRhythm.Block] { + blocks.map { PrintRhythm.Block(height: $0.0, spacingAbove: $0.1, keepsWithNext: $0.2) } + } + private func placed(_ slices: [PrintRhythm.Slice]) -> [String] { + slices.map { "\($0.block)@\($0.page):\(Int($0.y))" + ($0.offset > 0 || $0.height < 0 ? "+\(Int($0.offset))" : "") } + } + + func testPaginationDropsTheSpaceAtAPageTop() { + let slices = PrintRhythm.paginate(printBlocks([(100, 0, false), (50, 30, false), (600, 30, false), + (40, 30, false), (710, 30, false)]), + pageHeight: 700) + XCTAssertEqual(placed(slices), [ + "0@0:0", + "1@0:130", + "2@1:0", // 180 + 30 + 600 > 700: new page, no space above + "3@1:630", // 600 + 30 + 40 = 670 fits + "4@2:0", // taller than a page: a page per slice + "4@3:0+700", + ]) + XCTAssertEqual(slices.map(\.height), [100, 50, 600, 40, 700, 10]) + } + + /// Orbit.md on Letter: `## Configuration` ended page 1 and its table + /// started page 2. + func testAHeadingMovesWithTheBlockThatStartsTheNextPage() { + let slices = PrintRhythm.paginate(printBlocks([(600, 0, false), (30, 50, true), (79, 20, false)]), + pageHeight: 700) + XCTAssertEqual(placed(slices), ["0@0:0", "1@1:0", "2@1:50"]) + } + + func testAHeadingChainMovesTogether() { + let slices = PrintRhythm.paginate(printBlocks([(560, 0, false), (30, 50, true), (25, 15, true), + (100, 20, false), (17, 25, false)]), + pageHeight: 700) + XCTAssertEqual(placed(slices), ["0@0:0", "1@1:0", "2@1:45", "3@1:90", "4@1:215"]) + } + + func testAHeadingStaysWhenItCannotShareAPageWithTheNextBlock() { + // 30 + 20 + 690 > 700: moving the heading would only orphan it on + // the next page. + let slices = PrintRhythm.paginate(printBlocks([(600, 0, false), (30, 50, true), (690, 20, false)]), + pageHeight: 700) + XCTAssertEqual(placed(slices), ["0@0:0", "1@0:650", "2@1:0"]) + // A heading that already starts its page is never moved (no loop). + let top = PrintRhythm.paginate(printBlocks([(30, 0, true), (690, 20, false), (17, 25, false)]), + pageHeight: 700) + XCTAssertEqual(placed(top), ["0@0:0", "1@1:0", "2@2:0"]) + } + + func testAHeadingKeepsWithTheFirstSliceOfABlockTallerThanAPage() { + // Room below the heading: the first slice fills the rest of its page. + let roomy = PrintRhythm.paginate(printBlocks([(300, 0, false), (30, 50, true), (1500, 20, false)]), + pageHeight: 700) + XCTAssertEqual(placed(roomy), ["0@0:0", "1@0:350", "2@0:400", "2@1:0+300", "2@2:0+1000"]) + XCTAssertEqual(roomy.map(\.height), [300, 30, 300, 700, 500]) + // No room: the heading moves, the first slice fills the rest of the + // new page, and every slice is placed exactly once. + let full = PrintRhythm.paginate(printBlocks([(600, 0, false), (30, 50, true), (1500, 20, false)]), + pageHeight: 700) + XCTAssertEqual(placed(full), ["0@0:0", "1@1:0", "2@1:50", "2@2:0+650", "2@3:0+1350"]) + XCTAssertEqual(full.filter { $0.block == 2 }.map(\.height).reduce(0, +), 1500) + } + +} diff --git a/QuickMD/Screenshots/screenshot-1.png b/QuickMD/Screenshots/screenshot-1.png index b28f78a..2a15800 100644 Binary files a/QuickMD/Screenshots/screenshot-1.png and b/QuickMD/Screenshots/screenshot-1.png differ diff --git a/QuickMD/Screenshots/screenshot-2.png b/QuickMD/Screenshots/screenshot-2.png index 5d6ebd1..feee2be 100644 Binary files a/QuickMD/Screenshots/screenshot-2.png and b/QuickMD/Screenshots/screenshot-2.png differ diff --git a/QuickMD/Screenshots/screenshot-3.png b/QuickMD/Screenshots/screenshot-3.png index fb43ded..a889f6c 100644 Binary files a/QuickMD/Screenshots/screenshot-3.png and b/QuickMD/Screenshots/screenshot-3.png differ diff --git a/QuickMD/Screenshots/screenshot-4.png b/QuickMD/Screenshots/screenshot-4.png index 9444425..a23192d 100644 Binary files a/QuickMD/Screenshots/screenshot-4.png and b/QuickMD/Screenshots/screenshot-4.png differ diff --git a/QuickMD/Screenshots/screenshot-5.png b/QuickMD/Screenshots/screenshot-5.png index 063dec9..59c2379 100644 Binary files a/QuickMD/Screenshots/screenshot-5.png and b/QuickMD/Screenshots/screenshot-5.png differ diff --git a/QuickMD/Screenshots/screenshot-6.png b/QuickMD/Screenshots/screenshot-6.png index 07faee5..05f3c2a 100644 Binary files a/QuickMD/Screenshots/screenshot-6.png and b/QuickMD/Screenshots/screenshot-6.png differ diff --git a/QuickMD/Screenshots/screenshot-7.png b/QuickMD/Screenshots/screenshot-7.png index 4b164b3..1dd25e9 100644 Binary files a/QuickMD/Screenshots/screenshot-7.png and b/QuickMD/Screenshots/screenshot-7.png differ diff --git a/README.md b/README.md index e2eaaec..1029b2e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Perfect for developers, writers, students, and anyone who works with Markdown da - Find in document (`⌘F`) with match count and per-word navigation - Word-level highlighting across all block types (text, code, tables, blockquotes) - Table of Contents sidebar (`⌘⇧T`) — auto-generated from headings; right-click it to list every level or stop at H2, H3 or H4 -- Reading mode (`⌘⇧R`) — hides both sidebars and the hover buttons and centres the text in a 720 pt column; `Esc` brings everything back +- Reading mode (`⌘⇧R`) — hides both sidebars and the hover buttons and centres the text in a comfortable column (720 pt at 100 %, growing with zoom); `Esc` brings everything back - **Select across the whole document** — drag through paragraphs, headings, lists, quotes, code and tables (a word, a cell or a range of cells), it scrolls for you, Shift-click to extend, `⌘A` / `⌘C` to copy clean text (table cells as tab-separated values, math as LaTeX). Every copy shows how many characters and words were copied; optional auto-copy of selections in Settings → General - Copy entire document (`⌘⇧C`) or individual sections (hover heading → copy icon) - Export to PDF (`⌘⇧E`) — **vector text** (selectable, searchable) with **rendered Mermaid diagrams** — and Print (`⌘P`)