diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..0d6c98ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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). + ## [0.11.0] - 2026-07-31 ### Added diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..983af71b 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -349,15 +349,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 } diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..ff87b81a 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -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 @@ -85,6 +94,7 @@ public struct MarkdownEditorTheme: Sendable { bodyText: NSColor = .labelColor, mutedText: NSColor = .secondaryLabelColor, disabledText: NSColor = .tertiaryLabelColor, + headingText: NSColor? = nil, headingMarker: NSColor = .gray, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, @@ -98,6 +108,7 @@ public struct MarkdownEditorTheme: Sendable { self.bodyText = bodyText self.mutedText = mutedText self.disabledText = disabledText + self.headingText = headingText self.headingMarker = headingMarker self.link = link self.incompleteLink = incompleteLink diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..22a98a86 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -548,9 +548,15 @@ 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 @@ -558,7 +564,15 @@ enum MarkdownASTStyler { 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])) } diff --git a/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift new file mode 100644 index 00000000..bb8a8acb --- /dev/null +++ b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift @@ -0,0 +1,179 @@ +// +// HeadingFontAndColorTests.swift +// MarkdownEngineTests +// +// The two opt-in heading knobs: `HeadingStyle.fontName` (a dedicated heading +// typeface) and `MarkdownEditorTheme.headingText` (a dedicated heading text +// color). Both default to nil, which must keep the stock styling unchanged — +// headings derive from the base font with the bold trait and inherit the +// view-level bodyText foreground. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Heading font & color knobs") +struct HeadingFontAndColorTests { + + private let base: CGFloat = 14 + private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + + /// A real, always-installed face that differs from the system font in both + /// family and weight, so assertions can see it was used verbatim. + private let headingFace = "Menlo-Regular" + + /// Effective font at `pos`: the last styled range covering it that sets `.font`. + private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? { + var result: NSFont? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let f = a[.font] as? NSFont { result = f } + } + return result + } + + /// Effective color at `pos`: the last styled range covering it that sets `.foregroundColor`. + private func color(in attrs: [StyledRange], at pos: Int) -> NSColor? { + var result: NSColor? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let c = a[.foregroundColor] as? NSColor { result = c } + } + return result + } + + private func style( + _ text: String, + configuration: MarkdownEditorConfiguration = .default + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, configuration: configuration + ) + } + + // MARK: - HeadingStyle.fontName + + @Test("headings.fontName renders headings in that face at the multiplied size") + func headingFontNameUsedVerbatimAtMultipliedSize() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# One\n\nbody\n\n## Two": O=2, b=7, T=16 + let attrs = style("# One\n\nbody\n\n## Two", configuration: config) + + let h1 = font(in: attrs, at: 2) + #expect(h1?.fontName == headingFace) + #expect(h1?.pointSize == base * 2.0) + // The face is honored exactly: no synthetic bold on the chosen weight. + #expect(h1?.fontDescriptor.symbolicTraits.contains(.bold) == false) + + // Per-level multipliers still apply to the custom face. + let h2 = font(in: attrs, at: 16) + #expect(h2?.fontName == headingFace) + #expect(h2?.pointSize == base * 1.5) + + // Body text never takes the heading face (no .font range at all). + #expect(font(in: attrs, at: 7) == nil) + } + + @Test("emphasis inside a custom-face heading keeps family and size, adds traits") + func emphasisComposesOnTheCustomHeadingFace() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# **n*o*des**": n=4, o=6, d=8 + let attrs = style("# **n*o*des**", configuration: config) + let n = font(in: attrs, at: 4) + let o = font(in: attrs, at: 6) + let d = font(in: attrs, at: 8) + + #expect(n?.familyName == "Menlo") + #expect(o?.familyName == "Menlo") + #expect(d?.familyName == "Menlo") + #expect(n?.pointSize == base * 2.0) + #expect(o?.pointSize == base * 2.0) + #expect(d?.pointSize == base * 2.0) + #expect(n?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect(o?.fontDescriptor.symbolicTraits.contains([.bold, .italic]) == true) + #expect(d?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + @Test("an unresolvable fontName falls back to the stock heading font") + func unresolvableFontNameFallsBack() { + let config = MarkdownEditorConfiguration( + headings: HeadingStyle(fontName: "Not-A-Real-Font-Face") + ) + let stock = font(in: style("# Title"), at: 2) + let fallback = font(in: style("# Title", configuration: config), at: 2) + #expect(fallback == stock) + #expect(fallback?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + // MARK: - MarkdownEditorTheme.headingText + + @Test("theme.headingText colors heading text; # markers and body keep their own ink") + func headingTextColorsContentOnly() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# Title\n\nbody": marker=0..1, T=2, b=8 + let attrs = style("# Title\n\nbody", configuration: config) + + #expect(color(in: attrs, at: 2) == .systemPink) + // The `#` marker glyphs stay on headingMarker (the separate knob). + #expect(color(in: attrs, at: 0) == theme.headingMarker) + // Body text still inherits the view-level bodyText (no styled foreground). + #expect(color(in: attrs, at: 8) == nil) + } + + @Test("a link inside a colored heading keeps the link ink") + func linkInsideColoredHeadingKeepsLinkColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# [x](https://e.com)": x=3 + let attrs = style("# [x](https://e.com)", configuration: config) + #expect(color(in: attrs, at: 3) == theme.link) + } + + @Test("emphasis inside a colored heading keeps the heading color") + func emphasisInsideColoredHeadingKeepsHeadingColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# **bold**": b=4 — emphasis composes fonts only, so the ink survives. + let attrs = style("# **bold**", configuration: config) + #expect(color(in: attrs, at: 4) == .systemPink) + } + + // MARK: - Defaults stay byte-identical + + @Test("nil knobs: heading content carries the stock font and no foreground") + func nilKnobsKeepStockHeadingAttributes() { + // "# Title": T=2 + let attrs = style("# Title") + let heading = font(in: attrs, at: 2) + let stock = NSFont(name: fontName, size: base * 2.0) ?? .systemFont(ofSize: base * 2.0) + let stockBold = NSFont( + descriptor: stock.fontDescriptor.withSymbolicTraits( + stock.fontDescriptor.symbolicTraits.union(.bold)), + size: stock.pointSize + ) ?? stock + #expect(heading == stockBold) + // No styled range sets a heading foreground — bodyText inheritance. + #expect(color(in: attrs, at: 2) == nil) + } + + @Test("explicit-nil knobs produce value-identical styling to .default") + func nilKnobsMatchDefaultsExactly() { + let doc = "# One **bold** *i*\n\nbody `code`\n\n## Two\n\n- item\n\n> quote\n" + let expected = style(doc) + let explicitNil = MarkdownEditorConfiguration( + theme: MarkdownEditorTheme(headingText: nil), + headings: HeadingStyle(fontName: nil) + ) + let actual = style(doc, configuration: explicitNil) + + #expect(actual.count == expected.count) + for (a, e) in zip(actual, expected) { + #expect(a.range == e.range) + #expect((a.attributes as NSDictionary).isEqual(to: e.attributes)) + } + } +}