Skip to content
65 changes: 65 additions & 0 deletions frontend/src/PostBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,71 @@ describe("PostBody", () => {
expect(screen.getByText("Panel")).toBeInTheDocument();
});

it("keeps separator-free OCR rows in the existing image table path", () => {
render(
<PostBody
body={'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" />'}
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(
<PostBody
body={"Before\n\n| Field | Value | Note |\n| --- | --- | --- |\n| Owner | Buyer | |\n\nAfter"}
/>,
);

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(<PostBody body={"Alice | manager\nBob | engineer"} />);

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(
<PostBody
body={table}
structureUnits={[
{
unit_index: 0,
unit_kind_code: "dom",
unit_text: table,
indent_level: 0,
indent_source_code: "unresolved",
indent_confidence: 0,
indent_evidence: "",
},
]}
/>,
);

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(
<PostBody
Expand Down
53 changes: 41 additions & 12 deletions frontend/src/PostBody.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,46 @@ import { t } from "./i18n";
import type { PostContentUnit, PostImageContent } from "./api";
import type { ReactNode } from "react";

function parsePipeDelimitedTable(text: string): string[][] | null {
const rows = text
function parsePipeDelimitedTable(text: string, requireSeparator = true): string[][] | null {
const rawRows = text
.split(/\r?\n/)
.map((row) => {
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));
Comment thread
seonghobae marked this conversation as resolved.
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 <p>{text}</p>;
function renderPipeTable(
text: string,
className: string,
keyPrefix: string,
requireSeparator = true,
): ReactNode | null {
const rows = parsePipeDelimitedTable(text, requireSeparator);
if (!rows) return null;
return (
<table className="post-body-table post-image-text-table">
<table
key={`${keyPrefix}-table`}
className={className}
data-content-kind="table"
>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`post-image-text-row-${rowIndex}`}>
<tr key={`${keyPrefix}-row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`post-image-text-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
<td key={`${keyPrefix}-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
Expand All @@ -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) ?? (
<p>{text}</p>
)
);
}

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;

Expand Down Expand Up @@ -114,6 +136,13 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P
}
}

function renderTextSegment(segment: Extract<PostBodySegment, { kind: "text" }>, 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" ||
Expand Down Expand Up @@ -236,7 +265,7 @@ function renderStructuredUnits(
? unit.indent_level
: undefined;
rendered.push(
renderSegment(
renderTextSegment(
{
kind: "text",
text: unit.unit_text,
Expand Down Expand Up @@ -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);
})}
</div>
);
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/postBodyDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ describe("splitPostBody", () => {
]);
});

it("keeps a stray pipe line inside its surrounding paragraph", () => {
expect(splitPostBody("<p>Before<br>ratio A | B<br>After</p>")).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." },
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/postBodyDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,20 +172,44 @@ 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 = [];
};
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +181 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Separator accepted anywhere when grouping, required at row 1 when rendering

flushPipeTableRows groups pipe lines into a table paragraph when any row is a separator, but parsePipeDelimitedTable (frontend/src/PostBody.tsx:18) only renders when the separator is at row index 1. A table whose separator sits elsewhere is grouped, fails to parse, then renders as one <p> with newlines flattened to spaces. Affects only malformed Markdown tables.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
if (pipeTableRows.length > 0) flushPipeTableRows();
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +197 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Pipe-bearing list items merge into one paragraph

A line containing | with two or more non-empty cells is accumulated as a table-candidate row and continues, bypassing the LIST_ITEM_START flush at postBodyDisplay.ts. When these lines do not form a table (no separator row), flushPipeTableRows appends them back to lines (postBodyDisplay.ts), joining them into one paragraph. Consecutive plain-text list items whose text contains a pipe (e.g. - Owner | Alice / - Reviewer | Bob on single-newline lines) previously stayed separate via the list-item flush; they now collapse into a single run-on paragraph. No test covers this.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if (!line.trim()) {
flush();
continue;
}
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;
}
Expand Down