Repository navigation
Expand file tree
/
Copy pathwechat-md.html
More file actions
600 lines (547 loc) · 25.4 KB
/
Copy pathwechat-md.html
File metadata and controls
600 lines (547 loc) · 25.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown → 公众号(代码横向滚动)</title>
<!-- 代码高亮主题:用于在预览中算出每个 token 的颜色,再转成行内样式 -->
<link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/atom-one-dark.min.css">
<!-- MathJax:公式渲染成自包含 SVG(fontCache:local),再转 PNG 内嵌 -->
<script>
window.MathJax = {
tex: { inlineMath: [['$', '$']], displayMath: [['$$', '$$']] },
svg: { fontCache: 'local' },
startup: { typeset: false },
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.min.js"></script>
<style>
:root { --gap: 12px; }
* { box-sizing: border-box; }
body {
margin: 0; font: 14px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #222; background: #f5f6f7;
}
header {
padding: 12px 16px; background: #fff; border-bottom: 1px solid #e5e6eb;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
}
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
header .hint { color: #86909c; font-size: 12px; }
.toolbar { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
select, button {
font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid #d0d3d9;
background: #fff; cursor: pointer;
}
button.primary { background: #07c160; color: #fff; border-color: #07c160; font-weight: 600; }
button.primary:active { background: #06ad56; }
#status { font-size: 12px; color: #07c160; min-width: 60px; }
main { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); padding: var(--gap); height: calc(100vh - 56px); }
.pane { background: #fff; border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.pane > .label { padding: 6px 12px; font-size: 12px; color: #86909c; border-bottom: 1px solid #f0f1f3; background: #fafbfc; }
textarea {
flex: 1; border: 0; outline: 0; resize: none; padding: 16px;
font: 13px/1.6 "SF Mono", Menlo, Consolas, monospace; color: #333;
}
/* 预览区滚动容器,模拟手机宽度(375px)方便检查横向滚动效果 */
#preview-scroll { flex: 1; overflow: auto; padding: 16px; background: #fff; }
#preview { max-width: 100%; }
.phone #preview-scroll { background: #ebedf0; padding: 16px 0; display: flex; justify-content: center; }
.phone #preview { width: 375px; background: #fff; padding: 16px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
@media (max-width: 760px) { main { grid-template-columns: 1fr; height: auto; } textarea { min-height: 240px; } }
/* 代码图片导出弹窗 */
#modal { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; z-index: 50; padding: 24px; overflow: auto; }
#modal.open { display: block; }
.modal-box { max-width: 900px; margin: 0 auto; background: #fff; border-radius: 10px; padding: 20px; }
.modal-box .head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.modal-box .head h2 { font-size: 16px; margin: 0; }
.modal-box .head .tip { color: #86909c; font-size: 12px; }
.modal-box .head .x { margin-left: auto; }
.shot { margin: 16px 0; padding: 12px; border: 1px solid #eee; border-radius: 8px; }
.shot .bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.shot .bar b { font-size: 13px; }
.shot img { max-width: 100%; display: block; border-radius: 6px; }
</style>
</head>
<body>
<header>
<h1>Markdown → 公众号</h1>
<span class="hint">代码转图片内嵌 · 全行内样式 · 复制粘贴即可</span>
<div class="toolbar">
<label class="hint"><input type="checkbox" id="phone"> 模拟手机宽度</label>
<select id="theme" title="代码配色">
<option value="atom-one-dark">深色 (atom-one-dark)</option>
<option value="github">浅色 (github)</option>
<option value="atom-one-light">浅色 (atom-one-light)</option>
<option value="monokai">深色 (monokai)</option>
</select>
<select id="frame" title="代码图片底色">
<option value="none">代码底:无</option>
<option value="cream">代码底:米白</option>
<option value="apricot">代码底:暖杏</option>
<option value="gray">代码底:浅灰</option>
<option value="mint">代码底:薄荷</option>
<option value="sky">代码底:蓝天渐变</option>
<option value="sunset">代码底:晚霞渐变</option>
<option value="ocean">代码底:深海渐变</option>
</select>
<select id="page" title="整篇文章底色">
<option value="cream">文章底:米白</option>
<option value="none">文章底:白</option>
<option value="apricot">文章底:暖杏</option>
<option value="gray">文章底:浅灰</option>
<option value="mint">文章底:薄荷</option>
<option value="pink">文章底:淡粉</option>
<option value="blue">文章底:浅蓝</option>
</select>
<button id="copy" class="primary">复制全文(含代码图)</button>
<button id="export">单独导出图片</button>
<span id="status"></span>
</div>
</header>
<main>
<section class="pane">
<div class="label">Markdown 输入</div>
<textarea id="src" spellcheck="false"></textarea>
</section>
<section class="pane">
<div class="label">预览(复制的就是这个,已内联所有样式)</div>
<div id="preview-scroll"><div id="preview"></div></div>
</section>
</main>
<!-- 离屏「源」:渲染高亮+内联样式,用于算颜色/生成图片/复制正文(不直接展示) -->
<div id="source" style="position:absolute;left:-99999px;top:0;width:760px;"></div>
<div id="modal">
<div class="modal-box">
<div class="head">
<h2>代码图片</h2>
<span class="tip">右键/长按保存,或点「下载」。再到公众号文章里用「图片」按钮上传到对应占位处。</span>
<button class="x" onclick="document.getElementById('modal').classList.remove('open')">关闭</button>
</div>
<div style="margin-bottom:12px;"><button id="dl-all" class="primary">下载全部</button></div>
<div id="shots"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
<script>
const $ = s => document.querySelector(s);
const src = $('#src'), preview = $('#preview'), source = $('#source');
// —— 行内样式表(class 会被微信删掉,只有 inline style 能存活)——
const INLINE = {
H1: 'font-size:22px;font-weight:bold;margin:28px 0 16px;line-height:1.3;color:#1a1a1a;',
H2: 'font-size:19px;font-weight:bold;margin:24px 0 14px;line-height:1.3;color:#1a1a1a;border-left:4px solid #07c160;padding-left:10px;',
H3: 'font-size:17px;font-weight:bold;margin:20px 0 12px;line-height:1.3;color:#1a1a1a;',
H4: 'font-size:15px;font-weight:bold;margin:18px 0 10px;color:#1a1a1a;',
P: 'margin:14px 0;font-size:15px;line-height:1.8;color:#3f3f3f;letter-spacing:.3px;',
A: 'color:#576b95;text-decoration:none;',
STRONG: 'font-weight:bold;color:#1a1a1a;',
EM: 'font-style:italic;',
UL: 'margin:14px 0;padding-left:24px;',
OL: 'margin:14px 0;padding-left:24px;',
LI: 'margin:6px 0;font-size:15px;line-height:1.8;color:#3f3f3f;',
BLOCKQUOTE: 'margin:16px 0;padding:10px 16px;border-left:4px solid #dcdee0;background:#f7f8fa;color:#646566;',
HR: 'border:0;border-top:1px solid #e5e6eb;margin:24px 0;',
IMG: 'max-width:100%;border-radius:6px;display:block;margin:14px auto;',
TABLE: 'border-collapse:collapse;width:100%;margin:14px 0;font-size:14px;',
TH: 'border:1px solid #e5e6eb;padding:8px 12px;background:#fafbfc;font-weight:bold;',
TD: 'border:1px solid #e5e6eb;padding:8px 12px;',
};
marked.setOptions({ breaks: true, gfm: true });
async function render() {
// 关键顺序:先把公式从「原始 Markdown」里抠出来换成占位符,再交给 marked。
// 否则 Markdown 会先破坏 TeX(\{ → {、\_ → _、\\ 被吃掉、_i_ 变成 <em>),
// 而且 breaks:true 产生的 <br> 会把 $$…$$ 拆到不同文本节点里,跨行公式永远匹配不到。
const { text, store } = extractMath(src.value || '');
source.innerHTML = marked.parse(text);
// 新版 marked 已移除 highlight 选项,这里手动高亮:
// highlightElement 会给 <code> 加上 .hljs 类(带浅色文字)并生成带色 token
source.querySelectorAll('pre code').forEach(el => {
try { hljs.highlightElement(el); } catch {}
});
inlineStyles(source);
restoreMath(source, store); // 占位符 → MathJax SVG
await mathSvgToImg(source); // SVG → PNG 内嵌图(微信可收)
buildPreview();
}
// —— 公式占位符 ——
// 用纯字母数字,Markdown 不会改写它(不会被转义、不会被识别成强调/链接)
const MATH_PH = i => `xMaThPh${i}xEnDx`;
const MATH_PH_RE = /xMaThPh(\d+)xEnDx/g;
// 依次匹配:① 围栏代码块 ② 行内代码 ③ 块级公式 $$…$$ / \[…\] ④ 行内公式 $…$ / \(…\)
// 代码相关的分支只是「占位不动」,保证代码里的 $ 不会被当成公式。
const SCAN_RE = new RegExp([
/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1[ \t]*$/.source, // 1: 围栏
/`[^`\n]*`/.source,
/\$\$([\s\S]+?)\$\$/.source, // 2: 块级 $$
/\\\[([\s\S]+?)\\\]/.source, // 3: 块级 \[ \]
/\$(?!\s)((?:\\.|[^\\$\n])+?)\$/.source, // 4: 行内 $
/\\\(([\s\S]+?)\\\)/.source, // 5: 行内 \( \)
].join('|'), 'gm');
// 把原始 Markdown 里的公式抽出来,换成占位符
function extractMath(md) {
const store = [];
const text = md.replace(SCAN_RE, (m, _fence, dTex, dTex2, iTex, iTex2, offset, whole) => {
const display = dTex != null || dTex2 != null;
const tex = dTex ?? dTex2 ?? iTex ?? iTex2;
if (tex == null) return m; // 代码分支:原样保留
store.push({ tex, display });
const ph = MATH_PH(store.length - 1);
if (!display) return ph;
// 块级公式若本来就独占整行,两侧补空行,让 marked 把它单独成段(而不是并进上一段)
const alone = (offset === 0 || whole[offset - 1] === '\n')
&& (offset + m.length >= whole.length || whole[offset + m.length] === '\n');
return alone ? `\n\n${ph}\n\n` : ph;
});
return { text, store };
}
// 把占位符换回 MathJax 渲染的 SVG
function restoreMath(root, store) {
if (!store.length) return;
const ok = !!(window.MathJax && MathJax.tex2svg);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: n => n.nodeValue.includes('xMaThPh')
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
});
const nodes = []; for (let n; (n = walker.nextNode());) nodes.push(n);
nodes.forEach(node => {
const text = node.nodeValue;
const frag = document.createDocumentFragment();
let last = 0, m; MATH_PH_RE.lastIndex = 0;
while ((m = MATH_PH_RE.exec(text))) {
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
const item = store[+m[1]];
frag.appendChild(item ? renderTex(item, ok) : document.createTextNode(m[0]));
last = m.index + m[0].length;
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.replaceWith(frag);
});
// 块级公式若独占一个 <p>,把 <p> 拆掉:div 套在 p 里是非法结构,
// 粘到公众号会被重新解析而错位。注意只能看「直接子节点」——
// 含中文的公式(\text{否则})MathJax 会吐出真的 <text> 节点,
// 用 p.textContent 判断会永远不为空。
root.querySelectorAll('p').forEach(p => {
const boxes = [...p.children].filter(el => el.dataset && el.dataset.mathBlock);
if (!boxes.length) return;
const rest = [...p.childNodes].filter(n =>
!boxes.includes(n) &&
!(n.nodeType === 3 && !n.nodeValue.trim()) && // 空白文本
!(n.nodeType === 1 && n.tagName === 'BR')); // breaks:true 留下的 <br>
if (rest.length) return;
const frag = document.createDocumentFragment();
boxes.forEach(b => frag.appendChild(b));
p.replaceWith(frag);
});
}
// 单条 TeX → SVG 节点(失败或 MathJax 未就绪时,退回原始文本)
function renderTex(item, ok) {
const { tex, display } = item;
let svg;
if (ok) { try { svg = MathJax.tex2svg(tex, { display }).querySelector('svg'); } catch {} }
if (!svg) {
const d = display ? '$$' : '$';
return document.createTextNode(d + tex + d);
}
svg.style.color = '#1a1a1a';
if (!display) { svg.style.verticalAlign = 'middle'; return svg; }
const box = document.createElement('div');
box.dataset.mathBlock = '1';
box.style.cssText = 'text-align:center;margin:16px 0;overflow-x:auto;';
box.appendChild(svg);
return box;
}
// 单个 SVG → PNG dataURL(SVG 无外部资源,可安全画到 canvas)
function svgToPng(svg, w, h, scale) {
return new Promise((res, rej) => {
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', w); clone.setAttribute('height', h);
clone.style.color = '#1a1a1a';
const str = new XMLSerializer().serializeToString(clone);
const url = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(str)));
const im = new Image();
im.onload = () => {
const cv = document.createElement('canvas');
cv.width = Math.max(1, Math.ceil(w * scale));
cv.height = Math.max(1, Math.ceil(h * scale));
const ctx = cv.getContext('2d'); ctx.scale(scale, scale);
ctx.drawImage(im, 0, 0, w, h);
res({ url: cv.toDataURL('image/png'), w, h });
};
im.onerror = rej; im.src = url;
});
}
// 把 source 里所有数学 SVG 替换成 PNG 图(保持原始像素尺寸)
async function mathSvgToImg(root) {
const svgs = [...root.querySelectorAll('svg')];
for (const svg of svgs) {
const r = svg.getBoundingClientRect();
const w = r.width || 20, h = r.height || 20;
let png;
try { png = await svgToPng(svg, w, h, 3); } catch { continue; }
const img = document.createElement('img');
img.src = png.url;
// 宽公式在手机上要能缩放,不然会把版面撑破
img.style.cssText = `width:${png.w}px;max-width:100%;height:auto;vertical-align:middle;`;
svg.replaceWith(img);
}
}
// 整篇文章底色(纯色,渐变会被微信过滤)
const PAGES = {
none: '', cream: '#f7f3ea', apricot: '#faf1de', gray: '#f0f2f5',
mint: '#eaf4ef', pink: '#fbeef0', blue: '#eef2fb',
};
const PAGE_PAD = '20px 16px';
// 预览区 = 所见即所得:正文文字 + 代码块渲染成最终带底色的图片
function buildPreview() {
const clone = source.cloneNode(true);
clone.querySelectorAll('pre').forEach(pre => {
const cv = renderCodeImage(pre, $('#frame').value);
const img = document.createElement('img');
img.src = cv.toDataURL('image/png');
img.style.cssText = 'max-width:100%;display:block;margin:16px auto;border-radius:8px;';
pre.replaceWith(img);
});
preview.innerHTML = '';
while (clone.firstChild) preview.appendChild(clone.firstChild);
// 文章底色应用到预览
const pg = PAGES[$('#page').value];
preview.style.background = pg || '';
preview.style.padding = pg ? PAGE_PAD : '';
}
function inlineStyles(root) {
// 1) 普通块级/行内元素:套基础行内样式
root.querySelectorAll('*').forEach(el => {
const base = INLINE[el.tagName];
if (base) el.style.cssText = base + el.style.cssText;
});
// 2) 行内 code(不在 pre 里的)
root.querySelectorAll('code').forEach(code => {
if (code.closest('pre')) return;
code.style.cssText = 'background:#f3f4f6;color:#c0341d;padding:2px 6px;border-radius:4px;'
+ 'font-size:13px;font-family:Menlo,Consolas,monospace;word-break:break-word;';
});
// 3) 代码块:关键——横向滚动,不换行
root.querySelectorAll('pre').forEach(pre => {
const themeBg = getComputedStyle(pre.querySelector('code') || pre).backgroundColor;
pre.style.cssText =
'overflow-x:auto;-webkit-overflow-scrolling:touch;' + // 长行横向滚动
'background:' + (themeBg && themeBg !== 'rgba(0, 0, 0, 0)' ? themeBg : '#282c34') + ';' +
'border-radius:8px;padding:16px;margin:16px 0;' +
'font-size:13px;line-height:1.5;';
const code = pre.querySelector('code');
if (code) {
code.style.cssText =
'white-space:pre;' + // pre 而非 pre-wrap = 不自动换行
'display:block;font-family:Menlo,Consolas,monospace;' +
'background:transparent;padding:0;color:' + getComputedStyle(code).color + ';';
}
});
// 4) 高亮 token:把主题算出的颜色固化成行内 color
root.querySelectorAll('pre code span').forEach(span => {
const c = getComputedStyle(span).color;
if (c) span.style.color = c;
span.removeAttribute('class');
});
}
function flash(t){ const s=$('#status'); s.textContent=t; setTimeout(()=>s.textContent='',2500); }
// —— 复制全文:代码块直接内嵌成 base64 图片,粘到公众号时微信会自动收图上传 ——
async function copyProse() {
const clone = source.cloneNode(true);
clone.querySelectorAll('pre').forEach(pre => {
const cv = renderCodeImage(pre, $('#frame').value);
const img = document.createElement('img');
img.src = cv.toDataURL('image/png'); // base64 内嵌,粘贴时微信自动上传
img.style.cssText = 'max-width:100%;display:block;margin:16px auto;border-radius:8px;';
pre.replaceWith(img);
});
// 整篇包进带底色的 section(微信保留 section 行内背景)
const pg = PAGES[$('#page').value];
const html = pg
? `<section style="background:${pg};padding:${PAGE_PAD};">${clone.innerHTML}</section>`
: clone.innerHTML;
try {
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([clone.innerText], { type: 'text/plain' }),
})]);
flash('✓ 正文已复制');
} catch {
const tmp = document.createElement('div');
tmp.style.cssText = 'position:fixed;left:-9999px;'; tmp.innerHTML = html;
document.body.appendChild(tmp);
const range = document.createRange(); range.selectNodeContents(tmp);
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range);
document.execCommand('copy'); sel.removeAllRanges(); tmp.remove();
flash('✓ 正文已复制');
}
}
// —— 把一个高亮后的 <code> 拆成「行 → 片段(文本+颜色)」 ——
function tokenize(codeEl, defColor) {
const lines = [[]];
(function walk(node, color) {
node.childNodes.forEach(n => {
if (n.nodeType === 3) {
const parts = n.nodeValue.split('\n');
parts.forEach((p, i) => {
if (i > 0) lines.push([]);
if (p) lines[lines.length - 1].push({ text: p, color });
});
} else if (n.nodeType === 1) {
walk(n, n.style.color || color);
}
});
})(codeEl, defColor);
return lines;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
// —— 图片外层底色预设(字符串=纯色;数组=上→下渐变)——
const FRAMES = {
none: null,
cream: '#f5efe3',
apricot: '#f8ecd2',
gray: '#eaeef3',
mint: '#e2f0e9',
sky: ['#a1c4fd', '#c2e9fb'],
sunset: ['#ffd3a5', '#fd9eb6'],
ocean: ['#2b5876', '#4e4376'],
};
// —— 用 canvas 把代码块画成高清 PNG(外层底色 + 浮起的代码卡 + Mac 圆点)——
function renderCodeImage(pre, frameKey) {
const code = pre.querySelector('code');
const bg = pre.style.backgroundColor || '#282c34';
const defColor = code.style.color || '#abb2bf';
const lines = tokenize(code, defColor);
const s = 2; // 2x 高清
const fs = 15 * s, lh = Math.round(fs * 1.6);
const font = `${fs}px Menlo, Consolas, "SF Mono", monospace`;
const padX = 20 * s, padTop = 46 * s, padBot = 22 * s;
const m = document.createElement('canvas').getContext('2d'); m.font = font;
let maxW = 0;
for (const line of lines) {
let w = 0; for (const seg of line) w += m.measureText(seg.text).width;
maxW = Math.max(maxW, w);
}
const cardW = Math.ceil(maxW + padX * 2);
const cardH = Math.ceil(lines.length * lh + padTop + padBot);
const frame = FRAMES[frameKey];
const fp = frame ? 36 * s : 0; // 外层留白;无底色则贴边
const W = cardW + fp * 2, H = cardH + fp * 2;
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
const ctx = cv.getContext('2d');
// 外层底色
if (frame) {
if (Array.isArray(frame)) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, frame[0]); g.addColorStop(1, frame[1]);
ctx.fillStyle = g;
} else ctx.fillStyle = frame;
ctx.fillRect(0, 0, W, H);
}
// 代码卡(带阴影)
ctx.save();
if (frame) { ctx.shadowColor = 'rgba(0,0,0,.22)'; ctx.shadowBlur = 24 * s; ctx.shadowOffsetY = 8 * s; }
ctx.fillStyle = bg; roundRect(ctx, fp, fp, cardW, cardH, 12 * s); ctx.fill();
ctx.restore();
// Mac 圆点
['#ff5f56', '#ffbd2e', '#27c93f'].forEach((c, i) => {
ctx.fillStyle = c; ctx.beginPath();
ctx.arc(fp + padX + 6 * s + i * 22 * s, fp + 22 * s, 6 * s, 0, Math.PI * 2); ctx.fill();
});
// 代码文本
ctx.font = font; ctx.textBaseline = 'top';
lines.forEach((line, li) => {
let x = fp + padX; const y = fp + padTop + li * lh;
for (const seg of line) {
ctx.fillStyle = seg.color || defColor;
ctx.fillText(seg.text, x, y);
x += ctx.measureText(seg.text).width;
}
});
return cv;
}
function exportImages() {
const pres = source.querySelectorAll('pre');
const shots = $('#shots'); shots.innerHTML = '';
if (!pres.length) { flash('没有代码块'); return; }
const frameKey = $('#frame').value;
pres.forEach((pre, i) => {
const cv = renderCodeImage(pre, frameKey);
const url = cv.toDataURL('image/png');
const name = `code-${i + 1}.png`;
const item = document.createElement('div'); item.className = 'shot';
item.innerHTML = `<div class="bar"><b>代码图片 ${i + 1}</b>`
+ `<a download="${name}" href="${url}"><button>下载 ${name}</button></a></div>`;
const img = document.createElement('img'); img.src = url; item.appendChild(img);
item.dataset.name = name; item.dataset.url = url;
shots.appendChild(item);
});
$('#modal').classList.add('open');
}
$('#dl-all').addEventListener('click', () => {
$('#shots').querySelectorAll('.shot').forEach((it, i) => {
setTimeout(() => {
const a = document.createElement('a');
a.href = it.dataset.url; a.download = it.dataset.name; a.click();
}, i * 300);
});
});
function setTheme(name) {
$('#hljs-theme').href = `https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/${name}.min.css`;
// 等主题 CSS 加载完再重渲染,确保取到正确颜色
setTimeout(render, 200);
}
src.addEventListener('input', render);
$('#copy').addEventListener('click', copyProse);
$('#export').addEventListener('click', exportImages);
$('#theme').addEventListener('change', e => setTheme(e.target.value));
$('#frame').addEventListener('change', () => {
buildPreview(); // 预览实时更新
if ($('#modal').classList.contains('open')) exportImages();
});
$('#page').addEventListener('change', buildPreview);
$('#phone').addEventListener('change', e => document.body.classList.toggle('phone', e.target.checked));
// 示例内容
src.value = `# PyTorch 一行代码搞定线性代数
平时**买菜**用不上求根公式,但写模型时少不了它。下面演示一个很长的代码行,看它在手机上如何横向滚动而不破坏排版:
## 代码示例
\`\`\`python
import torch
# 这是一行故意写得很长的代码,用来测试公众号手机端代码块是否能横向滚动而不是乱掉乱换行影响阅读体验
x = torch.linspace(-10, 10, 1000).reshape(-1, 1)
y = 3 * x ** 2 + 2 * x + 1 + torch.randn_like(x) * 5 # 加点噪声,模拟真实数据
print(x.shape, y.shape)
\`\`\`
行内代码像这样:\`torch.linspace()\`,会自动适配。
## 数学公式
行内公式 $E = mc^2$ 也会转成图片。下面是块级公式:
$$\\frac{\\partial L}{\\partial w} = \\frac{1}{n}\\sum_{i=1}^{n} 2(wx_i + b - y_i)x_i$$
\`$$\` 单独占一行的多行写法(GitHub 风格)同样支持,公式里的 \`\\;\` \`\\{\` \`\\\\\` 都不会被 Markdown 吃掉:
$$
\\{(i,j) : 1\\le i\\le m,\\ 1\\le j\\le n\\} \\;=\\; [m]\\times[n]
$$
$$
\\operatorname{qsort}(S) \\;=\\; \\begin{cases}
S & \\lvert S\\rvert\\le 1\\\\
\\operatorname{qsort}(S_{<p}) \\,\\Vert\\, \\langle p\\rangle \\,\\Vert\\, \\operatorname{qsort}(S_{\\ge p}) & \\text{否则}
\\end{cases}
$$
> 提示:手机端长代码行会横向滚动,缩进不会被破坏。
- 列表项一
- 列表项二
- [链接示例](https://github.com)
`;
setTheme('atom-one-dark');
// MathJax 异步加载,就绪后重渲染一次,确保公式转成图片
if (window.MathJax && MathJax.startup && MathJax.startup.promise) {
MathJax.startup.promise.then(render);
}
</script>
</body>
</html>