/** * Layout prompt and decoder contract shared with okraPDF's parser-gemini * implementation. Kept in this standalone package so a public checkout can * install and build without monorepo workspace dependencies. * * Derived from ParseBench and @okrapdf/parser-gemini (MIT). */ export type DecodedLayoutBlock = { bbox: [number, number, number, number]; label: string; text: string; page?: number; }; const DIV_BLOCK = /]*)>([\s\S]*?)<\/div>/gi; const DATA_BBOX = /\bdata-bbox=["']([^"']+)["']/i; const DATA_LABEL = /\bdata-label=["']([^"']+)["']/i; const CLASS_LABEL = /\bclass=["']([^"']+)["']/i; const DATA_PAGE = /data-page=["'](\d+)["']/i; function tryParseBbox(raw: string): [number, number, number, number] | null { const value = raw.trim(); try { const parsed: unknown = JSON.parse(value.startsWith('[') ? value : `[${value}]`); if ( Array.isArray(parsed) && parsed.length === 4 && parsed.every((number) => typeof number === 'number' && Number.isFinite(number)) ) { return parsed as [number, number, number, number]; } } catch { // Ignore malformed blocks while continuing to decode later valid blocks. } return null; } export function parseLayoutBlocks(content: string): DecodedLayoutBlock[] { const blocks: DecodedLayoutBlock[] = []; for (const match of content.matchAll(DIV_BLOCK)) { const attributes = match[1]; const bboxMatch = DATA_BBOX.exec(attributes); const labelMatch = DATA_LABEL.exec(attributes) ?? CLASS_LABEL.exec(attributes); if (!bboxMatch || !labelMatch) continue; const bbox = tryParseBbox(bboxMatch[1]); if (!bbox) continue; const pageMatch = DATA_PAGE.exec(attributes); const page = pageMatch ? Number.parseInt(pageMatch[1], 10) : undefined; blocks.push({ bbox, label: labelMatch[1].trim().split(/\s+/)[0], text: match[2].trim(), ...(page && page > 0 ? { page } : {}), }); } return blocks; } export function swapGeminiBbox(blocks: DecodedLayoutBlock[]): DecodedLayoutBlock[] { return blocks.map((block) => { const [yMin, xMin, yMax, xMax] = block.bbox; return { ...block, bbox: [xMin, yMin, xMax, yMax] }; }); } export function itemsToMarkdown(blocks: DecodedLayoutBlock[]): string { const parts: string[] = []; for (const block of blocks) { const label = block.label.toLowerCase(); if (!block.text) continue; if (label === 'title') parts.push(`# ${block.text}`); else if (label === 'section-header' || label === 'section_header') { parts.push(`## ${block.text}`); } else if (label === 'formula') { parts.push(`$$\n${block.text}\n$$`); } else { parts.push(block.text); } } return parts.join('\n\n'); } const LABEL_MAP: Record = { caption: 'Caption', footnote: 'Footnote', formula: 'Formula', 'list-item': 'List-item', list_item: 'List-item', 'page-footer': 'Page-footer', page_footer: 'Page-footer', 'page-header': 'Page-header', page_header: 'Page-header', picture: 'Picture', figure: 'Picture', 'section-header': 'Section-header', section_header: 'Section-header', table: 'Table', text: 'Text', title: 'Title', }; export function canonicalLabel(raw: string): string { return LABEL_MAP[raw.toLowerCase()] ?? raw; } export const SYSTEM_PROMPT_LAYOUT = 'You are a document parser. Your task is to convert ' + 'document images to clean, well-structured markdown.' + '\n\nGuidelines:\n' + '- Preserve the document structure ' + '(headings, paragraphs, lists, tables)\n' + '- Convert tables to HTML format ' + '(, ,
, )\n' + '- For existing tables in the document: use colspan ' + 'and rowspan attributes to preserve merged cells ' + 'and hierarchical headers\n' + '- For charts/graphs being converted to tables: use ' + 'flat combined column headers (e.g., ' + '"Primary 2015" not separate rows) so each data ' + "cell's row contains all its labels\n" + '- Describe images/figures briefly in square brackets ' + 'like [Figure: description]\n' + '- Preserve any code blocks with appropriate syntax ' + 'highlighting\n' + '- Maintain reading order (left-to-right, ' + 'top-to-bottom for Western documents)\n' + '- Do not add commentary or explanations ' + '- only output the parsed content' + '\n\n' + 'Additionally, wrap each layout element in a
tag with:\n' + '- data-bbox="[x1, y1, x2, y2]" — bounding box in normalized 0-1000 ' + 'coordinates where x is horizontal (left edge = 0, right edge = 1000) ' + 'and y is vertical (top = 0, bottom = 1000). ' + 'x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.\n' + '- data-label="" — one of: Caption, Footnote, Formula, ' + 'List-item, Page-footer, Page-header, Picture, Section-header, ' + 'Table, Text, Title\n\n' + 'Place elements in reading order. Every piece of content must be ' + 'inside exactly one
wrapper.'; export const USER_PROMPT_LAYOUT = 'Parse this document page and output its content as ' + 'clean markdown, with each layout element wrapped in a ' + '
tag. ' + 'Use HTML tables for any tabular data. ' + 'For charts/graphs, use flat combined column headers. ' + 'Output ONLY the parsed content with div wrappers, ' + 'no explanations.'; export const SYSTEM_PROMPT_LAYOUT_GEMINI = SYSTEM_PROMPT_LAYOUT.replace( '"[x1, y1, x2, y2]" — bounding box in normalized 0-1000 ' + 'coordinates where x is horizontal (left edge = 0, right edge = 1000) ' + 'and y is vertical (top = 0, bottom = 1000). ' + 'x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.', '"[y_min, x_min, y_max, x_max]" — bounding box in normalized 0-1000 ' + 'coordinates where x is horizontal (left edge = 0, right edge = 1000) ' + 'and y is vertical (top = 0, bottom = 1000). ' + 'The order is [y_min, x_min, y_max, x_max].', ); export const USER_PROMPT_LAYOUT_GEMINI = USER_PROMPT_LAYOUT.replace( '[x1,y1,x2,y2]', '[y_min,x_min,y_max,x_max]', );