Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

364 lines
19 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <p> 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). <u>/<mark> use the UA underline / the rule above;
<kbd> matches the editor's inline-key chrome, <sub>/<sup> 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 <ol> below its own <hr>, 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 <ul>/<ol> clears the float so it falls below.
Lucide checkbox (a tinted <svg>, see HTMLRenderer/LucideIcons): unchecked =
dim outlined circle (--marker, the editor's tertiaryLabelColor); checked =
disc filled in the system accent (--check-fill, matching the editor's
controlAccentColor) with a white check baked into the SVG. `currentColor`
in the SVG inherits from `color` below. */
li.task { list-style: none; }
li.task > .task-check {
/* Sized a bit larger than 1em so the Lucide circle (r=10 in a 24-box, so it
underfills) reads as big as the editor's checkbox. margin-left is roughly
-(width + margin-right) so the task TEXT starts at the content edge,
lining up with sibling bullet/number text; hand-tuned to -1.45em (a hair
less negative than the -1.5em that formula gives) so the marker centers
over the bullet/number column at every nesting level.
margin-top (0.1em) centers the box on the first text line's cap-height
center — tuned visually for Iowan Old Style at 16pt / line-height 1.45. A
font-agnostic fix would measure NSFont.ascender at render time and emit an
inline margin-top. */
float: left; width: 1.2em; height: 1.2em; line-height: 0;
margin-top: 0.1em;
margin-right: 0.3em;
margin-left: -1.45em;
}
li.task > .task-check svg { display: block; width: 1.2em; height: 1.2em; }
.task-check--unchecked { color: var(--marker); }
.task-check--checked { color: var(--check-fill); }
li.task--checked > p { opacity: 0.45; text-decoration: line-through; }
li.task > p { display: inline; margin: 0; }
li.task > ul, li.task > ol { clear: left; }
/* Contain the checkbox float within its own item. Without this, a task item
that has no nested list (the float is never cleared by a child ul/ol)
leaks its float onto the FOLLOWING sibling, shoving that item's bullet/
number marker to the right — so sibling markers stop lining up. */
li.task::after { content: ""; display: block; clear: both; }
.blank-line { height: calc(var(--body-size) * var(--line-height)); }
/* Tables keep their natural (content-driven) width and scroll horizontally
inside .table-wrap instead of squeezing columns or forcing cell text to
wrap — same idiom as `pre`'s overflow-x below. */
.table-wrap { overflow-x: auto; margin: 1em 0; }
table { border-collapse: collapse; }
th, td { border: 1px solid var(--rule); padding: 6px 10px; }
thead th { background: var(--code-bg); }
img { max-width: 100%; }
img.math { vertical-align: middle; }
.math-display { text-align: center; margin: 1em 0; }
/* Stand-in for a plain-http image, which never loads under ATS. */
.md-image-blocked { display: inline-flex; align-items: center; gap: 0.4em;
color: var(--faint); background: var(--code-bg);
border: 1px dashed var(--rule); border-radius: 6px;
padding: 0.3em 0.6em; font-size: 0.9em; }
.md-image-blocked svg { width: 1.1em; height: 1.1em; flex: 0 0 auto; }
/* Callouts: tinted box + colored title; the icon sits as a non-shrinking
flex child so a long custom title wraps under the title text, never under
the icon — the layout the TextKit editor can't achieve. */
/* Outer margin matches the gap between two consecutive <pre> blocks (UA
stylesheet gives pre { margin: 1em 0 }; collapsing → 1em gap). Using
the same value here means neighboring callouts look equally spaced. */
.callout { background: var(--c-bg); border-radius: 8px; padding: 10px 14px; margin: 1em 0; }
/* Icon sits at the top so it stays on the first line of a wrapped title; its
box is exactly one line tall and centers the glyph, so it lines up with the
first line's text rather than floating above it. */
.callout-title { display: flex; align-items: flex-start; gap: 0.3em;
font-weight: 600; color: var(--c-accent); }
.callout-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
height: calc(var(--body-size) * var(--line-height)); }
/* Lucide glyphs sit a touch low against the title's optical (cap-height)
center; nudge the icon up so it reads as centered with the title text. */
.callout-icon svg { width: 1em; height: 1em; transform: translateY(-0.06em); }
/* Per-glyph optical nudge: a few Lucide icons sit high in their 24-box, so
push them down a hair to read as centered against the title cap-height.
Aliases share an icon, so they get the same value. */
.callout-info .callout-icon, .callout-todo .callout-icon,
.callout-question .callout-icon, .callout-help .callout-icon, .callout-faq .callout-icon,
.callout-quote .callout-icon, .callout-cite .callout-icon { padding-top: 0.05em; }
.callout-warning .callout-icon, .callout-attention .callout-icon,
.callout-bug .callout-icon { padding-top: 0.06em; }
.callout-example .callout-icon { padding-top: 0.1em; }
.callout-success .callout-icon, .callout-check .callout-icon, .callout-done .callout-icon,
.callout-failure .callout-icon, .callout-fail .callout-icon,
.callout-missing .callout-icon { padding-top: 0.15em; }
.callout-title-text { flex: 1 1 auto; }
.callout-body { margin-top: 0.4em; }
/* A title-only callout still emits an empty body div; collapse its top margin
so the box doesn't carry the 0.4em title gap as dead space at the bottom. */
.callout-body:empty { margin-top: 0; }
/* Reduce paragraph spacing inside callout bodies so nested callouts and
body text don't sit too far apart. The full 1em bottom margin (from the
global <p> rule) + the nested callout's 0.5em top margin would give
1.5em gap — halving the paragraph bottom margin brings it to ~1em. */
.callout-body > p { margin-bottom: 0.5em; }
.callout-body > :first-child { margin-top: 0; }
.callout-body > :last-child { margin-bottom: 0; }
/* A callout that is the last child of a callout body (e.g. the nested TIP
inside the NOTE) has its top margin removed so the space above it is
governed only by the preceding element's bottom margin (0.5em for a <p>
from .callout-body > p), not the combined margin collapse of 1em. */
.callout-body > .callout:last-child { margin-top: 0; }
@media print {
body { padding: 0; }
/* QUIRK: WebKit strips background colors when printing by default (it
follows the user's browser setting), even though WKWebView.createPDF
keeps them. `print-color-adjust: exact` forces faithful color output
so callout backgrounds, code blocks, and highlights survive printing. */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.callout, pre, blockquote, .table-wrap, .math-display { break-inside: avoid; }
h1, h2, h3, h4, h5, h6 { break-after: avoid; }
thead { display: table-header-group; }
}
"""
// MARK: Helpers
/// A CSS font stack: the (possibly multi-word) macOS family name quoted, then
/// a system fallback and a generic. WKWebView resolves installed families
/// (e.g. "Iowan Old Style") by name; the generic guards the rest.
private static func cssFontStack(_ family: String, generic: String) -> String {
let trimmed = family.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed == "ui-monospace" {
return "ui-monospace, \(generic)"
}
return "\"\(trimmed)\", -apple-system, \(generic)"
}
/// Resolves a (possibly dynamic/catalog) `NSColor` for the given appearance
/// to a CSS `rgba(...)`, preserving alpha. Used so list markers use the exact
/// same dim as the editor (`NSColor.tertiaryLabelColor`) and can't drift.
///
/// QUIRK: dynamic system colors like `tertiaryLabelColor` store a catalog
/// reference, not actual RGBA components — calling `usingColorSpace(.sRGB)`
/// on one outside a drawing context resolves to nil or returns the wrong
/// variant. `performAsCurrentDrawingAppearance` sets the appearance context
/// so the catalog resolves to the correct light or dark concrete color.
@MainActor
private static func resolvedRGBA(_ color: NSColor, dark: Bool) -> String {
var resolved = color
NSAppearance(named: dark ? .darkAqua : .aqua)?.performAsCurrentDrawingAppearance {
resolved = color.usingColorSpace(.sRGB) ?? color
}
guard let c = resolved.usingColorSpace(.sRGB) else {
return dark ? "rgba(235,235,245,0.25)" : "rgba(60,60,67,0.3)"
}
let r = Int((c.redComponent * 255).rounded())
let g = Int((c.greenComponent * 255).rounded())
let b = Int((c.blueComponent * 255).rounded())
return "rgba(\(r), \(g), \(b), \(trim(c.alphaComponent)))"
}
/// rgba(...) from a "#RRGGBB" hex and an alpha.
private static func rgba(_ hex: String, alpha: CGFloat) -> String {
guard let (r, g, b) = rgbComponents(hex) else { return hex }
return "rgba(\(r), \(g), \(b), \(trim(alpha)))"
}
private static func rgbComponents(_ hex: String) -> (Int, Int, Int)? {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h.removeFirst() }
guard h.count == 6, let rgb = UInt64(h, radix: 16) else { return nil }
return (Int((rgb >> 16) & 0xFF), Int((rgb >> 8) & 0xFF), Int(rgb & 0xFF))
}
/// Formats a CGFloat without a trailing ".0" so CSS reads cleanly.
private static func trim(_ v: CGFloat) -> String {
v == v.rounded() ? String(Int(v)) : String(format: "%g", v)
}
}