返回 DeepSeek-Reasonix
sessionExport.tsx
根目录 / desktop / frontend / src / lib / sessionExport.tsx
1 import { createRoot, type Root } from "react-dom/client";
2 import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3 import type { Components } from "react-markdown";
4 import katexCss from "katex/dist/katex.min.css?inline";
5 import { highlightToHtml } from "./highlight";
6 import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
7 import { normalizeMath } from "../components/mathNormalize";
8 import {
9 createRasterPdf,
10 isSafeInlineExportImage,
11 neutralizeExternalCssResources,
12 PDF_CONTENT_ASPECT,
13 planRasterSlices,
14 transformExportMarkdownUrl,
15 type RasterSlice,
16 } from "./sessionExportCore";
17
18 const EXPORT_WIDTH = 920;
19 const MAX_CANVAS_SIDE = 8192;
20
21 const EXPORT_STYLES = neutralizeExternalCssResources(`
22 ${katexCss}
23 .session-export-page,
24 .session-export-page * {
25 box-sizing: border-box;
26 }
27 .session-export-page {
28 --fg: #111827;
29 --fg-dim: #374151;
30 --fg-faint: #6b7280;
31 --bg: #ffffff;
32 --bg-soft: #f7f8fb;
33 --bg-elev-2: #eef2f7;
34 --border: #d8dee8;
35 --border-soft: #e7ebf1;
36 --accent: #2563eb;
37 --font-content: 15px;
38 --font-code: 13px;
39 --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
40 width: ${EXPORT_WIDTH}px;
41 min-height: 1px;
42 padding: 48px 56px;
43 background: #ffffff;
44 color: var(--fg);
45 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif;
46 font-size: 16px;
47 line-height: 1.68;
48 overflow-wrap: anywhere;
49 }
50 .session-export-page .md > :first-child {
51 margin-top: 0;
52 }
53 .session-export-page .md > :last-child {
54 margin-bottom: 0;
55 }
56 .session-export-page .md p {
57 margin: 0 0 12px;
58 }
59 .session-export-page .md h1,
60 .session-export-page .md h2,
61 .session-export-page .md h3,
62 .session-export-page .md h4 {
63 margin: 20px 0 10px;
64 line-height: 1.3;
65 font-weight: 650;
66 letter-spacing: 0;
67 }
68 .session-export-page .md h1 {
69 font-size: 1.65em;
70 padding-bottom: 14px;
71 border-bottom: 1px solid var(--border-soft);
72 }
73 .session-export-page .md h2 {
74 font-size: 1.28em;
75 }
76 .session-export-page .md h3 {
77 font-size: 1.12em;
78 }
79 .session-export-page .md h4 {
80 font-size: 1em;
81 color: var(--fg-dim);
82 }
83 .session-export-page .md ul,
84 .session-export-page .md ol {
85 margin: 0 0 12px;
86 padding-left: 24px;
87 }
88 .session-export-page .md li {
89 margin: 3px 0;
90 }
91 .session-export-page .md li::marker {
92 color: var(--fg-faint);
93 }
94 .session-export-page .md a {
95 color: var(--accent);
96 text-decoration: none;
97 }
98 .session-export-page .md blockquote {
99 margin: 0 0 14px;
100 padding: 4px 14px;
101 border-left: 3px solid var(--border);
102 color: var(--fg-dim);
103 background: #fafbfc;
104 }
105 .session-export-page .md hr {
106 border: none;
107 border-top: 1px solid var(--border);
108 margin: 20px 0;
109 }
110 .session-export-page .md-code {
111 font-family: var(--mono);
112 font-size: 0.88em;
113 background: var(--bg-elev-2);
114 border: 1px solid var(--border-soft);
115 border-radius: 5px;
116 padding: 1px 5px;
117 }
118 .session-export-page .md table {
119 width: 100%;
120 border-collapse: collapse;
121 margin: 0 0 14px;
122 font-size: var(--font-content);
123 display: table;
124 overflow: visible;
125 }
126 .session-export-page .md th,
127 .session-export-page .md td {
128 border: 1px solid var(--border);
129 padding: 7px 10px;
130 text-align: left;
131 vertical-align: top;
132 }
133 .session-export-page .md th {
134 background: var(--bg-soft);
135 font-weight: 650;
136 }
137 .session-export-page .code-block {
138 position: relative;
139 }
140 .session-export-page .copybtn,
141 .session-export-page .code-block__copy {
142 display: none !important;
143 }
144 .session-export-page .code {
145 margin: 10px 0;
146 padding: 12px 14px;
147 background: var(--bg-soft);
148 border: 1px solid var(--border-soft);
149 border-radius: 8px;
150 font-family: var(--mono);
151 font-size: var(--font-code);
152 line-height: 1.58;
153 overflow: visible;
154 white-space: pre-wrap;
155 color: var(--fg);
156 }
157 .session-export-page .hljs-comment,
158 .session-export-page .hljs-quote {
159 color: #6b7280;
160 }
161 .session-export-page .hljs-keyword,
162 .session-export-page .hljs-selector-tag,
163 .session-export-page .hljs-literal,
164 .session-export-page .hljs-section,
165 .session-export-page .hljs-doctag,
166 .session-export-page .hljs-type,
167 .session-export-page .hljs-name,
168 .session-export-page .hljs-strong {
169 color: #7c3aed;
170 }
171 .session-export-page .hljs-string,
172 .session-export-page .hljs-regexp,
173 .session-export-page .hljs-attribute,
174 .session-export-page .hljs-meta .hljs-string {
175 color: #047857;
176 }
177 .session-export-page .hljs-number,
178 .session-export-page .hljs-symbol,
179 .session-export-page .hljs-bullet,
180 .session-export-page .hljs-link,
181 .session-export-page .hljs-selector-attr,
182 .session-export-page .hljs-selector-pseudo {
183 color: #b45309;
184 }
185 .session-export-page .hljs-title,
186 .session-export-page .hljs-title.function_,
187 .session-export-page .hljs-section .hljs-title {
188 color: #1d4ed8;
189 }
190 .session-export-page .hljs-class .hljs-title,
191 .session-export-page .hljs-title.class_,
192 .session-export-page .hljs-built_in,
193 .session-export-page .hljs-attr {
194 color: #be123c;
195 }
196 .session-export-page .export-media-placeholder {
197 display: inline-block;
198 max-width: 100%;
199 margin: 4px 0;
200 padding: 7px 10px;
201 border: 1px solid var(--border-soft);
202 border-radius: 6px;
203 color: var(--fg-faint);
204 background: var(--bg-soft);
205 font-size: 0.9em;
206 }
207 `);
208
209 const staticMarkdownComponents: Components = {
210 pre: ({ children }) => <>{children}</>,
211 code: ({ className, children }) => {
212 const text = String(children ?? "");
213 const match = /language-([\w-]+)/.exec(className ?? "");
214 const isBlock = match !== null || text.includes("\n");
215 if (!isBlock) return <code className="md-code">{children}</code>;
216 return (
217 <pre className="code hljs" data-lang={match?.[1]}>
218 <code dangerouslySetInnerHTML={{ __html: highlightToHtml(text.replace(/\n$/, ""), match?.[1]) }} />
219 </pre>
220 );
221 },
222 a: ({ href, children }) => <a href={href}>{children}</a>,
223 img: ({ src, alt, title }) => {
224 if (isSafeInlineExportImage(src)) {
225 return <img src={src} alt={alt ?? ""} title={title} />;
226 }
227 return <span className="export-media-placeholder">[{alt || title || "image"}]</span>;
228 },
229 };
230
231 function StaticMarkdown({ text }: { text: string }) {
232 return (
233 <div className="md">
234 <ReactMarkdown
235 remarkPlugins={reasonixRemarkPlugins}
236 rehypePlugins={reasonixRehypePlugins}
237 components={staticMarkdownComponents}
238 urlTransform={(value, key) => transformExportMarkdownUrl(value, key, defaultUrlTransform)}
239 >
240 {normalizeMath(text)}
241 </ReactMarkdown>
242 </div>
243 );
244 }
245
246 function ExportSurface({ markdown }: { markdown: string }) {
247 return (
248 <>
249 <style>{EXPORT_STYLES}</style>
250 <div className="session-export-page">
251 <StaticMarkdown text={markdown} />
252 </div>
253 </>
254 );
255 }
256
257 interface RenderedExport {
258 root: Root;
259 host: HTMLDivElement;
260 surface: HTMLElement;
261 }
262
263 function nextFrame(): Promise<void> {
264 return new Promise((resolve) => requestAnimationFrame(() => resolve()));
265 }
266
267 async function waitForInlineImages(surface: HTMLElement): Promise<void> {
268 await Promise.all(
269 Array.from(surface.querySelectorAll("img")).map(async (image) => {
270 if (typeof image.decode === "function") {
271 try {
272 await image.decode();
273 } catch {
274 // Broken inline data stays as the browser's normal missing-image UI.
275 }
276 return;
277 }
278 if (image.complete) return;
279 await new Promise<void>((resolve) => {
280 const done = () => {
281 image.removeEventListener("load", done);
282 image.removeEventListener("error", done);
283 resolve();
284 };
285 image.addEventListener("load", done, { once: true });
286 image.addEventListener("error", done, { once: true });
287 if (image.complete) done();
288 });
289 }),
290 );
291 }
292
293 async function renderExportSurface(markdown: string): Promise<RenderedExport> {
294 const host = document.createElement("div");
295 host.style.position = "fixed";
296 host.style.left = "-100000px";
297 host.style.top = "0";
298 host.style.width = `${EXPORT_WIDTH}px`;
299 host.style.pointerEvents = "none";
300 host.style.background = "#ffffff";
301 document.body.appendChild(host);
302
303 const root = createRoot(host);
304 root.render(<ExportSurface markdown={markdown} />);
305
306 await nextFrame();
307 await nextFrame();
308 await document.fonts?.ready.catch(() => undefined);
309 await nextFrame();
310
311 const surface = host.querySelector<HTMLElement>(".session-export-page");
312 if (!surface) {
313 root.unmount();
314 host.remove();
315 throw new Error("Export surface was not rendered");
316 }
317
318 await waitForInlineImages(surface);
319 await nextFrame();
320
321 return { root, host, surface };
322 }
323
324 function disposeExport(rendered: RenderedExport): void {
325 rendered.root.unmount();
326 rendered.host.remove();
327 }
328
329 function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
330 return new Promise((resolve, reject) => {
331 canvas.toBlob((blob) => {
332 if (blob) resolve(blob);
333 else reject(new Error("Could not encode export image"));
334 }, type, quality);
335 });
336 }
337
338 function loadImage(url: string): Promise<HTMLImageElement> {
339 return new Promise((resolve, reject) => {
340 const img = new Image();
341 img.onload = () => resolve(img);
342 img.onerror = () => reject(new Error("Could not render export image"));
343 img.src = url;
344 });
345 }
346
347 function replaceExternalMedia(root: HTMLElement): void {
348 root.querySelectorAll("img, video, audio, iframe, object, embed").forEach((element) => {
349 if (element instanceof HTMLImageElement && isSafeInlineExportImage(element.getAttribute("src") ?? undefined)) return;
350 const placeholder = document.createElement("span");
351 placeholder.className = "export-media-placeholder";
352 const label = element.getAttribute("alt") || element.getAttribute("title") || element.tagName.toLowerCase();
353 placeholder.textContent = `[${label}]`;
354 element.replaceWith(placeholder);
355 });
356 }
357
358 function pruneSliceClone(source: HTMLElement, clone: HTMLElement, slice: RasterSlice): void {
359 const sourceMarkdown = source.querySelector<HTMLElement>(".md");
360 const cloneMarkdown = clone.querySelector<HTMLElement>(".md");
361 if (!sourceMarkdown || !cloneMarkdown) return;
362 const surfaceTop = source.getBoundingClientRect().top;
363 const markdownTop = sourceMarkdown.getBoundingClientRect().top;
364 const selected: Array<{ node: Element; top: number }> = [];
365 Array.from(sourceMarkdown.children).forEach((node) => {
366 const rect = node.getBoundingClientRect();
367 const top = rect.top - surfaceTop;
368 const bottom = rect.bottom - surfaceTop;
369 if (bottom > slice.offset && top < slice.offset + slice.height) {
370 selected.push({ node, top: rect.top - markdownTop });
371 }
372 });
373 if (selected.length === 0) return;
374
375 cloneMarkdown.replaceChildren();
376 cloneMarkdown.style.position = "relative";
377 cloneMarkdown.style.height = `${Math.max(1, sourceMarkdown.scrollHeight)}px`;
378 for (const item of selected) {
379 const child = item.node.cloneNode(true) as HTMLElement;
380 child.style.position = "absolute";
381 child.style.top = `${item.top}px`;
382 child.style.left = "0";
383 child.style.right = "0";
384 child.style.margin = "0";
385 cloneMarkdown.appendChild(child);
386 }
387 }
388
389 function serializeSurfaceSlice(surface: HTMLElement, width: number, slice: RasterSlice): string {
390 const clone = surface.cloneNode(true) as HTMLElement;
391 pruneSliceClone(surface, clone, slice);
392 replaceExternalMedia(clone);
393 clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
394 clone.style.width = `${width}px`;
395 clone.style.minHeight = `${slice.offset + slice.height}px`;
396 clone.style.transform = `translateY(-${slice.offset}px)`;
397 clone.style.transformOrigin = "top left";
398 const style = document.createElement("style");
399 style.textContent = EXPORT_STYLES;
400 clone.insertBefore(style, clone.firstChild);
401 const viewport = document.createElement("div");
402 viewport.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
403 viewport.style.width = `${width}px`;
404 viewport.style.height = `${slice.height}px`;
405 viewport.style.overflow = "hidden";
406 viewport.style.background = "#ffffff";
407 viewport.appendChild(clone);
408 return new XMLSerializer().serializeToString(viewport);
409 }
410
411 function exportScale(): number {
412 return Math.min(2, Math.max(1.5, window.devicePixelRatio || 1));
413 }
414
415 function naturalPageBreaks(surface: HTMLElement): number[] {
416 const surfaceTop = surface.getBoundingClientRect().top;
417 const markdown = surface.querySelector<HTMLElement>(".md");
418 if (!markdown) return [];
419 return Array.from(markdown.children).map((node) => Math.ceil(node.getBoundingClientRect().bottom - surfaceTop));
420 }
421
422 function planSurfaceSlices(surface: HTMLElement, maxSliceHeight: number): RasterSlice[] {
423 const height = Math.max(1, Math.ceil(surface.scrollHeight || surface.getBoundingClientRect().height || 1));
424 const breakpoints = naturalPageBreaks(surface);
425 const contentEnd = breakpoints.length > 0
426 ? breakpoints.reduce((maximum, value) => Math.max(maximum, value), 1)
427 : height;
428 return planRasterSlices(height, maxSliceHeight, breakpoints, contentEnd);
429 }
430
431 async function renderSurfaceSliceToCanvas(
432 surface: HTMLElement,
433 slice: RasterSlice,
434 scale: number,
435 ): Promise<HTMLCanvasElement> {
436 const width = Math.max(1, Math.ceil(surface.scrollWidth || surface.getBoundingClientRect().width || EXPORT_WIDTH));
437 const canvasWidth = Math.max(1, Math.floor(width * scale));
438 const canvasHeight = Math.max(1, Math.floor(slice.height * scale));
439 const serialized = serializeSurfaceSlice(surface, width, slice);
440 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${slice.height}" viewBox="0 0 ${width} ${slice.height}"><foreignObject width="100%" height="100%">${serialized}</foreignObject></svg>`;
441 // Chromium/WebView2 versions used by shipped desktop builds mark an SVG
442 // foreignObject loaded from a blob URL as cross-origin. A self-contained data
443 // URL keeps the image origin-clean so toBlob() can encode the canvas.
444 const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
445 const image = await loadImage(svgUrl);
446 const canvas = document.createElement("canvas");
447 canvas.width = canvasWidth;
448 canvas.height = canvasHeight;
449 const ctx = canvas.getContext("2d");
450 if (!ctx) throw new Error("Canvas is not available");
451 ctx.fillStyle = "#ffffff";
452 ctx.fillRect(0, 0, canvasWidth, canvasHeight);
453 ctx.scale(scale, scale);
454 ctx.drawImage(image, 0, 0, width, slice.height);
455 return canvas;
456 }
457
458 function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
459 const copy = new Uint8Array(bytes.byteLength);
460 copy.set(bytes);
461 return copy.buffer;
462 }
463
464 export function blobToBase64(blob: Blob): Promise<string> {
465 return new Promise((resolve, reject) => {
466 const reader = new FileReader();
467 reader.onload = () => {
468 const result = String(reader.result ?? "");
469 resolve(result.includes(",") ? result.slice(result.indexOf(",") + 1) : result);
470 };
471 reader.onerror = () => reject(reader.error ?? new Error("Could not read export payload"));
472 reader.readAsDataURL(blob);
473 });
474 }
475
476 async function renderSessionImages<T>(markdown: string, encode: (blob: Blob) => Promise<T>): Promise<T[]> {
477 const rendered = await renderExportSurface(markdown);
478 try {
479 const scale = exportScale();
480 const slices = planSurfaceSlices(rendered.surface, Math.floor(MAX_CANVAS_SIDE / scale));
481 const images: T[] = [];
482 for (const slice of slices) {
483 const canvas = await renderSurfaceSliceToCanvas(rendered.surface, slice, scale);
484 try {
485 images.push(await encode(await canvasToBlob(canvas, "image/png")));
486 } finally {
487 canvas.width = 1;
488 canvas.height = 1;
489 }
490 }
491 return images;
492 } finally {
493 disposeExport(rendered);
494 }
495 }
496
497 export function renderSessionImageBlobs(markdown: string): Promise<Blob[]> {
498 return renderSessionImages(markdown, async (blob) => blob);
499 }
500
501 export function renderSessionImageBase64Payloads(markdown: string): Promise<string[]> {
502 return renderSessionImages(markdown, blobToBase64);
503 }
504
505 export async function renderSessionPdfBlob(markdown: string, title: string): Promise<Blob> {
506 const rendered = await renderExportSurface(markdown);
507 try {
508 const scale = exportScale();
509 const width = Math.max(1, Math.ceil(rendered.surface.scrollWidth || rendered.surface.getBoundingClientRect().width || EXPORT_WIDTH));
510 const pageHeight = Math.max(1, Math.floor(width * PDF_CONTENT_ASPECT));
511 const slices = planSurfaceSlices(rendered.surface, pageHeight);
512 const images = [];
513 for (const slice of slices) {
514 const canvas = await renderSurfaceSliceToCanvas(rendered.surface, slice, scale);
515 try {
516 const jpegBlob = await canvasToBlob(canvas, "image/jpeg", 0.9);
517 images.push({ bytes: new Uint8Array(await jpegBlob.arrayBuffer()), width: canvas.width, height: canvas.height });
518 } finally {
519 canvas.width = 1;
520 canvas.height = 1;
521 }
522 }
523 const pdfBytes = createRasterPdf(images, title);
524 return new Blob([arrayBufferFromBytes(pdfBytes)], { type: "application/pdf" });
525 } finally {
526 disposeExport(rendered);
527 }
528 }
529
529 lines Plain Text