Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `MarkdownEditorTheme.tableRowBackground` fills a rendered table's body rows
(everything below the header). `nil` (the default) keeps the historical
unfilled body. The fill clips inside a rounded wrapper, interior rules and
the outer border stroke on top, and the slot participates in the table
image cache key.
- `TableStyle.verticalRules` (default `true`) controls interior column
separators in rendered tables. When `false`, only the outer border and the
horizontal rules between rows (including the header/body rule) draw, with
the horizontal rules spanning the full inner width; column sizing and cell
padding are unchanged. The knob participates in the table image cache key.
- `TableStyle.cornerRadius` rounds the rendered table wrapper's corners:
interior painting (header fill, separator rules) clips to the rounded
shape and the outer border rule strokes along the rounded path, staying
crisp at the corners. `0` (the default) keeps the historical
square-cornered rendering exactly.
- Table theming slots: `MarkdownEditorTheme.tableHeaderBackground` fills the
rendered header row (nil = the historical mutedText at 8% alpha) and
`MarkdownEditorTheme.tableRule` strokes the outer border and internal
rules (nil = mutedText at 50% alpha). Both participate in the table image
cache key.
- Custom heading typeface and color: `HeadingStyle.fontName` renders headings
in a specific PostScript face (honored exactly, so the chosen weight is
respected; an unresolvable name falls back to the stock bold base font),
and `MarkdownEditorTheme.headingText` colors heading text independently of
`bodyText` — the `#` glyphs stay on `headingMarker`, and inline constructs
inside a heading keep their own ink (both opt-in; the defaults are
unchanged).

### Fixed
- Interior table rules stroke in the themed rule color again. The rounded
wrapper change moved the outer border's `setStroke` below the separator
pass, which left interior rules on the drawing context's default black
instead of `MarkdownEditorTheme.tableRule`.

## [0.11.0] - 2026-07-31

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public struct MarkdownEditorConfiguration: Sendable {
public var codeBlock: CodeBlockStyle
public var inlineCode: InlineCodeStyle
public var lists: ListStyle
public var table: TableStyle
public var taskCheckbox: TaskCheckboxStyle
public var headings: HeadingStyle
public var imageEmbed: ImageEmbedStyle
Expand Down Expand Up @@ -92,6 +93,7 @@ public struct MarkdownEditorConfiguration: Sendable {
codeBlock: CodeBlockStyle = .default,
inlineCode: InlineCodeStyle = .default,
lists: ListStyle = .default,
table: TableStyle = .default,
taskCheckbox: TaskCheckboxStyle = .default,
headings: HeadingStyle = .default,
imageEmbed: ImageEmbedStyle = .default,
Expand All @@ -118,6 +120,7 @@ public struct MarkdownEditorConfiguration: Sendable {
self.codeBlock = codeBlock
self.inlineCode = inlineCode
self.lists = lists
self.table = table
self.taskCheckbox = taskCheckbox
self.headings = headings
self.imageEmbed = imageEmbed
Expand Down Expand Up @@ -349,15 +352,30 @@ public struct TaskCheckboxStyle: Sendable {
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
/// which are loosely based on browser default heading sizes.
public struct HeadingStyle: Sendable {
/// PostScript name of the typeface used for heading text, for example
/// `"AvenirNext-DemiBold"`. `nil` (the default) keeps the historical
/// behavior: headings render in the editor's base font with the bold
/// trait added.
///
/// The name is honored exactly, so the chosen face's weight and style
/// are respected — pick a `-Bold` / `-Semibold` face for heavier
/// headings. Emphasis inside a heading still composes on top of it:
/// bold / italic add their traits while the family and the per-level
/// size are kept. A name that doesn't resolve falls back to the default
/// heading font at draw time, so a typo degrades to the stock look
/// instead of changing metrics.
public var fontName: String?
/// Font-size multiplier per heading level (1...6).
public var fontMultipliers: [CGFloat]
/// Top spacing in `em` units per heading level (1...6).
public var topSpacingEm: [CGFloat]

public init(
fontName: String? = nil,
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
) {
self.fontName = fontName
self.fontMultipliers = fontMultipliers
self.topSpacingEm = topSpacingEm
}
Expand Down Expand Up @@ -462,6 +480,32 @@ public struct BlockquoteStyle: Sendable {
public static let `default` = BlockquoteStyle()
}

// MARK: - Tables

/// GFM table wrapper rendering knobs.
public struct TableStyle: Sendable {
/// Corner radius of the rendered table's outer wrapper. The interior
/// painting (header fill, separator rules) is clipped to the rounded
/// shape and the outer border rule is stroked along the rounded path,
/// so the rules stay crisp at the corners. `0` (the default) keeps the
/// historical square-cornered rendering exactly.
public var cornerRadius: CGFloat

/// Whether interior vertical column separators are drawn. `true` (the
/// default) keeps the historical full-grid look. When `false`, only the
/// outer border and the horizontal rules between rows (including the
/// header/body rule) are painted; the horizontal rules span the full
/// inner width, and column sizing and cell padding are unchanged.
public var verticalRules: Bool

public init(cornerRadius: CGFloat = 0, verticalRules: Bool = true) {
self.cornerRadius = max(0, cornerRadius)
self.verticalRules = verticalRules
}

public static let `default` = TableStyle()
}

// MARK: - Links

/// Foreground alpha values applied to link content in different states.
Expand Down
33 changes: 32 additions & 1 deletion Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ public struct MarkdownEditorTheme: Sendable {
/// Foreground color for content the engine wants to deemphasize further
/// than `mutedText` — for example, broken wiki-links.
public var disabledText: NSColor
/// Foreground color for heading text. `nil` (the default) keeps the
/// historical behavior: headings render in ``bodyText`` like the rest
/// of the document.
///
/// Only the heading's own text takes this color. The `#` marker glyphs
/// stay on ``headingMarker``, and inline constructs inside a heading
/// (links, inline code, extension spans) keep their own colors, exactly
/// as they do over ``bodyText``.
public var headingText: NSColor?
/// Foreground color for heading marker glyphs (`#`, `##`, …).
public var headingMarker: NSColor

Expand Down Expand Up @@ -79,12 +88,27 @@ public struct MarkdownEditorTheme: Sendable {
/// Background color used for `==highlight==` inline markup.
public var highlightColor: NSColor

// MARK: Tables

/// Fill behind a rendered table's header row. `nil` (the default) keeps
/// the historical ``mutedText`` at 8% alpha.
public var tableHeaderBackground: NSColor?
/// Fill behind a rendered table's body rows (everything below the
/// header). `nil` (the default) keeps the historical unfilled body, so
/// the editor background shows through. Interior rules and the outer
/// border stroke on top, and the fill clips inside a rounded wrapper.
public var tableRowBackground: NSColor?
/// Stroke color of a rendered table's outer border and internal rules.
/// `nil` (the default) keeps the historical ``mutedText`` at 50% alpha.
public var tableRule: NSColor?

// MARK: Init

public init(
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingText: NSColor? = nil,
headingMarker: NSColor = .gray,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
Expand All @@ -93,11 +117,15 @@ public struct MarkdownEditorTheme: Sendable {
latexLightModeText: NSColor = .black,
latexDarkModeText: NSColor = .white,
strikethroughColor: NSColor = .labelColor,
highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4)
highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4),
tableHeaderBackground: NSColor? = nil,
tableRowBackground: NSColor? = nil,
tableRule: NSColor? = nil
) {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingText = headingText
self.headingMarker = headingMarker
self.link = link
self.incompleteLink = incompleteLink
Expand All @@ -107,6 +135,9 @@ public struct MarkdownEditorTheme: Sendable {
self.latexDarkModeText = latexDarkModeText
self.strikethroughColor = strikethroughColor
self.highlightColor = highlightColor
self.tableHeaderBackground = tableHeaderBackground
self.tableRowBackground = tableRowBackground
self.tableRule = tableRule
}

/// System-native palette built from `NSColor` dynamic system colors.
Expand Down
22 changes: 18 additions & 4 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -548,17 +548,31 @@ enum MarkdownASTStyler {

case .heading(let level, let range, let markers, let inlines):
let multiplier = ctx.config.headings.fontMultiplier(for: level)
let headingBase = NSFont(name: ctx.fontName, size: ctx.baseFont.pointSize * multiplier)
?? .systemFont(ofSize: ctx.baseFont.pointSize * multiplier)
let headingFont = adding(.bold, to: headingBase)
let headingSize = ctx.baseFont.pointSize * multiplier
// A configured heading face is honored exactly — its weight is the
// embedder's choice, so no synthetic bold on top. A name that
// doesn't resolve degrades to the stock heading font (base family,
// bold trait), mirroring TaskCheckboxStyle's symbol fallback.
let headingFont = ctx.config.headings.fontName
.flatMap { NSFont(name: $0, size: headingSize) }
?? adding(.bold, to: NSFont(name: ctx.fontName, size: headingSize)
?? .systemFont(ofSize: headingSize))
let lineHeight = ceil(headingFont.ascender - headingFont.descender + headingFont.leading) + 1
let headingPara = NSMutableParagraphStyle()
headingPara.minimumLineHeight = lineHeight
headingPara.maximumLineHeight = lineHeight
headingPara.paragraphSpacingBefore = headingFont.pointSize * ctx.config.headings.topSpacingEm(for: level)
headingPara.paragraphSpacing = ctx.baseParagraphSpacing
attrs.append((ctx.ns.paragraphRange(for: range), [.paragraphStyle: headingPara]))
attrs.append((range, [.font: headingFont]))
// theme.headingText paints the whole heading line; the marker loop
// and the inline descent below both append LATER, so `#` glyphs
// keep headingMarker and links / code keep their own ink — the
// same later-range-wins layering the bodyText default relies on.
var headingAttrs: [NSAttributedString.Key: Any] = [.font: headingFont]
if let headingText = ctx.theme.headingText {
headingAttrs[.foregroundColor] = headingText
}
attrs.append((range, headingAttrs))
for marker in markers {
attrs.append((marker, [.foregroundColor: ctx.theme.headingMarker]))
}
Expand Down
Loading