From 1ebacdf534a2cbc639020a964ac2c7a760e1a774 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 02:47:22 +0900
Subject: [PATCH 1/4] fix(frontend): render markdown tables in post bodies
---
frontend/src/PostBody.test.tsx | 14 ++++++++++++++
frontend/src/PostBody.tsx | 25 +++++++++++++++++++------
frontend/src/postBodyDisplay.ts | 21 +++++++++++++++++++++
3 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx
index 1a9b00c4a..a04b7fcfa 100644
--- a/frontend/src/PostBody.test.tsx
+++ b/frontend/src/PostBody.test.tsx
@@ -295,6 +295,20 @@ describe("PostBody", () => {
expect(screen.getByText("Panel")).toBeInTheDocument();
});
+ 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("keeps source-image placement while showing persisted OCR and caption evidence", () => {
render(
{text}
;
+ if (!rows) return null;
return (
-
+
{rows.map((row, rowIndex) => (
-
+
{row.map((cell, cellIndex) => (
- | {cell} |
+ {cell} |
))}
))}
@@ -37,6 +37,12 @@ function renderImageText(text: string) {
);
}
+function renderImageText(text: string) {
+ return (
+ renderPipeTable(text, "post-body-table post-image-text-table", "post-image-text") ?? {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 +120,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" ||
@@ -274,7 +287,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.ts b/frontend/src/postBodyDisplay.ts
index 16cfb1004..9ce05c704 100644
--- a/frontend/src/postBodyDisplay.ts
+++ b/frontend/src/postBodyDisplay.ts
@@ -169,13 +169,33 @@ 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 = () => {
+ if (pipeTableRows.length >= 2) {
+ 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())) {
+ if (lines.length > 0) flush();
+ pipeTableRows.push(line);
+ continue;
+ }
+ }
+ if (pipeTableRows.length > 0) flushPipeTableRows();
if (!line.trim()) {
flush();
continue;
@@ -183,6 +203,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;
}
From 0d8b6f4a11a72a4571e97b58996766c5220e5c86 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 02:53:47 +0900
Subject: [PATCH 2/4] fix(frontend): harden markdown table rendering
---
frontend/src/PostBody.test.tsx | 30 ++++++++++++++++++++++++++++++
frontend/src/PostBody.tsx | 19 ++++++++++++++-----
2 files changed, 44 insertions(+), 5 deletions(-)
diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx
index a04b7fcfa..519c66a00 100644
--- a/frontend/src/PostBody.test.tsx
+++ b/frontend/src/PostBody.test.tsx
@@ -309,6 +309,36 @@ describe("PostBody", () => {
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 (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;
@@ -23,7 +28,11 @@ function renderPipeTable(text: string, className: string, keyPrefix: string): Re
const rows = parsePipeDelimitedTable(text);
if (!rows) return null;
return (
-
+
{rows.map((row, rowIndex) => (
@@ -249,7 +258,7 @@ function renderStructuredUnits(
? unit.indent_level
: undefined;
rendered.push(
- renderSegment(
+ renderTextSegment(
{
kind: "text",
text: unit.unit_text,
From 388037648ca125ccd569035b520ed8d2eea87fbc Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 02:55:58 +0900
Subject: [PATCH 3/4] fix(frontend): preserve separator-free OCR tables
---
frontend/src/PostBody.test.tsx | 21 +++++++++++++++++++++
frontend/src/PostBody.tsx | 17 ++++++++++++-----
2 files changed, 33 insertions(+), 5 deletions(-)
diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx
index 519c66a00..6a0a029a1 100644
--- a/frontend/src/PostBody.test.tsx
+++ b/frontend/src/PostBody.test.tsx
@@ -295,6 +295,27 @@ 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(
{
@@ -15,7 +15,7 @@ function parsePipeDelimitedTable(text: string): string[][] | null {
const separatorIndex = rawRows.findIndex(
(row) => row.length > 1 && row.every((cell) => /^:?-{3,}:?$/.test(cell)),
);
- if (separatorIndex < 1) return null;
+ if (requireSeparator && separatorIndex < 1) return null;
const rows = rawRows
.filter((_row, rowIndex) => rowIndex !== separatorIndex)
.filter((row) => row.length > 1 && row.some(Boolean));
@@ -24,8 +24,13 @@ function parsePipeDelimitedTable(text: string): string[][] | null {
return rows;
}
-function renderPipeTable(text: string, className: string, keyPrefix: string): ReactNode | null {
- const rows = parsePipeDelimitedTable(text);
+function renderPipeTable(
+ text: string,
+ className: string,
+ keyPrefix: string,
+ requireSeparator = true,
+): ReactNode | null {
+ const rows = parsePipeDelimitedTable(text, requireSeparator);
if (!rows) return null;
return (
{text}
+ renderPipeTable(text, "post-body-table post-image-text-table", "post-image-text", false) ?? (
+ {text}
+ )
);
}
From 5118500c8e1adf05e37da498558ef516ac9f6959 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 03:05:10 +0900
Subject: [PATCH 4/4] fix(frontend): confirm markdown tables before splitting
prose
---
frontend/src/PostBody.tsx | 2 +-
frontend/src/postBodyDisplay.test.ts | 12 ++++++++++++
frontend/src/postBodyDisplay.ts | 7 +++++--
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx
index b5347937e..30113026e 100644
--- a/frontend/src/PostBody.tsx
+++ b/frontend/src/PostBody.tsx
@@ -15,7 +15,7 @@ function parsePipeDelimitedTable(text: string, requireSeparator = true): string[
const separatorIndex = rawRows.findIndex(
(row) => row.length > 1 && row.every((cell) => /^:?-{3,}:?$/.test(cell)),
);
- if (requireSeparator && separatorIndex < 1) return null;
+ if (requireSeparator && separatorIndex !== 1) return null;
const rows = rawRows
.filter((_row, rowIndex) => rowIndex !== separatorIndex)
.filter((row) => row.length > 1 && row.some(Boolean));
diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts
index 29bad7fa3..2acd4e05c 100644
--- a/frontend/src/postBodyDisplay.test.ts
+++ b/frontend/src/postBodyDisplay.test.ts
@@ -110,6 +110,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 99a68473f..d151531b9 100644
--- a/frontend/src/postBodyDisplay.ts
+++ b/frontend/src/postBodyDisplay.ts
@@ -179,7 +179,11 @@ function splitSemanticParagraphs(text: string): string[] {
lines = [];
};
const flushPipeTableRows = () => {
- if (pipeTableRows.length >= 2) {
+ 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 {
@@ -193,7 +197,6 @@ function splitSemanticParagraphs(text: string): string[] {
if (trimmed.includes("|")) {
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
if (cells.length >= 2 && cells.some((cell) => cell.trim())) {
- if (lines.length > 0) flush();
pipeTableRows.push(line);
continue;
}