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