Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.13.1-separate-source-tables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Preserve adjacent source tables

## Fixed

- Keep consecutive persisted rows in separate buyer-facing tables when the
source post contains more than one HTML table, preserving the authored table
boundary without changing source text or semantic row content.
29 changes: 29 additions & 0 deletions frontend/src/PostBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ describe("PostBody", () => {
expect(screen.getByText("Unavailable source unit")).toHaveAttribute("data-indent-level", "0");
});

it("keeps adjacent source tables as separate buyer-facing tables", () => {
render(
<PostBody
body={
"<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>" +
"<table><tr><td>E</td><td>F</td></tr><tr><td>G</td><td>H</td></tr></table>"
}
structureUnits={[
["A", "B"],
["C", "D"],
["E", "F"],
["G", "H"],
].map(([left, right], unit_index) => ({
unit_index,
unit_kind_code: "dom",
unit_label: "tr",
unit_text: `${left} | ${right}`,
indent_level: 0,
indent_source_code: "explicit" as const,
indent_confidence: 1,
indent_evidence: "table row",
}))}
/>,
);

expect(screen.getAllByRole("table")).toHaveLength(2);
expect(screen.getAllByRole("row")).toHaveLength(4);
});

it("marks persisted footnotes as footnote evidence", () => {
render(
<PostBody
Expand Down
39 changes: 38 additions & 1 deletion frontend/src/PostBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,30 @@ function normalizedUnitText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}

/**
* Return direct row counts for each source table in document order.
*
* Persisted rows do not currently carry a table identifier. The source body
* is therefore the smallest trustworthy boundary for adjacent tables; when
* its row count disagrees with persisted rows, the renderer falls back to the
* old consecutive-row grouping instead of guessing.
*/
function sourceTableRowGroupSizes(body: string): number[] {
const document = new DOMParser().parseFromString(body, "text/html");
return Array.from(document.querySelectorAll("table"))
.map((table) =>
Array.from(table.children).reduce((count, child) => {
const tagName = child.tagName.toLowerCase();
if (tagName === "tr") return count + 1;
if (tagName !== "thead" && tagName !== "tbody" && tagName !== "tfoot") return count;
return count + Array.from(child.children).filter(
(row) => row.tagName.toLowerCase() === "tr",
).length;
}, 0),
)
.filter((rowCount) => rowCount > 0);
}

function renderStructuredUnits(
body: string,
structureUnits: PostContentUnit[],
Expand All @@ -137,6 +161,12 @@ function renderStructuredUnits(
);
const rendered: ReactNode[] = [];
let imageOrdinal = 0;
const sourceTableGroups = sourceTableRowGroupSizes(body);
const persistedTableRowCount = structureUnits.filter(isStructuredTableRow).length;
const hasTrustworthyTableGroups =
sourceTableGroups.length > 0 &&
sourceTableGroups.reduce((total, rowCount) => total + rowCount, 0) === persistedTableRowCount;
let tableGroupOrdinal = 0;
const sourceTextSegments = splitPostBody(body).filter(
(segment): segment is Extract<PostBodySegment, { kind: "text" }> => segment.kind === "text",
);
Expand Down Expand Up @@ -168,7 +198,14 @@ function renderStructuredUnits(
}
if (isStructuredTableRow(unit)) {
const rows: PostContentUnit[] = [];
while (index < structureUnits.length && isStructuredTableRow(structureUnits[index])) {
const expectedRowCount = hasTrustworthyTableGroups
? sourceTableGroups[tableGroupOrdinal++]
: undefined;
while (
index < structureUnits.length &&
isStructuredTableRow(structureUnits[index]) &&
(expectedRowCount === undefined || rows.length < expectedRowCount)
) {
rows.push(structureUnits[index]);
index += 1;
}
Expand Down
Loading