Skip to content

Commit 0a6aa50

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(files): align PDF export with Markdown editor
1 parent ef07358 commit 0a6aa50

14 files changed

Lines changed: 789 additions & 360 deletions

File tree

apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,30 @@
22
* @vitest-environment node
33
*/
44
import { PDFDocument } from 'pdf-lib'
5+
import { getDocument, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs'
56
import sharp from 'sharp'
67
import { describe, expect, it } from 'vitest'
7-
import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf'
8+
import { MarkdownPdfLimitError, renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf'
9+
10+
async function pdfPagesText(buffer: Buffer): Promise<string[]> {
11+
const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise
12+
try {
13+
return await Promise.all(
14+
Array.from({ length: document.numPages }, async (_, index) => {
15+
const page = await document.getPage(index + 1)
16+
const content = await page.getTextContent()
17+
return content.items.map((item) => ('str' in item ? item.str : '')).join(' ')
18+
})
19+
)
20+
} finally {
21+
await document.destroy()
22+
}
23+
}
824

925
describe('Markdown PDF rendering', () => {
1026
it('creates a valid multi-page PDF with GFM and an embedded image', async () => {
27+
const imageKey = 'workspace/ws-1/editor-image.png'
28+
const imageUrl = `/api/files/serve/${encodeURIComponent(imageKey)}?context=workspace`
1129
const image = await sharp({
1230
create: {
1331
width: 120,
@@ -26,7 +44,9 @@ describe('Markdown PDF rendering', () => {
2644
2745
> A useful blockquote with a [link](https://sim.ai).
2846
29-
Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 falls back safely.
47+
Smart quotes “work” and Greek Ω stays readable.
48+
49+
中文排版应该清晰易读。 العربية يجب أن تكون متصلة ومقروءة. हिन्दी पाठ स्पष्ट और पठनीय होना चाहिए। עברית צריכה להיות ברורה וקריאה.
3050
3151
- First item
3252
- Second item
@@ -40,14 +60,16 @@ Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 fal
4060
const exported = true
4161
\`\`\`
4262
43-
![Embedded image](/workspace/ws-1/files/image-1)
63+
![Embedded image](${imageUrl})
64+
65+
<img src="${imageUrl}" alt="Resized image" width="240" height="120">
4466
4567
${repeatedParagraphs}`
4668

4769
const buffer = await renderMarkdownPdf({
4870
markdown,
4971
title: 'Export title',
50-
images: new Map([['image-1', image]]),
72+
images: new Map([[`key:${imageKey}`, image]]),
5173
})
5274

5375
expect(buffer.subarray(0, 4).toString()).toBe('%PDF')
@@ -56,6 +78,46 @@ ${repeatedParagraphs}`
5678
const document = await PDFDocument.load(buffer)
5779
expect(document.getTitle()).toBe('Export title')
5880
expect(document.getPageCount()).toBeGreaterThan(1)
81+
82+
const text = (await pdfPagesText(buffer)).join(' ')
83+
expect(text).toContain('中文排版应该清晰易读')
84+
expect(text).toContain('العربية')
85+
// PDF extractors expose visually positioned Indic vowel marks before their base character.
86+
expect(text).toMatch(/[\u0900-\u097f]{4,}/u)
87+
expect(text).toContain('עברית')
88+
expect(text).not.toContain('Image: Embedded image')
89+
90+
const parsed = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise
91+
try {
92+
let imagePaints = 0
93+
for (let pageNumber = 1; pageNumber <= parsed.numPages; pageNumber += 1) {
94+
const operators = await (await parsed.getPage(pageNumber)).getOperatorList()
95+
imagePaints += operators.fnArray.filter(
96+
(operator) =>
97+
operator === OPS.paintImageXObject || operator === OPS.paintInlineImageXObject
98+
).length
99+
}
100+
expect(imagePaints).toBeGreaterThanOrEqual(2)
101+
} finally {
102+
await parsed.destroy()
103+
}
104+
})
105+
106+
it('keeps long table rows together and repeats the header across table pages', async () => {
107+
const rows = Array.from(
108+
{ length: 90 },
109+
(_, index) =>
110+
`| Row ${index + 1} | Description ${index + 1} with enough text to exercise wrapping |`
111+
).join('\n')
112+
const buffer = await renderMarkdownPdf({
113+
markdown: `# Table report\n\n| Name | Value |\n| --- | --- |\n${rows}`,
114+
title: 'Table report',
115+
})
116+
117+
const pages = await pdfPagesText(buffer)
118+
const tablePages = pages.filter((page) => page.includes('Row '))
119+
expect(tablePages.length).toBeGreaterThan(1)
120+
expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true)
59121
})
60122

61123
it('falls back instead of decoding an image above the pixel ceiling', async () => {
@@ -66,10 +128,19 @@ ${repeatedParagraphs}`
66128
const buffer = await renderMarkdownPdf({
67129
markdown: '![Too large](/api/files/view/image-1)',
68130
title: 'Bounded image',
69-
images: new Map([['image-1', oversizedSvg]]),
131+
images: new Map([['id:image-1', oversizedSvg]]),
70132
})
71133

72134
expect(buffer.subarray(0, 4).toString()).toBe('%PDF')
73135
expect((await PDFDocument.load(buffer)).getPageCount()).toBe(1)
136+
expect((await pdfPagesText(buffer)).join(' ')).toContain('Image: Too large')
137+
})
138+
139+
it('rejects a pathological number of document blocks before PDF layout', async () => {
140+
const markdown = Array.from({ length: 3_001 }, (_, index) => `Paragraph ${index}`).join('\n\n')
141+
142+
await expect(renderMarkdownPdf({ markdown, title: 'Too many blocks' })).rejects.toBeInstanceOf(
143+
MarkdownPdfLimitError
144+
)
74145
})
75146
})

0 commit comments

Comments
 (0)