diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 1a9b00c4a..6a0a029a1 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -295,6 +295,71 @@ describe("PostBody", () => { expect(screen.getByText("Panel")).toBeInTheDocument(); }); + it("keeps separator-free OCR rows in the existing image table path", () => { + render( + '} + imageContent={[ + { + unit_index: 0, + mime_type: "image/png", + status_code: "described", + extracted_text: "No. | Item\n1 | Panel", + caption: "A table image", + tags: [], + }, + ]} + />, + ); + + expect(screen.getByRole("table")).toHaveClass("post-image-text-table"); + expect(screen.getAllByRole("row")).toHaveLength(2); + }); + + it("renders a Markdown table in the source body and keeps empty cells", () => { + render( + , + ); + + expect(screen.getByRole("table")).toHaveClass("post-markdown-table"); + expect(screen.getAllByRole("row")).toHaveLength(2); + expect(screen.getByText("Owner")).toBeInTheDocument(); + expect(screen.getByText("Before")).toBeInTheDocument(); + expect(screen.getByText("After")).toBeInTheDocument(); + }); + + it("does not turn pipe-delimited prose into a table", () => { + render(); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByText((text) => text.includes("Alice | manager"))).toBeInTheDocument(); + }); + + it("renders Markdown tables when persisted text units are present", () => { + const table = "| Field | Value |\n| --- | --- |\n| Owner | Buyer |"; + render( + , + ); + + expect(screen.getByRole("table")).toHaveClass("post-markdown-table"); + expect(screen.getByText("Owner")).toBeInTheDocument(); + }); + it("keeps source-image placement while showing persisted OCR and caption evidence", () => { render( { const cells = row.split("|").map((cell) => cell.trim()); if (cells[0] === "") cells.shift(); if (cells[cells.length - 1] === "") cells.pop(); return cells; - }) - .filter((row) => !row.every((cell) => /^:?-{3,}:?$/.test(cell))) + }); + const separatorIndex = rawRows.findIndex( + (row) => row.length > 1 && row.every((cell) => /^:?-{3,}:?$/.test(cell)), + ); + if (requireSeparator && separatorIndex !== 1) return null; + const rows = rawRows + .filter((_row, rowIndex) => rowIndex !== separatorIndex) .filter((row) => row.length > 1 && row.some(Boolean)); if (rows.length < 2 || rows.some((row) => row.length !== rows[0].length)) return null; if (rows[0].length < 2) return null; return rows; } -function renderImageText(text: string) { - const rows = parsePipeDelimitedTable(text); - if (!rows) return

{text}

; +function renderPipeTable( + text: string, + className: string, + keyPrefix: string, + requireSeparator = true, +): ReactNode | null { + const rows = parsePipeDelimitedTable(text, requireSeparator); + if (!rows) return null; return ( - +
{rows.map((row, rowIndex) => ( - + {row.map((cell, cellIndex) => ( - + ))} ))} @@ -37,6 +51,14 @@ function renderImageText(text: string) { ); } +function renderImageText(text: string) { + return ( + renderPipeTable(text, "post-body-table post-image-text-table", "post-image-text", false) ?? ( +

{text}

+ ) + ); +} + const SAFE_EMBEDDED_IMAGE_SOURCE = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/i; @@ -114,6 +136,13 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P } } +function renderTextSegment(segment: Extract, index: number) { + return ( + renderPipeTable(segment.text, "post-body-table post-markdown-table", `post-markdown-${index}`) ?? + renderSegment(segment, index) + ); +} + function isStructuredTableRow(unit: PostContentUnit): boolean { return ( unit.unit_label === "tr" || @@ -236,7 +265,7 @@ function renderStructuredUnits( ? unit.indent_level : undefined; rendered.push( - renderSegment( + renderTextSegment( { kind: "text", text: unit.unit_text, @@ -274,7 +303,7 @@ export function PostBody({ {splitPostBody(body).map((segment, index) => { const content = segment.kind === "image" ? imageContent[imageOrdinal++] : undefined; if (segment.kind !== "text") return renderSegment(segment, index, content); - return renderSegment(segment, index, content); + return renderTextSegment(segment, index); })} ); diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index bde6bd936..743739697 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -124,6 +124,18 @@ describe("splitPostBody", () => { ]); }); + it("keeps a stray pipe line inside its surrounding paragraph", () => { + expect(splitPostBody("

Before
ratio A | B
After

")).toEqual([ + { kind: "text", text: "Before ratio A | B After" }, + ]); + }); + + it("space-joins consecutive pipe prose when no Markdown separator exists", () => { + expect(splitPostBody("Alice | manager\nBob | engineer")).toEqual([ + { kind: "text", text: "Alice | manager Bob | engineer" }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 51c040f17..1f69962e9 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -172,13 +172,36 @@ function stripHtmlTags(text: string): string { function splitSemanticParagraphs(text: string): string[] { const paragraphs: string[] = []; let lines: string[] = []; + let pipeTableRows: string[] = []; const flush = () => { const paragraph = lines.join(" ").trimEnd(); if (paragraph.trim()) paragraphs.push(paragraph); lines = []; }; + const flushPipeTableRows = () => { + const hasSeparator = pipeTableRows.some((row) => { + const cells = row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|"); + return cells.length >= 2 && cells.every((cell) => /^\s*:?-{3,}:?\s*$/.test(cell)); + }); + if (pipeTableRows.length >= 2 && hasSeparator) { + flush(); + paragraphs.push(pipeTableRows.map((row) => row.trim()).join("\n")); + } else { + lines.push(...pipeTableRows); + } + pipeTableRows = []; + }; for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed.includes("|")) { + const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|"); + if (cells.length >= 2 && cells.some((cell) => cell.trim())) { + pipeTableRows.push(line); + continue; + } + } + if (pipeTableRows.length > 0) flushPipeTableRows(); if (!line.trim()) { flush(); continue; @@ -186,6 +209,7 @@ function splitSemanticParagraphs(text: string): string[] { if (lines.length > 0 && LIST_ITEM_START.test(line)) flush(); lines.push(lines.length === 0 ? line.replace(/[ \t]+$/g, "") : line.trim()); } + if (pipeTableRows.length > 0) flushPipeTableRows(); flush(); return paragraphs; }
{cell}{cell}