返回 DeepSeek-Reasonix
sessionExportCore.ts
根目录 / desktop / frontend / src / lib / sessionExportCore.ts
1 import { isLocalFileHref } from "./localFileUrl";
2
3 const PDF_PAGE_WIDTH = 595.28;
4 const PDF_PAGE_HEIGHT = 841.89;
5 const PDF_MARGIN = 36;
6
7 export const PDF_CONTENT_ASPECT =
8 (PDF_PAGE_HEIGHT - PDF_MARGIN * 2) / (PDF_PAGE_WIDTH - PDF_MARGIN * 2);
9
10 export interface RasterSlice {
11 offset: number;
12 height: number;
13 }
14
15 export interface RasterPdfImage {
16 bytes: Uint8Array;
17 width: number;
18 height: number;
19 }
20
21 export function planRasterSlices(
22 totalHeight: number,
23 maxSliceHeight: number,
24 naturalBreakpoints: number[] = [],
25 contentEnd?: number,
26 ): RasterSlice[] {
27 const total = Math.max(1, Math.ceil(Number.isFinite(totalHeight) ? totalHeight : 1));
28 const limit = Math.max(1, Math.floor(Number.isFinite(maxSliceHeight) ? maxSliceHeight : 1));
29 // contentEnd lets callers distinguish meaningful content from trailing
30 // container whitespace. Plan pages through the content first, then retain
31 // only the trailing whitespace that still fits on the final content page.
32 const plannedTotal = Math.max(
33 1,
34 Math.min(total, Math.ceil(contentEnd !== undefined && Number.isFinite(contentEnd) ? contentEnd : total)),
35 );
36 const breakpoints = naturalBreakpoints
37 .filter((value) => Number.isFinite(value) && value > 0 && value < plannedTotal)
38 .map((value) => Math.floor(value))
39 .sort((a, b) => a - b);
40 const slices: RasterSlice[] = [];
41 let offset = 0;
42 while (offset < plannedTotal) {
43 const target = Math.min(plannedTotal, offset + limit);
44 let end = target;
45 if (target < plannedTotal) {
46 const earliestNaturalBreak = offset + Math.floor(limit * 0.55);
47 for (const breakpoint of breakpoints) {
48 if (breakpoint > target) break;
49 if (breakpoint >= earliestNaturalBreak) end = breakpoint;
50 }
51 }
52 if (end <= offset) end = target;
53 slices.push({ offset, height: end - offset });
54 offset = end;
55 }
56 const last = slices[slices.length - 1];
57 if (last && plannedTotal < total && last.height < limit) {
58 last.height += Math.min(total - plannedTotal, limit - last.height);
59 }
60 return slices;
61 }
62
63 // SVG foreignObject rendering becomes origin-tainted when its CSS references a
64 // font or image URL. Export surfaces use system fonts, so external resources are
65 // intentionally neutralised before the SVG is drawn onto a canvas.
66 export function neutralizeExternalCssResources(css: string): string {
67 return css.replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, "none");
68 }
69
70 export function isSafeInlineExportImage(src: string | undefined): boolean {
71 return /^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/]+={0,2}$/i.test(src?.trim() ?? "");
72 }
73
74 export function transformExportMarkdownUrl(
75 value: string,
76 key: string,
77 fallback: (value: string) => string,
78 ): string {
79 const trimmed = value.trim();
80 if (key === "src" && isSafeInlineExportImage(trimmed)) return trimmed;
81 // Local-path anchors from remarkLocalPathLinks are kept so an exported
82 // document stays clickable; everything else goes through the default
83 // transform which blanks javascript: and other unsafe schemes.
84 if (key === "href" && isLocalFileHref(trimmed)) return trimmed;
85 return fallback(value);
86 }
87
88 function bytesFromString(value: string): Uint8Array {
89 const bytes = new Uint8Array(value.length);
90 for (let i = 0; i < value.length; i++) {
91 bytes[i] = value.charCodeAt(i) & 0xff;
92 }
93 return bytes;
94 }
95
96 function concatBytes(chunks: Uint8Array[]): Uint8Array {
97 const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
98 const out = new Uint8Array(total);
99 let offset = 0;
100 for (const chunk of chunks) {
101 out.set(chunk, offset);
102 offset += chunk.length;
103 }
104 return out;
105 }
106
107 function pdfNumber(value: number): string {
108 return value.toFixed(3).replace(/\.?0+$/, "");
109 }
110
111 function pdfString(value: string): string {
112 return value
113 .replace(/[^\x20-\x7e]/g, "")
114 .replace(/\\/g, "\\\\")
115 .replace(/\(/g, "\\(")
116 .replace(/\)/g, "\\)");
117 }
118
119 export function createRasterPdf(images: RasterPdfImage[], title: string): Uint8Array {
120 if (images.length === 0) throw new Error("Cannot create a PDF without pages");
121
122 const contentWidth = PDF_PAGE_WIDTH - PDF_MARGIN * 2;
123 const contentHeight = PDF_PAGE_HEIGHT - PDF_MARGIN * 2;
124 const infoObjectId = 3 + images.length * 3;
125 const objectCount = infoObjectId;
126 const chunks: Uint8Array[] = [];
127 const offsets: number[] = new Array(objectCount + 1).fill(0);
128 let position = 0;
129
130 const push = (value: string | Uint8Array) => {
131 const bytes = typeof value === "string" ? bytesFromString(value) : value;
132 chunks.push(bytes);
133 position += bytes.length;
134 };
135 const addObject = (id: number, body: string) => {
136 offsets[id] = position;
137 push(`${id} 0 obj\n${body}\nendobj\n`);
138 };
139 const addStreamObject = (id: number, header: string, body: Uint8Array) => {
140 offsets[id] = position;
141 push(`${id} 0 obj\n${header}\nstream\n`);
142 push(body);
143 push("\nendstream\nendobj\n");
144 };
145
146 push("%PDF-1.4\n%\xff\xff\xff\xff\n");
147 addObject(1, "<< /Type /Catalog /Pages 2 0 R >>");
148 const kids = images.map((_, index) => `${3 + index * 3} 0 R`).join(" ");
149 addObject(2, `<< /Type /Pages /Kids [ ${kids} ] /Count ${images.length} >>`);
150
151 images.forEach((image, index) => {
152 const pageId = 3 + index * 3;
153 const contentId = pageId + 1;
154 const imageId = pageId + 2;
155 const renderedHeight = Math.min(contentHeight, image.height * (contentWidth / image.width));
156 const y = PDF_PAGE_HEIGHT - PDF_MARGIN - renderedHeight;
157 const stream = bytesFromString(
158 `q\n${pdfNumber(contentWidth)} 0 0 ${pdfNumber(renderedHeight)} ${pdfNumber(PDF_MARGIN)} ${pdfNumber(y)} cm\n/Im0 Do\nQ\n`,
159 );
160 addObject(
161 pageId,
162 `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pdfNumber(PDF_PAGE_WIDTH)} ${pdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /XObject << /Im0 ${imageId} 0 R >> /ProcSet [/PDF /ImageC] >> /Contents ${contentId} 0 R >>`,
163 );
164 addStreamObject(contentId, `<< /Length ${stream.length} >>`, stream);
165 addStreamObject(
166 imageId,
167 `<< /Type /XObject /Subtype /Image /Width ${image.width} /Height ${image.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.bytes.length} >>`,
168 image.bytes,
169 );
170 });
171
172 addObject(infoObjectId, `<< /Title (${pdfString(title)}) /Producer (Reasonix) >>`);
173 const xrefStart = position;
174 push(`xref\n0 ${objectCount + 1}\n0000000000 65535 f \n`);
175 for (let id = 1; id <= objectCount; id++) {
176 push(`${String(offsets[id]).padStart(10, "0")} 00000 n \n`);
177 }
178 push(`trailer\n<< /Size ${objectCount + 1} /Root 1 0 R /Info ${infoObjectId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`);
179 return concatBytes(chunks);
180 }
181
181 lines TYPESCRIPT