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