Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
553 changes: 553 additions & 0 deletions .agents/plans/clever-ivory-wind-table-api.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions src/api/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import { PermissionDeniedError } from "#src/security/errors";
import { DEFAULT_PERMISSIONS, type Permissions } from "#src/security/permissions";
import type { StandardSecurityHandler } from "#src/security/standard-handler.ts";
import type { SignOptions, SignResult } from "#src/signatures/types";
import { layoutTable } from "#src/tables/layout";
import { normalizeTable } from "#src/tables/normalize";
import { renderTable } from "#src/tables/render";
import type { DrawTableOptions, DrawTableResult, TableDefinition } from "#src/tables/types";
import type { FindTextOptions, PageText, TextMatch } from "#src/text/types";
import { writeComplete, writeIncremental } from "#src/writer/pdf-writer";
import { randomBytes } from "@noble/ciphers/utils.js";
Expand Down Expand Up @@ -2063,6 +2067,58 @@ export class PDF {
return this.fonts.getRef(font);
}

// ─────────────────────────────────────────────────────────────────────────────
// Table Drawing
// ─────────────────────────────────────────────────────────────────────────────

/**
* Draw a table starting on the given page, with automatic pagination.
*
* Tables can span multiple pages. New pages are created as needed with the
* same dimensions as `startPage`. The returned result includes the last page
* drawn on and the cursor position, so you can continue drawing after the table.
*
* @param startPage - The page to begin drawing on
* @param definition - Table structure: columns, head, body, and foot rows
* @param options - Layout bounds, style, and pagination options
* @returns Result with last page, cursor position, and pages used
*
* @example
* ```typescript
* const page = pdf.addPage({ size: "letter" });
* const result = pdf.drawTable(page, {
* columns: [
* { key: "item", width: 72 },
* { key: "desc", width: "*" },
* { key: "total", width: 72, align: "right" },
* ],
* head: [["Item", "Description", "Total"]],
* body: items.map(i => [i.name, i.desc, i.total]),
* }, {
* bounds: { x: 48, y: 72, width: 516, height: 640 },
* });
*
* result.lastPage.drawText("Thank you", {
* x: 48, y: result.cursorY - 24, size: 10,
* });
* ```
*/
drawTable(
startPage: PDFPage,
definition: TableDefinition,
options: DrawTableOptions,
): DrawTableResult {
const table = normalizeTable(definition, options);
const layout = layoutTable(table, options);

return renderTable(layout, options, startPage, () => {
return this.addPage({
width: startPage.width,
height: startPage.height,
});
});
}

// ─────────────────────────────────────────────────────────────────────────────
// Image Embedding
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
24 changes: 24 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,30 @@ export { PDFImage } from "./images/pdf-image";
// Drawing API
// ─────────────────────────────────────────────────────────────────────────────

// ─────────────────────────────────────────────────────────────────────────────
// Table API
// ─────────────────────────────────────────────────────────────────────────────

export type {
DrawTableOptions,
DrawTableResult,
TableCell,
TableCellStyle,
TableColumn,
TableDefinition,
TableFullRow,
TableHAlign,
TableOverflow,
TableOverflowWrap,
TableRepeat,
TableRow,
TableSparseRow,
TableVAlign,
TableWidth,
} from "#src/tables/types";

export { TableLayoutError, TableRowOverflowError } from "#src/tables/measure";

export {
type DrawCircleOptions,
type DrawEllipseOptions,
Expand Down
Empty file added src/tables/index.ts
Empty file.
182 changes: 182 additions & 0 deletions src/tables/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";

import { layoutTable } from "./layout";
import { TableRowOverflowError } from "./measure";
import { normalizeTable } from "./normalize";
import type { DrawTableOptions, TableDefinition } from "./types";

const bounds = { x: 48, y: 72, width: 500, height: 200 };
const baseOptions: DrawTableOptions = { bounds };

function makeTable(bodyRowCount: number, head = false, foot = false): TableDefinition {
return {
columns: [
{ key: "a", width: 250 },
{ key: "b", width: 250 },
],
head: head ? [["H1", "H2"]] : undefined,
body: Array.from({ length: bodyRowCount }, (_, i) => [`R${i}`, `V${i}`]),
foot: foot ? [["F1", "F2"]] : undefined,
};
}

function fragmentHeight(fragment: ReturnType<typeof layoutTable>["fragments"][number]) {
return [...fragment.headRows, ...fragment.rows, ...fragment.footRows].reduce(
(sum, row) => sum + row.rowHeight,
0,
);
}

describe("layoutTable", () => {
it("lays out a simple single-page table", () => {
const def = makeTable(3);
const table = normalizeTable(def, baseOptions);
const result = layoutTable(table, baseOptions);

expect(result.fragments).toHaveLength(1);
expect(result.fragments[0].rows).toHaveLength(3);
expect(result.fragments[0].isFirstPage).toBe(true);
expect(result.fragments[0].isLastPage).toBe(true);
});

it("paginates when rows exceed page height", () => {
// Create enough rows to overflow
const def = makeTable(20);
const table = normalizeTable(def, baseOptions);
const result = layoutTable(table, baseOptions);

expect(result.fragments.length).toBeGreaterThan(1);
expect(result.fragments[0].isFirstPage).toBe(true);
expect(result.fragments[0].isLastPage).toBe(false);
expect(result.fragments[result.fragments.length - 1].isLastPage).toBe(true);
});

it("repeats headers on every page", () => {
const def = makeTable(20, true);
const options: DrawTableOptions = { bounds, headRepeat: "everyPage" };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

for (const fragment of result.fragments) {
expect(fragment.headRows.length).toBeGreaterThan(0);
}
});

it("shows header only on first page with firstPage repeat", () => {
const def = makeTable(20, true);
const options: DrawTableOptions = { bounds, headRepeat: "firstPage" };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

expect(result.fragments[0].headRows.length).toBeGreaterThan(0);
if (result.fragments.length > 1) {
expect(result.fragments[1].headRows).toHaveLength(0);
}
});

it("shows footer only on last page with lastPage repeat", () => {
const shortBounds = { x: 0, y: 0, width: 100, height: 100 };
const def: TableDefinition = {
columns: [{ key: "a", width: 100 }],
body: [["1"], ["2"], ["3"], ["4"]],
foot: [["Footer"]],
};
const options: DrawTableOptions = { bounds: shortBounds, footRepeat: "lastPage" };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

const lastFragment = result.fragments[result.fragments.length - 1];
expect(lastFragment.footRows.length).toBeGreaterThan(0);
expect(result.fragments.length).toBeGreaterThan(1);
expect(result.fragments.every(fragment => fragmentHeight(fragment) <= shortBounds.height)).toBe(
true,
);

if (result.fragments.length > 1) {
expect(result.fragments[0].footRows).toHaveLength(0);
}
});

it("shows footer on every page with everyPage repeat", () => {
const def = makeTable(20, false, true);
const options: DrawTableOptions = { bounds, footRepeat: "everyPage" };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

for (const fragment of result.fragments) {
expect(fragment.footRows.length).toBeGreaterThan(0);
}
});

it("throws when a single row cannot fit on a fresh page", () => {
// Use a very small page height but matching column width
const def: TableDefinition = {
columns: [{ key: "a", width: 100 }],
body: [{ cells: [Array(200).fill("word").join(" ")], keepTogether: true }],
};
const tinyBounds = { x: 0, y: 0, width: 100, height: 30 };
const options: DrawTableOptions = { bounds: tinyBounds };
const table = normalizeTable(def, options);

expect(() => layoutTable(table, options)).toThrow(TableRowOverflowError);
});

it("allows a first-page-only header to push the first body row to the next page", () => {
const def: TableDefinition = {
columns: [{ key: "a", width: 100 }],
head: [["Header"]],
body: [["Body"]],
};
const options: DrawTableOptions = {
bounds: { x: 0, y: 0, width: 100, height: 40 },
headRepeat: "firstPage",
};
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

expect(result.fragments).toHaveLength(2);
expect(result.fragments[0].headRows).toHaveLength(1);
expect(result.fragments[0].rows).toHaveLength(0);
expect(result.fragments[1].headRows).toHaveLength(0);
expect(result.fragments[1].rows).toHaveLength(1);
});

it("splits rows without overflowing a fragment or emitting an empty trailing fragment", () => {
const def: TableDefinition = {
columns: [{ key: "a", width: 100 }],
body: [["short"], [Array(100).fill("word").join(" ")]],
};
const options: DrawTableOptions = { bounds: { x: 0, y: 0, width: 100, height: 40 } };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

expect(result.fragments.length).toBeGreaterThan(1);
expect(result.fragments.every(fragment => fragment.rows.length > 0)).toBe(true);
expect(
result.fragments.every(fragment => fragmentHeight(fragment) <= options.bounds.height),
).toBe(true);
expect(
result.fragments.flatMap(fragment => fragment.rows.filter(row => row.bodyIndex === 1)).length,
).toBeGreaterThan(1);
});

it("handles empty body with head and foot", () => {
const def: TableDefinition = {
columns: [
{ key: "a", width: 250 },
{ key: "b", width: 250 },
],
head: [["H1", "H2"]],
body: [],
foot: [["F1", "F2"]],
};
const options: DrawTableOptions = { bounds, footRepeat: "lastPage" };
const table = normalizeTable(def, options);
const result = layoutTable(table, options);

expect(result.fragments).toHaveLength(1);
expect(result.fragments[0].rows).toHaveLength(0);
expect(result.fragments[0].headRows.length).toBeGreaterThan(0);
expect(result.fragments[0].footRows.length).toBeGreaterThan(0);
});
});
Loading