@bytechain.cn/colamd-themes 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.claude/skills/colamd-themes-preview.skill.md +333 -0
  2. package/.claude/skills/colamd-themes.skill.md +10 -0
  3. package/.dsh/skills/colamd-themes/SKILL.md +14 -0
  4. package/.github/supported-topics.yml +17 -0
  5. package/.hermes/skills/tools/colamd-themes/SKILL.md +14 -0
  6. package/.openclaw/skills/colamd-themes/SKILL.md +1 -146
  7. package/.opencode/skills/colamd-themes/SKILL.md +17 -0
  8. package/.trae/rules/colamd-themes.md +0 -145
  9. package/CHANGELOG.md +318 -240
  10. package/DSH_SUPPORT_SUMMARY.md +87 -0
  11. package/README.md +414 -384
  12. package/README_CN.md +412 -384
  13. package/RELEASENOTE.md +541 -365
  14. package/bytechain.cn-colamd-themes-0.5.0.tgz +0 -0
  15. package/dist/cli.js +272 -32
  16. package/dist/cli.js.map +1 -1
  17. package/dist/color-utils.d.ts +1 -0
  18. package/dist/color-utils.js +25 -6
  19. package/dist/color-utils.js.map +1 -1
  20. package/dist/extractors/pdf-extractor.js +6 -1
  21. package/dist/extractors/pdf-extractor.js.map +1 -1
  22. package/dist/extractors/url-extractor.d.ts +0 -4
  23. package/dist/extractors/url-extractor.js +13 -126
  24. package/dist/extractors/url-extractor.js.map +1 -1
  25. package/dist/generator.d.ts +1 -1
  26. package/dist/generator.js +32 -11
  27. package/dist/generator.js.map +1 -1
  28. package/dist/models.d.ts +5 -4
  29. package/dist/models.js +2 -3
  30. package/dist/models.js.map +1 -1
  31. package/dist/renderer.d.ts +4 -2
  32. package/dist/renderer.js +68 -25
  33. package/dist/renderer.js.map +1 -1
  34. package/dist/templates/template.css +220 -498
  35. package/dist/templates/theme-paradigm.md +530 -658
  36. package/dist/templates/theme-template.hbs +256 -370
  37. package/dist/theme-store.js +19 -3
  38. package/dist/theme-store.js.map +1 -1
  39. package/dist/utils/document-paradigm.d.ts +12 -0
  40. package/dist/utils/document-paradigm.js +109 -0
  41. package/dist/utils/document-paradigm.js.map +1 -0
  42. package/dist/validators.d.ts +4 -1
  43. package/dist/validators.js +115 -1
  44. package/dist/validators.js.map +1 -1
  45. package/mcp-server/dist/index.d.ts +13 -0
  46. package/mcp-server/dist/index.d.ts.map +1 -0
  47. package/mcp-server/dist/index.js +0 -0
  48. package/mcp-server/dist/index.js.map +1 -0
  49. package/mcp-server/package-lock.json +1759 -0
  50. package/mcp-server/package.json +24 -0
  51. package/mcp-server/src/index.ts +0 -0
  52. package/mcp-server/tsconfig.json +19 -0
  53. package/package.json +54 -36
  54. package/skills/build-skills.sh +30 -5
  55. package/skills/colamd-themes/SKILL.md +180 -148
  56. package/src/cli.ts +474 -217
  57. package/src/color-systems.ts +289 -289
  58. package/src/color-utils.ts +137 -120
  59. package/src/contrast.ts +66 -66
  60. package/src/docx-style-paradigm.ts +811 -811
  61. package/src/export-cli.ts +356 -356
  62. package/src/extractors/base.ts +8 -8
  63. package/src/extractors/docx-extractor.ts +417 -417
  64. package/src/extractors/pdf-extractor.ts +533 -527
  65. package/src/extractors/url-extractor.ts +846 -981
  66. package/src/generator.ts +197 -169
  67. package/src/index.ts +29 -29
  68. package/src/models.ts +212 -212
  69. package/src/renderer.ts +332 -288
  70. package/src/templates/template.css +220 -498
  71. package/src/templates/theme-paradigm.md +530 -658
  72. package/src/templates/theme-template.hbs +256 -370
  73. package/src/theme-store.ts +185 -170
  74. package/src/types/pdf-parse.d.ts +12 -12
  75. package/src/utils/cli-utils.ts +84 -84
  76. package/src/utils/document-paradigm.ts +118 -0
  77. package/src/validators.ts +298 -176
  78. package/themes/academic-paper-pt.css +353 -1805
  79. package/themes/academic-paper.css +350 -1099
  80. package/themes/academic-style.css +342 -2430
  81. package/themes/colly.css +256 -370
  82. package/themes/elegant.css +288 -671
  83. package/themes/forest-ink.css +240 -664
  84. package/themes/guizang.css +250 -732
  85. package/themes/hermes-security.css +279 -0
  86. package/themes/meeting-minutes.css +256 -370
  87. package/themes/pixso-design.css +278 -1261
  88. package/themes/salvator-blog.css +256 -370
  89. package/themes/shimo-theme.css +256 -0
  90. package/themes/swiss-design.css +285 -746
  91. package/themes/template.css +220 -498
  92. package/themes/trae-blue.css +280 -737
  93. package/themes/word-contract.css +249 -0
  94. package/themes-preview/demo-content.md +187 -187
  95. package/tsconfig.json +18 -18
  96. package/src/mermaid-presets.ts +0 -142
  97. package/themes/swiss-design copy.css +0 -746
  98. package/themes-preview/colly-preview.html +0 -623
  99. package/themes-preview/meeting-minutes-preview.html +0 -625
  100. package/themes-preview/salvator-blog-preview.html +0 -623
@@ -1,981 +1,846 @@
1
- /**
2
- * Web 页面样式提取器 — v2.0
3
- *
4
- * 增强功能:
5
- * 1. 从 CSS 规则和内联样式中提取完整的元素样式定义
6
- * 2. 提取各级标题(H1-H6)的字体、字号、颜色、行高
7
- * 3. 提取正文字体、字号、行间距、字间距
8
- * 4. 提取表格(表头/单元格)的完整样式
9
- * 5. 检测代码块、引用块等特殊元素样式
10
- * 6. 集成 DocxStyleParadigm 标准参考值填充缺失字段
11
- * 7. 自动验证 WCAG 对比度合规性
12
- *
13
- * 技术优势(相比 PDF):
14
- * - 可直接从 <style> 标签和外部 CSS 中解析规则级样式
15
- * - 可从 DOM 元素的 style 属性获取内联样式
16
- * - 可通过 cheerio 获取元素的 computed-style 近似值
17
- */
18
-
19
- import axios from "axios";
20
- import * as cheerio from "cheerio";
21
- import { parse, walk, generate } from "css-tree";
22
- import type { Extractor } from "./base.js";
23
- import {
24
- type ThemeStyle,
25
- makeThemeStyle,
26
- type ColorPalette,
27
- } from "../models.js";
28
- import { normalizeThemeStyle } from "../color-utils.js";
29
- import {
30
- ACADEMIC_PAPER_DEFAULTS,
31
- BUSINESS_REPORT_DEFAULTS,
32
- TECHNICAL_DOC_DEFAULTS,
33
- getRecommendedPreset,
34
- validateDocumentStyle,
35
- type DocumentStyleParadigm,
36
- } from "../docx-style-paradigm.js";
37
-
38
- /** 解析后的 CSS 规则集合 */
39
- type CssRuleMap = Map<string, Map<string, string>>;
40
-
41
- /** 单个选择器匹配到的样式属性 */
42
- interface ElementStyles {
43
- fontFamily?: string;
44
- fontSize?: string;
45
- fontWeight?: string;
46
- color?: string;
47
- lineHeight?: string;
48
- backgroundColor?: string;
49
- borderColor?: string;
50
- borderLeftColor?: string;
51
- letterSpacing?: string;
52
- }
53
-
54
- export class UrlExtractor implements Extractor {
55
- canHandle(source: string): boolean {
56
- return /^https?:\/\//i.test(source);
57
- }
58
-
59
- /**
60
- * Validate URL to prevent SSRF attacks and ensure security.
61
- * - Only allows http/https protocols
62
- * - Blocks private network addresses (localhost, 10.x, 172.16-31.x, 192.168.x, 169.254.x)
63
- */
64
- private validateUrl(url: string): void {
65
- let parsed: URL;
66
- try {
67
- parsed = new URL(url);
68
- } catch {
69
- throw new Error(`Invalid URL format: ${url}`);
70
- }
71
-
72
- // Protocol whitelist
73
- if (!["http:", "https:"].includes(parsed.protocol)) {
74
- throw new Error(`Unsupported protocol "${parsed.protocol}". Only HTTP and HTTPS are allowed.`);
75
- }
76
-
77
- // Block private network ranges to prevent SSRF
78
- const hostname = parsed.hostname.toLowerCase();
79
- const isPrivate =
80
- hostname === "localhost" ||
81
- hostname === "127.0.0.1" ||
82
- hostname === "::1" ||
83
- hostname === "[::1]" ||
84
- hostname.startsWith("10.") ||
85
- hostname.startsWith("172.16.") ||
86
- hostname.startsWith("172.17.") ||
87
- hostname.startsWith("172.18.") ||
88
- hostname.startsWith("172.19.") ||
89
- hostname.startsWith("172.20.") ||
90
- hostname.startsWith("172.21.") ||
91
- hostname.startsWith("172.22.") ||
92
- hostname.startsWith("172.23.") ||
93
- hostname.startsWith("172.24.") ||
94
- hostname.startsWith("172.25.") ||
95
- hostname.startsWith("172.26.") ||
96
- hostname.startsWith("172.27.") ||
97
- hostname.startsWith("172.28.") ||
98
- hostname.startsWith("172.29.") ||
99
- hostname.startsWith("172.30.") ||
100
- hostname.startsWith("172.31.") ||
101
- hostname.startsWith("192.168.") ||
102
- hostname.startsWith("169.254.");
103
-
104
- if (isPrivate) {
105
- throw new Error(`Private network access denied: ${hostname}. Only public URLs are allowed for security reasons.`);
106
- }
107
- }
108
-
109
- async extract(source: string): Promise<ThemeStyle> {
110
- this.validateUrl(source);
111
-
112
- const response = await axios.get(source, {
113
- headers: { "User-Agent": "Mozilla/5.0 (compatible; ColaMD-Themes/1.0)" },
114
- timeout: 15000,
115
- maxRedirects: 3,
116
- maxContentLength: 10 * 1024 * 1024,
117
- responseType: "text",
118
- });
119
-
120
- const contentType = String(response.headers["content-type"] || "");
121
- if (!contentType.includes("text/html") && !contentType.includes("application/xhtml")) {
122
- throw new Error(
123
- `URL 返回非 HTML 内容 (${contentType}),请使用对应的文件类型命令`
124
- );
125
- }
126
-
127
- const html = typeof response.data === "string" ? response.data : String(response.data);
128
- const $ = cheerio.load(html);
129
- const theme = makeThemeStyle(this.detectName($, source), "url", source);
130
-
131
- const styleCss = this.extractAllStyles($);
132
- const cssRules = this.parseCssRules(styleCss);
133
-
134
- this.extractBodyStyles(theme, cssRules, $);
135
- this.extractHeadingStyles(theme, cssRules, $);
136
- this.extractTableStyles(theme, cssRules, $);
137
- this.extractCodeStyles(theme, cssRules, $);
138
- this.extractBlockquoteStyles(theme, cssRules, $);
139
- this.extractLinkAndAccent(theme, cssRules, $);
140
- this.inferDocumentCharacteristics(theme, $);
141
-
142
- this.applyParadigmDefaults(theme);
143
- normalizeThemeStyle(theme);
144
- return theme;
145
- }
146
-
147
- /**
148
- * 从页面标题或 URL 推断主题名称
149
- */
150
- private detectName($: cheerio.CheerioAPI, url: string): string {
151
- const title = $("title").text().trim();
152
- if (title) {
153
- return title
154
- .replace(/\s+/g, "-")
155
- .replace(/[|:/\\.<>?*]/g, "-")
156
- .replace(/-+/g, "-")
157
- .slice(0, 60);
158
- }
159
- try {
160
- const hostname = new URL(url).hostname.replace(/^www\./, "");
161
- return hostname.split(".")[0] || "web-theme";
162
- } catch {
163
- return "web-theme";
164
- }
165
- }
166
-
167
- /**
168
- * 提取所有 <style> 标签内的 CSS 文本
169
- */
170
- private extractAllStyles($: cheerio.CheerioAPI): string {
171
- const parts: string[] = [];
172
- $("style").each((_, el) => {
173
- const text = $(el).text().trim();
174
- if (text && !text.includes("@font-face")) {
175
- parts.push(text);
176
- }
177
- });
178
- return parts.join("\n");
179
- }
180
-
181
- /**
182
- * 使用 css-tree 将 CSS 文本解析为结构化规则映射
183
- */
184
- private parseCssRules(css: string): CssRuleMap {
185
- const result = new Map<string, Map<string, string>>();
186
- try {
187
- const ast = parse(css, {
188
- parseAtrulePrelude: false,
189
- parseRulePrelude: false,
190
- });
191
- walk(ast, {
192
- visit: "Rule",
193
- enter(node) {
194
- const prelude = generate(node.prelude).trim();
195
- const props = new Map<string, string>();
196
- walk(node.block, {
197
- visit: "Declaration",
198
- enter(decl) {
199
- props.set(decl.property.toLowerCase(), generate(decl.value));
200
- },
201
- });
202
- if (props.size > 0) {
203
- result.set(prelude, props);
204
- }
205
- },
206
- });
207
- } catch {
208
- // CSS 解析错误不阻断流程
209
- }
210
- return result;
211
- }
212
-
213
- /**
214
- * 提取正文(body/html)完整样式
215
- */
216
- private extractBodyStyles(
217
- theme: ThemeStyle,
218
- rules: CssRuleMap,
219
- $: cheerio.CheerioAPI
220
- ): void {
221
- const p = theme.palette;
222
- const t = theme.typography;
223
- const st = theme.seedTypography;
224
-
225
- const bodyStyles = this.resolveElementStyles(rules, $, [
226
- "body",
227
- "html",
228
- ":root",
229
- "#app",
230
- "#root",
231
- ".container",
232
- "main",
233
- ]);
234
-
235
- if (bodyStyles.fontFamily) {
236
- t.bodyFontFamily = this.normalizeFontFamily(bodyStyles.fontFamily);
237
- st.fontBody = t.bodyFontFamily;
238
- }
239
-
240
- if (bodyStyles.fontSize) {
241
- t.baseFontSize = this.normalizeFontSize(bodyStyles.fontSize);
242
- st.fontSizeRoot = t.baseFontSize;
243
- }
244
-
245
- if (bodyStyles.color) {
246
- p.textColor = this.normalizeColor(bodyStyles.color);
247
- }
248
-
249
- if (bodyStyles.backgroundColor) {
250
- p.bgColor = this.normalizeColor(bodyStyles.backgroundColor);
251
- }
252
-
253
- if (bodyStyles.lineHeight) {
254
- const lh = parseFloat(bodyStyles.lineHeight);
255
- if (lh >= 1.2 && lh <= 2.5) {
256
- t.baseLineHeight = lh;
257
- st.lineHeightBody = lh;
258
- }
259
- }
260
-
261
- // 从 body 标签的内联样式或属性补充
262
- const bodyTag = $("body");
263
- const inlineBg = bodyTag.attr("style")?.match(/background(?:-color)?:\s*([^;]+)/i)?.[1];
264
- if (inlineBg && !p.bgColor) {
265
- p.bgColor = this.normalizeColor(inlineBg);
266
- }
267
- }
268
-
269
- /**
270
- * 提取 H1-H6 各级标题的完整样式定义
271
- */
272
- private extractHeadingStyles(
273
- theme: ThemeStyle,
274
- rules: CssRuleMap,
275
- $: cheerio.CheerioAPI
276
- ): void {
277
- const headingLevels = ["h1", "h2", "h3", "h4", "h5", "h6"];
278
- const st = theme.seedTypography;
279
- const rootSize =
280
- parseFloat(theme.typography.baseFontSize) || 16;
281
-
282
- for (const level of headingLevels) {
283
- const styles = this.resolveElementStyles(rules, $, [level]);
284
-
285
- if (styles.fontSize) {
286
- const pxSize = this.normalizeFontSizeToPx(styles.fontSize, rootSize);
287
- const scale = pxSize / rootSize;
288
-
289
- switch (level) {
290
- case "h1":
291
- st.fontScaleH1 = Math.max(st.fontScaleH1, scale);
292
- break;
293
- case "h2":
294
- st.fontScaleH2 = Math.max(st.fontScaleH2, scale);
295
- break;
296
- case "h3":
297
- st.fontScaleH3 = Math.max(st.fontScaleH3, scale);
298
- break;
299
- case "h4":
300
- st.fontScaleH4 = Math.max(st.fontScaleH4, scale);
301
- break;
302
- case "h5":
303
- st.fontScaleH5 = Math.max(st.fontScaleH5, scale);
304
- break;
305
- case "h6":
306
- st.fontScaleH6 = Math.max(st.fontScaleH6, scale);
307
- break;
308
- }
309
- }
310
-
311
- if (styles.fontWeight) {
312
- const weight = parseInt(styles.fontWeight) || 400;
313
- if (weight > theme.typography.headingWeight) {
314
- theme.typography.headingWeight = Math.min(weight, 900);
315
- }
316
- }
317
-
318
- if (styles.color && level === "h1") {
319
- theme.palette.headingColor = this.normalizeColor(styles.color);
320
- }
321
-
322
- if (styles.fontFamily && level === "h1") {
323
- theme.typography.headingFontFamily = this.normalizeFontFamily(
324
- styles.fontFamily
325
- );
326
- st.fontHeading = theme.typography.headingFontFamily;
327
- }
328
-
329
- if (styles.lineHeight && level === "h1") {
330
- const lh = parseFloat(styles.lineHeight);
331
- if (lh >= 1.1 && lh <= 1.6) {
332
- st.lineHeightHeading = lh;
333
- }
334
- }
335
- }
336
-
337
- // 如果没有提取到标题字体,使用正文字体族
338
- if (!theme.typography.headingFontFamily) {
339
- theme.typography.headingFontFamily = theme.typography.bodyFontFamily;
340
- st.fontHeading = st.fontBody;
341
- }
342
- }
343
-
344
- /**
345
- * 提取表格(th/td)完整样式
346
- */
347
- private extractTableStyles(
348
- theme: ThemeStyle,
349
- rules: CssRuleMap,
350
- $: cheerio.CheerioAPI
351
- ): void {
352
- const p = theme.palette;
353
-
354
- const thStyles = this.resolveElementStyles(rules, $, [
355
- "th",
356
- "thead th",
357
- "table th",
358
- ".table th",
359
- ]);
360
-
361
- if (thStyles.backgroundColor) {
362
- p.tableHeaderBg = this.normalizeColor(thStyles.backgroundColor);
363
- }
364
- if (thStyles.color) {
365
- // 表头文字色暂存到 headingColor 备用
366
- if (!p.headingColor) p.headingColor = this.normalizeColor(thStyles.color);
367
- }
368
- if (thStyles.borderColor) {
369
- p.borderColor = this.normalizeColor(thStyles.borderColor);
370
- }
371
-
372
- const tdStyles = this.resolveElementStyles(rules, $, [
373
- "td",
374
- "tbody td",
375
- "table td",
376
- ".table td",
377
- ]);
378
-
379
- if (tdStyles.backgroundColor) {
380
- p.codeBg = this.normalizeColor(tdStyles.backgroundColor);
381
- }
382
-
383
- const tableStyles = this.resolveElementStyles(rules, $, [
384
- "table",
385
- ".table",
386
- ]);
387
-
388
- if (tableStyles.borderColor) {
389
- p.borderColor = p.borderColor || this.normalizeColor(tableStyles.borderColor);
390
- }
391
-
392
- // 检查页面是否包含表格元素
393
- if ($("table").length > 0 && !p.tableHeaderBg) {
394
- p.tableHeaderBg = ACADEMIC_PAPER_DEFAULTS.table.headerBgColor;
395
- }
396
- }
397
-
398
- /**
399
- * 提取代码块和内联代码样式
400
- */
401
- private extractCodeStyles(
402
- theme: ThemeStyle,
403
- rules: CssRuleMap,
404
- $: cheerio.CheerioAPI
405
- ): void {
406
- const p = theme.palette;
407
-
408
- const preStyles = this.resolveElementStyles(rules, $, [
409
- "pre",
410
- "code pre",
411
- "pre code",
412
- ".code-block",
413
- "pre[class]",
414
- ]);
415
-
416
- if (preStyles.backgroundColor) {
417
- p.codeBlockBg = this.normalizeColor(preStyles.backgroundColor);
418
- }
419
- if (preStyles.color) {
420
- p.codeBlockText = this.normalizeColor(preStyles.color);
421
- }
422
-
423
- const codeStyles = this.resolveElementStyles(rules, $, [
424
- "code",
425
- "p code",
426
- "li code",
427
- "inline-code",
428
- ":not(pre) > code",
429
- ]);
430
-
431
- if (codeStyles.backgroundColor) {
432
- p.codeBg = this.normalizeColor(codeStyles.backgroundColor);
433
- }
434
- if (codeStyles.color) {
435
- // 内联代码颜色暂存
436
- }
437
-
438
- // 检测代码高亮库的存在
439
- const hasHighlightJs = $("pre code.hljs").length > 0;
440
- const hasPrismjs = $("pre[class*=language-]").length > 0;
441
- if (hasHighlightJs || hasPrismjs) {
442
- if (!p.codeBlockBg) p.codeBlockBg = "#282c34";
443
- if (!p.codeBlockText) p.codeBlockText = "#abb2bf";
444
- }
445
- }
446
-
447
- /**
448
- * 提取引用块样式
449
- */
450
- private extractBlockquoteStyles(
451
- theme: ThemeStyle,
452
- rules: CssRuleMap,
453
- $: cheerio.CheerioAPI
454
- ): void {
455
- const p = theme.palette;
456
-
457
- const bqStyles = this.resolveElementStyles(rules, $, [
458
- "blockquote",
459
- "blockquote p",
460
- ".blockquote",
461
- ]);
462
-
463
- if (bqStyles.backgroundColor) {
464
- p.blockquoteBg = this.normalizeColor(bqStyles.backgroundColor);
465
- }
466
- if (bqStyles.borderLeftColor) {
467
- p.blockquoteBorder = this.normalizeColor(bqStyles.borderLeftColor);
468
- } else if (bqStyles.borderColor) {
469
- p.blockquoteBorder = this.normalizeColor(bqStyles.borderColor);
470
- }
471
- }
472
-
473
- /**
474
- * 提取链接和强调色
475
- */
476
- private extractLinkAndAccent(
477
- theme: ThemeStyle,
478
- rules: CssRuleMap,
479
- $: cheerio.CheerioAPI
480
- ): void {
481
- const p = theme.palette;
482
-
483
- const linkStyles = this.resolveElementStyles(rules, $, [
484
- "a",
485
- "a:link",
486
- "a:not(:visited)",
487
- ]);
488
-
489
- if (linkStyles.color) {
490
- p.accentColor = this.normalizeColor(linkStyles.color);
491
- }
492
-
493
- // 也检查 strong/b 元素的颜色
494
- const strongStyles = this.resolveElementStyles(rules, $, [
495
- "strong",
496
- "b",
497
- ".strong",
498
- ]);
499
-
500
- if (strongStyles.color && !p.accentColor) {
501
- p.accentColor = this.normalizeColor(strongStyles.color);
502
- }
503
- }
504
-
505
- /**
506
- * 综合推断文档特征(语言、类型、风格)
507
- */
508
- private inferDocumentCharacteristics(
509
- theme: ThemeStyle,
510
- $: cheerio.CheerioAPI
511
- ): void {
512
- const htmlText = $("body").text();
513
- const hasChinese = /[\u4e00-\u9fa5]/.test(htmlText.slice(0, 2000));
514
-
515
- // 检测常见框架/站点特征
516
- const isGitHub = /github\.com/i.test(theme.sourceUrl);
517
- const isDocsSite =
518
- /docs?|documentation|wiki|help/i.test(theme.sourceUrl + $("title").text());
519
- const isBlog = /blog|article|post/i.test(
520
- theme.sourceUrl + $("title").text()
521
- );
522
-
523
- if (isGitHub) {
524
- theme.description = `${theme.description} [GitHub 页面]`;
525
- } else if (isDocsSite) {
526
- theme.description = `${theme.description} [技术文档站]`;
527
- } else if (isBlog) {
528
- theme.description = `${theme.description} [博客/文章页]`;
529
- }
530
-
531
- // 根据语言特征微调默认配色
532
- if (hasChinese && !theme.palette.textColor) {
533
- theme.typography.bodyFontFamily =
534
- ACADEMIC_PAPER_DEFAULTS.body.fontFamily;
535
- theme.seedTypography.fontBody = ACADEMIC_PAPER_DEFAULTS.body.fontFamily;
536
- }
537
- }
538
-
539
- /**
540
- * 应用 DocxStyleParadigm 标准参考值填充缺失字段
541
- */
542
- private applyParadigmDefaults(theme: ThemeStyle): void {
543
- const preset = getRecommendedPreset();
544
- const p = theme.palette;
545
- const t = theme.typography;
546
- const st = theme.seedTypography;
547
-
548
- if (!p.textColor || p.textColor === "#000000") {
549
- p.textColor = preset.body.color;
550
- }
551
- if (!p.headingColor) {
552
- p.headingColor = preset.headings.h1.color;
553
- }
554
- if (!p.bgColor || p.bgColor === "#ffffff" || p.bgColor === "#fff") {
555
- p.bgColor = preset.pageBackground;
556
- }
557
- if (!p.textMuted) {
558
- p.textMuted = preset.mutedTextColor;
559
- }
560
- if (!p.accentColor || p.accentColor === "#0000ff" || p.accentColor === "#00f") {
561
- p.accentColor = preset.accentColor;
562
- }
563
- if (!p.tableHeaderBg) {
564
- p.tableHeaderBg = preset.table.headerBgColor;
565
- }
566
- if (!p.codeBg) {
567
- p.codeBg = preset.inlineCode.bgColor;
568
- }
569
- if (!p.codeBlockBg) {
570
- p.codeBlockBg = preset.codeBlock.bgColor;
571
- }
572
- if (!p.codeBlockText) {
573
- p.codeBlockText = preset.codeBlock.color;
574
- }
575
- if (!p.blockquoteBg) {
576
- p.blockquoteBg = preset.blockquote.bgColor;
577
- }
578
- if (!p.blockquoteBorder) {
579
- p.blockquoteBorder = preset.blockquote.borderLeftColor;
580
- }
581
- if (!p.selectionBg) {
582
- p.selectionBg = preset.selectionBg;
583
- }
584
- if (!p.borderColor) {
585
- p.borderColor = preset.table.borderColor;
586
- }
587
-
588
- if (
589
- !t.bodyFontFamily ||
590
- t.bodyFontFamily === "Georgia, serif"
591
- ) {
592
- t.bodyFontFamily = preset.body.fontFamily;
593
- }
594
- if (!t.headingFontFamily) {
595
- t.headingFontFamily = preset.headings.h1.fontFamily;
596
- }
597
- if (!t.baseFontSize) {
598
- t.baseFontSize = `${preset.body.fontSize}px`;
599
- }
600
- if (t.baseLineHeight < 1.4) {
601
- t.baseLineHeight = preset.body.lineHeight;
602
- }
603
-
604
- st.fontBody = st.fontBody || preset.body.fontFamily;
605
- st.fontHeading = st.fontHeading || preset.headings.h1.fontFamily;
606
- st.lineHeightBody = st.lineHeightBody || preset.body.lineHeight;
607
- st.lineHeightHeading =
608
- st.lineHeightHeading || preset.headings.h1.lineHeight;
609
-
610
- // 确保标题字号倍率符合范式约束
611
- if (st.fontScaleH1 < 1.75) st.fontScaleH1 = preset.headings.h1.fontSize / 16;
612
- if (st.fontScaleH2 < st.fontScaleH1 * 0.8)
613
- st.fontScaleH2 = preset.headings.h2.fontSize / 16;
614
- if (st.fontScaleH3 < st.fontScaleH2 * 0.85)
615
- st.fontScaleH3 = preset.headings.h3.fontSize / 16;
616
-
617
- const issues = validateDocumentStyle(this.themeToParadigm(theme));
618
- for (const issue of issues) {
619
- if (issue.level === "error") {
620
- console.warn(`[UrlExtractor] ${issue.element}: ${issue.message}`);
621
- }
622
- }
623
- }
624
-
625
- /**
626
- * 将 ThemeStyle 转换为 DocumentStyleParadigm 用于 WCAG 验证
627
- */
628
- private themeToParadigm(theme: ThemeStyle): DocumentStyleParadigm {
629
- return {
630
- pageBackground: theme.palette.bgColor,
631
- headings: {
632
- h1: {
633
- fontFamily: theme.seedTypography.fontHeading,
634
- fontSize: 16 * theme.seedTypography.fontScaleH1,
635
- fontWeight: theme.typography.headingWeight,
636
- color: theme.palette.headingColor,
637
- lineHeight: theme.seedTypography.lineHeightHeading,
638
- },
639
- h2: {
640
- fontFamily: theme.seedTypography.fontHeading,
641
- fontSize: 16 * theme.seedTypography.fontScaleH2,
642
- fontWeight: Math.max(600, theme.typography.headingWeight - 100),
643
- color: theme.palette.headingColor,
644
- lineHeight: theme.seedTypography.lineHeightHeading,
645
- },
646
- h3: {
647
- fontFamily: theme.seedTypography.fontHeading,
648
- fontSize: 16 * theme.seedTypography.fontScaleH3,
649
- fontWeight: 600,
650
- color: theme.palette.headingColor,
651
- lineHeight: theme.seedTypography.lineHeightHeading + 0.05,
652
- },
653
- h4: {
654
- fontFamily: theme.seedTypography.fontHeading,
655
- fontSize: 16 * theme.seedTypography.fontScaleH4,
656
- fontWeight: 600,
657
- color: theme.palette.textMuted,
658
- lineHeight: theme.seedTypography.lineHeightHeading + 0.05,
659
- },
660
- h5: {
661
- fontFamily: theme.seedTypography.fontHeading,
662
- fontSize: 16 * theme.seedTypography.fontScaleH5,
663
- fontWeight: 500,
664
- color: theme.palette.textMuted,
665
- lineHeight: theme.seedTypography.lineHeightBody - 0.25,
666
- },
667
- h6: {
668
- fontFamily: theme.seedTypography.fontHeading,
669
- fontSize: 16 * theme.seedTypography.fontScaleH6,
670
- fontWeight: 500,
671
- color: theme.palette.textMuted,
672
- lineHeight: theme.seedTypography.lineHeightBody - 0.2,
673
- },
674
- },
675
- body: {
676
- fontFamily: theme.seedTypography.fontBody,
677
- fontSize: parseInt(theme.typography.baseFontSize) || 16,
678
- fontWeight: 400,
679
- color: theme.palette.textColor,
680
- lineHeight: theme.typography.baseLineHeight,
681
- },
682
- blockquote: {
683
- fontFamily: theme.seedTypography.fontBody,
684
- fontSize: Math.max(
685
- 13,
686
- (parseInt(theme.typography.baseFontSize) || 16) - 2
687
- ),
688
- fontWeight: 400,
689
- color: theme.palette.textMuted,
690
- lineHeight: theme.typography.baseLineHeight - 0.15,
691
- borderLeftColor: theme.palette.blockquoteBorder,
692
- bgColor: theme.palette.blockquoteBg,
693
- },
694
- codeBlock: {
695
- fontFamily: theme.typography.codeFontFamily,
696
- fontSize: Math.max(
697
- 12,
698
- (parseInt(theme.typography.baseFontSize) || 16) - 3
699
- ),
700
- fontWeight: 400,
701
- color:
702
- theme.palette.codeBlockText || theme.palette.textColor,
703
- lineHeight: theme.seedTypography.lineHeightCode,
704
- bgColor: theme.palette.codeBlockBg,
705
- },
706
- inlineCode: {
707
- fontSize: Math.max(
708
- 12,
709
- (parseInt(theme.typography.baseFontSize) || 16) - 3
710
- ),
711
- color: theme.palette.accentColor,
712
- bgColor: theme.palette.codeBg,
713
- },
714
- table: {
715
- headerFontFamily: theme.seedTypography.fontHeading,
716
- headerFontSize: Math.max(
717
- 12,
718
- (parseInt(theme.typography.baseFontSize) || 16) - 3
719
- ),
720
- headerFontWeight: 700,
721
- headerColor: "#ffffff",
722
- headerBgColor: theme.palette.tableHeaderBg,
723
- headerLineHeight: 1.4,
724
-
725
- cellFontFamily: theme.seedTypography.fontBody,
726
- cellFontSize: Math.max(
727
- 11,
728
- (parseInt(theme.typography.baseFontSize) || 16) - 4
729
- ),
730
- cellColor: theme.palette.textColor,
731
- cellBgColor: theme.palette.bgColor,
732
- cellLineHeight: 1.5,
733
- cellPadding: "8px 12px",
734
- borderColor: theme.palette.borderColor,
735
- borderWidth: "1px",
736
- },
737
-
738
- accentColor: theme.palette.accentColor,
739
- mutedTextColor: theme.palette.textMuted,
740
- selectionBg: theme.palette.selectionBg,
741
- };
742
- }
743
-
744
- // ═══════════════════════════════════════════════════════════
745
- // 工具方法:CSS 属性解析与规范化
746
- // ═══════════════════════════════════════════════════════════
747
-
748
- /**
749
- * 为一组选择器查找匹配的 CSS 规则并合并结果
750
- * 优先级:更具体的选择器 > 更通用的选择器 > 内联样式
751
- */
752
- private resolveElementStyles(
753
- rules: CssRuleMap,
754
- $: cheerio.CheerioAPI,
755
- selectors: string[]
756
- ): ElementStyles {
757
- const merged: ElementStyles = {};
758
-
759
- for (const selector of selectors) {
760
- const ruleProps = this.findBestMatchingRule(rules, selector);
761
- if (ruleProps) {
762
- if (ruleProps.get("font-family") && !merged.fontFamily)
763
- merged.fontFamily = ruleProps.get("font-family");
764
- if (ruleProps.get("font-size") && !merged.fontSize)
765
- merged.fontSize = ruleProps.get("font-size");
766
- if (ruleProps.get("font-weight") && !merged.fontWeight)
767
- merged.fontWeight = ruleProps.get("font-weight");
768
- if (ruleProps.get("color") && !merged.color)
769
- merged.color = ruleProps.get("color");
770
- if (ruleProps.get("line-height") && !merged.lineHeight)
771
- merged.lineHeight = ruleProps.get("line-height");
772
- if (ruleProps.get("background-color") && !merged.backgroundColor)
773
- merged.backgroundColor = ruleProps.get("background-color");
774
- if (ruleProps.get("border-left-color") && !merged.borderColor)
775
- merged.borderColor = ruleProps.get("border-left-color");
776
- if (ruleProps.get("border-color") && !merged.borderColor)
777
- merged.borderColor = ruleProps.get("border-color");
778
- if (ruleProps.get("letter-spacing") && !merged.letterSpacing)
779
- merged.letterSpacing = ruleProps.get("letter-spacing");
780
- }
781
- }
782
-
783
- // 补充内联样式(最高优先级)
784
- for (const selector of selectors) {
785
- try {
786
- const el = $(selector).first();
787
- if (el.length) {
788
- const inlineAttr = el.attr("style");
789
- if (inlineAttr) {
790
- const inlineMap = this.parseInlineStyle(inlineAttr);
791
- if (inlineMap.fontFamily) merged.fontFamily = inlineMap.fontFamily;
792
- if (inlineMap.fontSize) merged.fontSize = inlineMap.fontSize;
793
- if (inlineMap.fontWeight) merged.fontWeight = inlineMap.fontWeight;
794
- if (inlineMap.color) merged.color = inlineMap.color;
795
- if (inlineMap.lineHeight) merged.lineHeight = inlineMap.lineHeight;
796
- if (inlineMap.backgroundColor)
797
- merged.backgroundColor = inlineMap.backgroundColor;
798
- }
799
- }
800
- } catch {
801
- // 选择器可能无效,跳过
802
- }
803
- }
804
-
805
- return merged;
806
- }
807
-
808
- /**
809
- * 在 CSS 规则中查找最佳匹配的选择器
810
- */
811
- private findBestMatchingRule(
812
- rules: CssRuleMap,
813
- targetSelector: string
814
- ): Map<string, string> | undefined {
815
- let bestMatch: Map<string, string> | undefined;
816
- let bestSpecificity = -1;
817
-
818
- for (const [selector, props] of rules) {
819
- if (
820
- selector.includes(targetSelector) ||
821
- targetSelector.includes(selector.replace(/[.#\[\]>+~]/g, "").split(" ").pop() || "")
822
- ) {
823
- const specificity = this.calculateSpecificity(selector);
824
- if (specificity > bestSpecificity) {
825
- bestSpecificity = specificity;
826
- bestMatch = props;
827
- }
828
- }
829
- }
830
-
831
- return bestMatch;
832
- }
833
-
834
- /**
835
- * 计算选择器特异性(简化版)
836
- */
837
- private calculateSpecificity(selector: string): number {
838
- let score = 0;
839
- score += (selector.match(/#/g) || []).length * 100;
840
- score += (selector.match(/\./g) || []).length * 10;
841
- score += (selector.match(/[a-zA-Z]/g) || []).length;
842
- return score;
843
- }
844
-
845
- /**
846
- * 解析内联 style 字符串为键值对
847
- */
848
- private parseInlineStyle(styleStr: string): Partial<ElementStyles> {
849
- const result: Partial<ElementStyles> = {};
850
- const declarations = styleStr.split(";");
851
-
852
- for (const decl of declarations) {
853
- const colonIndex = decl.indexOf(":");
854
- if (colonIndex === -1) continue;
855
-
856
- const prop = decl.slice(0, colonIndex).trim().toLowerCase();
857
- const value = decl.slice(colonIndex + 1).trim();
858
-
859
- switch (prop) {
860
- case "font-family":
861
- result.fontFamily = value;
862
- break;
863
- case "font-size":
864
- result.fontSize = value;
865
- break;
866
- case "font-weight":
867
- result.fontWeight = value;
868
- break;
869
- case "color":
870
- result.color = value;
871
- break;
872
- case "line-height":
873
- result.lineHeight = value;
874
- break;
875
- case "background-color":
876
- case "background":
877
- result.backgroundColor = value;
878
- break;
879
- case "border-color":
880
- case "border-left-color":
881
- result.borderColor = value;
882
- break;
883
- case "letter-spacing":
884
- result.letterSpacing = value;
885
- break;
886
- }
887
- }
888
-
889
- return result;
890
- }
891
-
892
- /**
893
- * 规范化字体家族名称(去除引号、清理多余空格)
894
- */
895
- private normalizeFontFamily(fontFamily: string): string {
896
- return fontFamily
897
- .replace(/['"]/g, "")
898
- .replace(/\s{2,}/g, " ")
899
- .trim();
900
- }
901
-
902
- /**
903
- * 规范化字号为 px 单位
904
- */
905
- private normalizeFontSize(sizeStr: string): string {
906
- return `${this.normalizeFontSizeToPx(sizeStr, 16)}px`;
907
- }
908
-
909
- /**
910
- * 将字号转换为 px 数值
911
- */
912
- private normalizeFontSizeToPx(sizeStr: string, rootPx: number): number {
913
- const trimmed = sizeStr.trim();
914
-
915
- // em/rem 单位转换
916
- const emMatch = trimmed.match(/^([\d.]+)(em|rem)$/i);
917
- if (emMatch) {
918
- return parseFloat(emMatch[1]) * rootPx;
919
- }
920
-
921
- // pt → px 转换 (1pt ≈ 1.333px)
922
- const ptMatch = trimmed.match(/^([\d.]+)pt$/i);
923
- if (ptMatch) {
924
- return parseFloat(ptMatch[1]) * 1.333;
925
- }
926
-
927
- // 直接是 px 或无单位数字
928
- const pxMatch = trimmed.match(/^([\d.]+)(px)?$/i);
929
- if (pxMatch) {
930
- return parseFloat(pxMatch[1]);
931
- }
932
-
933
- // 关键字号映射
934
- const keywordSizes: Record<string, number> = {
935
- "xx-small": 10,
936
- "x-small": 12,
937
- small: 14,
938
- medium: 16,
939
- large: 18,
940
- "x-large": 20,
941
- "xx-large": 24,
942
- };
943
-
944
- if (keywordSizes[trimmed]) {
945
- return keywordSizes[trimmed];
946
- }
947
-
948
- return rootPx;
949
- }
950
-
951
- /**
952
- * 规范化颜色值为标准 hex 格式
953
- */
954
- private normalizeColor(colorStr: string): string {
955
- const trimmed = colorStr.trim().toLowerCase();
956
-
957
- // 已经是 hex 格式
958
- if (/^#[0-9a-f]{3,8}$/.test(trimmed)) {
959
- return trimmed;
960
- }
961
-
962
- // rgb()/rgba() 格式
963
- const rgbMatch = trimmed.match(
964
- /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/
965
- );
966
- if (rgbMatch) {
967
- const r = parseInt(rgbMatch[1]);
968
- const g = parseInt(rgbMatch[2]);
969
- const b = parseInt(rgbMatch[3]);
970
- return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
971
- }
972
-
973
- // 透明色返回空字符串
974
- if (trimmed === "transparent" || trimmed === "none" || trimmed === "initial" || trimmed === "inherit") {
975
- return "";
976
- }
977
-
978
- // 返回原值(可能是 named color 等)
979
- return trimmed;
980
- }
981
- }
1
+ /**
2
+ * Web 页面样式提取器 — v2.0
3
+ *
4
+ * 增强功能:
5
+ * 1. 从 CSS 规则和内联样式中提取完整的元素样式定义
6
+ * 2. 提取各级标题(H1-H6)的字体、字号、颜色、行高
7
+ * 3. 提取正文字体、字号、行间距、字间距
8
+ * 4. 提取表格(表头/单元格)的完整样式
9
+ * 5. 检测代码块、引用块等特殊元素样式
10
+ * 6. 集成 DocxStyleParadigm 标准参考值填充缺失字段
11
+ * 7. 自动验证 WCAG 对比度合规性
12
+ *
13
+ * 技术优势(相比 PDF):
14
+ * - 可直接从 <style> 标签和外部 CSS 中解析规则级样式
15
+ * - 可从 DOM 元素的 style 属性获取内联样式
16
+ * - 可通过 cheerio 获取元素的 computed-style 近似值
17
+ */
18
+
19
+ import axios from "axios";
20
+ import * as cheerio from "cheerio";
21
+ import { parse, walk, generate } from "css-tree";
22
+ import type { Extractor } from "./base.js";
23
+ import {
24
+ type ThemeStyle,
25
+ makeThemeStyle,
26
+ type ColorPalette,
27
+ } from "../models.js";
28
+ import { normalizeThemeStyle } from "../color-utils.js";
29
+ import {
30
+ ACADEMIC_PAPER_DEFAULTS,
31
+ getRecommendedPreset,
32
+ validateDocumentStyle,
33
+ } from "../docx-style-paradigm.js";
34
+ import { themeToParadigm, logExtractorWarnings } from "../utils/document-paradigm.js";
35
+
36
+ /** 解析后的 CSS 规则集合 */
37
+ type CssRuleMap = Map<string, Map<string, string>>;
38
+
39
+ /** 单个选择器匹配到的样式属性 */
40
+ interface ElementStyles {
41
+ fontFamily?: string;
42
+ fontSize?: string;
43
+ fontWeight?: string;
44
+ color?: string;
45
+ lineHeight?: string;
46
+ backgroundColor?: string;
47
+ borderColor?: string;
48
+ borderLeftColor?: string;
49
+ letterSpacing?: string;
50
+ }
51
+
52
+ export class UrlExtractor implements Extractor {
53
+ canHandle(source: string): boolean {
54
+ return /^https?:\/\//i.test(source);
55
+ }
56
+
57
+ /**
58
+ * Validate URL to prevent SSRF attacks and ensure security.
59
+ * - Only allows http/https protocols
60
+ * - Blocks private network addresses (localhost, 10.x, 172.16-31.x, 192.168.x, 169.254.x)
61
+ */
62
+ private validateUrl(url: string): void {
63
+ let parsed: URL;
64
+ try {
65
+ parsed = new URL(url);
66
+ } catch {
67
+ throw new Error(`Invalid URL format: ${url}`);
68
+ }
69
+
70
+ // Protocol whitelist
71
+ if (!["http:", "https:"].includes(parsed.protocol)) {
72
+ throw new Error(`Unsupported protocol "${parsed.protocol}". Only HTTP and HTTPS are allowed.`);
73
+ }
74
+
75
+ // Block private network ranges to prevent SSRF
76
+ const hostname = parsed.hostname.toLowerCase();
77
+ const isPrivate =
78
+ hostname === "localhost" ||
79
+ hostname === "0.0.0.0" ||
80
+ /^127\./.test(hostname) ||
81
+ hostname === "::1" ||
82
+ hostname === "[::1]" ||
83
+ hostname === "::" ||
84
+ /^10\./.test(hostname) ||
85
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
86
+ /^192\.168\./.test(hostname) ||
87
+ /^169\.254\./.test(hostname) ||
88
+ /^100\.(6[4-9]|7\d|8\d|9\d|1[01]\d|12[0-7])\./.test(hostname) ||
89
+ /^\[?fc/.test(hostname) ||
90
+ /^fe[89ab]/.test(hostname);
91
+
92
+ if (isPrivate) {
93
+ throw new Error(`Private network access denied: ${hostname}. Only public URLs are allowed for security reasons.`);
94
+ }
95
+ }
96
+
97
+ async extract(source: string): Promise<ThemeStyle> {
98
+ this.validateUrl(source);
99
+
100
+ const response = await axios.get(source, {
101
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; ColaMD-Themes/1.0)" },
102
+ timeout: 15000,
103
+ maxRedirects: 3,
104
+ maxContentLength: 10 * 1024 * 1024,
105
+ responseType: "text",
106
+ });
107
+
108
+ const contentType = String(response.headers["content-type"] || "");
109
+ if (!contentType.includes("text/html") && !contentType.includes("application/xhtml")) {
110
+ throw new Error(
111
+ `URL 返回非 HTML 内容 (${contentType}),请使用对应的文件类型命令`
112
+ );
113
+ }
114
+
115
+ const html = typeof response.data === "string" ? response.data : String(response.data);
116
+ const $ = cheerio.load(html);
117
+ const theme = makeThemeStyle(this.detectName($, source), "url", source);
118
+
119
+ const styleCss = this.extractAllStyles($);
120
+ const cssRules = this.parseCssRules(styleCss);
121
+
122
+ this.extractBodyStyles(theme, cssRules, $);
123
+ this.extractHeadingStyles(theme, cssRules, $);
124
+ this.extractTableStyles(theme, cssRules, $);
125
+ this.extractCodeStyles(theme, cssRules, $);
126
+ this.extractBlockquoteStyles(theme, cssRules, $);
127
+ this.extractLinkAndAccent(theme, cssRules, $);
128
+ this.inferDocumentCharacteristics(theme, $);
129
+
130
+ this.applyParadigmDefaults(theme);
131
+ normalizeThemeStyle(theme);
132
+ return theme;
133
+ }
134
+
135
+ /**
136
+ * 从页面标题或 URL 推断主题名称
137
+ */
138
+ private detectName($: cheerio.CheerioAPI, url: string): string {
139
+ const title = $("title").text().trim();
140
+ if (title) {
141
+ return title
142
+ .replace(/\s+/g, "-")
143
+ .replace(/[|:/\\.<>?*]/g, "-")
144
+ .replace(/-+/g, "-")
145
+ .slice(0, 60);
146
+ }
147
+ try {
148
+ const hostname = new URL(url).hostname.replace(/^www\./, "");
149
+ return hostname.split(".")[0] || "web-theme";
150
+ } catch {
151
+ return "web-theme";
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 提取所有 <style> 标签内的 CSS 文本
157
+ */
158
+ private extractAllStyles($: cheerio.CheerioAPI): string {
159
+ const parts: string[] = [];
160
+ $("style").each((_, el) => {
161
+ const text = $(el).text().trim();
162
+ if (text && !text.includes("@font-face")) {
163
+ parts.push(text);
164
+ }
165
+ });
166
+ return parts.join("\n");
167
+ }
168
+
169
+ /**
170
+ * 使用 css-tree 将 CSS 文本解析为结构化规则映射
171
+ */
172
+ private parseCssRules(css: string): CssRuleMap {
173
+ const result = new Map<string, Map<string, string>>();
174
+ try {
175
+ const ast = parse(css, {
176
+ parseAtrulePrelude: false,
177
+ parseRulePrelude: false,
178
+ });
179
+ walk(ast, {
180
+ visit: "Rule",
181
+ enter(node) {
182
+ const prelude = generate(node.prelude).trim();
183
+ const props = new Map<string, string>();
184
+ walk(node.block, {
185
+ visit: "Declaration",
186
+ enter(decl) {
187
+ props.set(decl.property.toLowerCase(), generate(decl.value));
188
+ },
189
+ });
190
+ if (props.size > 0) {
191
+ result.set(prelude, props);
192
+ }
193
+ },
194
+ });
195
+ } catch {
196
+ // CSS 解析错误不阻断流程
197
+ }
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * 提取正文(body/html)完整样式
203
+ */
204
+ private extractBodyStyles(
205
+ theme: ThemeStyle,
206
+ rules: CssRuleMap,
207
+ $: cheerio.CheerioAPI
208
+ ): void {
209
+ const p = theme.palette;
210
+ const t = theme.typography;
211
+ const st = theme.seedTypography;
212
+
213
+ const bodyStyles = this.resolveElementStyles(rules, $, [
214
+ "body",
215
+ "html",
216
+ ":root",
217
+ "#app",
218
+ "#root",
219
+ ".container",
220
+ "main",
221
+ ]);
222
+
223
+ if (bodyStyles.fontFamily) {
224
+ t.bodyFontFamily = this.normalizeFontFamily(bodyStyles.fontFamily);
225
+ st.fontBody = t.bodyFontFamily;
226
+ }
227
+
228
+ if (bodyStyles.fontSize) {
229
+ t.baseFontSize = this.normalizeFontSize(bodyStyles.fontSize);
230
+ st.fontSizeRoot = t.baseFontSize;
231
+ }
232
+
233
+ if (bodyStyles.color) {
234
+ p.textColor = this.normalizeColor(bodyStyles.color);
235
+ }
236
+
237
+ if (bodyStyles.backgroundColor) {
238
+ p.bgColor = this.normalizeColor(bodyStyles.backgroundColor);
239
+ }
240
+
241
+ if (bodyStyles.lineHeight) {
242
+ const lh = parseFloat(bodyStyles.lineHeight);
243
+ if (lh >= 1.2 && lh <= 2.5) {
244
+ t.baseLineHeight = lh;
245
+ st.lineHeightBody = lh;
246
+ }
247
+ }
248
+
249
+ // 从 body 标签的内联样式或属性补充
250
+ const bodyTag = $("body");
251
+ const inlineBg = bodyTag.attr("style")?.match(/background(?:-color)?:\s*([^;]+)/i)?.[1];
252
+ if (inlineBg && !p.bgColor) {
253
+ p.bgColor = this.normalizeColor(inlineBg);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 提取 H1-H6 各级标题的完整样式定义
259
+ */
260
+ private extractHeadingStyles(
261
+ theme: ThemeStyle,
262
+ rules: CssRuleMap,
263
+ $: cheerio.CheerioAPI
264
+ ): void {
265
+ const headingLevels = ["h1", "h2", "h3", "h4", "h5", "h6"];
266
+ const st = theme.seedTypography;
267
+ const rootSize =
268
+ parseFloat(theme.typography.baseFontSize) || 16;
269
+
270
+ for (const level of headingLevels) {
271
+ const styles = this.resolveElementStyles(rules, $, [level]);
272
+
273
+ if (styles.fontSize) {
274
+ const pxSize = this.normalizeFontSizeToPx(styles.fontSize, rootSize);
275
+ const scale = pxSize / rootSize;
276
+
277
+ switch (level) {
278
+ case "h1":
279
+ st.fontScaleH1 = Math.max(st.fontScaleH1, scale);
280
+ break;
281
+ case "h2":
282
+ st.fontScaleH2 = Math.max(st.fontScaleH2, scale);
283
+ break;
284
+ case "h3":
285
+ st.fontScaleH3 = Math.max(st.fontScaleH3, scale);
286
+ break;
287
+ case "h4":
288
+ st.fontScaleH4 = Math.max(st.fontScaleH4, scale);
289
+ break;
290
+ case "h5":
291
+ st.fontScaleH5 = Math.max(st.fontScaleH5, scale);
292
+ break;
293
+ case "h6":
294
+ st.fontScaleH6 = Math.max(st.fontScaleH6, scale);
295
+ break;
296
+ }
297
+ }
298
+
299
+ if (styles.fontWeight) {
300
+ const weight = parseInt(styles.fontWeight) || 400;
301
+ if (weight > theme.typography.headingWeight) {
302
+ theme.typography.headingWeight = Math.min(weight, 900);
303
+ }
304
+ }
305
+
306
+ if (styles.color && level === "h1") {
307
+ theme.palette.headingColor = this.normalizeColor(styles.color);
308
+ }
309
+
310
+ if (styles.fontFamily && level === "h1") {
311
+ theme.typography.headingFontFamily = this.normalizeFontFamily(
312
+ styles.fontFamily
313
+ );
314
+ st.fontHeading = theme.typography.headingFontFamily;
315
+ }
316
+
317
+ if (styles.lineHeight && level === "h1") {
318
+ const lh = parseFloat(styles.lineHeight);
319
+ if (lh >= 1.1 && lh <= 1.6) {
320
+ st.lineHeightHeading = lh;
321
+ }
322
+ }
323
+ }
324
+
325
+ // 如果没有提取到标题字体,使用正文字体族
326
+ if (!theme.typography.headingFontFamily) {
327
+ theme.typography.headingFontFamily = theme.typography.bodyFontFamily;
328
+ st.fontHeading = st.fontBody;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * 提取表格(th/td)完整样式
334
+ */
335
+ private extractTableStyles(
336
+ theme: ThemeStyle,
337
+ rules: CssRuleMap,
338
+ $: cheerio.CheerioAPI
339
+ ): void {
340
+ const p = theme.palette;
341
+
342
+ const thStyles = this.resolveElementStyles(rules, $, [
343
+ "th",
344
+ "thead th",
345
+ "table th",
346
+ ".table th",
347
+ ]);
348
+
349
+ if (thStyles.backgroundColor) {
350
+ p.tableHeaderBg = this.normalizeColor(thStyles.backgroundColor);
351
+ }
352
+ if (thStyles.color) {
353
+ // 表头文字色暂存到 headingColor 备用
354
+ if (!p.headingColor) p.headingColor = this.normalizeColor(thStyles.color);
355
+ }
356
+ if (thStyles.borderColor) {
357
+ p.borderColor = this.normalizeColor(thStyles.borderColor);
358
+ }
359
+
360
+ const tdStyles = this.resolveElementStyles(rules, $, [
361
+ "td",
362
+ "tbody td",
363
+ "table td",
364
+ ".table td",
365
+ ]);
366
+
367
+ if (tdStyles.backgroundColor) {
368
+ p.codeBg = this.normalizeColor(tdStyles.backgroundColor);
369
+ }
370
+
371
+ const tableStyles = this.resolveElementStyles(rules, $, [
372
+ "table",
373
+ ".table",
374
+ ]);
375
+
376
+ if (tableStyles.borderColor) {
377
+ p.borderColor = p.borderColor || this.normalizeColor(tableStyles.borderColor);
378
+ }
379
+
380
+ // 检查页面是否包含表格元素
381
+ if ($("table").length > 0 && !p.tableHeaderBg) {
382
+ p.tableHeaderBg = ACADEMIC_PAPER_DEFAULTS.table.headerBgColor;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * 提取代码块和内联代码样式
388
+ */
389
+ private extractCodeStyles(
390
+ theme: ThemeStyle,
391
+ rules: CssRuleMap,
392
+ $: cheerio.CheerioAPI
393
+ ): void {
394
+ const p = theme.palette;
395
+
396
+ const preStyles = this.resolveElementStyles(rules, $, [
397
+ "pre",
398
+ "code pre",
399
+ "pre code",
400
+ ".code-block",
401
+ "pre[class]",
402
+ ]);
403
+
404
+ if (preStyles.backgroundColor) {
405
+ p.codeBlockBg = this.normalizeColor(preStyles.backgroundColor);
406
+ }
407
+ if (preStyles.color) {
408
+ p.codeBlockText = this.normalizeColor(preStyles.color);
409
+ }
410
+
411
+ const codeStyles = this.resolveElementStyles(rules, $, [
412
+ "code",
413
+ "p code",
414
+ "li code",
415
+ "inline-code",
416
+ ":not(pre) > code",
417
+ ]);
418
+
419
+ if (codeStyles.backgroundColor) {
420
+ p.codeBg = this.normalizeColor(codeStyles.backgroundColor);
421
+ }
422
+ if (codeStyles.color) {
423
+ // 内联代码颜色暂存
424
+ }
425
+
426
+ // 检测代码高亮库的存在
427
+ const hasHighlightJs = $("pre code.hljs").length > 0;
428
+ const hasPrismjs = $("pre[class*=language-]").length > 0;
429
+ if (hasHighlightJs || hasPrismjs) {
430
+ if (!p.codeBlockBg) p.codeBlockBg = "#282c34";
431
+ if (!p.codeBlockText) p.codeBlockText = "#abb2bf";
432
+ }
433
+ }
434
+
435
+ /**
436
+ * 提取引用块样式
437
+ */
438
+ private extractBlockquoteStyles(
439
+ theme: ThemeStyle,
440
+ rules: CssRuleMap,
441
+ $: cheerio.CheerioAPI
442
+ ): void {
443
+ const p = theme.palette;
444
+
445
+ const bqStyles = this.resolveElementStyles(rules, $, [
446
+ "blockquote",
447
+ "blockquote p",
448
+ ".blockquote",
449
+ ]);
450
+
451
+ if (bqStyles.backgroundColor) {
452
+ p.blockquoteBg = this.normalizeColor(bqStyles.backgroundColor);
453
+ }
454
+ if (bqStyles.borderLeftColor) {
455
+ p.blockquoteBorder = this.normalizeColor(bqStyles.borderLeftColor);
456
+ } else if (bqStyles.borderColor) {
457
+ p.blockquoteBorder = this.normalizeColor(bqStyles.borderColor);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * 提取链接和强调色
463
+ */
464
+ private extractLinkAndAccent(
465
+ theme: ThemeStyle,
466
+ rules: CssRuleMap,
467
+ $: cheerio.CheerioAPI
468
+ ): void {
469
+ const p = theme.palette;
470
+
471
+ const linkStyles = this.resolveElementStyles(rules, $, [
472
+ "a",
473
+ "a:link",
474
+ "a:not(:visited)",
475
+ ]);
476
+
477
+ if (linkStyles.color) {
478
+ p.accentColor = this.normalizeColor(linkStyles.color);
479
+ }
480
+
481
+ // 也检查 strong/b 元素的颜色
482
+ const strongStyles = this.resolveElementStyles(rules, $, [
483
+ "strong",
484
+ "b",
485
+ ".strong",
486
+ ]);
487
+
488
+ if (strongStyles.color && !p.accentColor) {
489
+ p.accentColor = this.normalizeColor(strongStyles.color);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * 综合推断文档特征(语言、类型、风格)
495
+ */
496
+ private inferDocumentCharacteristics(
497
+ theme: ThemeStyle,
498
+ $: cheerio.CheerioAPI
499
+ ): void {
500
+ const htmlText = $("body").text();
501
+ const hasChinese = /[\u4e00-\u9fa5]/.test(htmlText.slice(0, 2000));
502
+
503
+ // 检测常见框架/站点特征
504
+ const isGitHub = /github\.com/i.test(theme.sourceUrl);
505
+ const isDocsSite =
506
+ /docs?|documentation|wiki|help/i.test(theme.sourceUrl + $("title").text());
507
+ const isBlog = /blog|article|post/i.test(
508
+ theme.sourceUrl + $("title").text()
509
+ );
510
+
511
+ if (isGitHub) {
512
+ theme.description = `${theme.description} [GitHub 页面]`;
513
+ } else if (isDocsSite) {
514
+ theme.description = `${theme.description} [技术文档站]`;
515
+ } else if (isBlog) {
516
+ theme.description = `${theme.description} [博客/文章页]`;
517
+ }
518
+
519
+ // 根据语言特征微调默认配色
520
+ if (hasChinese && !theme.palette.textColor) {
521
+ theme.typography.bodyFontFamily =
522
+ ACADEMIC_PAPER_DEFAULTS.body.fontFamily;
523
+ theme.seedTypography.fontBody = ACADEMIC_PAPER_DEFAULTS.body.fontFamily;
524
+ }
525
+ }
526
+
527
+ /**
528
+ * 应用 DocxStyleParadigm 标准参考值填充缺失字段
529
+ */
530
+ private applyParadigmDefaults(theme: ThemeStyle): void {
531
+ const preset = getRecommendedPreset();
532
+ const p = theme.palette;
533
+ const t = theme.typography;
534
+ const st = theme.seedTypography;
535
+
536
+ if (!p.textColor || p.textColor === "#000000") {
537
+ p.textColor = preset.body.color;
538
+ }
539
+ if (!p.headingColor) {
540
+ p.headingColor = preset.headings.h1.color;
541
+ }
542
+ if (!p.bgColor || p.bgColor === "#ffffff" || p.bgColor === "#fff") {
543
+ p.bgColor = preset.pageBackground;
544
+ }
545
+ if (!p.textMuted) {
546
+ p.textMuted = preset.mutedTextColor;
547
+ }
548
+ if (!p.accentColor || p.accentColor === "#0000ff" || p.accentColor === "#00f") {
549
+ p.accentColor = preset.accentColor;
550
+ }
551
+ if (!p.tableHeaderBg) {
552
+ p.tableHeaderBg = preset.table.headerBgColor;
553
+ }
554
+ if (!p.codeBg) {
555
+ p.codeBg = preset.inlineCode.bgColor;
556
+ }
557
+ if (!p.codeBlockBg) {
558
+ p.codeBlockBg = preset.codeBlock.bgColor;
559
+ }
560
+ if (!p.codeBlockText) {
561
+ p.codeBlockText = preset.codeBlock.color;
562
+ }
563
+ if (!p.blockquoteBg) {
564
+ p.blockquoteBg = preset.blockquote.bgColor;
565
+ }
566
+ if (!p.blockquoteBorder) {
567
+ p.blockquoteBorder = preset.blockquote.borderLeftColor;
568
+ }
569
+ if (!p.selectionBg) {
570
+ p.selectionBg = preset.selectionBg;
571
+ }
572
+ if (!p.borderColor) {
573
+ p.borderColor = preset.table.borderColor;
574
+ }
575
+
576
+ if (
577
+ !t.bodyFontFamily ||
578
+ t.bodyFontFamily === "Georgia, serif"
579
+ ) {
580
+ t.bodyFontFamily = preset.body.fontFamily;
581
+ }
582
+ if (!t.headingFontFamily) {
583
+ t.headingFontFamily = preset.headings.h1.fontFamily;
584
+ }
585
+ if (!t.baseFontSize) {
586
+ t.baseFontSize = `${preset.body.fontSize}px`;
587
+ }
588
+ if (t.baseLineHeight < 1.4) {
589
+ t.baseLineHeight = preset.body.lineHeight;
590
+ }
591
+
592
+ st.fontBody = st.fontBody || preset.body.fontFamily;
593
+ st.fontHeading = st.fontHeading || preset.headings.h1.fontFamily;
594
+ st.lineHeightBody = st.lineHeightBody || preset.body.lineHeight;
595
+ st.lineHeightHeading =
596
+ st.lineHeightHeading || preset.headings.h1.lineHeight;
597
+
598
+ // 确保标题字号倍率符合范式约束
599
+ if (st.fontScaleH1 < 1.75) st.fontScaleH1 = preset.headings.h1.fontSize / 16;
600
+ if (st.fontScaleH2 < st.fontScaleH1 * 0.8)
601
+ st.fontScaleH2 = preset.headings.h2.fontSize / 16;
602
+ if (st.fontScaleH3 < st.fontScaleH2 * 0.85)
603
+ st.fontScaleH3 = preset.headings.h3.fontSize / 16;
604
+
605
+ const issues = validateDocumentStyle(themeToParadigm(theme));
606
+ logExtractorWarnings("UrlExtractor", issues.filter((i) => i.level === "error"));
607
+ }
608
+
609
+ // ═══════════════════════════════════════════════════════════
610
+ // 工具方法:CSS 属性解析与规范化
611
+ // ═══════════════════════════════════════════════════════════
612
+
613
+ /**
614
+ * 为一组选择器查找匹配的 CSS 规则并合并结果
615
+ * 优先级:更具体的选择器 > 更通用的选择器 > 内联样式
616
+ */
617
+ private resolveElementStyles(
618
+ rules: CssRuleMap,
619
+ $: cheerio.CheerioAPI,
620
+ selectors: string[]
621
+ ): ElementStyles {
622
+ const merged: ElementStyles = {};
623
+
624
+ for (const selector of selectors) {
625
+ const ruleProps = this.findBestMatchingRule(rules, selector);
626
+ if (ruleProps) {
627
+ if (ruleProps.get("font-family") && !merged.fontFamily)
628
+ merged.fontFamily = ruleProps.get("font-family");
629
+ if (ruleProps.get("font-size") && !merged.fontSize)
630
+ merged.fontSize = ruleProps.get("font-size");
631
+ if (ruleProps.get("font-weight") && !merged.fontWeight)
632
+ merged.fontWeight = ruleProps.get("font-weight");
633
+ if (ruleProps.get("color") && !merged.color)
634
+ merged.color = ruleProps.get("color");
635
+ if (ruleProps.get("line-height") && !merged.lineHeight)
636
+ merged.lineHeight = ruleProps.get("line-height");
637
+ if (ruleProps.get("background-color") && !merged.backgroundColor)
638
+ merged.backgroundColor = ruleProps.get("background-color");
639
+ if (ruleProps.get("border-left-color") && !merged.borderColor)
640
+ merged.borderColor = ruleProps.get("border-left-color");
641
+ if (ruleProps.get("border-color") && !merged.borderColor)
642
+ merged.borderColor = ruleProps.get("border-color");
643
+ if (ruleProps.get("letter-spacing") && !merged.letterSpacing)
644
+ merged.letterSpacing = ruleProps.get("letter-spacing");
645
+ }
646
+ }
647
+
648
+ // 补充内联样式(最高优先级)
649
+ for (const selector of selectors) {
650
+ try {
651
+ const el = $(selector).first();
652
+ if (el.length) {
653
+ const inlineAttr = el.attr("style");
654
+ if (inlineAttr) {
655
+ const inlineMap = this.parseInlineStyle(inlineAttr);
656
+ if (inlineMap.fontFamily) merged.fontFamily = inlineMap.fontFamily;
657
+ if (inlineMap.fontSize) merged.fontSize = inlineMap.fontSize;
658
+ if (inlineMap.fontWeight) merged.fontWeight = inlineMap.fontWeight;
659
+ if (inlineMap.color) merged.color = inlineMap.color;
660
+ if (inlineMap.lineHeight) merged.lineHeight = inlineMap.lineHeight;
661
+ if (inlineMap.backgroundColor)
662
+ merged.backgroundColor = inlineMap.backgroundColor;
663
+ }
664
+ }
665
+ } catch {
666
+ // 选择器可能无效,跳过
667
+ }
668
+ }
669
+
670
+ return merged;
671
+ }
672
+
673
+ /**
674
+ * 在 CSS 规则中查找最佳匹配的选择器
675
+ */
676
+ private findBestMatchingRule(
677
+ rules: CssRuleMap,
678
+ targetSelector: string
679
+ ): Map<string, string> | undefined {
680
+ let bestMatch: Map<string, string> | undefined;
681
+ let bestSpecificity = -1;
682
+
683
+ for (const [selector, props] of rules) {
684
+ if (
685
+ selector.includes(targetSelector) ||
686
+ targetSelector.includes(selector.replace(/[.#\[\]>+~]/g, "").split(" ").pop() || "")
687
+ ) {
688
+ const specificity = this.calculateSpecificity(selector);
689
+ if (specificity > bestSpecificity) {
690
+ bestSpecificity = specificity;
691
+ bestMatch = props;
692
+ }
693
+ }
694
+ }
695
+
696
+ return bestMatch;
697
+ }
698
+
699
+ /**
700
+ * 计算选择器特异性(简化版)
701
+ */
702
+ private calculateSpecificity(selector: string): number {
703
+ let score = 0;
704
+ score += (selector.match(/#/g) || []).length * 100;
705
+ score += (selector.match(/\./g) || []).length * 10;
706
+ score += (selector.match(/[a-zA-Z]/g) || []).length;
707
+ return score;
708
+ }
709
+
710
+ /**
711
+ * 解析内联 style 字符串为键值对
712
+ */
713
+ private parseInlineStyle(styleStr: string): Partial<ElementStyles> {
714
+ const result: Partial<ElementStyles> = {};
715
+ const declarations = styleStr.split(";");
716
+
717
+ for (const decl of declarations) {
718
+ const colonIndex = decl.indexOf(":");
719
+ if (colonIndex === -1) continue;
720
+
721
+ const prop = decl.slice(0, colonIndex).trim().toLowerCase();
722
+ const value = decl.slice(colonIndex + 1).trim();
723
+
724
+ switch (prop) {
725
+ case "font-family":
726
+ result.fontFamily = value;
727
+ break;
728
+ case "font-size":
729
+ result.fontSize = value;
730
+ break;
731
+ case "font-weight":
732
+ result.fontWeight = value;
733
+ break;
734
+ case "color":
735
+ result.color = value;
736
+ break;
737
+ case "line-height":
738
+ result.lineHeight = value;
739
+ break;
740
+ case "background-color":
741
+ case "background":
742
+ result.backgroundColor = value;
743
+ break;
744
+ case "border-color":
745
+ case "border-left-color":
746
+ result.borderColor = value;
747
+ break;
748
+ case "letter-spacing":
749
+ result.letterSpacing = value;
750
+ break;
751
+ }
752
+ }
753
+
754
+ return result;
755
+ }
756
+
757
+ /**
758
+ * 规范化字体家族名称(去除引号、清理多余空格)
759
+ */
760
+ private normalizeFontFamily(fontFamily: string): string {
761
+ return fontFamily
762
+ .replace(/['"]/g, "")
763
+ .replace(/\s{2,}/g, " ")
764
+ .trim();
765
+ }
766
+
767
+ /**
768
+ * 规范化字号为 px 单位
769
+ */
770
+ private normalizeFontSize(sizeStr: string): string {
771
+ return `${this.normalizeFontSizeToPx(sizeStr, 16)}px`;
772
+ }
773
+
774
+ /**
775
+ * 将字号转换为 px 数值
776
+ */
777
+ private normalizeFontSizeToPx(sizeStr: string, rootPx: number): number {
778
+ const trimmed = sizeStr.trim();
779
+
780
+ // em/rem 单位转换
781
+ const emMatch = trimmed.match(/^([\d.]+)(em|rem)$/i);
782
+ if (emMatch) {
783
+ return parseFloat(emMatch[1]) * rootPx;
784
+ }
785
+
786
+ // pt → px 转换 (1pt ≈ 1.333px)
787
+ const ptMatch = trimmed.match(/^([\d.]+)pt$/i);
788
+ if (ptMatch) {
789
+ return parseFloat(ptMatch[1]) * 1.333;
790
+ }
791
+
792
+ // 直接是 px 或无单位数字
793
+ const pxMatch = trimmed.match(/^([\d.]+)(px)?$/i);
794
+ if (pxMatch) {
795
+ return parseFloat(pxMatch[1]);
796
+ }
797
+
798
+ // 关键字号映射
799
+ const keywordSizes: Record<string, number> = {
800
+ "xx-small": 10,
801
+ "x-small": 12,
802
+ small: 14,
803
+ medium: 16,
804
+ large: 18,
805
+ "x-large": 20,
806
+ "xx-large": 24,
807
+ };
808
+
809
+ if (keywordSizes[trimmed]) {
810
+ return keywordSizes[trimmed];
811
+ }
812
+
813
+ return rootPx;
814
+ }
815
+
816
+ /**
817
+ * 规范化颜色值为标准 hex 格式
818
+ */
819
+ private normalizeColor(colorStr: string): string {
820
+ const trimmed = colorStr.trim().toLowerCase();
821
+
822
+ // 已经是 hex 格式
823
+ if (/^#[0-9a-f]{3,8}$/.test(trimmed)) {
824
+ return trimmed;
825
+ }
826
+
827
+ // rgb()/rgba() 格式
828
+ const rgbMatch = trimmed.match(
829
+ /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/
830
+ );
831
+ if (rgbMatch) {
832
+ const r = parseInt(rgbMatch[1]);
833
+ const g = parseInt(rgbMatch[2]);
834
+ const b = parseInt(rgbMatch[3]);
835
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
836
+ }
837
+
838
+ // 透明色返回空字符串
839
+ if (trimmed === "transparent" || trimmed === "none" || trimmed === "initial" || trimmed === "inherit") {
840
+ return "";
841
+ }
842
+
843
+ // 返回原值(可能是 named color 等)
844
+ return trimmed;
845
+ }
846
+ }