22 * @vitest -environment node
33 */
44import { PDFDocument } from 'pdf-lib'
5+ import { getDocument , OPS } from 'pdfjs-dist/legacy/build/pdf.mjs'
56import sharp from 'sharp'
67import { 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
925describe ( '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
4060const exported = true
4161\`\`\`
4262
43- 
63+ 
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 : '' ,
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