Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/docs/content/docs/integrations/mistral_parse.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Mistral Parser
description: Extract text from PDF documents
description: Extract text, tables, and images from PDF documents
integration:
type: "mistral_parse_v3"
color: "#000000"
Expand All @@ -10,12 +10,12 @@ import { ReferenceDetails } from "@/components/ui/reference-details"


{/* MANUAL-CONTENT-START:intro */}
Use [Mistral's OCR API](https://mistral.ai/) to extract text and page structure from a PDF uploaded to Sim or supplied by URL. The current parser returns page Markdown, image metadata, the model, and usage information. **Specific Pages** accepts zero-based indices such as `0,1,2`; leave it empty to process all pages. The block offers Markdown, plain-text, and JSON format values, but its current result uses the structured page output documented below. Base64 image data is a hidden tool input and has no block control.
Use [Mistral's OCR API](https://mistral.ai/) to extract text and page structure from a PDF uploaded to Sim or supplied by URL. The current parser returns page Markdown, image metadata, the model, and usage information. **Specific Pages** accepts zero-based indices such as `0,1,2`; leave it empty to process all pages. The block offers Markdown, plain-text, and JSON format values, but its current result uses the structured page output documented below. Turn on **Extract Images** to get the images Mistral finds in the document (figures, logos, photos) as files on the `images` output, up to 100 per parse; each page's image entry carries a `file_name` matching its file. **Max Images** and **Min Image Size** (advanced) narrow what is extracted. **Tables** keeps tables inline in the page Markdown by default, or returns them separately as Markdown or HTML in each page's `tables` list.
{/* MANUAL-CONTENT-END */}

## Use the block [#usage-instructions]

Extract text from uploaded PDF documents, or from a URL.
Extract text, tables, and images from uploaded PDF documents, or from a URL. Images come back as files an agent can pass on.

## Actions

Expand All @@ -31,7 +31,11 @@ Parse PDF documents using Mistral OCR API
| --------- | ---- | -------- | ----------- |
| `file` | file | Yes | Normalized UserFile from file upload or file reference |
| `resultType` | string | No | Legacy parser output format \(markdown, text, or json; default markdown\). Versions 2 and 3 return structured OCR pages regardless of this value. |
| `includeImageBase64` | boolean | No | Extract the images in the document \(figures, logos, photos\) as files on the images output |
| `pages` | array | No | Specific pages to process \(array of page numbers, starting from 0\) |
| `imageLimit` | number | No | Maximum number of images to extract \(at most 100\) |
| `imageMinSize` | number | No | Minimum height and width of images to extract from the PDF |
| `tableFormat` | string | No | Return tables separately on each page as markdown or html instead of inline in the page markdown |
| `apiKey` | string | Yes | Mistral API key \(MISTRAL_API_KEY\) |

#### Output
Expand All @@ -47,20 +51,21 @@ Parse PDF documents using Mistral OCR API
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data; returned only when the hidden includeImageBase64 input is enabled |
| ↳ `file_name` | string | Name of this image in the images output; present only when image extraction is on |
| ↳ `dimensions` | object | Page dimensions |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `tables` | array | Separate table objects, referenced from the markdown via placeholders like \[tbl-0.html\]. Mistral populates these only when table_format is "markdown" or "html"; Sim never sets it, so tables stay inline in the markdown and this list is empty |
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \["https://...", "mailto:..."\]\) |
| ↳ `tables` | array | Separate table objects \(\{ id, content, format \}\), referenced from the markdown via placeholders like \[tbl-0.html\]. Populated only when tableFormat is markdown or html; otherwise tables stay inline in the markdown and this list is empty |
| ↳ `hyperlinks` | array | Array of URL strings detected in the page |
| ↳ `header` | string | Page header content. Mistral returns it only when extract_header is true \(it defaults to false\); Sim never sets it, so this is not returned |
| ↳ `footer` | string | Page footer content. Mistral returns it only when extract_footer is true \(it defaults to false\); Sim never sets it, so this is not returned |
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
| `usage_info` | object | Usage and processing statistics |
| ↳ `pages_processed` | number | Total number of pages processed |
| ↳ `doc_size_bytes` | number | Document file size in bytes |
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
| `images` | file[] | Images extracted from the document, named page-<index>-<image id>; empty unless image extraction is on |

</ReferenceDetails>

Expand Down
68 changes: 66 additions & 2 deletions apps/sim/blocks/blocks/mistral_parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
IntegrationType,
type SubBlockType,
} from '@/blocks/types'
import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils'
import {
createVersionedToolSelector,
normalizeFileInput,
parseOptionalNumberInput,
} from '@/blocks/utils'

export const MistralParseBlock: BlockConfig = {
type: 'mistral_parse',
Expand Down Expand Up @@ -304,7 +308,9 @@ export const MistralParseV3Block: BlockConfig = {
sunset: undefined,
type: 'mistral_parse_v3',
name: 'Mistral Parser',
description: 'Extract text from PDF documents',
description: 'Extract text, tables, and images from PDF documents',
longDescription:
'Extract text, tables, and images from uploaded PDF documents, or from a URL. Images come back as files an agent can pass on.',
hideFromToolbar: false,
/* v3 renamed the canonical pair's advanced member `filePath` -> `fileReference`,
so the inherited sentence would reference a subblock this block lacks. */
Expand Down Expand Up @@ -358,6 +364,38 @@ export const MistralParseV3Block: BlockConfig = {
type: 'short-input',
placeholder: 'e.g. 0,1,2 (leave empty for all pages)',
},
{
id: 'tableFormat',
title: 'Tables',
type: 'dropdown',
options: [
{ id: 'inline', label: 'Inline in page markdown' },
{ id: 'markdown', label: 'Separate (Markdown)' },
{ id: 'html', label: 'Separate (HTML)' },
],
value: () => 'inline',
},
{
id: 'includeImageBase64',
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
title: 'Extract Images',
type: 'switch',
},
{
id: 'imageLimit',
title: 'Max Images',
type: 'short-input',
placeholder: 'Up to 100 (default 100)',
condition: { field: 'includeImageBase64', value: true },
mode: 'advanced',
},
{
id: 'imageMinSize',
title: 'Min Image Size (px)',
type: 'short-input',
placeholder: 'Skip images smaller than this on both sides',
condition: { field: 'includeImageBase64', value: true },
mode: 'advanced',
},
{
id: 'apiKey',
title: 'API Key',
Expand Down Expand Up @@ -416,6 +454,24 @@ export const MistralParseV3Block: BlockConfig = {
parameters.pages = pagesArray
}

parameters.tableFormat =
params.tableFormat === 'markdown' || params.tableFormat === 'html'
? params.tableFormat
: undefined

if (params.includeImageBase64) {
parameters.includeImageBase64 = true
parameters.imageLimit = parseOptionalNumberInput(params.imageLimit, 'Max Images', {
integer: true,
min: 0,
})
parameters.imageMinSize = parseOptionalNumberInput(
params.imageMinSize,
'Min Image Size',
{ integer: true, min: 0 }
)
}

return parameters
},
},
Expand All @@ -425,12 +481,20 @@ export const MistralParseV3Block: BlockConfig = {
apiKey: { type: 'string', description: 'Mistral API key' },
resultType: { type: 'string', description: 'Output format type' },
pages: { type: 'string', description: 'Page selection' },
tableFormat: { type: 'string', description: 'Table output: inline, markdown, or html' },
includeImageBase64: { type: 'boolean', description: 'Extract document images as files' },
imageLimit: { type: 'number', description: 'Maximum number of images to extract' },
imageMinSize: { type: 'number', description: 'Minimum image size in pixels' },
},
outputs: {
pages: { type: 'array', description: 'Array of page objects from Mistral OCR' },
model: { type: 'string', description: 'Mistral OCR model identifier' },
usage_info: { type: 'json', description: 'Usage statistics from the API' },
document_annotation: { type: 'string', description: 'Structured annotation data' },
images: {
type: 'file[]',
description: 'Images extracted from the document when Extract Images is on',
},
},
}

Expand Down
16 changes: 11 additions & 5 deletions apps/sim/lib/internal/mistral/execute-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
mistralParseInputSchema,
} from '@/lib/internal/mistral/input'
import { isMistralInputWithinLimit } from '@/lib/internal/mistral/input-size'
import { executeMistralParse } from '@/lib/internal/mistral/operations'
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
import { executeMistralToolParse } from '@/lib/internal/mistral/operations'
import { isInternalToolFileResult } from '@/lib/internal/tool-operations/file-result'
import type {
InternalToolOperationHandler,
InternalToolOperationResult,
} from '@/lib/internal/tool-operations/types'
import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready'

const logger = createLogger('MistralToolExecution')
Expand All @@ -26,7 +30,9 @@ function validationResponse(error: z.ZodError): Response {
)
}

export const executeMistralTool: InternalToolOperationHandler = async (request) => {
export const executeMistralTool: InternalToolOperationHandler<InternalToolOperationResult> = async (
request
) => {
request.signal?.throwIfAborted()
if (!['mistral_parser', 'mistral_parser_v2', 'mistral_parser_v3'].includes(request.toolId)) {
return Response.json(
Expand All @@ -51,14 +57,14 @@ export const executeMistralTool: InternalToolOperationHandler = async (request)
if (!parsed.success) return validationResponse(parsed.error)

try {
const result = await executeMistralParse(parsed.data, {
const result = await executeMistralToolParse(request.toolId, parsed.data, {
headers: request.headers,
requestId: request.requestId,
signal: request.signal,
userId: request.context.userId,
})
request.signal?.throwIfAborted()
return Response.json(result)
return isInternalToolFileResult(result) ? result : Response.json(result)
} catch (error) {
request.signal?.throwIfAborted()
if (error instanceof ProviderCapacityDeferredError) {
Expand Down
63 changes: 63 additions & 0 deletions apps/sim/lib/internal/mistral/images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { extractOcrImageFiles, MAX_OCR_EXTRACTED_IMAGES } from '@/lib/internal/mistral/images'

const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.from('synthetic jpeg body')])
const dataUri = (bytes: Buffer) => `data:image/jpeg;base64,${bytes.toString('base64')}`

function image(id: string, imageBase64: string | undefined) {
return {
id,
top_left_x: 0,
top_left_y: 0,
bottom_right_x: 10,
bottom_right_y: 10,
image_base64: imageBase64,
}
}

describe('extractOcrImageFiles', () => {
it('moves image bytes into files named as storage will name them, with no base64 left in the page output', () => {
const { output, files } = extractOcrImageFiles({
pages: [
{
index: 2,
markdown: '![img-0.jpeg](img-0.jpeg)',
images: [image('img-0.jpeg', dataUri(JPEG))],
},
],
})
expect(files).toEqual([{ buffer: JPEG, name: 'page-2-img-0.jpg', mimeType: 'image/jpeg' }])
expect(JSON.stringify(output)).not.toContain(JPEG.toString('base64'))
expect(output.pages).toEqual([
expect.objectContaining({
images: [expect.objectContaining({ id: 'img-0.jpeg', file_name: 'page-2-img-0.jpg' })],
}),
])
})

it('stores at most the image cap and keeps the bounding boxes of the rest without a file', () => {
const images = Array.from({ length: MAX_OCR_EXTRACTED_IMAGES + 1 }, (_, i) =>
image(`img-${i}.jpeg`, JPEG.toString('base64'))
)
const { output, files } = extractOcrImageFiles({ pages: [{ index: 0, images }] })
expect(files).toHaveLength(MAX_OCR_EXTRACTED_IMAGES)
const pageImages = (output.pages as Array<{ images: Array<Record<string, unknown>> }>)[0].images
expect(pageImages).toHaveLength(MAX_OCR_EXTRACTED_IMAGES + 1)
expect(pageImages.at(-1)).not.toHaveProperty('file_name')
expect(pageImages.at(-1)).not.toHaveProperty('image_base64')
})

it('skips an image over the per-image cap instead of failing the parse', () => {
const huge = Buffer.alloc(21 * 1024 * 1024)
const { output, files } = extractOcrImageFiles({
pages: [
{
index: 0,
images: [image('img-0.png', dataUri(huge)), image('img-1.jpg', dataUri(JPEG))],
},
],
})
expect(files.map((file) => file.name)).toEqual(['page-0-img-1.jpg'])
expect(JSON.stringify(output)).not.toContain('image_base64')
})
})
68 changes: 68 additions & 0 deletions apps/sim/lib/internal/mistral/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { isRecordLike } from '@sim/utils/object'
import { decodeDataUriWithinLimit } from '@/lib/file-parsers/data-uri'
import { isFileParserError } from '@/lib/file-parsers/errors'
import type { InternalToolFile } from '@/lib/internal/tool-operations/file-result'
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file'
import { resolveStoredFileMetadata } from '@/lib/uploads/utils/stored-file-metadata'

/** Most images one parse stores; Mistral's `image_limit` is clamped to it. */
export const MAX_OCR_EXTRACTED_IMAGES = 100

/** Largest single image kept; a bigger one is a page scan, not a figure. */
const MAX_OCR_IMAGE_BYTES = 20 * 1024 * 1024

interface OcrImageFiles {
/** The OCR output with every `image_base64` removed and each kept image named. */
output: Record<string, unknown>
files: InternalToolFile[]
}

/** The decoded image, or null when it is over the per-image cap. */
function decodeImage(imageBase64: string): { buffer: Buffer; mediaType: string | null } | null {
const dataUri = imageBase64.startsWith('data:') ? imageBase64 : `data:;base64,${imageBase64}`
try {
return decodeDataUriWithinLimit(dataUri, MAX_OCR_IMAGE_BYTES)
} catch (error) {
if (isFileParserError(error) && error.code === 'complexity_limit') return null
throw error
}
}

/**
* Turns the base64 images in a Mistral OCR response into files the executor
* stores, so image bytes never travel through block outputs. Each kept image
* gets a `file_name` matching its stored file; an image past the count, the
* per-image cap, or the total byte budget keeps its bounding box but no file.
*/
export function extractOcrImageFiles(output: unknown): OcrImageFiles {
if (!isRecordLike(output)) return { output: {}, files: [] }
if (!Array.isArray(output.pages)) return { output, files: [] }
const files: InternalToolFile[] = []
let totalBytes = 0
const pages = output.pages.map((page: unknown) => {
if (!isRecordLike(page) || !Array.isArray(page.images)) return page
const pageIndex = typeof page.index === 'number' ? page.index : 0
const images = page.images.map((image: unknown) => {
if (!isRecordLike(image)) return image
const { image_base64: imageBase64, ...rest } = image
if (typeof imageBase64 !== 'string' || !imageBase64) return rest
if (files.length >= MAX_OCR_EXTRACTED_IMAGES) return rest
const decoded = decodeImage(imageBase64)
if (!decoded || totalBytes + decoded.buffer.length > MAX_BUFFERED_TRANSFER_BYTES) return rest
totalBytes += decoded.buffer.length
const id = typeof image.id === 'string' && image.id ? image.id : `img-${files.length}.jpeg`
/** Storage renames an image from its bytes; naming it the same way keeps `file_name` matching. */
const draftName = `page-${pageIndex}-${id}`
const { fileName, mimeType } = resolveStoredFileMetadata(
draftName,
resolveEffectiveMimeType(decoded.mediaType, draftName),
decoded.buffer
)
files.push({ buffer: decoded.buffer, name: fileName, mimeType })
return { ...rest, file_name: fileName }
})
return { ...page, images }
})
return { output: { ...output, pages }, files }
}
5 changes: 3 additions & 2 deletions apps/sim/lib/internal/mistral/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ export const mistralParseInputSchema = z.object({
resultType: z.string().optional(),
pages: z.array(z.number()).max(MISTRAL_OCR_REQUEST_POLICY.maxPages).optional(),
includeImageBase64: z.boolean().optional(),
imageLimit: z.number().optional(),
imageMinSize: z.number().optional(),
imageLimit: z.number().int().min(0).optional(),
imageMinSize: z.number().int().min(0).optional(),
tableFormat: z.enum(['markdown', 'html']).optional(),
[RESOLVED_SECRET_PROVENANCE_FIELD]: resolvedSecretTraceProvenanceSchema.optional(),
})

Expand Down
Loading
Loading