Skip to content
11 changes: 11 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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.3] - 2026-11-04

Two rendering fixes.

### Fixed
- A blank line under a heading, or after a paragraph or list before the next heading, no longer shows as an extra empty line. A document looks the same with or without it, as on GitHub.
- A display formula that cannot be typeset (for example one with `\not\le`) shows its LaTeX source as a code block instead of an empty space. Formulas that typeset look as before.

### Changed
- Mac App Store version: QuickMD may ask for a rating with the standard macOS prompt, at most once per version and no more than every four months. It asks only after documents were opened on at least three different days over a week or more, after a minute of reading, and never while you edit or right after the Mac sat idle. The count stays on your Mac.

## [1.14.2] - 2026-11-04

Front matter you can read at a glance.
Expand Down
22 changes: 18 additions & 4 deletions QuickMD/QuickMD.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
A1B2C3D401000220 /* FrontMatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; };
A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; };
A1B2C3D401000222 /* FrontMatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000221 /* FrontMatterTests.swift */; };
A1B2C3D401000230 /* ReviewPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; };
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 */; };
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 */; };
Expand Down Expand Up @@ -219,6 +223,9 @@
A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = "<group>"; };
A1B2C3D400000220 /* FrontMatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatter.swift; sourceTree = "<group>"; };
A1B2C3D400000221 /* FrontMatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatterTests.swift; sourceTree = "<group>"; };
A1B2C3D400000230 /* ReviewPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPrompt.swift; sourceTree = "<group>"; };
A1B2C3D400000231 /* ReviewPromptTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTrigger.swift; sourceTree = "<group>"; };
A1B2C3D400000232 /* ReviewPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTests.swift; sourceTree = "<group>"; };
A1B2C3D400000200 /* PrintableTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintableTableTests.swift; sourceTree = "<group>"; };
A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = "<group>"; };
A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -332,6 +339,7 @@
A1B2C3D4000000F0 /* HeadingLayoutTests.swift */,
A1B2C3D4000001B2 /* TableTextTests.swift */,
A1B2C3D400000221 /* FrontMatterTests.swift */,
A1B2C3D400000232 /* ReviewPromptTests.swift */,
A1B2C3D400000200 /* PrintableTableTests.swift */,
A1B2C3D4000001C1 /* TableSelectionTests.swift */,
A1B2C3D4000000C1 /* DocumentSelectionTests.swift */,
Expand Down Expand Up @@ -362,6 +370,8 @@
A1B2C3D400000069 /* BlockHeightMeasurer.swift */,
A1B2C3D4000001B0 /* TableTextBuilder.swift */,
A1B2C3D400000220 /* FrontMatter.swift */,
A1B2C3D400000230 /* ReviewPrompt.swift */,
A1B2C3D400000231 /* ReviewPromptTrigger.swift */,
A1B2C3D40000006E /* SVGImageDecoder.swift */,
A1B2C3D400000080 /* DataImageURI.swift */,
A1B2C3D400000081 /* ImageLoader.swift */,
Expand Down Expand Up @@ -654,6 +664,8 @@
A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */,
A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */,
A1B2C3D401000220 /* FrontMatter.swift in Sources */,
A1B2C3D401000230 /* ReviewPrompt.swift in Sources */,
A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */,
A1B2C3D4010001B2 /* TableTextView.swift in Sources */,
A1B2C3D40100006C /* VirtualBlockList.swift in Sources */,
A1B2C3D40100006D /* SVGBlockView.swift in Sources */,
Expand Down Expand Up @@ -706,6 +718,7 @@
A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */,
A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */,
A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */,
A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */,
A1B2C3D40100006F /* SVGImageDecoder.swift in Sources (Tests) */,
A1B2C3D401000081 /* DataImageURI.swift in Sources (Tests) */,
A1B2C3D401000083 /* ImageLoader.swift in Sources (Tests) */,
Expand Down Expand Up @@ -737,6 +750,7 @@
A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */,
A1B2C3D4010001B3 /* TableTextTests.swift in Sources */,
A1B2C3D401000222 /* FrontMatterTests.swift in Sources */,
A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */,
A1B2C3D401000201 /* PrintableTableTests.swift in Sources */,
A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */,
);
Expand Down Expand Up @@ -877,7 +891,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = VJ92852M32;
ENABLE_APP_SANDBOX = YES;
Expand All @@ -902,7 +916,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.14.2;
MARKETING_VERSION = 1.14.3;
PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand All @@ -921,7 +935,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = VJ92852M32;
ENABLE_APP_SANDBOX = YES;
Expand All @@ -946,7 +960,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.14.2;
MARKETING_VERSION = 1.14.3;
PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
81 changes: 69 additions & 12 deletions QuickMD/QuickMD/BlockHeightMeasurer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,11 +496,43 @@ struct MathRendering {
let inlineImage: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> NSImage?
/// Height `MTMathUILabel` will take for a display-math block.
let displayHeight: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> CGFloat
/// False when the engine cannot typeset this display formula at all (an
/// unsupported command such as `\not\le`). `MTMathUILabel` would draw an
/// empty row for it, so `substitutingUntypesettableMath` shows the source
/// instead. Defaults to "always typesets" so stubs keep their old behaviour.
var typesets: (_ latex: String) -> Bool = { _ in true }

/// No math engine: inline math degrades to the italic-literal fallback and
/// display math to zero intrinsic height. Only for tests and previews.
static let none = MathRendering(inlineImage: { _, _, _ in nil },
displayHeight: { _, _, _ in 0 })

/// `blocks` with every `.mathBlock` this engine cannot typeset turned into a
/// ```` ```latex ```` code block of the same LaTeX — same `id` (ids are
/// positional) and same `sourceLine`.
///
/// Why a block substitution and not a fallback inside `MathBlockView`: the
/// row would then have to be a code block for the view, the measurer
/// (`.exact` height), search, selection/copy, Source Edit landing and
/// print/PDF alike — six places that would each need their own "failed
/// math" branch. Swapping the block once per parse, before anything is laid
/// out, gives all of them the existing code-block path.
///
/// Same thread rule as `measure(math:)`: the engine behind `.swiftMath` is
/// main-thread only, so call this on the main thread with it (the parse
/// task's main-actor step, `PDFExportManager.generateMultiPagePDF` for
/// print and PDF). Documents without display math return without consulting
/// the engine.
func substitutingUntypesettableMath(in blocks: [MarkdownBlock]) -> [MarkdownBlock] {
guard blocks.contains(where: { if case .mathBlock = $0.content { return true } else { return false } })
else { return blocks }
return blocks.map { block in
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)
}
}
}

// MARK: - AttributedString → NSAttributedString
Expand Down Expand Up @@ -847,14 +879,10 @@ enum BlockHeightMeasurer {
kinds.append(.exact)

case .codeBlock(let code, let language):
let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale)
let inner = exactHeight(text: ns, width: codeWidth)
let stack = inner
+ (language.isEmpty ? codeChromeWithoutLanguage : codeChromeWithLanguage)
// The always-present copy button is the ZStack's other child, so
// it is a floor under the row — see `Code.copyButtonFloorHeight`.
heights.append(max(stack, BlockLayout.Code.copyButtonFloorHeight)
+ 2 * BlockLayout.Document.codeOuterVerticalPadding)
heights.append(codeRowHeight(code: code, theme: theme, fontScale: fontScale,
codeWidth: codeWidth,
chrome: language.isEmpty ? codeChromeWithoutLanguage
: codeChromeWithLanguage))
kinds.append(.exact)

case .heading(let level, let title, _):
Expand Down Expand Up @@ -921,23 +949,42 @@ enum BlockHeightMeasurer {
mathPendingRows: mathPendingRows)
}

/// One `.codeBlock` row: the code laid out at `codeWidth` plus `chrome`
/// (`BlockLayout.Code.verticalChrome` for the block's language state —
/// passed in so `measure` computes it once per document, not per block).
/// Shared by `measure` and `measureMathRows` (a display formula the engine
/// cannot typeset becomes a code row there).
private static func codeRowHeight(code: String, theme: MarkdownTheme, fontScale: CGFloat,
codeWidth: CGFloat, chrome: CGFloat) -> CGFloat {
let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale)
let stack = exactHeight(text: ns, width: codeWidth) + chrome
// The always-present copy button is the ZStack's other child, so it is
// a floor under the row — see `Code.copyButtonFloorHeight`.
return max(stack, BlockLayout.Code.copyButtonFloorHeight)
+ 2 * BlockLayout.Document.codeOuterVerticalPadding
}

/// Finishes the rows an off-main `measure(math: nil)` deferred.
///
/// `@MainActor` because this is the only place in the measurement path
/// allowed to touch the math engine (thread rule in the file header). The
/// caller runs it after the background pass lands, before the table is used
/// for layout.
///
/// Nothing else about the table changes — same `kinds`, same `contentWidth`,
/// every other height and converted string carried over — so
/// Nothing else about the table changes — same `kinds` (bar a substituted
/// formula, see `blocks`), same `contentWidth`, every other height and
/// converted string carried over — so
/// `measureMathRows(m.mathPendingRows, in: m, …, math: engine)` equals a
/// straight `measure(…, math: engine)`, row for row.
///
/// - Parameters:
/// - pending: normally `measured.mathPendingRows`. Indices outside the
/// table, and rows that don't need the engine, are ignored.
/// - blocks: the same array `measured` was produced from (row *i* is
/// `blocks[i]`).
/// `blocks[i]`), or that array after
/// `MathRendering.substitutingUntypesettableMath`: a deferred
/// `.mathBlock` row that became a `.codeBlock` is measured as a code row
/// here and its kind turns `.exact`, exactly what `measure` gives it.
/// - contentWidth: MUST be the width `measured` was produced at.
/// - reusing: strings a PREVIOUS measurement of the same content already
/// built, keyed by block id — normally the caller's last
Expand Down Expand Up @@ -971,6 +1018,7 @@ enum BlockHeightMeasurer {
#endif

var heights = measured.table.heights
var kinds = measured.table.kinds
var converted = measured.converted
let textWidth = BlockLayout.clampedWidth(contentWidth)

Expand Down Expand Up @@ -1003,13 +1051,22 @@ enum BlockHeightMeasurer {
+ 2 * BlockLayout.Math.verticalPadding
+ 2 * BlockLayout.Document.mathOuterVerticalPadding

case .codeBlock(let code, let language):
// Only a substituted formula gets here (a parsed code block is
// never pending). Rare, so the chrome is computed per row.
heights[index] = codeRowHeight(
code: code, theme: theme, fontScale: fontScale,
codeWidth: BlockLayout.clampedWidth(contentWidth - 2 * BlockLayout.Code.horizontalPadding),
chrome: BlockLayout.Code.verticalChrome(hasLanguage: !language.isEmpty, fontScale: fontScale))
kinds[index] = .exact

default:
continue
}
}

return MeasuredBlocks(
table: BlockHeightTable(heights: heights, kinds: measured.table.kinds,
table: BlockHeightTable(heights: heights, kinds: kinds,
contentWidth: measured.table.contentWidth),
converted: converted,
tables: measured.tables,
Expand Down
28 changes: 20 additions & 8 deletions QuickMD/QuickMD/MarkdownBlockParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ struct MarkdownBlockParser: Sendable {
// 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: { !$0.line.trimmingCharacters(in: .whitespaces).isEmpty }) else {
guard buffer.contains(where: { !Self.isBlank($0.line) }) else {
buffer.removeAll()
return
}
Expand Down Expand Up @@ -874,16 +874,28 @@ struct MarkdownBlockParser: Sendable {
}

let chunk = buffer[chunkStart..<sliceEnd]
// Each chunk reports the original line of ITS first NON-BLANK line —
// the separator blank lines the previous block left behind are not
// content. A chunk with no content at all (a long run of blank lines
// sliced off on its own) is dropped for the same reason as above.
guard let chunkSourceLine = chunk.first(where: { !$0.line.trimmingCharacters(in: .whitespaces).isEmpty })?.originalIndex else {
// Blank lines at either END of a chunk are block separators, not
// content: the renderer turns every blank line into empty
// paragraphs, so `## H\n\ntext` drew a whole empty line above
// "text" that `## H\ntext` did not (GitHub/CommonMark render both
// alike), and a list followed by a blank line and a heading an
// extra one below the list. Spacing between blocks is the list's
// job. Only blank lines BETWEEN content lines stay — they are the
// paragraph separation inside the block. Trimmed per chunk, so a
// chunk cut after a blank line (see above) does not end with it
// and the next one does not start with the rest of the run.
//
// The chunk reports the original line of its first NON-blank line,
// which is now also the first line it renders. A chunk with no
// content at all (a long run of blank lines sliced off on its own)
// is dropped for the same reason as the all-blank buffer above.
guard let first = chunk.firstIndex(where: { !Self.isBlank($0.line) }),
let last = chunk.lastIndex(where: { !Self.isBlank($0.line) }) else {
chunkStart = sliceEnd
continue
}
let slice = chunk.map(\.line).joined(separator: "\n")
blocks.append(.text(index: index, renderer.render(slice), sourceLine: chunkSourceLine))
let slice = buffer[first...last].map(\.line).joined(separator: "\n")
blocks.append(.text(index: index, renderer.render(slice), sourceLine: buffer[first].originalIndex))
index += 1
chunkStart = sliceEnd
}
Expand Down
5 changes: 5 additions & 0 deletions QuickMD/QuickMD/MarkdownExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,11 @@ class PDFExportManager {
return nil
}

// A display formula SwiftMath cannot typeset prints as the same
// ```latex code block the screen shows (MarkdownView's parse task makes
// the identical substitution). Main actor: the engine is main-only.
let blocks = MathRendering.swiftMath.substitutingUntypesettableMath(in: blocks)

// Measure each block's size in points
var measuredBlocks: [(block: MarkdownBlock, size: CGSize)] = []

Expand Down
Loading
Loading