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