import AppKit // MARK: - HTMLTheme // // Emits the CSS for Read mode / PDF export from the *same* `EditorTheme` and // `CalloutStyle` models the editor renders from, so the two can't drift. The // theme is the single source of truth for the values it carries (body font/size, // accent, code color, line/paragraph spacing, callout colors); spacing for // elements the theme doesn't model (headings, list indent) uses tasteful // document defaults. // // Colors are resolved for one appearance (`dark`); the Read view re-renders when // the system appearance flips. enum HTMLTheme { @MainActor static func css(_ theme: EditorTheme, callouts: [String: CalloutStyle], dark: Bool, maxContentWidthPoints: Double = .greatestFiniteMagnitude) -> String { let bg = dark ? "#1e1e1e" : "#ffffff" let fg = dark ? "#e6e6e6" : "#1a1a1a" let faint = dark ? "#9a9a9a" : "#6a6a6a" let rule = dark ? "#3a3a3a" : "#e0e0e0" let codeBg = dark ? "#2a2a2a" : "#f4f4f4" // line-height: editor `NSParagraphStyle.lineSpacing` adds extra points // *between* lines on top of the font's natural leading (~1.2×). The CSS // equivalent is 1.2 + (lineSpacing / fontSize). let lineHeight = 1.2 + theme.lineSpacing / theme.fontSize // CSS px and AppKit points are both device-independent, so the editor's // physical cap (EditorTextView.maxContentWidthPoints) carries over as-is. // A huge/infinite value means "uncapped" in the editor too; `none` skips // the constraint instead of emitting an unusable giant number. let pageMaxWidth = maxContentWidthPoints < 100_000 ? "\(trim(CGFloat(maxContentWidthPoints)))px" : "none" return """ :root { --body-font: \(cssFontStack(theme.fontName, generic: "serif")); --body-size: \(trim(theme.fontSize))px; --mono-font: \(cssFontStack(theme.monospaceFontName.isEmpty ? "ui-monospace" : theme.monospaceFontName, generic: "monospace")); --mono-size: \(trim(theme.monospaceFontSize))px; --accent: \(theme.linkBlueHex); --code: \(theme.codeHex); --bg: \(bg); --fg: \(fg); --faint: \(faint); --rule: \(rule); --code-bg: \(codeBg); --marker: \(resolvedRGBA(.tertiaryLabelColor, dark: dark)); --check-fill: \(resolvedRGBA(.controlAccentColor, dark: dark)); --line-height: \(trim(lineHeight)); --para-space: \(trim(max(theme.paragraphSpacingBefore, 0)))px; --page-max-width: \(pageMaxWidth); } \(calloutVars(callouts, dark: dark)) \(staticRules) \(codeTokenRules(dark: dark)) """ } // MARK: Code syntax colors /// `.tok-*` color rules for fenced code blocks, from the shared /// `CodeSyntaxPalette` so Read mode matches the editor token-for-token. The /// `pre code` rule overrides the static `var(--fg)` so plain (un-tokenized) /// code uses the palette's plain color too, like the editor. private static func codeTokenRules(dark: Bool) -> String { func rule(_ selector: String, _ type: CodeHighlighter.TokenType?) -> String { "\(selector) { color: \(CodeSyntaxPalette.hex(type, dark: dark)); }" } return [ rule("pre code", nil), rule("pre code .tok-keyword", .keyword), rule("pre code .tok-type", .type), rule("pre code .tok-string", .string), rule("pre code .tok-number", .number), rule("pre code .tok-comment", .comment), rule("pre code .tok-function", .function), ].joined(separator: "\n") } // MARK: Callout custom properties @MainActor private static func calloutVars(_ callouts: [String: CalloutStyle], dark: Bool) -> String { // De-dup styles shared by aliases: emit one rule block per type key. var out = "" for type in callouts.keys.sorted() { let style = callouts[type]! let accent = style.accentHex(dark: dark) let border = style.resolvedBorderHex(dark: dark) let bg = style.explicitBackgroundHex(dark: dark) ?? rgba(accent, alpha: style.backgroundAlpha) out += """ .callout-\(type) { --c-accent: \(accent); --c-border: \(border); --c-bg: \(bg); --c-border-width: \(trim(style.borderWidth))px; \(borderEdgeRules(style.borderEdges)) } """ } return out } private static func borderEdgeRules(_ edges: CalloutStyle.Edges) -> String { var parts: [String] = [] if edges.contains(.left) { parts.append("border-left: var(--c-border-width) solid var(--c-border);") } if edges.contains(.top) { parts.append("border-top: var(--c-border-width) solid var(--c-border);") } if edges.contains(.right) { parts.append("border-right: var(--c-border-width) solid var(--c-border);") } if edges.contains(.bottom) { parts.append("border-bottom: var(--c-border-width) solid var(--c-border);") } return parts.joined(separator: " ") } // MARK: Static element rules private static let staticRules = """ * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } body { font-family: var(--body-font); font-size: var(--body-size); line-height: var(--line-height); color: var(--fg); background: var(--bg); margin: 0; padding: 48px 24px; } .page { max-width: var(--page-max-width); margin: 0 auto; } /* Styled-source spacing: paragraphs and blocks get a full line's breathing room, so the cadence feels like a clean, readable version of Edit mode rather than a collapsed publication layout. */ p { margin: 0 0 1em; } h1, h2, h3, h4, h5, h6 { line-height: 1.25; font-weight: 600; margin: 1.4em 0 0.5em; } h1 { font-size: 1.9em; } h2 { font-size: 1.55em; } h3 { font-size: 1.3em; } h4 { font-size: 1.1em; } h5 { font-size: 1em; } h6 { font-size: 0.9em; color: var(--faint); } :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; } a { color: var(--accent); text-decoration: underline; } code { font-family: var(--mono-font); font-size: 0.92em; color: var(--code); background: var(--code-bg); padding: 0.1em 0.35em; border-radius: 4px; } pre { background: var(--code-bg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; /* tab-size: browsers default to 8; match the common editor convention of 4. */ tab-size: 4; -moz-tab-size: 4; } pre code { color: var(--fg); background: none; padding: 0; font-size: var(--mono-size); } blockquote { margin: 1em 0; padding: 0.5em 1em; border-left: 3px solid var(--rule); color: var(--faint); } /* Without this, the 1em bottom margin on the last

inside a blockquote creates asymmetric vertical padding — the blockquote looks heavier at the bottom than at the top. Reset it so padding alone controls the spacing. */ blockquote > p:last-child { margin-bottom: 0; } /* A nested blockquote that is the last child of its parent blockquote (or callout body) would otherwise leave 1em of extra space below itself inside the parent's padding. Collapse it. */ blockquote > blockquote:last-child, .callout-body > blockquote:last-child { margin-bottom: 0; } hr { border: none; border-top: 1px solid var(--rule); margin: 1.6em 0; } mark { background: rgba(255, 200, 0, 0.3); color: inherit; padding: 0 0.1em; } /* Whitelisted inline HTML rendered in Read mode (see HTMLRenderer sanitizeInlineHTML). / use the UA underline / the rule above; matches the editor's inline-key chrome, / get the standard line-height-safe reset. */ kbd { font-family: var(--mono-font); font-size: 0.92em; background: var(--code-bg); border: 1px solid var(--rule); border-radius: 4px; padding: 0.05em 0.4em; } sub, sup { font-size: 0.75em; line-height: 0; position: relative; vertical-align: baseline; } sup { top: -0.5em; } sub { bottom: -0.25em; } /* Footnotes (see HTMLRenderer.renderFootnotesSection): in-text `[^id]` refs are plain (undecorated) superscript links; the bottom-of-page list is a smaller, dimmer

    below its own
    , each entry ending in a backref arrow to the in-text marker. */ sup.footnote-ref a { text-decoration: none; } hr.footnotes-sep { margin-bottom: 0.8em; } ol.footnotes { font-size: 0.85em; color: var(--faint); } ol.footnotes li { margin: 0.4em 0; } a.footnote-backref { text-decoration: none; margin-left: 0.2em; font-size: 0.9em; line-height: 1; } /* Match the editor's list indentation: level-1 text begins at one marker slot past the marker (~2.25em), and each nesting level steps in by one slot (~1.25em). Same dot at every level, like Edit mode. */ /* Only direct children of .page and .callout-body get block margin (1em top + bottom) and the wider level-1 indent (2.25em). Nested lists inside list items stay at 0 margin — otherwise each level compounds to large gaps. */ ul, ol { margin: 0; padding-left: 1.25em; } .page > ul, .page > ol, .callout-body > ul, .callout-body > ol { margin: 1em 0; padding-left: 2.25em; } li > ul, li > ol { margin: 0; } ul { list-style-type: disc; } li { margin: 0.2em 0; } li::marker { color: var(--marker); font-size: 0.85em; } li > p { margin: 0; } /* Task items: float the checkbox into the marker slot so the label and wrapped lines sit at the same content edge as bullet/number text. The negative margin-left pulls the checkbox into the list's padding area; the nested