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) => (
- | {cell} |
+ {cell} |
))}
))}
@@ -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;
}