diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..a0456821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..ae57fd75 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -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 @@ -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, @@ -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 @@ -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 } @@ -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. diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..d4001f5e 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 @@ -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, @@ -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 @@ -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. 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/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 21f0b734..8d4a9dbe 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -61,10 +61,15 @@ extension MarkdownStyler { private static func themeKeyPrefix(ctx: StylingContext, appearance: NSAppearance) -> String { let theme = ctx.configuration.theme + func optionalIdentity(_ color: NSColor?) -> String { + color.map { "\(ObjectIdentifier($0))" } ?? "nil" + } let identity = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|" + "\(ObjectIdentifier(theme.bodyText))|\(ObjectIdentifier(theme.mutedText))|" + "\(ObjectIdentifier(theme.highlightColor))|\(ObjectIdentifier(ctx.codeBackgroundColor))|" + "\(ObjectIdentifier(theme.latexLightModeText))|\(ObjectIdentifier(theme.latexDarkModeText))|" + + "\(optionalIdentity(theme.tableHeaderBackground))|\(optionalIdentity(theme.tableRowBackground))|" + + "\(optionalIdentity(theme.tableRule))|" + "\(ObjectIdentifier(type(of: ctx.services.latex)))" themeKeyLock.lock() @@ -87,6 +92,9 @@ extension MarkdownStyler { colorKey(ctx.codeBackgroundColor, under: appearance), colorKey(theme.latexLightModeText, under: appearance), colorKey(theme.latexDarkModeText, under: appearance), + theme.tableHeaderBackground.map { colorKey($0, under: appearance) } ?? "nil", + theme.tableRowBackground.map { colorKey($0, under: appearance) } ?? "nil", + theme.tableRule.map { colorKey($0, under: appearance) } ?? "nil", "\(ObjectIdentifier(type(of: ctx.services.latex)))", ].joined(separator: "|") @@ -146,7 +154,13 @@ extension MarkdownStyler { // highlighted under one config and literal under another — those must // never share a cached image. let extensionKey = ctx.configuration.extensionRegistry.fingerprint - let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|x\(extensionKey)|w\(widthKey)|" + source) as NSString + // The wrapper radius and rule orientation change the rendered pixels, + // so they are part of the cache identity like every other rendering + // input. + let radiusKey = ctx.configuration.table.cornerRadius + let verticalRulesKey = ctx.configuration.table.verticalRules ? 1 : 0 + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + + "|x\(extensionKey)|w\(widthKey)|r\(radiusKey)|v\(verticalRulesKey)|" + source) as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } @@ -158,7 +172,9 @@ extension MarkdownStyler { latex: ctx.services.latex, appearance: appearance, availableWidth: availableWidth, - extensions: ctx.configuration.extensions + extensions: ctx.configuration.extensions, + cornerRadius: ctx.configuration.table.cornerRadius, + verticalRules: ctx.configuration.table.verticalRules ) tableImageCache.setObject(image, forKey: key) return (image, true) @@ -453,21 +469,26 @@ extension MarkdownStyler { latex: any LatexRenderer, appearance: NSAppearance, availableWidth: CGFloat, - extensions: [any MarkdownExtension] = [] + extensions: [any MarkdownExtension] = [], + cornerRadius: CGFloat = 0, + verticalRules: Bool = true ) -> NSImage { let columnCount = table.alignments.count let cellHPadding: CGFloat = 12 let cellVPadding: CGFloat = 6 let borderWidth: CGFloat = 1 // Resolve under the real appearance: `.withAlphaComponent()` freezes a dynamic color otherwise. - func mutedColor(alpha: CGFloat) -> NSColor { - var resolved: NSColor = theme.mutedText + func resolved(_ color: NSColor) -> NSColor { + var result = color appearance.performAsCurrentDrawingAppearance { - resolved = theme.mutedText.usingColorSpace(.sRGB) ?? theme.mutedText + result = color.usingColorSpace(.sRGB) ?? color } - return resolved.withAlphaComponent(alpha) + return result + } + func mutedColor(alpha: CGFloat) -> NSColor { + resolved(theme.mutedText).withAlphaComponent(alpha) } - let borderColor = mutedColor(alpha: 0.5) + let borderColor = theme.tableRule.map(resolved) ?? mutedColor(alpha: 0.5) let baseLineHeight: CGFloat = ceil(baseFont.ascender - baseFont.descender + baseFont.leading) let minColumnContentWidth: CGFloat = 16 @@ -598,10 +619,32 @@ extension MarkdownStyler { } let alignments = table.alignments - let headerFill = mutedColor(alpha: 0.08) + let headerFill = theme.tableHeaderBackground.map(resolved) ?? mutedColor(alpha: 0.08) + let rowFill = theme.tableRowBackground.map(resolved) + + // Clamp so tiny tables can't invert the rounded path. + let radius = max(0, min(cornerRadius, min(size.width, size.height) / 2 - borderWidth)) + let outerRect = NSRect( + x: borderWidth / 2, + y: borderWidth / 2, + width: size.width - borderWidth, + height: size.height - borderWidth + ) // Flipped image so AppKit handles the y-flip; a manual transform mirror would flip glyphs too. return NSImage(size: size, flipped: true) { _ in + // Rounded wrapper: the outer border stroke runs along the rounded + // path, and the interior painting (header fill, separator rule + // ends) is clipped to it so nothing pokes out of the corners. + let outer = radius > 0 + ? NSBezierPath(roundedRect: outerRect, xRadius: radius, yRadius: radius) + : NSBezierPath(rect: outerRect) + + if radius > 0 { + NSGraphicsContext.saveGraphicsState() + outer.addClip() + } + // Header row fill headerFill.setFill() NSBezierPath(rect: NSRect( @@ -611,24 +654,34 @@ extension MarkdownStyler { height: rowContentHeights[0] + 2 * cellVPadding )).fill() - // Outer border - borderColor.setStroke() - let outer = NSBezierPath(rect: NSRect( - x: borderWidth / 2, - y: borderWidth / 2, - width: size.width - borderWidth, - height: size.height - borderWidth - )) - outer.lineWidth = borderWidth - outer.stroke() + // Body row fill — everything below the header, so the rule band + // between header and body is covered too; the interior rules and + // outer border stroke on top of it afterwards. + if let rowFill { + let bodyTop = borderWidth + rowContentHeights[0] + 2 * cellVPadding + rowFill.setFill() + NSBezierPath(rect: NSRect( + x: borderWidth, + y: bodyTop, + width: size.width - 2 * borderWidth, + height: size.height - borderWidth - bodyTop + )).fill() + } - // Internal separators + // Internal separators. Stroked in the border color explicitly: + // the context's default stroke is black, and the outer border's + // setStroke happens later (it moved below the clip restore when + // the rounded wrapper landed). Row rules always span the full + // inner width; column rules only exist in the full-grid look. + borderColor.setStroke() let separators = NSBezierPath() separators.lineWidth = borderWidth - for i in 1.. 0 { NSGraphicsContext.restoreGraphicsState() } + + // Outer border — stroked unclipped so the rule stays crisp at + // the rounded corners. + borderColor.setStroke() + outer.lineWidth = borderWidth + outer.stroke() + func drawCell(_ s: NSAttributedString, col: Int, row: Int) { guard col < columnCount else { return } let cellLeft = columnLeft[col] + cellHPadding 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)) + } + } +} diff --git a/Tests/MarkdownEngineTests/TableCornerRadiusTests.swift b/Tests/MarkdownEngineTests/TableCornerRadiusTests.swift new file mode 100644 index 00000000..984ad359 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableCornerRadiusTests.swift @@ -0,0 +1,120 @@ +// +// TableCornerRadiusTests.swift +// MarkdownEngineTests +// +// `TableStyle.cornerRadius`: the rendered table wrapper is clipped to a +// rounded shape with the outer border rule stroked along the rounded path. +// The default (0) keeps the historical square-cornered rendering, and the +// radius participates in the render cache key. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table corner radius") +struct TableCornerRadiusTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + private func sample(_ image: NSImage, x: Int, y: Int) -> NSColor? { + let size = image.size + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + return bitmap.colorAt(x: x, y: y) + } + + @Test("default radius is zero and negatives clamp") + func defaults() { + #expect(TableStyle.default.cornerRadius == 0) + #expect(MarkdownEditorConfiguration.default.table.cornerRadius == 0) + #expect(TableStyle(cornerRadius: -3).cornerRadius == 0) + } + + @Test("a radius empties the corner and keeps the mid-edge rule crisp") + func roundedCornerPixels() throws { + let source = "| head | col |\n|---|---|\n| a | b |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var square = MarkdownEditorConfiguration.default + square.theme.tableRule = .blue + var rounded = square + rounded.table.cornerRadius = 6 + + let (squareImage, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: square), + appearance: aqua, availableWidth: 2000 + ) + let (roundedImage, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: rounded), + appearance: aqua, availableWidth: 2000 + ) + + // The square wrapper inks the very corner; the rounded wrapper leaves + // it empty. + let squareCorner = try #require(sample(squareImage, x: 0, y: 0)) + let roundedCorner = try #require(sample(roundedImage, x: 0, y: 0)) + #expect(squareCorner.alphaComponent > 0.4) + #expect(roundedCorner.alphaComponent < 0.1) + + // Mid-edge border rule stays inked (crisp rule along the rounded path). + let midEdge = try #require(sample(roundedImage, x: 0, y: Int(roundedImage.size.height / 2))) + #expect(midEdge.alphaComponent > 0.4) + #expect(midEdge.blueComponent > 0.9) + + // The corner curve reconnects with the border within the radius: a + // pixel just inside the corner diagonal is inked again. + let onCurve = try #require(sample(roundedImage, x: 2, y: 2)) + #expect(onCurve.alphaComponent > 0.2) + } + + @Test("changing the radius renders fresh instead of reusing the cache") + func radiusChangeRendersFresh() throws { + let source = "| rho | sigma |\n|---|---|\n| 21 | 22 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: makeContext(for: source), + appearance: aqua, availableWidth: 2000 + ) + + var rounded = MarkdownEditorConfiguration.default + rounded.table.cornerRadius = 4 + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: rounded), + appearance: aqua, availableWidth: 2000 + ) + #expect(repainted.rendered) + } +} diff --git a/Tests/MarkdownEngineTests/TableRowFillTests.swift b/Tests/MarkdownEngineTests/TableRowFillTests.swift new file mode 100644 index 00000000..5c05f565 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableRowFillTests.swift @@ -0,0 +1,131 @@ +// +// TableRowFillTests.swift +// MarkdownEngineTests +// +// `MarkdownEditorTheme.tableRowBackground`: fills the rendered table's body +// rows (below the header). `nil` keeps the historical unfilled body. The +// fill clips inside a rounded wrapper, rules stroke on top, and the slot +// participates in the render cache key. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table row fill") +struct TableRowFillTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + private func bitmap(_ image: NSImage) -> NSBitmapImageRep? { + let size = image.size + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + return rep + } + + private let source = "| head | col |\n|---|---|\n| a | b |\n| c | d |" + + private func render( + rowFill: NSColor?, + cornerRadius: CGFloat = 0 + ) throws -> NSBitmapImageRep { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + var config = MarkdownEditorConfiguration.default + config.theme.tableRowBackground = rowFill + config.table.cornerRadius = cornerRadius + let (image, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: config), + appearance: aqua, availableWidth: 2000 + ) + return try #require(bitmap(image)) + } + + /// A body-cell probe point: past the header band, away from text glyphs + /// and rules — just inside the left border at three quarters height. + private func bodyProbe(_ rep: NSBitmapImageRep) -> (x: Int, y: Int) { + (x: 4, y: rep.pixelsHigh * 3 / 4) + } + + @Test("default keeps the body unfilled") + func defaultBodyUnfilled() throws { + let rep = try render(rowFill: nil) + let (x, y) = bodyProbe(rep) + let color = try #require(rep.colorAt(x: x, y: y)) + #expect(color.alphaComponent < 0.1) + } + + @Test("the slot fills body rows but not the header") + func rowFillPixels() throws { + let rep = try render(rowFill: .red) + let (x, y) = bodyProbe(rep) + let body = try #require(rep.colorAt(x: x, y: y)) + #expect(body.alphaComponent > 0.9) + #expect(body.redComponent > 0.8) + #expect(body.greenComponent < 0.2) + + // Header band keeps the header fill (default muted, not red). + let header = try #require(rep.colorAt(x: x, y: 6)) + #expect(header.redComponent < 0.8 || header.alphaComponent < 0.9) + } + + @Test("the fill clips inside rounded corners") + func rowFillClipsToRadius() throws { + let rep = try render(rowFill: .red, cornerRadius: 8) + // The bottom-left pixel is outside the rounded path: no fill ink. + let corner = try #require(rep.colorAt(x: 0, y: rep.pixelsHigh - 1)) + #expect(corner.alphaComponent < 0.1) + // Mid-height on the left edge stays filled right up to the border. + let (x, y) = bodyProbe(rep) + let body = try #require(rep.colorAt(x: x, y: y)) + #expect(body.redComponent > 0.8) + } + + @Test("changing the slot renders fresh instead of reusing the cache") + func slotChangeRendersFresh() throws { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: makeContext(for: source), + appearance: aqua, availableWidth: 2000 + ) + + var filled = MarkdownEditorConfiguration.default + filled.theme.tableRowBackground = .systemTeal + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: filled), + appearance: aqua, availableWidth: 2000 + ) + #expect(repainted.rendered) + } +} diff --git a/Tests/MarkdownEngineTests/TableRuleOrientationTests.swift b/Tests/MarkdownEngineTests/TableRuleOrientationTests.swift new file mode 100644 index 00000000..7b80ac96 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableRuleOrientationTests.swift @@ -0,0 +1,166 @@ +// +// TableRuleOrientationTests.swift +// MarkdownEngineTests +// +// `TableStyle.verticalRules`: the full-grid look (default) draws interior +// column separators; turning it off keeps only the outer border and the +// horizontal rules between rows, spanning the full inner width. The knob +// participates in the render cache key, and interior rules stroke in the +// themed rule color. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table rule orientation") +struct TableRuleOrientationTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + private func bitmap(_ image: NSImage) -> NSBitmapImageRep? { + let size = image.size + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + return rep + } + + private func isRuleBlue(_ color: NSColor?) -> Bool { + guard let color else { return false } + return color.alphaComponent > 0.4 && color.blueComponent > 0.8 && color.redComponent < 0.3 + } + + /// Renders the sample table with a blue rule color so rule pixels are + /// unambiguous against the fill and text inks. + private func render(source: String, verticalRules: Bool) throws -> NSBitmapImageRep { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + var config = MarkdownEditorConfiguration.default + config.theme.tableRule = .blue + config.table.verticalRules = verticalRules + let (image, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: config), + appearance: aqua, availableWidth: 2000 + ) + return try #require(bitmap(image)) + } + + /// Interior horizontal-rule rows: y positions strictly inside the image + /// where the pixel at mid-x is rule-colored. + private func interiorRuleRows(_ rep: NSBitmapImageRep) -> [Int] { + let midX = rep.pixelsWide / 2 + return (2..<(rep.pixelsHigh - 2)).filter { isRuleBlue(rep.colorAt(x: midX, y: $0)) } + } + + private let source = "| head | col | third |\n|---|---|---|\n| a | b | c |\n| d | e | f |" + + @Test("default keeps the full grid") + func defaults() { + #expect(TableStyle.default.verticalRules) + #expect(MarkdownEditorConfiguration.default.table.verticalRules) + } + + @Test("grid off leaves no interior vertical-rule pixels") + func noVerticalRulePixels() throws { + let grid = try render(source: source, verticalRules: true) + let rows = try render(source: source, verticalRules: false) + + // Probe a row strictly between two horizontal rules (mid body row): + // halfway between the first two interior rule rows, or below the last + // one when the header fill occupies the first band. + let gridRules = interiorRuleRows(grid) + let rowsRules = interiorRuleRows(rows) + #expect(!gridRules.isEmpty) + #expect(!rowsRules.isEmpty) + let probeY = try #require(zip(rowsRules.dropFirst(), rowsRules).map { ($0 + $1) / 2 }.first) + + func interiorVerticalHits(_ rep: NSBitmapImageRep, y: Int) -> Int { + (2..<(rep.pixelsWide - 2)).filter { isRuleBlue(rep.colorAt(x: $0, y: y)) }.count + } + // The full grid inks column separators inside the row band; the + // rows-only look inks nothing between the borders there. + #expect(interiorVerticalHits(grid, y: probeY) >= 2) + #expect(interiorVerticalHits(rows, y: probeY) == 0) + } + + @Test("horizontal rules stay and span the full inner width") + func horizontalRulesSpanFullWidth() throws { + let rows = try render(source: source, verticalRules: false) + let ruleRows = interiorRuleRows(rows) + // Header/body rule plus one rule between the two body rows. + #expect(ruleRows.count >= 2) + for y in ruleRows { + #expect(isRuleBlue(rows.colorAt(x: 2, y: y))) + #expect(isRuleBlue(rows.colorAt(x: rows.pixelsWide - 3, y: y))) + } + } + + @Test("outer border still draws on all four sides") + func outerBorderIntact() throws { + let rows = try render(source: source, verticalRules: false) + let midX = rows.pixelsWide / 2 + let midY = rows.pixelsHigh / 2 + #expect(isRuleBlue(rows.colorAt(x: midX, y: 0))) + #expect(isRuleBlue(rows.colorAt(x: midX, y: rows.pixelsHigh - 1))) + #expect(isRuleBlue(rows.colorAt(x: 0, y: midY))) + #expect(isRuleBlue(rows.colorAt(x: rows.pixelsWide - 1, y: midY))) + } + + @Test("interior rules stroke in the themed rule color, not black") + func interiorRulesUseThemeColor() throws { + // Regression guard: the rounded-wrapper change moved the outer + // border's setStroke below the separator pass, which left interior + // rules on the context's default black. + let grid = try render(source: source, verticalRules: true) + let ruleRows = interiorRuleRows(grid) + #expect(!ruleRows.isEmpty) + } + + @Test("flipping the knob renders fresh instead of reusing the cache") + func knobChangeRendersFresh() throws { + let source = "| tau | ups |\n|---|---|\n| 31 | 32 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: makeContext(for: source), + appearance: aqua, availableWidth: 2000 + ) + + var rowsOnly = MarkdownEditorConfiguration.default + rowsOnly.table.verticalRules = false + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: rowsOnly), + appearance: aqua, availableWidth: 2000 + ) + #expect(repainted.rendered) + } +} diff --git a/Tests/MarkdownEngineTests/TableThemingTests.swift b/Tests/MarkdownEngineTests/TableThemingTests.swift new file mode 100644 index 00000000..a930287c --- /dev/null +++ b/Tests/MarkdownEngineTests/TableThemingTests.swift @@ -0,0 +1,120 @@ +// +// TableThemingTests.swift +// MarkdownEngineTests +// +// Table theming slots: `MarkdownEditorTheme.tableHeaderBackground` and +// `MarkdownEditorTheme.tableRule`. Defaults (nil) must keep the historical +// mutedText-derived fills, and the slots must participate in the render +// cache key so themed and stock tables never share an image. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table theming slots") +struct TableThemingTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + private func sample(_ image: NSImage, x: Int, y: Int) -> NSColor? { + let size = image.size + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + // NSImage draws bottom-up; convert the top-down y used by the table + // renderer into the bitmap's coordinate space. + return bitmap.colorAt(x: x, y: y) + } + + @Test("slots default to nil") + func slotsDefaultToNil() { + #expect(MarkdownEditorTheme.default.tableHeaderBackground == nil) + #expect(MarkdownEditorTheme.default.tableRule == nil) + } + + @Test("custom header fill and rule ink reach the rendered bitmap") + func customColorsReachTheBitmap() throws { + let source = "| head | col |\n|---|---|\n| a | b |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var themed = MarkdownEditorConfiguration.default + themed.theme.tableHeaderBackground = .red + themed.theme.tableRule = .blue + + let (image, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: themed), + appearance: aqua, availableWidth: 2000 + ) + + // Top-left interior of the header row (inside the 1pt border, away + // from any glyph) carries the header fill. + let headerPixel = try #require(sample(image, x: 3, y: 3)) + #expect(headerPixel.redComponent > 0.9) + #expect(headerPixel.blueComponent < 0.3) + + // The outer border column carries the rule ink. + let rulePixel = try #require(sample(image, x: 0, y: Int(image.size.height / 2))) + #expect(rulePixel.blueComponent > 0.9) + #expect(rulePixel.redComponent < 0.3) + } + + // The cache key must cover the new slots — a theme differing only in a + // table slot must be a miss, never the stock cached image. + @Test("changing a table slot renders fresh instead of reusing the cache") + func tableSlotChangeRendersFresh() throws { + let source = "| iota | kappa |\n|---|---|\n| 11 | 12 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: makeContext(for: source), + appearance: aqua, availableWidth: 2000 + ) + + var ruled = MarkdownEditorConfiguration.default + ruled.theme.tableRule = .systemPink + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: ruled), + appearance: aqua, availableWidth: 2000 + ) + #expect(repainted.rendered) + + var filled = MarkdownEditorConfiguration.default + filled.theme.tableHeaderBackground = .systemTeal + let refilled = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: filled), + appearance: aqua, availableWidth: 2000 + ) + #expect(refilled.rendered) + } +}