Skip to content

Commit 656d17d

Browse files
authored
feat(cloud-preview): ✨ Improve cloud preview integration (#61)
* feat(cloud-preview): improve cloud preview integration * fix(clipboard): harden image copy source validation
1 parent 6e53948 commit 656d17d

28 files changed

+1001
-37
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "markpdfdown",
3-
"version": "0.1.5-tone",
3+
"version": "0.1.0-local",
44
"description": "A high-quality PDF to Markdown tool based on large language model visual recognition.",
55
"author": "MarkPDFdown",
66
"main": "dist/main/index.js",

src/core/infrastructure/services/CloudService.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,109 @@ class CloudService {
3737

3838
private constructor() {}
3939

40+
private extractDownloadFileName(contentDisposition: string, fallback: string): string {
41+
const rfc5987Name = this.parseRFC5987Filename(contentDisposition);
42+
if (rfc5987Name) {
43+
return this.sanitizeDownloadFileName(rfc5987Name, fallback);
44+
}
45+
46+
const plainName = this.parsePlainFilename(contentDisposition);
47+
if (!plainName) {
48+
return this.sanitizeDownloadFileName(fallback, fallback);
49+
}
50+
51+
const repairedName = this.tryRepairUtf8Mojibake(plainName);
52+
return this.sanitizeDownloadFileName(repairedName || plainName, fallback);
53+
}
54+
55+
private parseRFC5987Filename(contentDisposition: string): string | null {
56+
const match = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
57+
if (!match) return null;
58+
59+
const rawValue = match[1]?.trim();
60+
if (!rawValue) return null;
61+
62+
const unquoted = rawValue.replace(/^"(.*)"$/, '$1');
63+
const parts = unquoted.match(/^([^']*)'[^']*'(.*)$/);
64+
if (!parts) return null;
65+
66+
const charset = (parts[1] || 'utf-8').trim().toLowerCase();
67+
const encodedValue = parts[2] || '';
68+
69+
try {
70+
if (charset === 'utf-8' || charset === 'utf8') {
71+
return decodeURIComponent(encodedValue);
72+
}
73+
74+
const bytes = this.percentDecodeToBytes(encodedValue);
75+
if (charset === 'iso-8859-1' || charset === 'latin1') {
76+
return Buffer.from(bytes).toString('latin1');
77+
}
78+
return Buffer.from(bytes).toString('utf8');
79+
} catch {
80+
return null;
81+
}
82+
}
83+
84+
private parsePlainFilename(contentDisposition: string): string | null {
85+
const match = contentDisposition.match(/filename\s*=\s*("(?:\\.|[^"])*"|[^;]+)/i);
86+
if (!match) return null;
87+
88+
let value = match[1]?.trim();
89+
if (!value) return null;
90+
91+
if (value.startsWith('"') && value.endsWith('"')) {
92+
value = value.slice(1, -1).replace(/\\"/g, '"');
93+
}
94+
95+
return value;
96+
}
97+
98+
private percentDecodeToBytes(input: string): number[] {
99+
const bytes: number[] = [];
100+
for (let i = 0; i < input.length; i++) {
101+
const ch = input[i];
102+
if (ch === '%' && i + 2 < input.length) {
103+
const hex = input.slice(i + 1, i + 3);
104+
const parsed = Number.parseInt(hex, 16);
105+
if (!Number.isNaN(parsed)) {
106+
bytes.push(parsed);
107+
i += 2;
108+
continue;
109+
}
110+
}
111+
bytes.push(input.charCodeAt(i));
112+
}
113+
return bytes;
114+
}
115+
116+
private tryRepairUtf8Mojibake(input: string): string | null {
117+
const hasCjk = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(input);
118+
if (hasCjk) return null;
119+
120+
const latinSupplementCount = Array.from(input).filter((ch) => {
121+
const code = ch.charCodeAt(0);
122+
return code >= 0x00c0 && code <= 0x00ff;
123+
}).length;
124+
if (latinSupplementCount < 2) return null;
125+
126+
const repaired = Buffer.from(input, 'latin1').toString('utf8');
127+
if (!repaired) return null;
128+
129+
const repairedHasCjk = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(repaired);
130+
const roundTrip = Buffer.from(repaired, 'utf8').toString('latin1') === input;
131+
if (repairedHasCjk && roundTrip) {
132+
return repaired;
133+
}
134+
return null;
135+
}
136+
137+
private sanitizeDownloadFileName(input: string, fallback: string): string {
138+
// Sanitize: extract basename and strip control/reserved characters
139+
// eslint-disable-next-line no-control-regex
140+
return path.basename(input).replace(/[\u0000-\u001f<>:"|?*]/g, '_') || fallback;
141+
}
142+
40143
private normalizeCheckoutStatus(data: any): PaymentCheckoutStatusApiResponse | null {
41144
if (!data || typeof data !== 'object') {
42145
return null;
@@ -454,11 +557,8 @@ class CloudService {
454557
}
455558

456559
const contentDisposition = res.headers.get('Content-Disposition') || '';
457-
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
458-
const rawName = match ? match[1] : `task-${id}.pdf`;
459-
// Sanitize: extract basename and strip control/reserved characters
460-
// eslint-disable-next-line no-control-regex
461-
const fileName = path.basename(rawName).replace(/[\u0000-\u001f<>:"|?*]/g, '_') || `task-${id}.pdf`;
560+
const fallbackName = `task-${id}.pdf`;
561+
const fileName = this.extractDownloadFileName(contentDisposition, fallbackName);
462562

463563
const buffer = await res.arrayBuffer();
464564
return { success: true, data: { buffer, fileName } };

src/core/infrastructure/services/__tests__/CloudService.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,65 @@ describe('CloudService', () => {
283283
expect(result.data?.fileName).toBe('task-task-xyz.pdf')
284284
})
285285

286+
it('downloadPdf decodes RFC5987 filename* for non-english names', async () => {
287+
const cloudService = (await import('../CloudService.js')).default
288+
const response = makeJsonResponse(200, {})
289+
response.headers.get.mockReturnValue(
290+
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%80%E6%9C%AF%E6%89%8B%E5%86%8C.pdf",
291+
)
292+
response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer)
293+
mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response)
294+
295+
const result = await cloudService.downloadPdf('task-cn')
296+
expect(result.data?.fileName).toBe('中文技术手册.pdf')
297+
})
298+
299+
it('downloadPdf decodes RFC5987 latin1 filename* values', async () => {
300+
const cloudService = (await import('../CloudService.js')).default
301+
const response = makeJsonResponse(200, {})
302+
response.headers.get.mockReturnValue("attachment; filename*=ISO-8859-1''caf%E9.pdf")
303+
response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer)
304+
mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response)
305+
306+
const result = await cloudService.downloadPdf('task-latin1')
307+
expect(result.data?.fileName).toBe('café.pdf')
308+
})
309+
310+
it('downloadPdf falls back to task file name on malformed RFC5987 value', async () => {
311+
const cloudService = (await import('../CloudService.js')).default
312+
const response = makeJsonResponse(200, {})
313+
response.headers.get.mockReturnValue("attachment; filename*=UTF-8''bad%ZZ.pdf")
314+
response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer)
315+
mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response)
316+
317+
const result = await cloudService.downloadPdf('task-malformed')
318+
expect(result.data?.fileName).toBe('task-task-malformed.pdf')
319+
})
320+
321+
it('downloadPdf falls back to utf8 decode for unknown RFC5987 charset', async () => {
322+
const cloudService = (await import('../CloudService.js')).default
323+
const response = makeJsonResponse(200, {})
324+
response.headers.get.mockReturnValue("attachment; filename*=X-UNKNOWN''%E4%B8%AD%E6%96%87.pdf")
325+
response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer)
326+
mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response)
327+
328+
const result = await cloudService.downloadPdf('task-unknown-charset')
329+
expect(result.data?.fileName).toBe('中文.pdf')
330+
})
331+
332+
it('downloadPdf repairs common UTF-8 mojibake in filename', async () => {
333+
const cloudService = (await import('../CloudService.js')).default
334+
const response = makeJsonResponse(200, {})
335+
const original = '中文手册2.0.pdf'
336+
const mojibake = Buffer.from(original, 'utf8').toString('latin1')
337+
response.headers.get.mockReturnValue(`attachment; filename="${mojibake}"`)
338+
response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer)
339+
mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response)
340+
341+
const result = await cloudService.downloadPdf('task-mojibake')
342+
expect(result.data?.fileName).toBe(original)
343+
})
344+
286345
it('downloadPdf/getPageImage return error on non-OK response', async () => {
287346
const cloudService = (await import('../CloudService.js')).default
288347
mockAuthManager.fetchWithAuth

src/main/ipc/__tests__/handlers.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ vi.mock('../../../shared/ipc/channels.js', () => ({
152152
FILE: {
153153
GET_IMAGE_PATH: 'file:getImagePath',
154154
DOWNLOAD_MARKDOWN: 'file:downloadMarkdown',
155+
COPY_IMAGE_TO_CLIPBOARD: 'file:copyImageToClipboard',
155156
SELECT_DIALOG: 'file:selectDialog',
156157
UPLOAD: 'file:upload',
157158
UPLOAD_FILE_CONTENT: 'file:uploadFileContent',

src/main/ipc/handlers/__tests__/file.handler.test.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ const mockDialog = {
1414
showSaveDialog: vi.fn()
1515
}
1616

17+
const mockClipboard = {
18+
writeImage: vi.fn(),
19+
}
20+
21+
const mockNativeImage = {
22+
createFromPath: vi.fn(),
23+
createFromDataURL: vi.fn(),
24+
createFromBuffer: vi.fn(),
25+
}
26+
1727
const mockFs = {
1828
existsSync: vi.fn(),
1929
mkdirSync: vi.fn(),
@@ -36,7 +46,9 @@ const mockIpcMain = {
3646
// Mock modules
3747
vi.mock('electron', () => ({
3848
ipcMain: mockIpcMain,
39-
dialog: mockDialog
49+
dialog: mockDialog,
50+
clipboard: mockClipboard,
51+
nativeImage: mockNativeImage,
4052
}))
4153

4254
vi.mock('path', () => ({
@@ -66,6 +78,7 @@ vi.mock('../../../../shared/ipc/channels.js', () => ({
6678
FILE: {
6779
GET_IMAGE_PATH: 'file:getImagePath',
6880
DOWNLOAD_MARKDOWN: 'file:downloadMarkdown',
81+
COPY_IMAGE_TO_CLIPBOARD: 'file:copyImageToClipboard',
6982
SELECT_DIALOG: 'file:selectDialog',
7083
UPLOAD: 'file:upload',
7184
UPLOAD_FILE_CONTENT: 'file:uploadFileContent'
@@ -87,11 +100,112 @@ describe('File Handler', () => {
87100
mockFileLogic.getUploadDir.mockReturnValue('/uploads')
88101
mockFs.statSync.mockReturnValue({ size: 1024 })
89102
mockFs.existsSync.mockReturnValue(true)
103+
const fakeImage = { isEmpty: vi.fn(() => false) }
104+
mockNativeImage.createFromPath.mockReturnValue(fakeImage)
105+
mockNativeImage.createFromDataURL.mockReturnValue(fakeImage)
106+
mockNativeImage.createFromBuffer.mockReturnValue(fakeImage)
90107

91108
const { registerFileHandlers } = await import('../file.handler.js')
92109
registerFileHandlers()
93110
})
94111

112+
describe('file:copyImageToClipboard', () => {
113+
it('should copy image from local path successfully', async () => {
114+
const handler = handlers.get('file:copyImageToClipboard')
115+
const result = await handler!({}, '/tmp/page.png')
116+
117+
expect(result).toEqual({
118+
success: true,
119+
data: { copied: true },
120+
})
121+
expect(mockNativeImage.createFromPath).toHaveBeenCalledWith('/tmp/page.png')
122+
expect(mockClipboard.writeImage).toHaveBeenCalledTimes(1)
123+
})
124+
125+
it('should copy image from data URL successfully', async () => {
126+
const handler = handlers.get('file:copyImageToClipboard')
127+
const result = await handler!({}, 'data:image/png;base64,abcd')
128+
129+
expect(result.success).toBe(true)
130+
expect(mockNativeImage.createFromDataURL).toHaveBeenCalledWith('data:image/png;base64,abcd')
131+
expect(mockClipboard.writeImage).toHaveBeenCalledTimes(1)
132+
})
133+
134+
it('should copy image from file URL successfully', async () => {
135+
const handler = handlers.get('file:copyImageToClipboard')
136+
const result = await handler!({}, 'file:///tmp/page.png')
137+
138+
expect(result.success).toBe(true)
139+
expect(mockNativeImage.createFromPath).toHaveBeenCalledWith(expect.stringContaining('page.png'))
140+
expect(mockClipboard.writeImage).toHaveBeenCalledTimes(1)
141+
})
142+
143+
it('should return error when image source is missing', async () => {
144+
const handler = handlers.get('file:copyImageToClipboard')
145+
const result = await handler!({}, '')
146+
147+
expect(result).toEqual({
148+
success: false,
149+
error: 'Image source is required',
150+
})
151+
})
152+
153+
it('should reject remote image URLs', async () => {
154+
const handler = handlers.get('file:copyImageToClipboard')
155+
const result = await handler!({}, 'https://cdn.example.com/page.png')
156+
157+
expect(result).toEqual({
158+
success: false,
159+
error: 'Remote image URLs are not allowed',
160+
})
161+
expect(mockClipboard.writeImage).not.toHaveBeenCalled()
162+
})
163+
164+
it('should return error when image is empty', async () => {
165+
mockNativeImage.createFromPath.mockReturnValueOnce({
166+
isEmpty: vi.fn(() => true),
167+
})
168+
169+
const handler = handlers.get('file:copyImageToClipboard')
170+
const result = await handler!({}, '/tmp/empty.png')
171+
172+
expect(result).toEqual({
173+
success: false,
174+
error: 'Image data is empty or invalid',
175+
})
176+
expect(mockClipboard.writeImage).not.toHaveBeenCalled()
177+
})
178+
179+
it('should return error when nativeImage creation throws', async () => {
180+
mockNativeImage.createFromPath.mockImplementationOnce(() => {
181+
throw new Error('createFromPath failed')
182+
})
183+
184+
const handler = handlers.get('file:copyImageToClipboard')
185+
const result = await handler!({}, '/tmp/bad.png')
186+
187+
expect(result).toEqual({
188+
success: false,
189+
error: 'createFromPath failed',
190+
})
191+
expect(mockClipboard.writeImage).not.toHaveBeenCalled()
192+
})
193+
194+
it('should return error when clipboard write throws', async () => {
195+
mockClipboard.writeImage.mockImplementationOnce(() => {
196+
throw new Error('clipboard failed')
197+
})
198+
199+
const handler = handlers.get('file:copyImageToClipboard')
200+
const result = await handler!({}, '/tmp/page.png')
201+
202+
expect(result).toEqual({
203+
success: false,
204+
error: 'clipboard failed',
205+
})
206+
})
207+
})
208+
95209
describe('file:getImagePath', () => {
96210
it('should return image path and exists status', async () => {
97211
mockFs.existsSync.mockReturnValue(true)

0 commit comments

Comments
 (0)